АРЧ АП11 - Архитектура после ге :
This commit is contained in:
@@ -1,7 +1,112 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveCounterpartyAddressIntent = resolveCounterpartyAddressIntent;
|
||||
function hasUnicodeOpenItemsAccountSignal(text) {
|
||||
const normalized = String(text ?? "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (/(?:\u0445\u0432\u043e\u0441\u0442|\u0434\u043e\u043b\u0433|\u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442|\u0432\u0438\u0441)/iu.test(normalized) &&
|
||||
/(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?\s*(?:\u2116|#)?\s*(?:60|62|76)(?:\.\d{2})?|(?:60|62|76)(?:\.\d{2})?\s*(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?))/iu.test(normalized));
|
||||
}
|
||||
function hasUnicodeCounterpartyShipmentItemFlowSignal(text) {
|
||||
return /(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0430\u043b)/iu.test(text) &&
|
||||
/(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text);
|
||||
}
|
||||
function hasUnicodePassiveShipmentByCounterpartySignal(text) {
|
||||
return (/(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0435\u043d(?:\u044b|\u043e|\u0430)?)/iu.test(text) &&
|
||||
/(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text) &&
|
||||
/(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c)/iu.test(text));
|
||||
}
|
||||
function hasUnicodeDocumentSignal(text) {
|
||||
return /(?:\u0434\u043e\u043a(?:\u0443\u043c\u0435\u043d\u0442(?:\u044b|\u043e\u0432|\u0430\u043c|\u0430\u043c\u0438|\u0430\u0445)?|\u0438)?|\u0434\u043e\u043a\u0438)(?=$|[\s,.;:!?()])/iu.test(text);
|
||||
}
|
||||
function hasUnicodeLikelyCounterpartyAfterBy(text) {
|
||||
const match = text.match(/(?:^|[\s(])\u043f\u043e\s+([\p{L}\d][\p{L}\d._-]{1,})(?=$|[\s,.;:!?()])/iu);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const token = String(match[1] ?? "").toLowerCase();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const stopWords = new Set([
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c",
|
||||
"\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438",
|
||||
"\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u044e",
|
||||
"\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438",
|
||||
"\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e",
|
||||
"\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0443",
|
||||
"\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u043e\u043c",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u0443",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u043e\u043c",
|
||||
"\u0441\u0447\u0435\u0442\u0443",
|
||||
"\u0441\u0447\u0451\u0442\u0443",
|
||||
"\u0441\u0447\u0435\u0442\u043e\u043c",
|
||||
"\u0441\u0447\u0451\u0442\u043e\u043c",
|
||||
"\u0434\u0430\u0442\u0435",
|
||||
"\u0434\u0430\u0442\u0443",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434\u0443",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u0441\u043a\u043b\u0430\u0434\u0443",
|
||||
"\u0441\u043a\u043b\u0430\u0434\u0435",
|
||||
"\u0431\u0430\u043d\u043a\u0443",
|
||||
"\u0431\u0430\u043d\u043a\u0435",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0443"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
function resolveCounterpartyAddressIntent(text, deps) {
|
||||
if (hasUnicodeOpenItemsAccountSignal(text)) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (/(?:хвост|долг|незакрыт|вис)/iu.test(text) &&
|
||||
/(?:счету?|сч[её]ту?)\s*60|60\s*(?:счет|сч[её]т)/iu.test(text)) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (/отгружал/iu.test(text) &&
|
||||
/(?:товар|услуг|позици|номенклатур)/iu.test(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (/отгружен(?:ы|о|а)?/iu.test(text) &&
|
||||
/(?:товар|услуг|позици|номенклатур)/iu.test(text) &&
|
||||
/контрагентом/iu.test(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasUnicodeCounterpartyShipmentItemFlowSignal(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasUnicodePassiveShipmentByCounterpartySignal(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (deps.hasOpenContractsListSignal(text)) {
|
||||
return {
|
||||
intent: "open_contracts_confirmed_as_of_date",
|
||||
@@ -124,6 +229,13 @@ function resolveCounterpartyAddressIntent(text, deps) {
|
||||
reasons: ["bank_ops_by_counterparty_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasUnicodeDocumentSignal(text) && hasUnicodeLikelyCounterpartyAfterBy(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_counterparty_signal_detected"]
|
||||
};
|
||||
}
|
||||
if ((deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||
(deps.hasPartyAnchorMention(text) ||
|
||||
deps.hasLooseByAnchorMention(text) ||
|
||||
|
||||
@@ -844,6 +844,17 @@ function extractShipmentCounterpartyValue(text) {
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
function extractInstrumentalCounterpartyValue(text) {
|
||||
const match = String(text ?? "").match(/(?:контрагентом|поставщиком|клиентом|заказчиком)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = cleanupAnchorValue(String(match[1] ?? ""));
|
||||
if (!candidate || !isLikelyCounterpartyToken(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
function hasExplicitAccountCue(text) {
|
||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||
}
|
||||
@@ -1508,6 +1519,17 @@ function extractAddressFilters(userMessage, intent) {
|
||||
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
intent === "list_contracts_by_counterparty")) {
|
||||
const instrumentalCounterparty = extractInstrumentalCounterpartyValue(text);
|
||||
if (instrumentalCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(instrumentalCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_instrumental_phrase");
|
||||
}
|
||||
}
|
||||
if (!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
|
||||
+686
-903
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,26 @@ function hasInventoryAccount41Anchor(text) {
|
||||
function hasInventoryAsOfCue(text) {
|
||||
return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(text);
|
||||
}
|
||||
function hasPlainRussianInventoryOnHandSignal(text) {
|
||||
const normalized = String(text ?? "").toLowerCase().replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasStockCue = /(?:остатк|склад|товар(?:ы|ов)?|номенклатур|позици|на\s+складе|по\s+складу)/iu.test(normalized);
|
||||
if (!hasStockCue) {
|
||||
return false;
|
||||
}
|
||||
const hasRequestCue = /(?:какие|какой|какая|что|покажи|показать|получить|дай|выведи|чекни|посмотри|есть\s+ли)/iu.test(normalized);
|
||||
const hasSnapshotCue = /(?:на\s+(?:дату|сегодня|сейчас|март|апрел|май|мая|июн|июл|август|сентябр|октябр|ноябр|декабр|январ|феврал)|\b(?:19|20)\d{2}\b)/iu.test(normalized);
|
||||
return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized));
|
||||
}
|
||||
function hasInventoryOnHandSignal(text) {
|
||||
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text);
|
||||
const hasStockStateCue = /(?:(?:что|ч[еёо])\s+там\s+на\s+склад(?:е|у|ом|ах)|(?:что|ч[еёо]).*происход(?:ит|ило|ящее).*(?:на\s+)?склад(?:е|у|ом|ах)|происход(?:ит|ило|ящее)\s+на\s+склад(?:е|у|ом|ах)|ситуац(?:ия|ии)\s+на\s+склад(?:е|у|ом|ах)|обстановк(?:а|и)\s+на\s+склад(?:е|у|ом|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text);
|
||||
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
||||
const hasStockLexeme = /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
||||
if (!hasStockLexeme && !hasAccount41Anchor) {
|
||||
const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text);
|
||||
if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) {
|
||||
return false;
|
||||
}
|
||||
if (hasInventoryProvenanceSignalV2(text) ||
|
||||
@@ -29,6 +43,9 @@ function hasInventoryOnHandSignal(text) {
|
||||
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||
return true;
|
||||
}
|
||||
if (hasPlainRussianRootCue) {
|
||||
return true;
|
||||
}
|
||||
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
||||
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue);
|
||||
}
|
||||
@@ -171,6 +188,14 @@ function resolveInventoryAddressIntent(text) {
|
||||
reasons: ["inventory_purchase_date_signal_detected"]
|
||||
};
|
||||
}
|
||||
if ((text.includes("по этой позиции") || text.includes("по этому товару")) &&
|
||||
(text.includes("когда была закупка") || text.includes("когда купили"))) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "medium",
|
||||
reasons: ["inventory_purchase_date_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
|
||||
@@ -1461,6 +1461,24 @@ function isOrganizationScopedInventoryIntent(intent) {
|
||||
intent === "inventory_purchase_to_sale_chain" ||
|
||||
intent === "inventory_aging_by_purchase_date");
|
||||
}
|
||||
function shouldDeferInventoryOrganizationClarification(intent, filters, semanticFrame) {
|
||||
if (!isOrganizationScopedInventoryIntent(intent)) {
|
||||
return false;
|
||||
}
|
||||
const hasItemScope = Boolean(toNonEmptyFilterValue(filters.item)) ||
|
||||
semanticFrame?.anchor_kind === "item" ||
|
||||
semanticFrame?.anchor_kind === "selected_object" ||
|
||||
semanticFrame?.selected_object_scope_detected === true;
|
||||
if (hasItemScope) {
|
||||
return true;
|
||||
}
|
||||
return (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 collectOrganizationCandidatesFromRows(rows) {
|
||||
return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(rows.map((row) => row.organization).filter((value) => Boolean(value)));
|
||||
}
|
||||
@@ -2584,6 +2602,7 @@ class AddressQueryService {
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&
|
||||
!activeOrganization &&
|
||||
!resolvedOrganizationFromMessage &&
|
||||
!shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) &&
|
||||
knownOrganizations.length > 1) {
|
||||
return buildOrganizationClarificationExecutionResult({
|
||||
mode,
|
||||
|
||||
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.contractCandidatesFromRows = contractCandidatesFromRows;
|
||||
exports.composeFactualReply = composeFactualReply;
|
||||
exports.inferReplyType = inferReplyType;
|
||||
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
|
||||
const replyPackaging_1 = require("./replyPackaging");
|
||||
const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders");
|
||||
const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders");
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values
|
||||
@@ -2135,6 +2135,30 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||
if (inventoryReply) {
|
||||
return inventoryReply;
|
||||
}
|
||||
const counterpartyAnalyticsReply = (0, counterpartyAnalyticsReplyBuilders_1.composeCounterpartyAnalyticsReply)(intent, rows, options, {
|
||||
formatPercent,
|
||||
formatDateRu,
|
||||
formatMoneyRub,
|
||||
extractYearFromIso,
|
||||
detectCounterpartyProfileFocus,
|
||||
detectCounterpartyLifecycleFocus,
|
||||
hasCounterpartyLifecycleLongevityQuestion,
|
||||
hasCounterpartyActivityAgeQuestion,
|
||||
detectRankingLimit,
|
||||
detectValueRankingFocus,
|
||||
detectContractValueFocus,
|
||||
detectMinOpsForAvgCheck,
|
||||
extractRequestedYearFromQuestion,
|
||||
extractCounterpartyName,
|
||||
extractContractName,
|
||||
counterpartyLookupMatches,
|
||||
toUtcDayTimestamp,
|
||||
formatAgeYearsMonthsDays,
|
||||
normalizeQuestionText
|
||||
});
|
||||
if (counterpartyAnalyticsReply) {
|
||||
return counterpartyAnalyticsReply;
|
||||
}
|
||||
if (intent === "document_type_and_account_section_profile") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
@@ -2366,692 +2390,6 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
const focus = detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead = focus === "suppliers_only"
|
||||
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Заказчиков (только customer-роль): ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
|
||||
const lines = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
}
|
||||
else if (activeCounterparties > 0) {
|
||||
lines.push(`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`);
|
||||
}
|
||||
else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Роли контрагентов по активности:");
|
||||
lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`);
|
||||
lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`);
|
||||
lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
|
||||
lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines.push("По role-split контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST");
|
||||
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
|
||||
const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR");
|
||||
const byCounterparty = new Map();
|
||||
for (const row of activityFirstRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityYearRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: null,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(options.userMessage);
|
||||
const rankingLimit = detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
const scopeLabel = focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty = counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const directLine = observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`;
|
||||
const lines = [directLine];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
}
|
||||
const lines = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
|
||||
"Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
|
||||
: "По выбранному окну активности заказчики не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan = item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
}));
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(longevityQuestion
|
||||
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
|
||||
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
const usageLead = totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
|
||||
const lines = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран (catalog + usage aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника).");
|
||||
}
|
||||
lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`);
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = detectValueRankingFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = normalizeQuestionText(options.userMessage);
|
||||
const byCounterparty = new Map();
|
||||
const byYear = new Map();
|
||||
const deals = [];
|
||||
for (const row of rows) {
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set([counterparty])
|
||||
});
|
||||
}
|
||||
else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? ""));
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name));
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
const lines = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам (bank-doc value aggregate)."
|
||||
: "Собран профиль поступлений от заказчиков (bank-doc value aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "total_flow") {
|
||||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: summaryLines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}`));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
|
||||
}
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
|
||||
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
|
||||
}));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = detectContractValueFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map();
|
||||
for (const row of rows) {
|
||||
const contract = extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract));
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const lines = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по количеству операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (intent === "vat_payable_forecast") {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
|
||||
+632
@@ -0,0 +1,632 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.composeCounterpartyAnalyticsReply = composeCounterpartyAnalyticsReply;
|
||||
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
|
||||
const replyContracts_1 = require("./replyContracts");
|
||||
function groupRowsByMarker(rows) {
|
||||
const rowsByMarker = new Map();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker).push(row);
|
||||
}
|
||||
return rowsByMarker;
|
||||
}
|
||||
function formatOptionalDate(value, formatDateRu) {
|
||||
return value ? formatDateRu(value) : "дата не указана";
|
||||
}
|
||||
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
const focus = deps.detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead = focus === "suppliers_only"
|
||||
? `Контрагентов только в роли поставщика: ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Контрагентов только в роли заказчика: ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Контрагентов со смешанной ролью: ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по документальной активности: ${activeCounterparties}.`;
|
||||
const lines = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран по справочнику и документальной активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
}
|
||||
else if (activeCounterparties > 0) {
|
||||
lines.push(`Полный итог по справочнику не получен, поэтому даю оценку по документальной активности: ${activeCounterparties}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Распределение ролей по активности:");
|
||||
lines.push(`1. Только заказчики: ${customerOnly}.`);
|
||||
lines.push(`2. Только поставщики: ${supplierOnly}.`);
|
||||
lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`);
|
||||
lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines.push("По распределению ролей агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST");
|
||||
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
|
||||
const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR");
|
||||
const byCounterparty = new Map();
|
||||
for (const row of activityFirstRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityYearRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: null,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
for (const row of activityRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = deps.detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = deps.extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = deps.hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = deps.hasCounterpartyActivityAgeQuestion(options.userMessage);
|
||||
const rankingLimit = deps.detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
const scopeLabel = focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty = counterparties.find((item) => deps.counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`
|
||||
];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
const organizationHint = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays = firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel = observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
}
|
||||
const lines = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества: ${counterparties.length}.`,
|
||||
"Собран профиль длительности сотрудничества по годам и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков по платежным документам.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества."
|
||||
: "По выбранному окну активные заказчики не найдены.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan = item.firstPeriod && item.lastPeriod
|
||||
? ` | период: ${formatOptionalDate(item.firstPeriod, deps.formatDateRu)}..${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`
|
||||
: "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${deps.formatDateRu(item.lastPeriod)}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
}));
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker) => (rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? deps.formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
const usageLead = totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`;
|
||||
const lines = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран по справочнику и подтвержденным операциям.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
}
|
||||
else {
|
||||
lines.push("Общее количество договоров не получено из доступного среза справочника.");
|
||||
}
|
||||
lines.push(`Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`);
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = deps.detectValueRankingFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
|
||||
const byCounterparty = new Map();
|
||||
const byYear = new Map();
|
||||
const deals = [];
|
||||
for (const row of rows) {
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set([counterparty])
|
||||
});
|
||||
}
|
||||
else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? ""));
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name));
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
const lines = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам по платежным документам."
|
||||
: "Собран профиль поступлений от заказчиков по платежным документам.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (focus === "total_flow") {
|
||||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${deps.formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${deps.formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(summaryLines);
|
||||
}
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
}
|
||||
else {
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${deps.formatMoneyRub(item.avgCheck)} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)}`));
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты с минимум 3 операциями.");
|
||||
}
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;
|
||||
return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`;
|
||||
}));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = deps.detectContractValueFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map();
|
||||
for (const row of rows) {
|
||||
const contract = deps.extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
}
|
||||
else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract));
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
const lines = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту и подтвержденным операциям.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines);
|
||||
}
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по количеству операций:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${deps.formatMoneyRub(item.turnover)} | контрагентов: ${item.counterparties.size}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} активных договоров с минимальным оборотом:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по сумме оборота:`);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`));
|
||||
return (0, replyContracts_1.buildFactualListReply)(lines);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -421,12 +421,17 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
|
||||
const rootContextOnly = followupContext.root_context_only === true;
|
||||
const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||
const normalized = String(userMessage ?? "");
|
||||
const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized);
|
||||
const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
|
||||
/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized);
|
||||
(/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized) ||
|
||||
hasSameDateHint(normalized) ||
|
||||
hasSamePeriodHint(normalized));
|
||||
const canReenterInventoryRoot = comingFromInventoryDrilldown ||
|
||||
rootContextOnly ||
|
||||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
||||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
|
||||
(currentFrameKind === "generic" &&
|
||||
hasExplicitInventoryRootSnapshotCue &&
|
||||
(hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue));
|
||||
if (!canReenterInventoryRoot) {
|
||||
return false;
|
||||
}
|
||||
@@ -791,15 +796,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||
!hasExplicitPeriodLiteral(userMessage) &&
|
||||
!hasExplicitCurrentDateHint(userMessage)) {
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
if ((Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
||||
@@ -1025,6 +1028,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
||||
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
||||
const isVatFollowup = hasVatCue(normalizedMessage);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
||||
const vatIntent = hasVatTaxPaymentCue(normalizedMessage)
|
||||
? "vat_liability_confirmed_for_tax_period"
|
||||
@@ -1038,7 +1043,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
};
|
||||
}
|
||||
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
||||
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
|
||||
if (allowOpenItemsFollowupFallback &&
|
||||
!inventorySelectedObjectFollowup &&
|
||||
hasOpenItemsHint(normalizedMessage) &&
|
||||
hasAnyPartyAnchor) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "low",
|
||||
@@ -1060,8 +1068,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
|
||||
};
|
||||
}
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
|
||||
@@ -19,9 +19,7 @@ function containsLetterLikeChars(text) {
|
||||
return /[A-Za-z\u0400-\u04FF]/u.test(source);
|
||||
}
|
||||
function createAssistantBoundaryPolicy(deps) {
|
||||
const defaultChannel = String(deps.activeMcpChannel ?? "default");
|
||||
function buildAssistantDataScopeContractReply(scopeProbe = null) {
|
||||
const channel = String(scopeProbe?.channel ?? defaultChannel);
|
||||
const organizations = Array.isArray(scopeProbe?.organizations)
|
||||
? scopeProbe.organizations
|
||||
.map((item) => String(item ?? "").trim())
|
||||
@@ -29,27 +27,27 @@ function createAssistantBoundaryPolicy(deps) {
|
||||
: [];
|
||||
if (organizations.length === 1) {
|
||||
return [
|
||||
`Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
|
||||
"Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
|
||||
`Сейчас доступна организация: ${organizations[0]}.`,
|
||||
"Могу сразу показать по ней документы, операции, договоры или остатки."
|
||||
].join(" ");
|
||||
}
|
||||
if (organizations.length > 1) {
|
||||
const preview = organizations.slice(0, 10).join(", ");
|
||||
return [
|
||||
`Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
|
||||
"Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
|
||||
`Сейчас доступны организации (${organizations.length}): ${preview}.`,
|
||||
"Скажите, по какой организации смотреть данные."
|
||||
].join(" ");
|
||||
}
|
||||
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
||||
return [
|
||||
`Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
|
||||
"Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур."
|
||||
"Сейчас не удалось определить список организаций из подключенной базы.",
|
||||
`Техническая причина: ${scopeProbe.error}.`,
|
||||
"Проверьте подключение, и я сразу назову доступный контур."
|
||||
].join(" ");
|
||||
}
|
||||
return [
|
||||
`Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
|
||||
"Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
|
||||
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
|
||||
"Сейчас вижу только данные текущего подключенного контура.",
|
||||
"Если в нем несколько организаций, скажите, по какой смотреть данные."
|
||||
].join(" ");
|
||||
}
|
||||
function buildAssistantDataScopeSelectionReply(organization) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.formatIsoDateForReply = formatIsoDateForReply;
|
||||
exports.readAddressDebugFilters = readAddressDebugFilters;
|
||||
exports.readAddressDebugItem = readAddressDebugItem;
|
||||
exports.readAddressDebugOrganization = readAddressDebugOrganization;
|
||||
exports.readAddressDebugScopedDate = readAddressDebugScopedDate;
|
||||
exports.isGroundedAddressDebug = isGroundedAddressDebug;
|
||||
exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot;
|
||||
function fallbackToNonEmptyString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
function toRecordObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function formatIsoDateForReply(value) {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
}
|
||||
function readAddressDebugFilters(debug) {
|
||||
return toRecordObject(debug?.extracted_filters);
|
||||
}
|
||||
function readAddressDebugItem(debug, toNonEmptyString = fallbackToNonEmptyString) {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
return (toNonEmptyString(extractedFilters?.item) ??
|
||||
(String(debug?.anchor_type ?? "") === "item"
|
||||
? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw)
|
||||
: null));
|
||||
}
|
||||
function readAddressDebugOrganization(debug, toNonEmptyString = fallbackToNonEmptyString) {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
return toNonEmptyString(extractedFilters?.organization) ?? toNonEmptyString(rootFrameContext?.organization);
|
||||
}
|
||||
function readAddressDebugScopedDate(debug) {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
return (formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
||||
formatIsoDateForReply(extractedFilters?.period_to));
|
||||
}
|
||||
function isGroundedAddressDebug(debug, toNonEmptyString = fallbackToNonEmptyString) {
|
||||
if (!debug || typeof debug !== "object") {
|
||||
return false;
|
||||
}
|
||||
const executionLane = toNonEmptyString(debug.execution_lane);
|
||||
if (executionLane !== "address_query") {
|
||||
return false;
|
||||
}
|
||||
const answerGroundingCheck = toRecordObject(debug.answer_grounding_check);
|
||||
const groundingStatus = toNonEmptyString(answerGroundingCheck?.status);
|
||||
return groundingStatus === "grounded";
|
||||
}
|
||||
function isGroundedInventoryContextDebug(debug, toNonEmptyString) {
|
||||
if (!isGroundedAddressDebug(debug, toNonEmptyString)) {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(debug?.detected_intent);
|
||||
const capabilityId = toNonEmptyString(debug?.capability_id);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
const rootIntent = toNonEmptyString(rootFrameContext?.root_intent);
|
||||
return (detectedIntent === "inventory_on_hand_as_of_date" ||
|
||||
capabilityId === "confirmed_inventory_on_hand_as_of_date" ||
|
||||
rootIntent === "inventory_on_hand_as_of_date");
|
||||
}
|
||||
function resolveAssistantContinuitySnapshot(input) {
|
||||
const toNonEmptyString = input.toNonEmptyString ?? fallbackToNonEmptyString;
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
let lastGroundedAddressDebug = null;
|
||||
let lastGroundedItemAddressDebug = null;
|
||||
let lastGroundedInventoryAddressDebug = null;
|
||||
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||
const item = sessionItems[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (!isGroundedAddressDebug(debug, toNonEmptyString)) {
|
||||
continue;
|
||||
}
|
||||
if (!lastGroundedAddressDebug) {
|
||||
lastGroundedAddressDebug = debug;
|
||||
}
|
||||
if (!lastGroundedItemAddressDebug && readAddressDebugItem(debug, toNonEmptyString)) {
|
||||
lastGroundedItemAddressDebug = debug;
|
||||
}
|
||||
if (!lastGroundedInventoryAddressDebug && isGroundedInventoryContextDebug(debug, toNonEmptyString)) {
|
||||
lastGroundedInventoryAddressDebug = debug;
|
||||
}
|
||||
if (lastGroundedAddressDebug && lastGroundedItemAddressDebug && lastGroundedInventoryAddressDebug) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const primaryDebug = lastGroundedItemAddressDebug ?? lastGroundedAddressDebug;
|
||||
return {
|
||||
lastGroundedAddressDebug,
|
||||
lastGroundedItemAddressDebug,
|
||||
lastGroundedInventoryAddressDebug,
|
||||
activeItem: readAddressDebugItem(primaryDebug, toNonEmptyString),
|
||||
activeOrganization: readAddressDebugOrganization(primaryDebug, toNonEmptyString),
|
||||
activeScopedDate: readAddressDebugScopedDate(primaryDebug),
|
||||
hasGroundedAddressContext: Boolean(lastGroundedAddressDebug),
|
||||
hasGroundedItemContext: Boolean(lastGroundedItemAddressDebug),
|
||||
hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug)
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createAssistantDataScopePolicy = createAssistantDataScopePolicy;
|
||||
// @ts-nocheck
|
||||
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
|
||||
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
|
||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||
function normalizeScopeLabel(value) {
|
||||
return String(value ?? "")
|
||||
@@ -235,6 +236,12 @@ function createAssistantDataScopePolicy(deps) {
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
if ((0, assistantContinuityPolicy_1.isGroundedAddressDebug)(debug)) {
|
||||
const groundedOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)((0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug));
|
||||
if (groundedOrganization) {
|
||||
return groundedOrganization;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ async function runAssistantLivingChatRuntime(input) {
|
||||
chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
|
||||
@@ -108,7 +108,7 @@ function createAssistantLivingModePolicy(deps) {
|
||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
|
||||
if (!hasMemoryCue || !(hasDiscussionCue || hasExplicitRecapPrompt)) {
|
||||
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitRecapPrompt) {
|
||||
|
||||
+125
-93
@@ -5,13 +5,13 @@ exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapa
|
||||
exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply;
|
||||
exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext;
|
||||
exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy;
|
||||
function formatIsoDateForReply(value) {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
|
||||
function toNonEmptyString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
function collectMessageSamples(input) {
|
||||
const values = [
|
||||
@@ -30,89 +30,17 @@ function hasSignalAcrossSamples(samples, detector) {
|
||||
function hasExplicitRecapPromptSignal(samples) {
|
||||
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(sample));
|
||||
}
|
||||
function findLastGroundedInventoryAddressDebug(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
|
||||
? debug.answer_grounding_check
|
||||
: null;
|
||||
const groundingStatus = String(answerGroundingCheck?.status ?? "");
|
||||
const detectedIntent = String(debug.detected_intent ?? "");
|
||||
const capabilityId = String(debug.capability_id ?? "");
|
||||
const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object"
|
||||
? debug.address_root_frame_context
|
||||
: null;
|
||||
const rootIntent = String(rootFrameContext?.root_intent ?? "");
|
||||
const isInventoryContext = detectedIntent === "inventory_on_hand_as_of_date" ||
|
||||
capabilityId === "confirmed_inventory_on_hand_as_of_date" ||
|
||||
rootIntent === "inventory_on_hand_as_of_date";
|
||||
if (groundingStatus === "grounded" && isInventoryContext) {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findLastAddressDebugWithItem(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (String(debug.execution_lane ?? "") !== "address_query") {
|
||||
continue;
|
||||
}
|
||||
const extractedFilters = debug.extracted_filters && typeof debug.extracted_filters === "object"
|
||||
? debug.extracted_filters
|
||||
: null;
|
||||
const itemLabel = String(extractedFilters?.item ?? "").trim() ||
|
||||
(String(debug.anchor_type ?? "") === "item"
|
||||
? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim()
|
||||
: "");
|
||||
if (itemLabel) {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findLastAddressDebug(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (String(item.debug.execution_lane ?? "") === "address_query") {
|
||||
return item.debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||
const rootFrameContext = input.addressDebug?.address_root_frame_context &&
|
||||
typeof input.addressDebug.address_root_frame_context === "object"
|
||||
? input.addressDebug.address_root_frame_context
|
||||
: null;
|
||||
const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
||||
? input.addressDebug.extracted_filters
|
||||
: null;
|
||||
const extractedFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(input.addressDebug);
|
||||
const organization = input.organization ??
|
||||
input.toNonEmptyString(rootFrameContext?.organization) ??
|
||||
input.toNonEmptyString(extractedFilters?.organization);
|
||||
const lastAsOfDate = formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
||||
formatIsoDateForReply(extractedFilters?.as_of_date);
|
||||
const lastAsOfDate = (0, assistantContinuityPolicy_1.formatIsoDateForReply)(rootFrameContext?.as_of_date) ??
|
||||
(0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.as_of_date);
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
const referenceLine = lastAsOfDate
|
||||
? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.`
|
||||
@@ -128,6 +56,93 @@ function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||
"Если хочешь, сразу покажу нужный исторический период."
|
||||
].join("\n");
|
||||
}
|
||||
function normalizeRecapIdentity(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[«»"'`]/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
function buildRecapFactLine(input) {
|
||||
const detectedIntent = String(input.debug?.detected_intent ?? "");
|
||||
const scopedDate = (0, assistantContinuityPolicy_1.readAddressDebugScopedDate)(input.debug);
|
||||
const itemPart = input.item ? `по позиции «${input.item}»` : null;
|
||||
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
|
||||
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
||||
if (detectedIntent === "inventory_on_hand_as_of_date") {
|
||||
return `смотрели остатки${organizationPart ? ` ${organizationPart}` : ""}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_provenance_for_item" && itemPart) {
|
||||
return `разобрали, кто поставлял ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_documents_for_item" && itemPart) {
|
||||
return `подняли документы закупки ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_sale_trace_for_item" && itemPart) {
|
||||
return `разобрали, кому продавали ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_to_sale_chain" && itemPart) {
|
||||
return `проследили цепочку от закупки до продажи ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_profitability_for_item" && itemPart) {
|
||||
return `смотрели рентабельность ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_aging_by_purchase_date" && itemPart) {
|
||||
return `смотрели возраст остатков ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) {
|
||||
return `смотрели активность в базе 1С ${organizationPart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "list_documents_by_counterparty" && organizationPart) {
|
||||
return `поднимали документы ${organizationPart}${datePart}`.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function collectRecentRecapFacts(input) {
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
if (sessionItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const currentItemKey = normalizeRecapIdentity(input.item);
|
||||
const currentOrganizationKey = normalizeRecapIdentity(input.organization);
|
||||
const facts = [];
|
||||
const seen = new Set();
|
||||
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||
const item = sessionItems[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (!(0, assistantContinuityPolicy_1.isGroundedAddressDebug)(item.debug, input.toNonEmptyString)) {
|
||||
continue;
|
||||
}
|
||||
const debugItem = (0, assistantContinuityPolicy_1.readAddressDebugItem)(item.debug, input.toNonEmptyString);
|
||||
const debugOrganization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(item.debug, input.toNonEmptyString);
|
||||
const itemMatches = currentItemKey ? normalizeRecapIdentity(debugItem) === currentItemKey : false;
|
||||
const organizationMatches = currentOrganizationKey
|
||||
? normalizeRecapIdentity(debugOrganization) === currentOrganizationKey
|
||||
: false;
|
||||
if (currentItemKey && !itemMatches) {
|
||||
continue;
|
||||
}
|
||||
if (!currentItemKey && currentOrganizationKey && !organizationMatches) {
|
||||
continue;
|
||||
}
|
||||
const fact = buildRecapFactLine({
|
||||
debug: item.debug,
|
||||
item: debugItem,
|
||||
organization: debugOrganization
|
||||
});
|
||||
if (!fact || seen.has(fact)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(fact);
|
||||
facts.push(fact);
|
||||
if (facts.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return facts.reverse();
|
||||
}
|
||||
function buildAddressMemoryRecapReply(input) {
|
||||
const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
||||
? input.addressDebug.extracted_filters
|
||||
@@ -136,18 +151,27 @@ function buildAddressMemoryRecapReply(input) {
|
||||
typeof input.addressDebug.address_root_frame_context === "object"
|
||||
? input.addressDebug.address_root_frame_context
|
||||
: null;
|
||||
const item = input.toNonEmptyString(extractedFilters?.item) ??
|
||||
(String(input.addressDebug?.anchor_type ?? "") === "item"
|
||||
? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ??
|
||||
input.toNonEmptyString(input.addressDebug?.anchor_value_raw)
|
||||
: null);
|
||||
const item = (0, assistantContinuityPolicy_1.readAddressDebugItem)(input.addressDebug, input.toNonEmptyString);
|
||||
const organization = input.organization ??
|
||||
input.toNonEmptyString(extractedFilters?.organization) ??
|
||||
input.toNonEmptyString(rootFrameContext?.organization);
|
||||
const scopedDate = formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
||||
formatIsoDateForReply(extractedFilters?.period_to);
|
||||
const scopedDate = (0, assistantContinuityPolicy_1.readAddressDebugScopedDate)(input.addressDebug);
|
||||
const recapFacts = collectRecentRecapFacts({
|
||||
sessionItems: input.sessionItems,
|
||||
item,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
if (item) {
|
||||
if (recapFacts.length > 0) {
|
||||
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
return [
|
||||
`Да, помню. По позиции «${item}»${organizationPart}${datePart} мы уже выяснили:`,
|
||||
...recapFacts.map((fact) => `- ${fact}.`),
|
||||
"Могу сразу продолжить по ней: поставщик, закупка, документы или продажа."
|
||||
].join("\n");
|
||||
}
|
||||
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
return [
|
||||
@@ -168,21 +192,29 @@ function buildAddressMemoryRecapReply(input) {
|
||||
function resolveAssistantLivingChatMemoryContext(input) {
|
||||
const contextualInventoryHistoryCapabilityFollowup = String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
||||
const contextualMemoryRecapFollowup = String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected";
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
const continuity = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString
|
||||
});
|
||||
return {
|
||||
contextualInventoryHistoryCapabilityFollowup,
|
||||
contextualMemoryRecapFollowup,
|
||||
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
||||
? findLastGroundedInventoryAddressDebug(sessionItems)
|
||||
? continuity.lastGroundedInventoryAddressDebug
|
||||
: null,
|
||||
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
||||
? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems)
|
||||
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
||||
: null
|
||||
};
|
||||
}
|
||||
function createAssistantMemoryRecapPolicy(deps) {
|
||||
function resolveRouteMemorySignals(input) {
|
||||
const samples = collectMessageSamples(input);
|
||||
const continuity = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString
|
||||
});
|
||||
const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug;
|
||||
const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal);
|
||||
const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal);
|
||||
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
|
||||
@@ -191,13 +223,13 @@ function createAssistantMemoryRecapPolicy(deps) {
|
||||
!input.dataScopeMetaQuery &&
|
||||
!input.dataRetrievalSignal &&
|
||||
historicalCapabilitySignal &&
|
||||
deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)),
|
||||
deps.isGroundedInventoryContextDebug(groundedInventoryContext)),
|
||||
contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery &&
|
||||
!input.capabilityMetaQuery &&
|
||||
!input.aggregateBusinessAnalyticsSignal &&
|
||||
memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
input.hasPriorAddressDebug)
|
||||
continuity.hasGroundedAddressContext)
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
+176
-10
@@ -102,10 +102,16 @@ function createAssistantRoutePolicy(deps) {
|
||||
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
||||
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
||||
hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
|
||||
const analyticsSample = compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`.toLowerCase());
|
||||
const colloquialCustomerValueSignal = /(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|customer|client|counterparty)/iu.test(analyticsSample) &&
|
||||
/(?:(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433)|(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433).*(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441)|(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433))/iu.test(analyticsSample);
|
||||
const turnoverAggregateSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(analyticsSample);
|
||||
const aggregateBusinessAnalyticsBridgeDetected = colloquialCustomerValueSignal || turnoverAggregateSignal;
|
||||
const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
|
||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) ||
|
||||
aggregateBusinessAnalyticsBridgeDetected;
|
||||
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
||||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
||||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
||||
@@ -195,15 +201,20 @@ function createAssistantRoutePolicy(deps) {
|
||||
!capabilityMetaQuery &&
|
||||
!dataRetrievalSignal);
|
||||
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
||||
!capabilityMetaQuery &&
|
||||
!dataRetrievalSignal &&
|
||||
!aggregateBusinessAnalyticsSignal &&
|
||||
!effectiveAddressFollowupSignal &&
|
||||
resolvedModeDetection.mode === "unsupported" &&
|
||||
resolvedIntentResolution.intent === "unknown");
|
||||
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
|
||||
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")));
|
||||
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
|
||||
deterministicNonDomainGuard &&
|
||||
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
||||
!baseToolGatePreservesAddressLane &&
|
||||
!protectedInventoryShortFollowup &&
|
||||
!organizationClarificationContinuationDetected);
|
||||
const lastAddressAssistantDebug = sessionItems
|
||||
@@ -220,7 +231,8 @@ function createAssistantRoutePolicy(deps) {
|
||||
strongDataSignal,
|
||||
aggregateBusinessAnalyticsSignal,
|
||||
lastGroundedAddressDebug,
|
||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug)
|
||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug),
|
||||
sessionItems
|
||||
});
|
||||
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
||||
const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected;
|
||||
@@ -392,6 +404,143 @@ function createAssistantRoutePolicy(deps) {
|
||||
}
|
||||
};
|
||||
}
|
||||
const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" &&
|
||||
semanticApplyCanonicalRecommended &&
|
||||
strictDeepInvestigationBypassAllowed &&
|
||||
llmContractIntent &&
|
||||
ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent));
|
||||
if (supportedExactInvestigativeAddressBypass) {
|
||||
return {
|
||||
runAddressLane: true,
|
||||
toolGateDecision: "run_address_lane",
|
||||
toolGateReason: "address_signal_detected",
|
||||
livingMode: "address_data",
|
||||
livingReason: "address_lane_triggered",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: true,
|
||||
strict_deep_investigation_bypass_allowed: true,
|
||||
semantic_deep_investigation_hint_detected: semanticExtractionContract?.guard_hints?.deep_investigation_signal_detected === true,
|
||||
semantic_aggregate_shape_detected: false,
|
||||
followup_semantic_override_to_deep_allowed: false
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: false,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: true,
|
||||
tool_gate_decision: "run_address_lane",
|
||||
tool_gate_reason: "address_signal_detected",
|
||||
living_mode: "address_data",
|
||||
living_reason: "address_lane_triggered"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (colloquialCustomerValueSignal) {
|
||||
return {
|
||||
runAddressLane: true,
|
||||
toolGateDecision: "run_address_lane",
|
||||
toolGateReason: "address_signal_detected",
|
||||
livingMode: "address_data",
|
||||
livingReason: "address_lane_triggered",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: true,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: true,
|
||||
strict_deep_investigation_bypass_allowed: false,
|
||||
semantic_deep_investigation_hint_detected: false,
|
||||
semantic_aggregate_shape_detected: true,
|
||||
followup_semantic_override_to_deep_allowed: false
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: false,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: true,
|
||||
tool_gate_decision: "run_address_lane",
|
||||
tool_gate_reason: "address_signal_detected",
|
||||
living_mode: "address_data",
|
||||
living_reason: "address_lane_triggered"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
const standaloneAggregateAnalyticsFollowupFallback = Boolean(followupContext &&
|
||||
aggregateBusinessAnalyticsBridgeDetected &&
|
||||
llmContractMode === null &&
|
||||
!((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||
openContractsAddressSignal));
|
||||
if (standaloneAggregateAnalyticsFollowupFallback) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
toolGateDecision: "skip_address_lane",
|
||||
toolGateReason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
livingMode: "deep_analysis",
|
||||
livingReason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: false,
|
||||
strict_deep_investigation_bypass_allowed: false,
|
||||
semantic_deep_investigation_hint_detected: false,
|
||||
semantic_aggregate_shape_detected: true,
|
||||
followup_semantic_override_to_deep_allowed: true
|
||||
},
|
||||
followup_context_detected: true,
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: true,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: false,
|
||||
tool_gate_decision: "skip_address_lane",
|
||||
tool_gate_reason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
living_mode: "deep_analysis",
|
||||
living_reason: "aggregate_analytics_signal_fallback_to_deep"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (nonDomainQueryIndexed) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
@@ -422,7 +571,6 @@ function createAssistantRoutePolicy(deps) {
|
||||
};
|
||||
}
|
||||
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
||||
llmPreDecomposeMeta?.applied &&
|
||||
llmContractMode === "address_query") ||
|
||||
@@ -447,6 +595,12 @@ function createAssistantRoutePolicy(deps) {
|
||||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||
openContractsAddressSignal);
|
||||
const supportedAddressRouteCandidateDetected = Boolean(supportedAddressIntentDetected ||
|
||||
keepAddressLaneByIntent ||
|
||||
(baseToolGate?.runAddressLane &&
|
||||
llmContractMode !== "deep_analysis" &&
|
||||
!strictDeepInvestigationCueDetected &&
|
||||
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? ""))));
|
||||
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
|
||||
typeof semanticExtractionContract.guard_hints === "object"
|
||||
? semanticExtractionContract.guard_hints
|
||||
@@ -481,8 +635,7 @@ function createAssistantRoutePolicy(deps) {
|
||||
semanticDeepInvestigationHintDetected ||
|
||||
aggregateBusinessAnalyticsSignal) &&
|
||||
!preserveAddressLaneSignal &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!supportedAddressRouteCandidateDetected &&
|
||||
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
||||
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
||||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
||||
@@ -492,6 +645,19 @@ function createAssistantRoutePolicy(deps) {
|
||||
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
||||
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
||||
hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage));
|
||||
const exactAddressIntentProtectedFromSemanticDeepHint = Boolean(supportedAddressRouteCandidateDetected &&
|
||||
llmContractMode === "address_query" &&
|
||||
llmContractIntent &&
|
||||
ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent) &&
|
||||
semanticApplyCanonicalRecommended &&
|
||||
!deepAnalysisPreferenceDetected &&
|
||||
!strictDeepInvestigationCueDetected &&
|
||||
!semanticAggregateShapeDetected);
|
||||
const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected &&
|
||||
!deepAnalysisPreferenceDetected &&
|
||||
(exactAddressIntentProtectedFromSemanticDeepHint ||
|
||||
!semanticDeepInvestigationHintDetected ||
|
||||
strictDeepInvestigationBypassAllowed));
|
||||
const vatExplainFollowupSignal = Boolean(followupContext &&
|
||||
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
|
||||
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
|
||||
@@ -501,17 +667,16 @@ function createAssistantRoutePolicy(deps) {
|
||||
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||
!llmRuntimeUnavailableDetected &&
|
||||
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!protectAddressLaneFromFallback &&
|
||||
!vatExplainFollowupSignal &&
|
||||
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
||||
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||
!llmRuntimeUnavailableDetected &&
|
||||
aggregateBusinessAnalyticsSignal &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!protectAddressLaneFromFallback &&
|
||||
(!followupContext ||
|
||||
llmContractMode === "unsupported" ||
|
||||
llmContractMode === null ||
|
||||
semanticAggregateShapeDetected ||
|
||||
!semanticApplyCanonicalRecommended ||
|
||||
standaloneAddressTopicSignal));
|
||||
@@ -631,10 +796,11 @@ function createAssistantRoutePolicy(deps) {
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
||||
supported_address_intent_detected: supportedAddressRouteCandidateDetected,
|
||||
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||
exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint,
|
||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
|
||||
@@ -174,7 +174,7 @@ function intersectsSpan(start, end, spans) {
|
||||
function hasAccountContextAround(text, start, end) {
|
||||
const left = text.slice(Math.max(0, start - 28), start);
|
||||
const right = text.slice(end, Math.min(text.length, end + 28));
|
||||
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`);
|
||||
return /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(`${left} ${right}`);
|
||||
}
|
||||
function extractAccountsFromTextDetailed(text, options) {
|
||||
const lower = String(text ?? "").toLowerCase();
|
||||
@@ -184,8 +184,8 @@ function extractAccountsFromTextDetailed(text, options) {
|
||||
const percentSpans = collectPercentLikeSpans(lower);
|
||||
const contractSpans = collectContractLikeSpans(lower);
|
||||
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
|
||||
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(lower);
|
||||
const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
|
||||
const hasAccountingLexeme = /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(lower);
|
||||
const contextualPattern = /(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:\d{2})(?:\.\d{2})?)/giu;
|
||||
let contextualMatch = null;
|
||||
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
||||
const token = String(contextualMatch[1] ?? "").trim();
|
||||
@@ -811,7 +811,15 @@ function resolveDomainPolarityGuard(input) {
|
||||
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
||||
const lower = repairedMessage.toLowerCase();
|
||||
const accountExtraction = extractAccountsFromTextDetailed(lower);
|
||||
const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]);
|
||||
let accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]);
|
||||
if (accounts.length === 0) {
|
||||
const directAccountMentions = Array.from(repairedMessage.matchAll(/(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:60|62|76)(?:\.\d{2})?)/giu))
|
||||
.map((match) => String(match[1] ?? "").trim())
|
||||
.filter((token) => token.length > 0);
|
||||
if (directAccountMentions.length > 0) {
|
||||
accounts = uniqueStrings([...accounts, ...directAccountMentions]);
|
||||
}
|
||||
}
|
||||
const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item) => Boolean(item)));
|
||||
const settlementSignal = input.focusDomainHint === "settlements_60_62" ||
|
||||
prefixes.has("60") ||
|
||||
|
||||
+16
-2
@@ -3701,6 +3701,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
? llmPreDecomposeMeta.semanticExtractionContract
|
||||
: null;
|
||||
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
|
||||
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
|
||||
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
|
||||
semanticCanonicalRecommended;
|
||||
const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
|
||||
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
|
||||
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
||||
@@ -3713,6 +3716,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
Boolean(llmPreDecomposeMeta?.applied) &&
|
||||
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
||||
hasStrongDataIntentSignal(repairedInputMessage);
|
||||
const hasBusinessRankingAddressSignal = /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433)?|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
|
||||
const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) ||
|
||||
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
||||
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
||||
@@ -3721,11 +3725,12 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
hasAccountingSignal(repairedInputMessage) ||
|
||||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
||||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
||||
hasBusinessRankingAddressSignal ||
|
||||
sameDateAccountFollowupSignal;
|
||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||
llmContractIntent === "unknown";
|
||||
const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
||||
const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal;
|
||||
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
||||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
||||
hasAccountingSignal(rawMessageForGate) ||
|
||||
@@ -3739,6 +3744,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
!hasClassifierSignal &&
|
||||
!hasIntentSignal &&
|
||||
!hasLexicalAddressSignal &&
|
||||
!llmSupportedDeepAddressIntentSignal &&
|
||||
!strongDataSignalFromRawMessage &&
|
||||
!strongDataSignalFromEffectiveMessage) {
|
||||
return {
|
||||
@@ -3749,6 +3755,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
}
|
||||
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
||||
!hasAnyAddressSignal &&
|
||||
!llmSupportedDeepAddressIntentSignal &&
|
||||
!strongDataSignalFromRawMessage &&
|
||||
!strongDataSignalFromEffectiveMessage) {
|
||||
return {
|
||||
@@ -3770,7 +3777,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
? "llm_canonical_candidate_detected"
|
||||
: hasLlmCanonicalDataSignal
|
||||
? "llm_canonical_data_signal_detected"
|
||||
: "address_signal_detected"
|
||||
: llmSupportedDeepAddressIntentSignal
|
||||
? "address_signal_detected"
|
||||
: "address_signal_detected"
|
||||
};
|
||||
}
|
||||
if (followupContext) {
|
||||
@@ -3886,6 +3895,11 @@ function hasAggregateBusinessAnalyticsSignal(text) {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasTurnoverByEntityBridgeSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(normalized) &&
|
||||
/(?:\b\u043f\u043e\s+[\p{L}\d._-]{3,}|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|customer|supplier|counterparty|company)/iu.test(normalized);
|
||||
if (hasTurnoverByEntityBridgeSignal) {
|
||||
return true;
|
||||
}
|
||||
const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized);
|
||||
if (!hasMetricCue) {
|
||||
return false;
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
|
||||
function createAssistantTransitionPolicy(deps) {
|
||||
function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
|
||||
if (!hasInventoryRootFrame) {
|
||||
return false;
|
||||
}
|
||||
const normalized = deps
|
||||
.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase())
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (deps.countTokens(normalized) > 10) {
|
||||
return false;
|
||||
}
|
||||
const hasInventoryLexeme = /(?:остат|склад|товар|номенклатур|позиц)/iu.test(normalized);
|
||||
const hasRestatementCue = /(?:еще\s+раз|снова|повтори|повтори\s+еще\s+раз|верни|покажи)/iu.test(normalized) &&
|
||||
/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test(normalized);
|
||||
const hasBareSnapshotSameDateCue = hasInventoryLexeme &&
|
||||
/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test(normalized);
|
||||
const bareSnapshotSameDatePhrases = [
|
||||
"\u043d\u0430 \u0442\u0443 \u0436\u0435 \u0434\u0430\u0442",
|
||||
"\u043d\u0430 \u044d\u0442\u0443 \u0436\u0435 \u0434\u0430\u0442",
|
||||
"\u043d\u0430 \u044d\u0442\u0443 \u0434\u0430\u0442",
|
||||
"\u044d\u0442\u0443 \u0434\u0430\u0442",
|
||||
"\u0442\u0430 \u0436\u0435 \u0434\u0430\u0442\u0430",
|
||||
"\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u044d\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434"
|
||||
];
|
||||
const hasBareSnapshotSameDatePhraseCue = hasInventoryLexeme && bareSnapshotSameDatePhrases.some((phrase) => normalized.includes(phrase));
|
||||
return (hasInventoryLexeme &&
|
||||
(hasRestatementCue || hasBareSnapshotSameDateCue || hasBareSnapshotSameDatePhraseCue) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(normalized));
|
||||
}
|
||||
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
|
||||
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
if (!normalized || deps.countTokens(normalized) > 4) {
|
||||
@@ -16,6 +48,62 @@ function createAssistantTransitionPolicy(deps) {
|
||||
}
|
||||
return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized);
|
||||
}
|
||||
function shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent) {
|
||||
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
if (!normalized || deps.countTokens(normalized) > 4) {
|
||||
return false;
|
||||
}
|
||||
if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") {
|
||||
return false;
|
||||
}
|
||||
if (/(?:bank|payment|wire|statement)/iu.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^(?:а|и|ну)?\s*(?:покажи\s+)?по\s+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized);
|
||||
}
|
||||
function inferStandaloneAddressTopicFamily(userMessage) {
|
||||
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:ндс|vat)/iu.test(normalized)) {
|
||||
return "vat";
|
||||
}
|
||||
if (/(?:остат|склад|товар|номенклатур|позици)/iu.test(normalized)) {
|
||||
return "inventory";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function resolveDisplayedEntityRetargetIntent(userMessage, entityType) {
|
||||
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (entityType === "counterparty") {
|
||||
if (/(?:договор|контракт)/iu.test(normalized)) {
|
||||
return "list_contracts_by_counterparty";
|
||||
}
|
||||
if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) {
|
||||
return "bank_operations_by_counterparty";
|
||||
}
|
||||
if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) {
|
||||
return "list_documents_by_counterparty";
|
||||
}
|
||||
if (/(?:сколько\s+денег|сколько\s+принес|выручк|сумм[аы]?|оплатил|продаж)/iu.test(normalized)) {
|
||||
return "customer_revenue_and_payments";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (entityType === "contract") {
|
||||
if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) {
|
||||
return "bank_operations_by_contract";
|
||||
}
|
||||
if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) {
|
||||
return "list_documents_by_contract";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
|
||||
const previousAddressItem = deps.findLastAddressAssistantItem(items);
|
||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||
@@ -78,6 +166,10 @@ function createAssistantTransitionPolicy(deps) {
|
||||
const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage)
|
||||
? deps.hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
|
||||
: false;
|
||||
const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame));
|
||||
const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage)
|
||||
? hasInventoryRootRestatementLikeSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
|
||||
: false;
|
||||
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
|
||||
hasAlternateIndexReferenceSignal ||
|
||||
hasOrganizationClarificationContinuation ||
|
||||
@@ -86,6 +178,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||
inventoryShortFollowupAlternate ||
|
||||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
deps.hasFollowupMarker(userMessage) ||
|
||||
deps.hasReferentialPointer(userMessage) ||
|
||||
@@ -100,6 +194,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||
!hasAlternateFollowupSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal) {
|
||||
@@ -109,6 +205,8 @@ function createAssistantTransitionPolicy(deps) {
|
||||
!hasAlternateFollowupSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal) {
|
||||
@@ -131,8 +229,38 @@ function createAssistantTransitionPolicy(deps) {
|
||||
? resolvedAlternateIntent
|
||||
: null;
|
||||
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
||||
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent);
|
||||
if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) {
|
||||
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent) ??
|
||||
inferStandaloneAddressTopicFamily(userMessage) ??
|
||||
(deps.toNonEmptyString(alternateMessage) ? inferStandaloneAddressTopicFamily(String(alternateMessage ?? "")) : null);
|
||||
const foreignAccountingPivotOverInventory = deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage);
|
||||
const familyMismatchStandaloneTopic = Boolean(hasStandaloneAddressTopic &&
|
||||
sourceIntentFamily &&
|
||||
explicitIntentFamily &&
|
||||
sourceIntentFamily !== explicitIntentFamily &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasIndexReferenceSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!inventoryShortFollowupPrimary &&
|
||||
!inventoryShortFollowupAlternate &&
|
||||
!foreignAccountingPivotOverInventory &&
|
||||
!deps.hasFollowupMarker(userMessage) &&
|
||||
!deps.hasReferentialPointer(userMessage) &&
|
||||
(!deps.toNonEmptyString(alternateMessage)
|
||||
? true
|
||||
: !deps.hasFollowupMarker(String(alternateMessage ?? "")) &&
|
||||
!deps.hasReferentialPointer(String(alternateMessage ?? ""))));
|
||||
if (familyMismatchStandaloneTopic) {
|
||||
return null;
|
||||
}
|
||||
if (sourceIntentFamily &&
|
||||
explicitIntentFamily &&
|
||||
sourceIntentFamily !== explicitIntentFamily &&
|
||||
!hasStrongFollowupReference &&
|
||||
!foreignAccountingPivotOverInventory) {
|
||||
return null;
|
||||
}
|
||||
let previousIntent = sourceIntent;
|
||||
@@ -144,7 +272,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
||||
: null;
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent);
|
||||
if (suggestedIntent && !keepPreviousIntent) {
|
||||
previousIntent = suggestedIntent;
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
@@ -248,6 +376,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||
: "generic"
|
||||
: null;
|
||||
let resolvedCounterpartyFromDisplay = false;
|
||||
let displayedEntityTargetIntent = null;
|
||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
||||
const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" ||
|
||||
@@ -314,7 +443,15 @@ function createAssistantTransitionPolicy(deps) {
|
||||
currentFrameKind === "inventory_root") &&
|
||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
|
||||
const inventoryRootRestatementPivot = Boolean(inventoryRootFrame &&
|
||||
(deps.isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
deps.isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
currentFrameKind === "inventory_drilldown" ||
|
||||
currentFrameKind === "inventory_root" ||
|
||||
currentFrameKind === "generic") &&
|
||||
(hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot || inventoryRootRestatementPivot;
|
||||
if (rootScopedPivot) {
|
||||
previousIntent = null;
|
||||
previousAnchorType = null;
|
||||
@@ -330,6 +467,7 @@ function createAssistantTransitionPolicy(deps) {
|
||||
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||
: null);
|
||||
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
||||
displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(userMessage, resolvedEntityFromFollowup.entityType);
|
||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||
previousAnchorType = "counterparty";
|
||||
@@ -396,11 +534,13 @@ function createAssistantTransitionPolicy(deps) {
|
||||
inventoryShortFollowupAlternate ||
|
||||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
hasSelectedObjectInventorySignalPrimary ||
|
||||
hasSelectedObjectInventorySignalAlternate));
|
||||
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
|
||||
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
|
||||
: explicitIntent ?? previousIntent ?? undefined;
|
||||
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
|
||||
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
|
||||
return {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
@@ -430,14 +570,15 @@ function createAssistantTransitionPolicy(deps) {
|
||||
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||
const rootContextOnly = selectionMode === "carry_root_context";
|
||||
const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null;
|
||||
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
||||
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
||||
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
||||
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||
: rootContextOnly
|
||||
? rootIntent ?? explicitIntent ?? null
|
||||
: explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null
|
||||
: carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||
const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
||||
const hasExplicitIntent = Boolean(explicitIntent);
|
||||
|
||||
@@ -38,10 +38,138 @@ type CounterpartyIntentDeps = {
|
||||
hasGenericAddressLookupSignal: (text: string) => boolean;
|
||||
};
|
||||
|
||||
function hasUnicodeOpenItemsAccountSignal(text: string): boolean {
|
||||
const normalized = String(text ?? "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/(?:\u0445\u0432\u043e\u0441\u0442|\u0434\u043e\u043b\u0433|\u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442|\u0432\u0438\u0441)/iu.test(normalized) &&
|
||||
/(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?\s*(?:\u2116|#)?\s*(?:60|62|76)(?:\.\d{2})?|(?:60|62|76)(?:\.\d{2})?\s*(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:\u0430|\u0443|\u043e\u043c|\u043e\u0432)?))/iu.test(
|
||||
normalized
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasUnicodeCounterpartyShipmentItemFlowSignal(text: string): boolean {
|
||||
return /(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0430\u043b)/iu.test(text) &&
|
||||
/(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text);
|
||||
}
|
||||
|
||||
function hasUnicodePassiveShipmentByCounterpartySignal(text: string): boolean {
|
||||
return (
|
||||
/(?:\u043e\u0442\u0433\u0440\u0443\u0436\u0435\u043d(?:\u044b|\u043e|\u0430)?)/iu.test(text) &&
|
||||
/(?:\u0442\u043e\u0432\u0430\u0440|\u0443\u0441\u043b\u0443\u0433|\u043f\u043e\u0437\u0438\u0446\u0438|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440)/iu.test(text) &&
|
||||
/(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c)/iu.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function hasUnicodeDocumentSignal(text: string): boolean {
|
||||
return /(?:\u0434\u043e\u043a(?:\u0443\u043c\u0435\u043d\u0442(?:\u044b|\u043e\u0432|\u0430\u043c|\u0430\u043c\u0438|\u0430\u0445)?|\u0438)?|\u0434\u043e\u043a\u0438)(?=$|[\s,.;:!?()])/iu.test(text);
|
||||
}
|
||||
|
||||
function hasUnicodeLikelyCounterpartyAfterBy(text: string): boolean {
|
||||
const match = text.match(/(?:^|[\s(])\u043f\u043e\s+([\p{L}\d][\p{L}\d._-]{1,})(?=$|[\s,.;:!?()])/iu);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const token = String(match[1] ?? "").toLowerCase();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const stopWords = new Set([
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u043c",
|
||||
"\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438",
|
||||
"\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u044e",
|
||||
"\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438",
|
||||
"\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e",
|
||||
"\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0443",
|
||||
"\u0434\u043e\u0433\u043e\u0432\u043e\u0440\u043e\u043c",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u0443",
|
||||
"\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u043e\u043c",
|
||||
"\u0441\u0447\u0435\u0442\u0443",
|
||||
"\u0441\u0447\u0451\u0442\u0443",
|
||||
"\u0441\u0447\u0435\u0442\u043e\u043c",
|
||||
"\u0441\u0447\u0451\u0442\u043e\u043c",
|
||||
"\u0434\u0430\u0442\u0435",
|
||||
"\u0434\u0430\u0442\u0443",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434\u0443",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u0441\u043a\u043b\u0430\u0434\u0443",
|
||||
"\u0441\u043a\u043b\u0430\u0434\u0435",
|
||||
"\u0431\u0430\u043d\u043a\u0443",
|
||||
"\u0431\u0430\u043d\u043a\u0435",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b",
|
||||
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0443"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
|
||||
export function resolveCounterpartyAddressIntent(
|
||||
text: string,
|
||||
deps: CounterpartyIntentDeps
|
||||
): AddressIntentResolution | null {
|
||||
if (hasUnicodeOpenItemsAccountSignal(text)) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:хвост|долг|незакрыт|вис)/iu.test(text) &&
|
||||
/(?:счету?|сч[её]ту?)\s*60|60\s*(?:счет|сч[её]т)/iu.test(text)
|
||||
) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
/отгружал/iu.test(text) &&
|
||||
/(?:товар|услуг|позици|номенклатур)/iu.test(text)
|
||||
) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
/отгружен(?:ы|о|а)?/iu.test(text) &&
|
||||
/(?:товар|услуг|позици|номенклатур)/iu.test(text) &&
|
||||
/контрагентом/iu.test(text)
|
||||
) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasUnicodeCounterpartyShipmentItemFlowSignal(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasUnicodePassiveShipmentByCounterpartySignal(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["counterparty_item_flow_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (deps.hasOpenContractsListSignal(text)) {
|
||||
return {
|
||||
intent: "open_contracts_confirmed_as_of_date",
|
||||
@@ -200,6 +328,14 @@ export function resolveCounterpartyAddressIntent(
|
||||
};
|
||||
}
|
||||
|
||||
if (hasUnicodeDocumentSignal(text) && hasUnicodeLikelyCounterpartyAfterBy(text)) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_counterparty_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(deps.hasAny(text, deps.documentsByCounterpartyHints) || deps.hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||
(deps.hasPartyAnchorMention(text) ||
|
||||
|
||||
@@ -957,6 +957,20 @@ function extractShipmentCounterpartyValue(text: string): string | undefined {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function extractInstrumentalCounterpartyValue(text: string): string | undefined {
|
||||
const match = String(text ?? "").match(
|
||||
/(?:контрагентом|поставщиком|клиентом|заказчиком)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = cleanupAnchorValue(String(match[1] ?? ""));
|
||||
if (!candidate || !isLikelyCounterpartyToken(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function hasExplicitAccountCue(text: string): boolean {
|
||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||
}
|
||||
@@ -1744,6 +1758,19 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty" ||
|
||||
intent === "list_contracts_by_counterparty")
|
||||
) {
|
||||
const instrumentalCounterparty = extractInstrumentalCounterpartyValue(text);
|
||||
if (instrumentalCounterparty) {
|
||||
filters.counterparty = cleanupAnchorValue(instrumentalCounterparty);
|
||||
warnings.push("counterparty_anchor_derived_from_instrumental_phrase");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!filters.counterparty &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,25 @@ function hasInventoryAsOfCue(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasPlainRussianInventoryOnHandSignal(text: string): boolean {
|
||||
const normalized = String(text ?? "").toLowerCase().replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasStockCue =
|
||||
/(?:остатк|склад|товар(?:ы|ов)?|номенклатур|позици|на\s+складе|по\s+складу)/iu.test(normalized);
|
||||
if (!hasStockCue) {
|
||||
return false;
|
||||
}
|
||||
const hasRequestCue =
|
||||
/(?:какие|какой|какая|что|покажи|показать|получить|дай|выведи|чекни|посмотри|есть\s+ли)/iu.test(normalized);
|
||||
const hasSnapshotCue =
|
||||
/(?:на\s+(?:дату|сегодня|сейчас|март|апрел|май|мая|июн|июл|август|сентябр|октябр|ноябр|декабр|январ|феврал)|\b(?:19|20)\d{2}\b)/iu.test(
|
||||
normalized
|
||||
);
|
||||
return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized));
|
||||
}
|
||||
|
||||
function hasInventoryOnHandSignal(text: string): boolean {
|
||||
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
|
||||
text
|
||||
@@ -26,7 +45,8 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
||||
const hasStockLexeme =
|
||||
/(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
||||
if (!hasStockLexeme && !hasAccount41Anchor) {
|
||||
const hasPlainRussianRootCue = hasPlainRussianInventoryOnHandSignal(text);
|
||||
if (!hasStockLexeme && !hasAccount41Anchor && !hasPlainRussianRootCue) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -51,6 +71,9 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||
return true;
|
||||
}
|
||||
if (hasPlainRussianRootCue) {
|
||||
return true;
|
||||
}
|
||||
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) &&
|
||||
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue);
|
||||
}
|
||||
@@ -263,6 +286,17 @@ export function resolveInventoryAddressIntent(text: string): AddressIntentResolu
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(text.includes("по этой позиции") || text.includes("по этому товару")) &&
|
||||
(text.includes("когда была закупка") || text.includes("когда купили"))
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "medium",
|
||||
reasons: ["inventory_purchase_date_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
|
||||
@@ -1816,6 +1816,32 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDeferInventoryOrganizationClarification(
|
||||
intent: AddressIntent,
|
||||
filters: AddressFilterSet,
|
||||
semanticFrame: AddressSemanticFrame | null | undefined
|
||||
): boolean {
|
||||
if (!isOrganizationScopedInventoryIntent(intent)) {
|
||||
return false;
|
||||
}
|
||||
const hasItemScope =
|
||||
Boolean(toNonEmptyFilterValue(filters.item)) ||
|
||||
semanticFrame?.anchor_kind === "item" ||
|
||||
semanticFrame?.anchor_kind === "selected_object" ||
|
||||
semanticFrame?.selected_object_scope_detected === true;
|
||||
if (hasItemScope) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
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 collectOrganizationCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
|
||||
return mergeKnownOrganizations(rows.map((row) => row.organization).filter((value): value is string => Boolean(value)));
|
||||
}
|
||||
@@ -3224,6 +3250,7 @@ export class AddressQueryService {
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&
|
||||
!activeOrganization &&
|
||||
!resolvedOrganizationFromMessage &&
|
||||
!shouldDeferInventoryOrganizationClarification(intent.intent, filters.extracted_filters, semanticFrame) &&
|
||||
knownOrganizations.length > 1
|
||||
) {
|
||||
return buildOrganizationClarificationExecutionResult({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ComposeReplyResult,
|
||||
type ComposeReplySemantics
|
||||
} from "./replyPackaging";
|
||||
import { composeCounterpartyAnalyticsReply } from "./counterpartyAnalyticsReplyBuilders";
|
||||
import { composeInventoryReply } from "./inventoryReplyBuilders";
|
||||
|
||||
export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging";
|
||||
@@ -2751,6 +2752,31 @@ function composeFactualReplyBody(
|
||||
return inventoryReply;
|
||||
}
|
||||
|
||||
const counterpartyAnalyticsReply = composeCounterpartyAnalyticsReply(intent, rows, options, {
|
||||
formatPercent,
|
||||
formatDateRu,
|
||||
formatMoneyRub,
|
||||
extractYearFromIso,
|
||||
detectCounterpartyProfileFocus,
|
||||
detectCounterpartyLifecycleFocus,
|
||||
hasCounterpartyLifecycleLongevityQuestion,
|
||||
hasCounterpartyActivityAgeQuestion,
|
||||
detectRankingLimit,
|
||||
detectValueRankingFocus,
|
||||
detectContractValueFocus,
|
||||
detectMinOpsForAvgCheck,
|
||||
extractRequestedYearFromQuestion,
|
||||
extractCounterpartyName,
|
||||
extractContractName,
|
||||
counterpartyLookupMatches,
|
||||
toUtcDayTimestamp,
|
||||
formatAgeYearsMonthsDays,
|
||||
normalizeQuestionText
|
||||
});
|
||||
if (counterpartyAnalyticsReply) {
|
||||
return counterpartyAnalyticsReply;
|
||||
}
|
||||
|
||||
if (intent === "document_type_and_account_section_profile") {
|
||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||
for (const row of rows) {
|
||||
@@ -3035,854 +3061,6 @@ function composeFactualReplyBody(
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker)!.push(row);
|
||||
}
|
||||
|
||||
const sumMarker = (marker: string): number =>
|
||||
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
|
||||
const focus = detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead =
|
||||
focus === "suppliers_only"
|
||||
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Заказчиков (только customer-роль): ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
} else if (activeCounterparties > 0) {
|
||||
lines.push(
|
||||
`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`
|
||||
);
|
||||
} else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Роли контрагентов по активности:");
|
||||
lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`);
|
||||
lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`);
|
||||
lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
|
||||
lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
} else {
|
||||
lines.push("По role-split контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST"
|
||||
);
|
||||
const activityRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"
|
||||
);
|
||||
const activityYearRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"
|
||||
);
|
||||
const byCounterparty = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
opsCount: number;
|
||||
lastPeriod: string | null;
|
||||
firstPeriod: string | null;
|
||||
firstObservedActivity: string | null;
|
||||
years: Set<number>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of activityFirstRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of activityYearRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: null,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of activityRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = hasCounterpartyActivityAgeQuestion(options.userMessage);
|
||||
const rankingLimit = detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
const scopeLabel =
|
||||
focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty =
|
||||
counterparties.find((item) => counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays =
|
||||
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel =
|
||||
observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const directLine =
|
||||
observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`;
|
||||
const lines: string[] = [directLine];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const organizationHint = normalizeOrganizationScopeValue(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce<string | null>((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce<string | null>((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set<number>();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays =
|
||||
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel =
|
||||
observedDays !== null
|
||||
? formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines: string[] = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
|
||||
"Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(
|
||||
longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
|
||||
: "По выбранному окну активности заказчики не найдены."
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan =
|
||||
item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
})
|
||||
);
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(
|
||||
longevityQuestion
|
||||
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
|
||||
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker)!.push(row);
|
||||
}
|
||||
|
||||
const sumMarker = (marker: string): number =>
|
||||
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts =
|
||||
totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
|
||||
const usageLead =
|
||||
totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран (catalog + usage aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
} else {
|
||||
lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника).");
|
||||
}
|
||||
|
||||
lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`);
|
||||
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = detectValueRankingFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = normalizeQuestionText(options.userMessage);
|
||||
|
||||
const byCounterparty = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
total: number;
|
||||
ops: number;
|
||||
maxSingle: number;
|
||||
minSingle: number;
|
||||
lastPeriod: string | null;
|
||||
}
|
||||
>();
|
||||
const byYear = new Map<
|
||||
number,
|
||||
{
|
||||
year: number;
|
||||
total: number;
|
||||
ops: number;
|
||||
maxSingle: number;
|
||||
counterparties: Set<string>;
|
||||
}
|
||||
>();
|
||||
const deals: Array<{ period: string | null; registrator: string; counterparty: string; amount: number }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
} else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set<string>([counterparty])
|
||||
});
|
||||
} else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort(
|
||||
(a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)
|
||||
);
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
|
||||
const rankedDealsTop = [...deals].sort(
|
||||
(a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "")
|
||||
);
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(
|
||||
profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name)
|
||||
);
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
|
||||
const lines: string[] = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам (bank-doc value aggregate)."
|
||||
: "Собран профиль поступлений от заказчиков (bank-doc value aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "total_flow") {
|
||||
const periodLine =
|
||||
options.periodFrom && options.periodTo
|
||||
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: summaryLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
} else {
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
} else {
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
|
||||
}
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
|
||||
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
|
||||
})
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = detectContractValueFocus(options.userMessage);
|
||||
const limit = detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map<
|
||||
string,
|
||||
{
|
||||
contract: string;
|
||||
turnover: number;
|
||||
docs: number;
|
||||
lastPeriod: string | null;
|
||||
counterparties: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const contract = extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
} else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort(
|
||||
(a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)
|
||||
);
|
||||
const rankedByDocs = [...contractRows].sort(
|
||||
(a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract)
|
||||
);
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
|
||||
const lines: string[] = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по количеству операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`
|
||||
)
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === "vat_payable_forecast") {
|
||||
const rowsByMarker = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
|
||||
+847
@@ -0,0 +1,847 @@
|
||||
import { normalizeOrganizationScopeValue } from "../assistantOrganizationMatcher";
|
||||
import type { AddressIntent } from "../../types/addressQuery";
|
||||
import { buildFactualListReply, buildFactualSummaryReply } from "./replyContracts";
|
||||
import type { ComposeFactualReplyOptions, ComposeReplyResult } from "./replyPackaging";
|
||||
import type { ComposeStageRow } from "./composeStage";
|
||||
|
||||
type CounterpartyProfileFocus =
|
||||
| "full_profile"
|
||||
| "total_only"
|
||||
| "roles_only"
|
||||
| "suppliers_only"
|
||||
| "customers_only"
|
||||
| "mixed_only";
|
||||
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
|
||||
type ValueRankingFocus =
|
||||
| "top_by_total"
|
||||
| "total_flow"
|
||||
| "top_years_by_total"
|
||||
| "top_by_ops"
|
||||
| "top_by_max_single"
|
||||
| "top_by_avg_check_min_ops"
|
||||
| "top_deals"
|
||||
| "bottom_deals";
|
||||
type ContractValueFocus = "top_by_turnover" | "bottom_by_turnover_active" | "top_by_docs";
|
||||
|
||||
interface CounterpartyActivityPoint {
|
||||
name: string;
|
||||
opsCount: number;
|
||||
lastPeriod: string | null;
|
||||
firstPeriod: string | null;
|
||||
firstObservedActivity: string | null;
|
||||
years: Set<number>;
|
||||
}
|
||||
|
||||
interface CounterpartyValuePoint {
|
||||
name: string;
|
||||
total: number;
|
||||
ops: number;
|
||||
maxSingle: number;
|
||||
minSingle: number;
|
||||
lastPeriod: string | null;
|
||||
}
|
||||
|
||||
interface CounterpartyYearPoint {
|
||||
year: number;
|
||||
total: number;
|
||||
ops: number;
|
||||
maxSingle: number;
|
||||
counterparties: Set<string>;
|
||||
}
|
||||
|
||||
interface CounterpartyDealPoint {
|
||||
period: string | null;
|
||||
registrator: string;
|
||||
counterparty: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface ContractValuePoint {
|
||||
contract: string;
|
||||
turnover: number;
|
||||
docs: number;
|
||||
lastPeriod: string | null;
|
||||
counterparties: Set<string>;
|
||||
}
|
||||
|
||||
interface CounterpartyAnalyticsReplyDeps {
|
||||
formatPercent: (value: number, total: number) => string | null;
|
||||
formatDateRu: (isoDate: string) => string;
|
||||
formatMoneyRub: (value: number) => string;
|
||||
extractYearFromIso: (value: string | null) => number | null;
|
||||
detectCounterpartyProfileFocus: (userMessage: string | null | undefined) => CounterpartyProfileFocus;
|
||||
detectCounterpartyLifecycleFocus: (userMessage: string | null | undefined) => CounterpartyLifecycleFocus;
|
||||
hasCounterpartyLifecycleLongevityQuestion: (userMessage: string | null | undefined) => boolean;
|
||||
hasCounterpartyActivityAgeQuestion: (userMessage: string | null | undefined) => boolean;
|
||||
detectRankingLimit: (userMessage: string | null | undefined, defaultLimit?: number) => number;
|
||||
detectValueRankingFocus: (userMessage: string | null | undefined) => ValueRankingFocus;
|
||||
detectContractValueFocus: (userMessage: string | null | undefined) => ContractValueFocus;
|
||||
detectMinOpsForAvgCheck: (userMessage: string | null | undefined) => number;
|
||||
extractRequestedYearFromQuestion: (userMessage: string | null | undefined) => number | null;
|
||||
extractCounterpartyName: (row: ComposeStageRow) => string | null;
|
||||
extractContractName: (row: ComposeStageRow) => string | null;
|
||||
counterpartyLookupMatches: (candidate: string | null | undefined, hint: string | null | undefined) => boolean;
|
||||
toUtcDayTimestamp: (isoDate: string | null | undefined) => number | null;
|
||||
formatAgeYearsMonthsDays: (daysRaw: number) => string;
|
||||
normalizeQuestionText: (value: string | null | undefined) => string;
|
||||
}
|
||||
|
||||
function groupRowsByMarker(rows: ComposeStageRow[]): Map<string, ComposeStageRow[]> {
|
||||
const rowsByMarker = new Map<string, ComposeStageRow[]>();
|
||||
for (const row of rows) {
|
||||
const marker = String(row.registrator ?? "").trim().toUpperCase();
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsByMarker.has(marker)) {
|
||||
rowsByMarker.set(marker, []);
|
||||
}
|
||||
rowsByMarker.get(marker)!.push(row);
|
||||
}
|
||||
return rowsByMarker;
|
||||
}
|
||||
|
||||
function formatOptionalDate(value: string | null, formatDateRu: (isoDate: string) => string): string {
|
||||
return value ? formatDateRu(value) : "дата не указана";
|
||||
}
|
||||
|
||||
export function composeCounterpartyAnalyticsReply(
|
||||
intent: AddressIntent,
|
||||
rows: ComposeStageRow[],
|
||||
options: ComposeFactualReplyOptions = {},
|
||||
deps: CounterpartyAnalyticsReplyDeps
|
||||
): ComposeReplyResult | null {
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker: string): number =>
|
||||
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
|
||||
const totalCounterparties = sumMarker("CP_TOTAL");
|
||||
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
|
||||
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
|
||||
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
|
||||
const activeUnion = sumMarker("CP_ACTIVE_UNION");
|
||||
|
||||
const customerOnly = Math.max(0, customerActive - mixedActive);
|
||||
const supplierOnly = Math.max(0, supplierActive - mixedActive);
|
||||
const resolvedActive = customerOnly + supplierOnly + mixedActive;
|
||||
const activeCounterparties = Math.max(activeUnion, resolvedActive);
|
||||
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
|
||||
|
||||
const focus = deps.detectCounterpartyProfileFocus(options.userMessage);
|
||||
const includeTotal = focus === "full_profile" || focus === "total_only";
|
||||
const includeRoles = focus === "full_profile" || focus === "roles_only";
|
||||
const directLead =
|
||||
focus === "suppliers_only"
|
||||
? `Контрагентов только в роли поставщика: ${supplierOnly}.`
|
||||
: focus === "customers_only"
|
||||
? `Контрагентов только в роли заказчика: ${customerOnly}.`
|
||||
: focus === "mixed_only"
|
||||
? `Контрагентов со смешанной ролью: ${mixedActive}.`
|
||||
: includeTotal && totalCounterparties > 0
|
||||
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
|
||||
: `Активных контрагентов по документальной активности: ${activeCounterparties}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
directLead,
|
||||
"Профиль контрагентов собран по справочнику и документальной активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (includeTotal) {
|
||||
if (totalCounterparties > 0) {
|
||||
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
|
||||
} else if (activeCounterparties > 0) {
|
||||
lines.push(`Полный итог по справочнику не получен, поэтому даю оценку по документальной активности: ${activeCounterparties}.`);
|
||||
} else {
|
||||
lines.push("По количеству контрагентов агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRoles) {
|
||||
if (resolvedActive > 0 || activeCounterparties > 0) {
|
||||
lines.push("Распределение ролей по активности:");
|
||||
lines.push(`1. Только заказчики: ${customerOnly}.`);
|
||||
lines.push(`2. Только поставщики: ${supplierOnly}.`);
|
||||
lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`);
|
||||
lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`);
|
||||
if (otherCounterparties !== null) {
|
||||
lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`);
|
||||
}
|
||||
} else {
|
||||
lines.push("По распределению ролей агрегатных строк не найдено.");
|
||||
}
|
||||
}
|
||||
|
||||
if (focus === "suppliers_only") {
|
||||
lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`);
|
||||
}
|
||||
if (focus === "customers_only") {
|
||||
lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`);
|
||||
}
|
||||
if (focus === "mixed_only") {
|
||||
lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`);
|
||||
}
|
||||
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
if (intent === "counterparty_activity_lifecycle") {
|
||||
const activityFirstRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_FIRST"
|
||||
);
|
||||
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
|
||||
const activityYearRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"
|
||||
);
|
||||
const byCounterparty = new Map<string, CounterpartyActivityPoint>();
|
||||
|
||||
for (const row of activityFirstRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!current.firstObservedActivity || (row.period ?? "") < current.firstObservedActivity) {
|
||||
current.firstObservedActivity = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of activityYearRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: null,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of activityRows) {
|
||||
const name = deps.extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
firstObservedActivity: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = deps.detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = deps.extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = deps.hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const activityAgeQuestion = deps.hasCounterpartyActivityAgeQuestion(options.userMessage);
|
||||
const rankingLimit = deps.detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
const scopeLabel =
|
||||
focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
: requestedYear
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
|
||||
if (activityAgeQuestion) {
|
||||
const focusedCounterparty =
|
||||
counterparties.find((item) => deps.counterpartyLookupMatches(item.name, options.counterpartyHint)) ?? null;
|
||||
if (focusedCounterparty) {
|
||||
const firstObservedActivity = focusedCounterparty.firstObservedActivity ?? focusedCounterparty.firstPeriod;
|
||||
const lastObservedActivity = focusedCounterparty.lastPeriod;
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(firstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(lastObservedActivity);
|
||||
const observedDays =
|
||||
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel =
|
||||
observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: focusedCounterparty.years.size > 0
|
||||
? `${focusedCounterparty.years.size} г.`
|
||||
: null;
|
||||
const lines: string[] = [
|
||||
observedAgeLabel && firstObservedActivity && lastObservedActivity
|
||||
? `По активности в базе 1С контрагент ${focusedCounterparty.name} наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности в базе 1С контрагент ${focusedCounterparty.name} найден в подтвержденных движениях.`
|
||||
];
|
||||
if (firstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(firstObservedActivity)}.`);
|
||||
}
|
||||
if (lastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(lastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${focusedCounterparty.opsCount}.`);
|
||||
if (focusedCounterparty.years.size > 0) {
|
||||
const years = Array.from(focusedCounterparty.years).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
const organizationHint = normalizeOrganizationScopeValue(options.organizationHint ?? null);
|
||||
if (organizationHint && counterparties.length > 0) {
|
||||
const organizationFirstObservedActivity = counterparties.reduce<string | null>((earliest, item) => {
|
||||
const candidate = item.firstObservedActivity ?? item.firstPeriod ?? null;
|
||||
if (!candidate) {
|
||||
return earliest;
|
||||
}
|
||||
if (!earliest || candidate < earliest) {
|
||||
return candidate;
|
||||
}
|
||||
return earliest;
|
||||
}, null);
|
||||
const organizationLastObservedActivity = counterparties.reduce<string | null>((latest, item) => {
|
||||
const candidate = item.lastPeriod ?? item.firstPeriod ?? item.firstObservedActivity ?? null;
|
||||
if (!candidate) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || candidate > latest) {
|
||||
return candidate;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
const organizationYears = new Set<number>();
|
||||
let organizationOpsCount = 0;
|
||||
for (const item of counterparties) {
|
||||
organizationOpsCount += item.opsCount;
|
||||
for (const year of item.years) {
|
||||
organizationYears.add(year);
|
||||
}
|
||||
}
|
||||
const firstTimestamp = deps.toUtcDayTimestamp(organizationFirstObservedActivity);
|
||||
const lastTimestamp = deps.toUtcDayTimestamp(organizationLastObservedActivity);
|
||||
const observedDays =
|
||||
firstTimestamp !== null && lastTimestamp !== null && lastTimestamp >= firstTimestamp
|
||||
? Math.floor((lastTimestamp - firstTimestamp) / 86_400_000)
|
||||
: null;
|
||||
const observedAgeLabel =
|
||||
observedDays !== null
|
||||
? deps.formatAgeYearsMonthsDays(observedDays)
|
||||
: organizationYears.size > 0
|
||||
? `${organizationYears.size} г.`
|
||||
: null;
|
||||
const lines: string[] = [
|
||||
observedAgeLabel && organizationFirstObservedActivity && organizationLastObservedActivity
|
||||
? `По активности организации ${organizationHint} в базе 1С наблюдается минимум ${observedAgeLabel}.`
|
||||
: `По активности организации ${organizationHint} в базе 1С найдены подтвержденные движения.`
|
||||
];
|
||||
if (organizationFirstObservedActivity) {
|
||||
lines.push(`Первая подтвержденная активность: ${deps.formatDateRu(organizationFirstObservedActivity)}.`);
|
||||
}
|
||||
if (organizationLastObservedActivity) {
|
||||
lines.push(`Последняя подтвержденная активность: ${deps.formatDateRu(organizationLastObservedActivity)}.`);
|
||||
}
|
||||
lines.push(`Подтвержденных операций в агрегате: ${organizationOpsCount}.`);
|
||||
if (organizationYears.size > 0) {
|
||||
const years = Array.from(organizationYears).sort((a, b) => a - b);
|
||||
lines.push(`Годы с активностью в базе: ${years.join(", ")}.`);
|
||||
}
|
||||
lines.push("Это возраст активности организации в 1С по подтвержденным движениям, а не дата регистрации юрлица.");
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества: ${counterparties.length}.`,
|
||||
"Собран профиль длительности сотрудничества по годам и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков по платежным документам.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (counterparties.length === 0) {
|
||||
lines.push(
|
||||
longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества."
|
||||
: "По выбранному окну активные заказчики не найдены."
|
||||
);
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan =
|
||||
item.firstPeriod && item.lastPeriod
|
||||
? ` | период: ${formatOptionalDate(item.firstPeriod, deps.formatDateRu)}..${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`
|
||||
: "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${deps.formatDateRu(item.lastPeriod)}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
})
|
||||
);
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
}
|
||||
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (intent === "contract_usage_overview") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
const sumMarker = (marker: string): number =>
|
||||
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
|
||||
const totalContracts = sumMarker("CT_TOTAL");
|
||||
const usedContracts = sumMarker("CT_USED");
|
||||
const unusedContracts =
|
||||
totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
|
||||
const usedShare = totalContracts > 0 ? deps.formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
|
||||
|
||||
const usageLead =
|
||||
totalContracts > 0
|
||||
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
|
||||
: `Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`;
|
||||
|
||||
const lines: string[] = [
|
||||
usageLead,
|
||||
"Профиль договорной базы собран по справочнику и подтвержденным операциям.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (totalContracts > 0) {
|
||||
lines.push(`Всего договоров в базе: ${totalContracts}.`);
|
||||
} else {
|
||||
lines.push("Общее количество договоров не получено из доступного среза справочника.");
|
||||
}
|
||||
|
||||
lines.push(`Использованных договоров с подтвержденной связью с операциями: ${usedContracts}.`);
|
||||
if (unusedContracts !== null) {
|
||||
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
|
||||
}
|
||||
if (usedShare) {
|
||||
lines.push(`Доля используемых договоров: ${usedShare}.`);
|
||||
}
|
||||
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
|
||||
const isSupplier = intent === "supplier_payouts_profile";
|
||||
const focus = deps.detectValueRankingFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
|
||||
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
|
||||
|
||||
const byCounterparty = new Map<string, CounterpartyValuePoint>();
|
||||
const byYear = new Map<number, CounterpartyYearPoint>();
|
||||
const deals: CounterpartyDealPoint[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = byCounterparty.get(counterparty);
|
||||
if (!current) {
|
||||
byCounterparty.set(counterparty, {
|
||||
name: counterparty,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
minSingle: amount,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
} else {
|
||||
current.total += amount;
|
||||
current.ops += 1;
|
||||
current.maxSingle = Math.max(current.maxSingle, amount);
|
||||
current.minSingle = Math.min(current.minSingle, amount);
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
|
||||
deals.push({
|
||||
period: row.period,
|
||||
registrator: row.registrator,
|
||||
counterparty,
|
||||
amount
|
||||
});
|
||||
|
||||
const year = deps.extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
const yearBucket = byYear.get(year);
|
||||
if (!yearBucket) {
|
||||
byYear.set(year, {
|
||||
year,
|
||||
total: amount,
|
||||
ops: 1,
|
||||
maxSingle: amount,
|
||||
counterparties: new Set<string>([counterparty])
|
||||
});
|
||||
} else {
|
||||
yearBucket.total += amount;
|
||||
yearBucket.ops += 1;
|
||||
yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount);
|
||||
yearBucket.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedByMaxSingle = [...profileRows].sort(
|
||||
(a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)
|
||||
);
|
||||
const rankedByAvgCheck = [...profileRows]
|
||||
.filter((item) => item.ops >= minOpsForAvgCheck)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
avgCheck: item.total / item.ops
|
||||
}))
|
||||
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
|
||||
const rankedDealsTop = [...deals].sort(
|
||||
(a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? "")
|
||||
);
|
||||
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
|
||||
const activeCounterpartiesForBottom = new Set(
|
||||
profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name)
|
||||
);
|
||||
const rankedDealsBottom = [...deals]
|
||||
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
|
||||
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
|
||||
|
||||
const lines: string[] = [
|
||||
isSupplier
|
||||
? "Собран профиль выплат поставщикам по платежным документам."
|
||||
: "Собран профиль поступлений от заказчиков по платежным документам.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Уникальных контрагентов: ${profileRows.length}.`
|
||||
];
|
||||
|
||||
if (profileRows.length === 0) {
|
||||
lines.push("По выбранному окну данных платежные строки не найдены.");
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "total_flow") {
|
||||
const periodLine =
|
||||
options.periodFrom && options.periodTo
|
||||
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(
|
||||
`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${deps.formatMoneyRub(rankedByYearTotal[0].total)}).`
|
||||
);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(
|
||||
`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${deps.formatMoneyRub(rankedByTotal[0].total)}).`
|
||||
);
|
||||
}
|
||||
return buildFactualSummaryReply(summaryLines);
|
||||
}
|
||||
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
|
||||
} else {
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "top_by_ops") {
|
||||
const visible = rankedByOps.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
|
||||
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "top_by_max_single") {
|
||||
const visible = rankedByMaxSingle.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
|
||||
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "top_by_avg_check_min_ops") {
|
||||
const visible = rankedByAvgCheck.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
|
||||
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
|
||||
lines.unshift(heading);
|
||||
if (visible.length === 0) {
|
||||
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
|
||||
} else {
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | средний чек: ${deps.formatMoneyRub(item.avgCheck)} | операций: ${item.ops} | сумма: ${deps.formatMoneyRub(item.total)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "top_deals") {
|
||||
const visible = rankedDealsTop.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
|
||||
: `Топ-${visible.length} самых крупных разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "bottom_deals") {
|
||||
const visible = rankedDealsBottom.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} самых маленьких разовых выплат:`
|
||||
: `Топ-${visible.length} самых маленьких разовых поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (activeOnlyForBottomDeals) {
|
||||
lines.push("Фильтр: только активные контрагенты с минимум 3 операциями.");
|
||||
}
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
const visible = rankedByTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} поставщиков по сумме выплат:`
|
||||
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;
|
||||
return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`;
|
||||
})
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (intent === "contract_usage_and_value") {
|
||||
const focus = deps.detectContractValueFocus(options.userMessage);
|
||||
const limit = deps.detectRankingLimit(options.userMessage, 20);
|
||||
const byContract = new Map<string, ContractValuePoint>();
|
||||
|
||||
for (const row of rows) {
|
||||
const contract = deps.extractContractName(row);
|
||||
const amount = row.amount ?? 0;
|
||||
if (!contract || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const counterparty = deps.extractCounterpartyName(row);
|
||||
const current = byContract.get(contract);
|
||||
if (!current) {
|
||||
byContract.set(contract, {
|
||||
contract,
|
||||
turnover: amount,
|
||||
docs: 1,
|
||||
lastPeriod: row.period,
|
||||
counterparties: new Set(counterparty ? [counterparty] : [])
|
||||
});
|
||||
} else {
|
||||
current.turnover += amount;
|
||||
current.docs += 1;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if (counterparty) {
|
||||
current.counterparties.add(counterparty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contractRows = Array.from(byContract.values());
|
||||
const rankedByTurnover = [...contractRows].sort(
|
||||
(a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract)
|
||||
);
|
||||
const rankedByDocs = [...contractRows].sort(
|
||||
(a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract)
|
||||
);
|
||||
const rankedBottomActive = [...contractRows]
|
||||
.filter((item) => item.docs > 0 && item.turnover > 0)
|
||||
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
|
||||
|
||||
const lines: string[] = [
|
||||
`Активных договоров: ${contractRows.length}.`,
|
||||
"Собран профиль договоров по обороту и подтвержденным операциям.",
|
||||
`Строк источника: ${rows.length}.`,
|
||||
`Договорных агрегатов: ${contractRows.length}.`
|
||||
];
|
||||
|
||||
if (contractRows.length === 0) {
|
||||
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
|
||||
return buildFactualSummaryReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "top_by_docs") {
|
||||
const visible = rankedByDocs.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по количеству операций:`);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${deps.formatMoneyRub(item.turnover)} | контрагентов: ${item.counterparties.size}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
if (focus === "bottom_by_turnover_active") {
|
||||
const visible = rankedBottomActive.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} активных договоров с минимальным оборотом:`);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
const visible = rankedByTurnover.slice(0, limit);
|
||||
lines.unshift(`Топ-${visible.length} договоров по сумме оборота:`);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.contract} | оборот: ${deps.formatMoneyRub(item.turnover)} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${formatOptionalDate(item.lastPeriod, deps.formatDateRu)}`
|
||||
)
|
||||
);
|
||||
return buildFactualListReply(lines);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -539,16 +539,21 @@ function shouldRestoreInventoryRootFrame(
|
||||
const comingFromInventoryDrilldown =
|
||||
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||
const normalized = String(userMessage ?? "");
|
||||
const hasExplicitInventoryRootSnapshotCue = /(?:склад|остат(?:ок|ки)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized);
|
||||
const hasInventoryRootRestatementCue =
|
||||
/(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
|
||||
/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(
|
||||
(/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(
|
||||
normalized
|
||||
);
|
||||
) ||
|
||||
hasSameDateHint(normalized) ||
|
||||
hasSamePeriodHint(normalized));
|
||||
const canReenterInventoryRoot =
|
||||
comingFromInventoryDrilldown ||
|
||||
rootContextOnly ||
|
||||
(currentFrameKind === "inventory_root" && (hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue)) ||
|
||||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
|
||||
(currentFrameKind === "generic" &&
|
||||
hasExplicitInventoryRootSnapshotCue &&
|
||||
(hasSameDateHint(normalized) || hasSamePeriodHint(normalized) || hasInventoryRootRestatementCue));
|
||||
if (!canReenterInventoryRoot) {
|
||||
return false;
|
||||
}
|
||||
@@ -998,23 +1003,21 @@ function mergeFollowupFilters(
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!sameDateRequested &&
|
||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||
!hasExplicitPeriodLiteral(userMessage) &&
|
||||
!hasExplicitCurrentDateHint(userMessage)
|
||||
) {
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
if (
|
||||
!sameDateRequested &&
|
||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||
!hasExplicitPeriodLiteral(userMessage) &&
|
||||
!hasExplicitCurrentDateHint(userMessage)
|
||||
) {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||
merged.as_of_date = inheritedAsOfDate;
|
||||
reasons.push("as_of_date_from_followup_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
(Boolean(previousPeriodFrom) || Boolean(previousPeriodTo)) &&
|
||||
hasSelectedObjectInventorySignal(userMessage) &&
|
||||
@@ -1280,6 +1283,9 @@ function deriveIntentWithFollowupContext(
|
||||
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
|
||||
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
|
||||
const isVatFollowup = hasVatCue(normalizedMessage);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup =
|
||||
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
|
||||
if (detectedIntent.intent === "unknown" && isVatFollowup) {
|
||||
const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage)
|
||||
@@ -1295,7 +1301,12 @@ function deriveIntentWithFollowupContext(
|
||||
}
|
||||
|
||||
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
|
||||
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
|
||||
if (
|
||||
allowOpenItemsFollowupFallback &&
|
||||
!inventorySelectedObjectFollowup &&
|
||||
hasOpenItemsHint(normalizedMessage) &&
|
||||
hasAnyPartyAnchor
|
||||
) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "low",
|
||||
@@ -1323,9 +1334,6 @@ function deriveIntentWithFollowupContext(
|
||||
};
|
||||
}
|
||||
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup =
|
||||
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
|
||||
@@ -53,10 +53,7 @@ function containsLetterLikeChars(text: unknown): boolean {
|
||||
}
|
||||
|
||||
export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps): AssistantBoundaryPolicy {
|
||||
const defaultChannel = String(deps.activeMcpChannel ?? "default");
|
||||
|
||||
function buildAssistantDataScopeContractReply(scopeProbe: Record<string, unknown> | null = null): string {
|
||||
const channel = String(scopeProbe?.channel ?? defaultChannel);
|
||||
const organizations = Array.isArray(scopeProbe?.organizations)
|
||||
? scopeProbe.organizations
|
||||
.map((item) => String(item ?? "").trim())
|
||||
@@ -65,30 +62,30 @@ export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps)
|
||||
|
||||
if (organizations.length === 1) {
|
||||
return [
|
||||
`Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
|
||||
"Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
|
||||
`Сейчас доступна организация: ${organizations[0]}.`,
|
||||
"Могу сразу показать по ней документы, операции, договоры или остатки."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
if (organizations.length > 1) {
|
||||
const preview = organizations.slice(0, 10).join(", ");
|
||||
return [
|
||||
`Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
|
||||
"Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
|
||||
`Сейчас доступны организации (${organizations.length}): ${preview}.`,
|
||||
"Скажите, по какой организации смотреть данные."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
|
||||
return [
|
||||
`Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
|
||||
"Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контур."
|
||||
"Сейчас не удалось определить список организаций из подключенной базы.",
|
||||
`Техническая причина: ${scopeProbe.error}.`,
|
||||
"Проверьте подключение, и я сразу назову доступный контур."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return [
|
||||
`Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
|
||||
"Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
|
||||
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
|
||||
"Сейчас вижу только данные текущего подключенного контура.",
|
||||
"Если в нем несколько организаций, скажите, по какой смотреть данные."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface AssistantContinuitySnapshotInput {
|
||||
sessionItems?: unknown[];
|
||||
toNonEmptyString?: (value: unknown) => string | null;
|
||||
}
|
||||
|
||||
export interface AssistantContinuitySnapshot {
|
||||
lastGroundedAddressDebug: Record<string, unknown> | null;
|
||||
lastGroundedItemAddressDebug: Record<string, unknown> | null;
|
||||
lastGroundedInventoryAddressDebug: Record<string, unknown> | null;
|
||||
activeItem: string | null;
|
||||
activeOrganization: string | null;
|
||||
activeScopedDate: string | null;
|
||||
hasGroundedAddressContext: boolean;
|
||||
hasGroundedItemContext: boolean;
|
||||
hasGroundedInventoryContext: boolean;
|
||||
}
|
||||
|
||||
function fallbackToNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function toRecordObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function formatIsoDateForReply(value: unknown): string | null {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
}
|
||||
|
||||
export function readAddressDebugFilters(debug: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
return toRecordObject(debug?.extracted_filters);
|
||||
}
|
||||
|
||||
export function readAddressDebugItem(
|
||||
debug: Record<string, unknown> | null,
|
||||
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
|
||||
): string | null {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
return (
|
||||
toNonEmptyString(extractedFilters?.item) ??
|
||||
(String(debug?.anchor_type ?? "") === "item"
|
||||
? toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw)
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
export function readAddressDebugOrganization(
|
||||
debug: Record<string, unknown> | null,
|
||||
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
|
||||
): string | null {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
return toNonEmptyString(extractedFilters?.organization) ?? toNonEmptyString(rootFrameContext?.organization);
|
||||
}
|
||||
|
||||
export function readAddressDebugScopedDate(debug: Record<string, unknown> | null): string | null {
|
||||
const extractedFilters = readAddressDebugFilters(debug);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
return (
|
||||
formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
||||
formatIsoDateForReply(extractedFilters?.period_to)
|
||||
);
|
||||
}
|
||||
|
||||
export function isGroundedAddressDebug(
|
||||
debug: Record<string, unknown> | null,
|
||||
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
|
||||
): boolean {
|
||||
if (!debug || typeof debug !== "object") {
|
||||
return false;
|
||||
}
|
||||
const executionLane = toNonEmptyString(debug.execution_lane);
|
||||
if (executionLane !== "address_query") {
|
||||
return false;
|
||||
}
|
||||
const answerGroundingCheck = toRecordObject(debug.answer_grounding_check);
|
||||
const groundingStatus = toNonEmptyString(answerGroundingCheck?.status);
|
||||
return groundingStatus === "grounded";
|
||||
}
|
||||
|
||||
function isGroundedInventoryContextDebug(
|
||||
debug: Record<string, unknown> | null,
|
||||
toNonEmptyString: (value: unknown) => string | null
|
||||
): boolean {
|
||||
if (!isGroundedAddressDebug(debug, toNonEmptyString)) {
|
||||
return false;
|
||||
}
|
||||
const detectedIntent = toNonEmptyString(debug?.detected_intent);
|
||||
const capabilityId = toNonEmptyString(debug?.capability_id);
|
||||
const rootFrameContext = toRecordObject(debug?.address_root_frame_context);
|
||||
const rootIntent = toNonEmptyString(rootFrameContext?.root_intent);
|
||||
return (
|
||||
detectedIntent === "inventory_on_hand_as_of_date" ||
|
||||
capabilityId === "confirmed_inventory_on_hand_as_of_date" ||
|
||||
rootIntent === "inventory_on_hand_as_of_date"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssistantContinuitySnapshot(
|
||||
input: AssistantContinuitySnapshotInput
|
||||
): AssistantContinuitySnapshot {
|
||||
const toNonEmptyString = input.toNonEmptyString ?? fallbackToNonEmptyString;
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
let lastGroundedAddressDebug: Record<string, unknown> | null = null;
|
||||
let lastGroundedItemAddressDebug: Record<string, unknown> | null = null;
|
||||
let lastGroundedInventoryAddressDebug: Record<string, unknown> | null = null;
|
||||
|
||||
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (!isGroundedAddressDebug(debug, toNonEmptyString)) {
|
||||
continue;
|
||||
}
|
||||
if (!lastGroundedAddressDebug) {
|
||||
lastGroundedAddressDebug = debug;
|
||||
}
|
||||
if (!lastGroundedItemAddressDebug && readAddressDebugItem(debug, toNonEmptyString)) {
|
||||
lastGroundedItemAddressDebug = debug;
|
||||
}
|
||||
if (!lastGroundedInventoryAddressDebug && isGroundedInventoryContextDebug(debug, toNonEmptyString)) {
|
||||
lastGroundedInventoryAddressDebug = debug;
|
||||
}
|
||||
if (lastGroundedAddressDebug && lastGroundedItemAddressDebug && lastGroundedInventoryAddressDebug) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const primaryDebug = lastGroundedItemAddressDebug ?? lastGroundedAddressDebug;
|
||||
return {
|
||||
lastGroundedAddressDebug,
|
||||
lastGroundedItemAddressDebug,
|
||||
lastGroundedInventoryAddressDebug,
|
||||
activeItem: readAddressDebugItem(primaryDebug, toNonEmptyString),
|
||||
activeOrganization: readAddressDebugOrganization(primaryDebug, toNonEmptyString),
|
||||
activeScopedDate: readAddressDebugScopedDate(primaryDebug),
|
||||
hasGroundedAddressContext: Boolean(lastGroundedAddressDebug),
|
||||
hasGroundedItemContext: Boolean(lastGroundedItemAddressDebug),
|
||||
hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug)
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
mergeKnownOrganizations,
|
||||
normalizeOrganizationScopeValue
|
||||
} from "./assistantOrganizationMatcher";
|
||||
import {
|
||||
isGroundedAddressDebug,
|
||||
readAddressDebugOrganization
|
||||
} from "./assistantContinuityPolicy";
|
||||
|
||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -311,6 +315,12 @@ export function createAssistantDataScopePolicy(deps: AssistantDataScopePolicyDep
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
if (isGroundedAddressDebug(debug)) {
|
||||
const groundedOrganization = normalizeOrganizationScopeValue(readAddressDebugOrganization(debug));
|
||||
if (groundedOrganization) {
|
||||
return groundedOrganization;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -320,6 +320,7 @@ export async function runAssistantLivingChatRuntime(
|
||||
chatText = buildAddressMemoryRecapReplyFromPolicy({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
|
||||
@@ -172,7 +172,7 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
|
||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
|
||||
if (!hasMemoryCue || !(hasDiscussionCue || hasExplicitRecapPrompt)) {
|
||||
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitRecapPrompt) {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
formatIsoDateForReply,
|
||||
isGroundedAddressDebug,
|
||||
readAddressDebugFilters,
|
||||
readAddressDebugItem,
|
||||
readAddressDebugOrganization,
|
||||
readAddressDebugScopedDate,
|
||||
resolveAssistantContinuitySnapshot
|
||||
} from "./assistantContinuityPolicy";
|
||||
|
||||
export interface ResolveAssistantRouteMemorySignalsInput {
|
||||
rawUserMessage?: unknown;
|
||||
repairedRawUserMessage?: unknown;
|
||||
@@ -12,6 +22,7 @@ export interface ResolveAssistantRouteMemorySignalsInput {
|
||||
aggregateBusinessAnalyticsSignal?: boolean;
|
||||
lastGroundedAddressDebug?: unknown;
|
||||
hasPriorAddressDebug?: boolean;
|
||||
sessionItems?: unknown[];
|
||||
}
|
||||
|
||||
export interface AssistantRouteMemorySignals {
|
||||
@@ -37,13 +48,12 @@ export interface AssistantMemoryRecapPolicyDeps {
|
||||
isGroundedInventoryContextDebug: (debug: unknown) => boolean;
|
||||
}
|
||||
|
||||
function formatIsoDateForReply(value: unknown): string | null {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] {
|
||||
@@ -77,84 +87,6 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function findLastGroundedInventoryAddressDebug(items: unknown[]): Record<string, unknown> | null {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
const answerGroundingCheck =
|
||||
debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
|
||||
? (debug.answer_grounding_check as Record<string, unknown>)
|
||||
: null;
|
||||
const groundingStatus = String(answerGroundingCheck?.status ?? "");
|
||||
const detectedIntent = String(debug.detected_intent ?? "");
|
||||
const capabilityId = String(debug.capability_id ?? "");
|
||||
const rootFrameContext =
|
||||
debug.address_root_frame_context && typeof debug.address_root_frame_context === "object"
|
||||
? (debug.address_root_frame_context as Record<string, unknown>)
|
||||
: null;
|
||||
const rootIntent = String(rootFrameContext?.root_intent ?? "");
|
||||
const isInventoryContext =
|
||||
detectedIntent === "inventory_on_hand_as_of_date" ||
|
||||
capabilityId === "confirmed_inventory_on_hand_as_of_date" ||
|
||||
rootIntent === "inventory_on_hand_as_of_date";
|
||||
if (groundingStatus === "grounded" && isInventoryContext) {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLastAddressDebugWithItem(items: unknown[]): Record<string, unknown> | null {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (String(debug.execution_lane ?? "") !== "address_query") {
|
||||
continue;
|
||||
}
|
||||
const extractedFilters =
|
||||
debug.extracted_filters && typeof debug.extracted_filters === "object"
|
||||
? (debug.extracted_filters as Record<string, unknown>)
|
||||
: null;
|
||||
const itemLabel =
|
||||
String(extractedFilters?.item ?? "").trim() ||
|
||||
(String(debug.anchor_type ?? "") === "item"
|
||||
? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim()
|
||||
: "");
|
||||
if (itemLabel) {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLastAddressDebug(items: unknown[]): Record<string, unknown> | null {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (String(item.debug.execution_lane ?? "") === "address_query") {
|
||||
return item.debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||
organization: string | null;
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
@@ -165,10 +97,7 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||
typeof input.addressDebug.address_root_frame_context === "object"
|
||||
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
||||
: null;
|
||||
const extractedFilters =
|
||||
input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object"
|
||||
? (input.addressDebug.extracted_filters as Record<string, unknown>)
|
||||
: null;
|
||||
const extractedFilters = readAddressDebugFilters(input.addressDebug);
|
||||
const organization =
|
||||
input.organization ??
|
||||
input.toNonEmptyString(rootFrameContext?.organization) ??
|
||||
@@ -192,9 +121,111 @@ export function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeRecapIdentity(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[«»"'`]/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function buildRecapFactLine(input: {
|
||||
debug: Record<string, unknown> | null;
|
||||
item: string | null;
|
||||
organization: string | null;
|
||||
}): string | null {
|
||||
const detectedIntent = String(input.debug?.detected_intent ?? "");
|
||||
const scopedDate = readAddressDebugScopedDate(input.debug);
|
||||
const itemPart = input.item ? `по позиции «${input.item}»` : null;
|
||||
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
|
||||
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
||||
if (detectedIntent === "inventory_on_hand_as_of_date") {
|
||||
return `смотрели остатки${organizationPart ? ` ${organizationPart}` : ""}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_provenance_for_item" && itemPart) {
|
||||
return `разобрали, кто поставлял ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_documents_for_item" && itemPart) {
|
||||
return `подняли документы закупки ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_sale_trace_for_item" && itemPart) {
|
||||
return `разобрали, кому продавали ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_to_sale_chain" && itemPart) {
|
||||
return `проследили цепочку от закупки до продажи ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_profitability_for_item" && itemPart) {
|
||||
return `смотрели рентабельность ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "inventory_aging_by_purchase_date" && itemPart) {
|
||||
return `смотрели возраст остатков ${itemPart}${datePart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "counterparty_activity_lifecycle" && organizationPart) {
|
||||
return `смотрели активность в базе 1С ${organizationPart}`.trim();
|
||||
}
|
||||
if (detectedIntent === "list_documents_by_counterparty" && organizationPart) {
|
||||
return `поднимали документы ${organizationPart}${datePart}`.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectRecentRecapFacts(input: {
|
||||
sessionItems?: unknown[];
|
||||
item: string | null;
|
||||
organization: string | null;
|
||||
toNonEmptyString: (value: unknown) => string | null;
|
||||
}): string[] {
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
if (sessionItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const currentItemKey = normalizeRecapIdentity(input.item);
|
||||
const currentOrganizationKey = normalizeRecapIdentity(input.organization);
|
||||
const facts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
|
||||
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | null;
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (!isGroundedAddressDebug(item.debug, input.toNonEmptyString)) {
|
||||
continue;
|
||||
}
|
||||
const debugItem = readAddressDebugItem(item.debug, input.toNonEmptyString);
|
||||
const debugOrganization = readAddressDebugOrganization(item.debug, input.toNonEmptyString);
|
||||
const itemMatches = currentItemKey ? normalizeRecapIdentity(debugItem) === currentItemKey : false;
|
||||
const organizationMatches = currentOrganizationKey
|
||||
? normalizeRecapIdentity(debugOrganization) === currentOrganizationKey
|
||||
: false;
|
||||
if (currentItemKey && !itemMatches) {
|
||||
continue;
|
||||
}
|
||||
if (!currentItemKey && currentOrganizationKey && !organizationMatches) {
|
||||
continue;
|
||||
}
|
||||
const fact = buildRecapFactLine({
|
||||
debug: item.debug,
|
||||
item: debugItem,
|
||||
organization: debugOrganization
|
||||
});
|
||||
if (!fact || seen.has(fact)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(fact);
|
||||
facts.push(fact);
|
||||
if (facts.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return facts.reverse();
|
||||
}
|
||||
|
||||
export function buildAddressMemoryRecapReply(input: {
|
||||
organization: string | null;
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
sessionItems?: unknown[];
|
||||
toNonEmptyString: (value: unknown) => string | null;
|
||||
}): string {
|
||||
const extractedFilters =
|
||||
@@ -206,22 +237,29 @@ export function buildAddressMemoryRecapReply(input: {
|
||||
typeof input.addressDebug.address_root_frame_context === "object"
|
||||
? (input.addressDebug.address_root_frame_context as Record<string, unknown>)
|
||||
: null;
|
||||
const item =
|
||||
input.toNonEmptyString(extractedFilters?.item) ??
|
||||
(String(input.addressDebug?.anchor_type ?? "") === "item"
|
||||
? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ??
|
||||
input.toNonEmptyString(input.addressDebug?.anchor_value_raw)
|
||||
: null);
|
||||
const item = readAddressDebugItem(input.addressDebug, input.toNonEmptyString);
|
||||
const organization =
|
||||
input.organization ??
|
||||
input.toNonEmptyString(extractedFilters?.organization) ??
|
||||
input.toNonEmptyString(rootFrameContext?.organization);
|
||||
const scopedDate =
|
||||
formatIsoDateForReply(extractedFilters?.as_of_date) ??
|
||||
formatIsoDateForReply(rootFrameContext?.as_of_date) ??
|
||||
formatIsoDateForReply(extractedFilters?.period_to);
|
||||
const scopedDate = readAddressDebugScopedDate(input.addressDebug);
|
||||
const recapFacts = collectRecentRecapFacts({
|
||||
sessionItems: input.sessionItems,
|
||||
item,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
|
||||
if (item) {
|
||||
if (recapFacts.length > 0) {
|
||||
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
return [
|
||||
`Да, помню. По позиции «${item}»${organizationPart}${datePart} мы уже выяснили:`,
|
||||
...recapFacts.map((fact) => `- ${fact}.`),
|
||||
"Могу сразу продолжить по ней: поставщик, закупка, документы или продажа."
|
||||
].join("\n");
|
||||
}
|
||||
const datePart = scopedDate ? ` в срезе на ${scopedDate}` : "";
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
return [
|
||||
@@ -249,15 +287,18 @@ export function resolveAssistantLivingChatMemoryContext(
|
||||
String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected";
|
||||
const contextualMemoryRecapFollowup =
|
||||
String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected";
|
||||
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
|
||||
const continuity = resolveAssistantContinuitySnapshot({
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString
|
||||
});
|
||||
return {
|
||||
contextualInventoryHistoryCapabilityFollowup,
|
||||
contextualMemoryRecapFollowup,
|
||||
lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup
|
||||
? findLastGroundedInventoryAddressDebug(sessionItems)
|
||||
? continuity.lastGroundedInventoryAddressDebug
|
||||
: null,
|
||||
lastMemoryAddressDebug: contextualMemoryRecapFollowup
|
||||
? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems)
|
||||
? continuity.lastGroundedItemAddressDebug ?? continuity.lastGroundedAddressDebug
|
||||
: null
|
||||
};
|
||||
}
|
||||
@@ -269,6 +310,11 @@ export function createAssistantMemoryRecapPolicy(
|
||||
input: ResolveAssistantRouteMemorySignalsInput
|
||||
): AssistantRouteMemorySignals {
|
||||
const samples = collectMessageSamples(input);
|
||||
const continuity = resolveAssistantContinuitySnapshot({
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString
|
||||
});
|
||||
const groundedInventoryContext = continuity.lastGroundedInventoryAddressDebug ?? input.lastGroundedAddressDebug;
|
||||
const historicalCapabilitySignal = hasSignalAcrossSamples(
|
||||
samples,
|
||||
deps.hasHistoricalCapabilityFollowupSignal
|
||||
@@ -284,7 +330,7 @@ export function createAssistantMemoryRecapPolicy(
|
||||
!input.dataScopeMetaQuery &&
|
||||
!input.dataRetrievalSignal &&
|
||||
historicalCapabilitySignal &&
|
||||
deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)
|
||||
deps.isGroundedInventoryContextDebug(groundedInventoryContext)
|
||||
),
|
||||
contextualMemoryRecapFollowupDetected: Boolean(
|
||||
!input.dataScopeMetaQuery &&
|
||||
@@ -292,7 +338,7 @@ export function createAssistantMemoryRecapPolicy(
|
||||
!input.aggregateBusinessAnalyticsSignal &&
|
||||
memoryRecapSignal &&
|
||||
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
|
||||
input.hasPriorAddressDebug
|
||||
continuity.hasGroundedAddressContext
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,10 +138,16 @@ export function createAssistantRoutePolicy(deps) {
|
||||
hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
|
||||
hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
|
||||
hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
|
||||
const analyticsSample = compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`.toLowerCase());
|
||||
const colloquialCustomerValueSignal = /(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|customer|client|counterparty)/iu.test(analyticsSample) &&
|
||||
/(?:(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433)|(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433).*(?:\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0451\u0441|\u0437\u0430\u043d\u0435\u0441|\u0437\u0430\u043d\u0451\u0441)|(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u044c\u0433))/iu.test(analyticsSample);
|
||||
const turnoverAggregateSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(analyticsSample);
|
||||
const aggregateBusinessAnalyticsBridgeDetected = colloquialCustomerValueSignal || turnoverAggregateSignal;
|
||||
const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
|
||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
|
||||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage) ||
|
||||
aggregateBusinessAnalyticsBridgeDetected;
|
||||
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
|
||||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
|
||||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
|
||||
@@ -231,33 +237,39 @@ export function createAssistantRoutePolicy(deps) {
|
||||
!capabilityMetaQuery &&
|
||||
!dataRetrievalSignal);
|
||||
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
|
||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
|
||||
!capabilityMetaQuery &&
|
||||
!dataRetrievalSignal &&
|
||||
!aggregateBusinessAnalyticsSignal &&
|
||||
!effectiveAddressFollowupSignal &&
|
||||
resolvedModeDetection.mode === "unsupported" &&
|
||||
resolvedIntentResolution.intent === "unknown");
|
||||
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
|
||||
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? "")));
|
||||
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
|
||||
deterministicNonDomainGuard &&
|
||||
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
|
||||
!baseToolGatePreservesAddressLane &&
|
||||
!protectedInventoryShortFollowup &&
|
||||
!organizationClarificationContinuationDetected);
|
||||
const lastAddressAssistantDebug = sessionItems
|
||||
? findLastAddressAssistantItem(sessionItems)?.debug ?? null
|
||||
: null;
|
||||
const memorySignals = resolveRouteMemorySignals({
|
||||
rawUserMessage,
|
||||
repairedRawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
repairedEffectiveAddressUserMessage,
|
||||
const memorySignals = resolveRouteMemorySignals({
|
||||
rawUserMessage,
|
||||
repairedRawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
repairedEffectiveAddressUserMessage,
|
||||
dataScopeMetaQuery,
|
||||
capabilityMetaQuery,
|
||||
dataRetrievalSignal,
|
||||
strongDataSignal,
|
||||
aggregateBusinessAnalyticsSignal,
|
||||
lastGroundedAddressDebug,
|
||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug)
|
||||
});
|
||||
strongDataSignal,
|
||||
aggregateBusinessAnalyticsSignal,
|
||||
lastGroundedAddressDebug,
|
||||
hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug),
|
||||
sessionItems
|
||||
});
|
||||
const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected;
|
||||
const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected;
|
||||
const organizationFactLookupDetected = hasOrganizationFactLookupSignal(rawUserMessage) ||
|
||||
@@ -428,6 +440,143 @@ export function createAssistantRoutePolicy(deps) {
|
||||
}
|
||||
};
|
||||
}
|
||||
const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" &&
|
||||
semanticApplyCanonicalRecommended &&
|
||||
strictDeepInvestigationBypassAllowed &&
|
||||
llmContractIntent &&
|
||||
ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent));
|
||||
if (supportedExactInvestigativeAddressBypass) {
|
||||
return {
|
||||
runAddressLane: true,
|
||||
toolGateDecision: "run_address_lane",
|
||||
toolGateReason: "address_signal_detected",
|
||||
livingMode: "address_data",
|
||||
livingReason: "address_lane_triggered",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: true,
|
||||
strict_deep_investigation_bypass_allowed: true,
|
||||
semantic_deep_investigation_hint_detected: semanticExtractionContract?.guard_hints?.deep_investigation_signal_detected === true,
|
||||
semantic_aggregate_shape_detected: false,
|
||||
followup_semantic_override_to_deep_allowed: false
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: false,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: true,
|
||||
tool_gate_decision: "run_address_lane",
|
||||
tool_gate_reason: "address_signal_detected",
|
||||
living_mode: "address_data",
|
||||
living_reason: "address_lane_triggered"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (colloquialCustomerValueSignal) {
|
||||
return {
|
||||
runAddressLane: true,
|
||||
toolGateDecision: "run_address_lane",
|
||||
toolGateReason: "address_signal_detected",
|
||||
livingMode: "address_data",
|
||||
livingReason: "address_lane_triggered",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: true,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: true,
|
||||
strict_deep_investigation_bypass_allowed: false,
|
||||
semantic_deep_investigation_hint_detected: false,
|
||||
semantic_aggregate_shape_detected: true,
|
||||
followup_semantic_override_to_deep_allowed: false
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: false,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: true,
|
||||
tool_gate_decision: "run_address_lane",
|
||||
tool_gate_reason: "address_signal_detected",
|
||||
living_mode: "address_data",
|
||||
living_reason: "address_lane_triggered"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
const standaloneAggregateAnalyticsFollowupFallback = Boolean(followupContext &&
|
||||
aggregateBusinessAnalyticsBridgeDetected &&
|
||||
llmContractMode === null &&
|
||||
!((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||
openContractsAddressSignal));
|
||||
if (standaloneAggregateAnalyticsFollowupFallback) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
toolGateDecision: "skip_address_lane",
|
||||
toolGateReason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
livingMode: "deep_analysis",
|
||||
livingReason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
semantic_contract_valid: semanticContractValid,
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: false,
|
||||
strict_deep_investigation_bypass_allowed: false,
|
||||
semantic_deep_investigation_hint_detected: false,
|
||||
semantic_aggregate_shape_detected: true,
|
||||
followup_semantic_override_to_deep_allowed: true
|
||||
},
|
||||
followup_context_detected: true,
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
deep_analysis_signal_fallback_to_deep: false,
|
||||
aggregate_analytics_signal_fallback_to_deep: true,
|
||||
deep_session_continuation_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: false,
|
||||
tool_gate_decision: "skip_address_lane",
|
||||
tool_gate_reason: "aggregate_analytics_signal_fallback_to_deep",
|
||||
living_mode: "deep_analysis",
|
||||
living_reason: "aggregate_analytics_signal_fallback_to_deep"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (nonDomainQueryIndexed) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
@@ -458,7 +607,6 @@ export function createAssistantRoutePolicy(deps) {
|
||||
};
|
||||
}
|
||||
const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal;
|
||||
const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
|
||||
const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected &&
|
||||
llmPreDecomposeMeta?.applied &&
|
||||
llmContractMode === "address_query") ||
|
||||
@@ -483,6 +631,12 @@ export function createAssistantRoutePolicy(deps) {
|
||||
Boolean((resolvedIntentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(resolvedIntentResolution.intent)) ||
|
||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||
openContractsAddressSignal);
|
||||
const supportedAddressRouteCandidateDetected = Boolean(supportedAddressIntentDetected ||
|
||||
keepAddressLaneByIntent ||
|
||||
(baseToolGate?.runAddressLane &&
|
||||
llmContractMode !== "deep_analysis" &&
|
||||
!strictDeepInvestigationCueDetected &&
|
||||
["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected", "llm_canonical_data_signal_detected"].includes(String(baseToolGate?.reason ?? ""))));
|
||||
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
|
||||
typeof semanticExtractionContract.guard_hints === "object"
|
||||
? semanticExtractionContract.guard_hints
|
||||
@@ -517,8 +671,7 @@ export function createAssistantRoutePolicy(deps) {
|
||||
semanticDeepInvestigationHintDetected ||
|
||||
aggregateBusinessAnalyticsSignal) &&
|
||||
!preserveAddressLaneSignal &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!supportedAddressRouteCandidateDetected &&
|
||||
(!followupContext || followupSemanticOverrideToDeepAllowed));
|
||||
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
|
||||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
|
||||
@@ -528,6 +681,19 @@ export function createAssistantRoutePolicy(deps) {
|
||||
hasDirectDeepAnalysisSignal(repairedRawUserMessage) ||
|
||||
hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) ||
|
||||
hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage));
|
||||
const exactAddressIntentProtectedFromSemanticDeepHint = Boolean(supportedAddressRouteCandidateDetected &&
|
||||
llmContractMode === "address_query" &&
|
||||
llmContractIntent &&
|
||||
ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent) &&
|
||||
semanticApplyCanonicalRecommended &&
|
||||
!deepAnalysisPreferenceDetected &&
|
||||
!strictDeepInvestigationCueDetected &&
|
||||
!semanticAggregateShapeDetected);
|
||||
const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected &&
|
||||
!deepAnalysisPreferenceDetected &&
|
||||
(exactAddressIntentProtectedFromSemanticDeepHint ||
|
||||
!semanticDeepInvestigationHintDetected ||
|
||||
strictDeepInvestigationBypassAllowed));
|
||||
const vatExplainFollowupSignal = Boolean(followupContext &&
|
||||
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
|
||||
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
|
||||
@@ -537,17 +703,16 @@ export function createAssistantRoutePolicy(deps) {
|
||||
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||
!llmRuntimeUnavailableDetected &&
|
||||
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!protectAddressLaneFromFallback &&
|
||||
!vatExplainFollowupSignal &&
|
||||
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
|
||||
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||
!llmRuntimeUnavailableDetected &&
|
||||
aggregateBusinessAnalyticsSignal &&
|
||||
!keepAddressLaneByIntent &&
|
||||
!supportedAddressIntentDetected &&
|
||||
!protectAddressLaneFromFallback &&
|
||||
(!followupContext ||
|
||||
llmContractMode === "unsupported" ||
|
||||
llmContractMode === null ||
|
||||
semanticAggregateShapeDetected ||
|
||||
!semanticApplyCanonicalRecommended ||
|
||||
standaloneAddressTopicSignal));
|
||||
@@ -667,10 +832,11 @@ export function createAssistantRoutePolicy(deps) {
|
||||
semantic_apply_canonical_recommended: semanticApplyCanonicalRecommended,
|
||||
semantic_reason_codes: semanticReasonCodes,
|
||||
semantic_route_arbitration: {
|
||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
||||
supported_address_intent_detected: supportedAddressRouteCandidateDetected,
|
||||
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||
exact_address_intent_protected_from_semantic_deep_hint: exactAddressIntentProtectedFromSemanticDeepHint,
|
||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||
},
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
|
||||
@@ -193,7 +193,7 @@ function intersectsSpan(start: number, end: number, spans: Array<{ start: number
|
||||
function hasAccountContextAround(text: string, start: number, end: number): boolean {
|
||||
const left = text.slice(Math.max(0, start - 28), start);
|
||||
const right = text.slice(end, Math.min(text.length, end + 28));
|
||||
return /(?:счет|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(
|
||||
return /(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account|schet|оплат|расч[её]т|расчет|аванс|зач[её]т|долг|постав|покуп|supplier|customer|settlement|payment|ндс|vat|проводк|posting)/iu.test(
|
||||
`${left} ${right}`
|
||||
);
|
||||
}
|
||||
@@ -217,11 +217,11 @@ function extractAccountsFromTextDetailed(text: string, options?: { forceAccountC
|
||||
const contractSpans = collectContractLikeSpans(lower);
|
||||
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans, ...contractSpans];
|
||||
const hasAccountingLexeme =
|
||||
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(
|
||||
/(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?|оплат|расч[её]т|расчет|аванс|долг|settlement|payment|supplier|customer|постав|покуп)/iu.test(
|
||||
lower
|
||||
);
|
||||
const contextualPattern =
|
||||
/(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
|
||||
/(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:\d{2})(?:\.\d{2})?)/giu;
|
||||
let contextualMatch: RegExpExecArray | null = null;
|
||||
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
|
||||
const token = String(contextualMatch[1] ?? "").trim();
|
||||
@@ -961,7 +961,19 @@ export function resolveDomainPolarityGuard(input: {
|
||||
const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? ""));
|
||||
const lower = repairedMessage.toLowerCase();
|
||||
const accountExtraction = extractAccountsFromTextDetailed(lower);
|
||||
const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]);
|
||||
let accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]);
|
||||
if (accounts.length === 0) {
|
||||
const directAccountMentions = Array.from(
|
||||
repairedMessage.matchAll(
|
||||
/(?:^|[^\p{L}\d])(?:сч(?:е|ё)т(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)[\s#:№]*((?:60|62|76)(?:\.\d{2})?)/giu
|
||||
)
|
||||
)
|
||||
.map((match) => String(match[1] ?? "").trim())
|
||||
.filter((token) => token.length > 0);
|
||||
if (directAccountMentions.length > 0) {
|
||||
accounts = uniqueStrings([...accounts, ...directAccountMentions]);
|
||||
}
|
||||
}
|
||||
const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item): item is string => Boolean(item)));
|
||||
const settlementSignal =
|
||||
input.focusDomainHint === "settlements_60_62" ||
|
||||
|
||||
@@ -3657,6 +3657,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
? llmPreDecomposeMeta.semanticExtractionContract
|
||||
: null;
|
||||
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
|
||||
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
|
||||
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
|
||||
semanticCanonicalRecommended;
|
||||
const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
|
||||
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
|
||||
const hasLlmCanonicalSignal = semanticCanonicalRecommended &&
|
||||
@@ -3669,6 +3672,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
Boolean(llmPreDecomposeMeta?.applied) &&
|
||||
(llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) &&
|
||||
hasStrongDataIntentSignal(repairedInputMessage);
|
||||
const hasBusinessRankingAddressSignal = /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?).*(?:\u0434\u0435\u043d\u0435\u0433)?|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
|
||||
const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) ||
|
||||
hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage);
|
||||
const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
||||
@@ -3677,12 +3681,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
hasAccountingSignal(repairedInputMessage) ||
|
||||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
|
||||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
|
||||
hasBusinessRankingAddressSignal ||
|
||||
sameDateAccountFollowupSignal;
|
||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||
llmContractIntent === "unknown";
|
||||
const hasAnyAddressSignal =
|
||||
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
||||
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal || llmSupportedDeepAddressIntentSignal;
|
||||
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
||||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
||||
hasAccountingSignal(rawMessageForGate) ||
|
||||
@@ -3696,6 +3701,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
!hasClassifierSignal &&
|
||||
!hasIntentSignal &&
|
||||
!hasLexicalAddressSignal &&
|
||||
!llmSupportedDeepAddressIntentSignal &&
|
||||
!strongDataSignalFromRawMessage &&
|
||||
!strongDataSignalFromEffectiveMessage) {
|
||||
return {
|
||||
@@ -3706,6 +3712,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
}
|
||||
if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
|
||||
!hasAnyAddressSignal &&
|
||||
!llmSupportedDeepAddressIntentSignal &&
|
||||
!strongDataSignalFromRawMessage &&
|
||||
!strongDataSignalFromEffectiveMessage) {
|
||||
return {
|
||||
@@ -3725,8 +3732,10 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
? "address_intent_resolver_detected"
|
||||
: hasLlmCanonicalSignal
|
||||
? "llm_canonical_candidate_detected"
|
||||
: hasLlmCanonicalDataSignal
|
||||
: hasLlmCanonicalDataSignal
|
||||
? "llm_canonical_data_signal_detected"
|
||||
: llmSupportedDeepAddressIntentSignal
|
||||
? "address_signal_detected"
|
||||
: "address_signal_detected"
|
||||
};
|
||||
}
|
||||
@@ -3843,6 +3852,11 @@ function hasAggregateBusinessAnalyticsSignal(text) {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasTurnoverByEntityBridgeSignal = /(?:\u043e\u0431\u043e\u0440\u043e\u0442(?:\u044b)?|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue).*(?:\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|year|month|quarter)/iu.test(normalized) &&
|
||||
/(?:\b\u043f\u043e\s+[\p{L}\d._-]{3,}|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|customer|supplier|counterparty|company)/iu.test(normalized);
|
||||
if (hasTurnoverByEntityBridgeSignal) {
|
||||
return true;
|
||||
}
|
||||
const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized);
|
||||
if (!hasMetricCue) {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
// @ts-nocheck
|
||||
|
||||
export function createAssistantTransitionPolicy(deps) {
|
||||
function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
|
||||
if (!hasInventoryRootFrame) {
|
||||
return false;
|
||||
}
|
||||
const normalized = deps
|
||||
.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase())
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (deps.countTokens(normalized) > 10) {
|
||||
return false;
|
||||
}
|
||||
const hasInventoryLexeme = /(?:остат|склад|товар|номенклатур|позиц)/iu.test(normalized);
|
||||
const hasRestatementCue =
|
||||
/(?:еще\s+раз|снова|повтори|повтори\s+еще\s+раз|верни|покажи)/iu.test(normalized) &&
|
||||
/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasBareSnapshotSameDateCue =
|
||||
hasInventoryLexeme &&
|
||||
/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|тот\s+же\s+период|этот\s+же\s+период)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const bareSnapshotSameDatePhrases = [
|
||||
"\u043d\u0430 \u0442\u0443 \u0436\u0435 \u0434\u0430\u0442",
|
||||
"\u043d\u0430 \u044d\u0442\u0443 \u0436\u0435 \u0434\u0430\u0442",
|
||||
"\u043d\u0430 \u044d\u0442\u0443 \u0434\u0430\u0442",
|
||||
"\u044d\u0442\u0443 \u0434\u0430\u0442",
|
||||
"\u0442\u0430 \u0436\u0435 \u0434\u0430\u0442\u0430",
|
||||
"\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u044d\u0442\u043e\u0442 \u0436\u0435 \u043f\u0435\u0440\u0438\u043e\u0434"
|
||||
];
|
||||
const hasBareSnapshotSameDatePhraseCue =
|
||||
hasInventoryLexeme && bareSnapshotSameDatePhrases.some((phrase) => normalized.includes(phrase));
|
||||
return (
|
||||
hasInventoryLexeme &&
|
||||
(hasRestatementCue || hasBareSnapshotSameDateCue || hasBareSnapshotSameDatePhraseCue) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
|
||||
const normalized = deps.compactWhitespace(
|
||||
deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()
|
||||
@@ -21,6 +63,71 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent) {
|
||||
const normalized = deps.compactWhitespace(
|
||||
deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()
|
||||
);
|
||||
if (!normalized || deps.countTokens(normalized) > 4) {
|
||||
return false;
|
||||
}
|
||||
if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") {
|
||||
return false;
|
||||
}
|
||||
if (/(?:bank|payment|wire|statement)/iu.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^(?:а|и|ну)?\s*(?:покажи\s+)?по\s+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function inferStandaloneAddressTopicFamily(userMessage) {
|
||||
const normalized = deps.compactWhitespace(
|
||||
deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()
|
||||
);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:ндс|vat)/iu.test(normalized)) {
|
||||
return "vat";
|
||||
}
|
||||
if (/(?:остат|склад|товар|номенклатур|позици)/iu.test(normalized)) {
|
||||
return "inventory";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDisplayedEntityRetargetIntent(userMessage, entityType) {
|
||||
const normalized = deps.compactWhitespace(
|
||||
deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()
|
||||
);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (entityType === "counterparty") {
|
||||
if (/(?:договор|контракт)/iu.test(normalized)) {
|
||||
return "list_contracts_by_counterparty";
|
||||
}
|
||||
if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) {
|
||||
return "bank_operations_by_counterparty";
|
||||
}
|
||||
if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) {
|
||||
return "list_documents_by_counterparty";
|
||||
}
|
||||
if (/(?:сколько\s+денег|сколько\s+принес|выручк|сумм[аы]?|оплатил|продаж)/iu.test(normalized)) {
|
||||
return "customer_revenue_and_payments";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (entityType === "contract") {
|
||||
if (/(?:банк|выписк|плат[её]ж|оплат|statement|payment|wire)/iu.test(normalized)) {
|
||||
return "bank_operations_by_contract";
|
||||
}
|
||||
if (/(?:документ|накладн|счет|сч[её]т|акт|реализац|поступл)/iu.test(normalized)) {
|
||||
return "list_documents_by_contract";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAddressFollowupCarryoverContext(
|
||||
userMessage,
|
||||
items,
|
||||
@@ -108,6 +215,18 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
Boolean(recentInventoryRootFrame)
|
||||
)
|
||||
: false;
|
||||
const hasInventoryRootRestatementPrimary = hasInventoryRootRestatementLikeSignal(
|
||||
userMessage,
|
||||
sourceIntentHint,
|
||||
Boolean(recentInventoryRootFrame)
|
||||
);
|
||||
const hasInventoryRootRestatementAlternate = deps.toNonEmptyString(alternateMessage)
|
||||
? hasInventoryRootRestatementLikeSignal(
|
||||
String(alternateMessage ?? ""),
|
||||
sourceIntentHint,
|
||||
Boolean(recentInventoryRootFrame)
|
||||
)
|
||||
: false;
|
||||
let hasStrongFollowupReference =
|
||||
hasPrimaryIndexReferenceSignal ||
|
||||
hasAlternateIndexReferenceSignal ||
|
||||
@@ -117,6 +236,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
inventoryShortFollowupAlternate ||
|
||||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
Boolean(debtRoleSwapIntent) ||
|
||||
deps.hasFollowupMarker(userMessage) ||
|
||||
deps.hasReferentialPointer(userMessage) ||
|
||||
@@ -133,6 +254,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
!hasAlternateFollowupSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal
|
||||
@@ -144,6 +267,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
!hasAlternateFollowupSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasIndexReferenceSignal
|
||||
@@ -168,8 +293,46 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
? resolvedAlternateIntent
|
||||
: null;
|
||||
const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent);
|
||||
const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent);
|
||||
if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) {
|
||||
const explicitIntentFamily =
|
||||
deps.resolveAddressIntentFamily(explicitIntent) ??
|
||||
inferStandaloneAddressTopicFamily(userMessage) ??
|
||||
(deps.toNonEmptyString(alternateMessage) ? inferStandaloneAddressTopicFamily(String(alternateMessage ?? "")) : null);
|
||||
const foreignAccountingPivotOverInventory = deps.hasForeignAccountingPivotOverInventoryMessage(
|
||||
userMessage,
|
||||
alternateMessage
|
||||
);
|
||||
const familyMismatchStandaloneTopic = Boolean(
|
||||
hasStandaloneAddressTopic &&
|
||||
sourceIntentFamily &&
|
||||
explicitIntentFamily &&
|
||||
sourceIntentFamily !== explicitIntentFamily &&
|
||||
!hasOrganizationClarificationContinuation &&
|
||||
!hasImplicitContinuationSignal &&
|
||||
!hasIndexReferenceSignal &&
|
||||
!hasInventoryRootTemporalFollowupPrimary &&
|
||||
!hasInventoryRootTemporalFollowupAlternate &&
|
||||
!hasInventoryRootRestatementPrimary &&
|
||||
!hasInventoryRootRestatementAlternate &&
|
||||
!inventoryShortFollowupPrimary &&
|
||||
!inventoryShortFollowupAlternate &&
|
||||
!foreignAccountingPivotOverInventory &&
|
||||
!deps.hasFollowupMarker(userMessage) &&
|
||||
!deps.hasReferentialPointer(userMessage) &&
|
||||
(!deps.toNonEmptyString(alternateMessage)
|
||||
? true
|
||||
: !deps.hasFollowupMarker(String(alternateMessage ?? "")) &&
|
||||
!deps.hasReferentialPointer(String(alternateMessage ?? "")))
|
||||
);
|
||||
if (familyMismatchStandaloneTopic) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
sourceIntentFamily &&
|
||||
explicitIntentFamily &&
|
||||
sourceIntentFamily !== explicitIntentFamily &&
|
||||
!hasStrongFollowupReference &&
|
||||
!foreignAccountingPivotOverInventory
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let previousIntent = sourceIntent;
|
||||
@@ -181,7 +344,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||
? deps.toNonEmptyString(followupOffer.suggested_intents[0])
|
||||
: null;
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent);
|
||||
if (suggestedIntent && !keepPreviousIntent) {
|
||||
previousIntent = suggestedIntent;
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
@@ -297,6 +460,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
: "generic"
|
||||
: null;
|
||||
let resolvedCounterpartyFromDisplay = false;
|
||||
let displayedEntityTargetIntent = null;
|
||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||
let previousFilters =
|
||||
previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {};
|
||||
@@ -376,7 +540,17 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)
|
||||
);
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
|
||||
const inventoryRootRestatementPivot = Boolean(
|
||||
inventoryRootFrame &&
|
||||
(deps.isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
deps.isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
currentFrameKind === "inventory_drilldown" ||
|
||||
currentFrameKind === "inventory_root" ||
|
||||
currentFrameKind === "generic") &&
|
||||
(hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate) &&
|
||||
!deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)
|
||||
);
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot || inventoryRootRestatementPivot;
|
||||
if (rootScopedPivot) {
|
||||
previousIntent = null;
|
||||
previousAnchorType = null;
|
||||
@@ -396,6 +570,10 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||
: null);
|
||||
if (resolvedEntityFromFollowup && !rootScopedPivot) {
|
||||
displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(
|
||||
userMessage,
|
||||
resolvedEntityFromFollowup.entityType
|
||||
);
|
||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||
previousAnchorType = "counterparty";
|
||||
@@ -465,13 +643,15 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
inventoryShortFollowupAlternate ||
|
||||
hasInventoryRootTemporalFollowupPrimary ||
|
||||
hasInventoryRootTemporalFollowupAlternate ||
|
||||
hasInventoryRootRestatementPrimary ||
|
||||
hasInventoryRootRestatementAlternate ||
|
||||
hasSelectedObjectInventorySignalPrimary ||
|
||||
hasSelectedObjectInventorySignalAlternate)
|
||||
);
|
||||
const carryoverTargetIntent =
|
||||
followupSelectionMode === "carry_root_context"
|
||||
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
|
||||
: explicitIntent ?? previousIntent ?? undefined;
|
||||
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
|
||||
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
|
||||
return {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
@@ -507,6 +687,7 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||
const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||
const rootContextOnly = selectionMode === "carry_root_context";
|
||||
const carryoverTargetIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.target_intent) ?? null;
|
||||
const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
|
||||
const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
|
||||
@@ -514,8 +695,8 @@ export function createAssistantTransitionPolicy(deps) {
|
||||
selectionMode === "switch_to_suggested_intent"
|
||||
? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||
: rootContextOnly
|
||||
? rootIntent ?? explicitIntent ?? null
|
||||
: explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null
|
||||
: carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||
const rewrittenByPredecompose =
|
||||
deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase());
|
||||
|
||||
@@ -54,6 +54,13 @@ describe("addressCounterpartyIntentSignals", () => {
|
||||
expect(result?.reasons).toContain("counterparty_item_flow_signal_detected");
|
||||
});
|
||||
|
||||
it("classifies passive shipment wording with instrumental counterparty phrasing", () => {
|
||||
const result = resolveAddressIntent("какие товары или услуги были отгружены нашей компании контрагентом чапурновым?");
|
||||
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result.reasons).toContain("counterparty_item_flow_signal_detected");
|
||||
});
|
||||
|
||||
it("keeps the main resolver behavior stable through counterparty-owner delegation", () => {
|
||||
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveCounterpartyAddressIntent } from "../src/services/addressCounterpartyIntentSignals";
|
||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||
|
||||
const utf8Deps = {
|
||||
hasAny: (text: string, hints: readonly string[]) => hints.some((hint) => text.includes(hint)),
|
||||
openItemsHints: ["хвост", "долг", "open items"],
|
||||
openContractsHints: ["договор", "контракт", "open contracts"],
|
||||
documentsByCounterpartyHints: ["документы по", "documents by counterparty"],
|
||||
bankOperationsByCounterpartyHints: ["банк", "выписка", "bank operations by counterparty"],
|
||||
documentsByContractHints: ["документы по договору", "documents by contract"],
|
||||
hasCounterpartyDebtLongevitySignal: () => false,
|
||||
hasInventoryAgingSignal: () => false,
|
||||
hasInventoryProvenanceSignalV2: () => false,
|
||||
hasInventoryPurchaseDocumentsSignalV2: () => false,
|
||||
hasInventorySaleTraceSignalV2: () => false,
|
||||
hasAccountNumberAnchor: (text: string) => /(?:60|62|76)/.test(text),
|
||||
hasCompactAccountCodeToken: () => false,
|
||||
hasPeriodCoverageProfileSignal: () => false,
|
||||
hasPartyAnchorMention: () => false,
|
||||
hasContractAnchorSignal: (text: string) => text.includes("договор"),
|
||||
hasAccountBalanceSignal: () => false,
|
||||
hasDocumentTypeAndAccountSectionProfileSignal: () => false,
|
||||
hasCounterpartyPopulationAndRolesSignal: () => false,
|
||||
hasCounterpartyActivityLifecycleSignal: () => false,
|
||||
hasContractUsageOverviewSignal: () => false,
|
||||
hasOpenContractsListSignal: () => false,
|
||||
hasCustomerRevenueAndPaymentsSignal: () => false,
|
||||
hasSupplierPayoutsProfileSignal: () => false,
|
||||
hasContractUsageAndValueSignal: () => false,
|
||||
hasContractListByCounterpartySignal: () => false,
|
||||
hasBankOperationSignal: (text: string) => text.includes("банк"),
|
||||
hasDocumentSignal: (text: string) => text.includes("док"),
|
||||
hasLooseByAnchorMention: () => false,
|
||||
hasHeuristicCounterpartyAnchor: () => false,
|
||||
hasCounterpartyShipmentItemFlowSignal: (text: string) => text.includes("отгружал"),
|
||||
hasImplicitCounterpartyAnchorAroundDocs: () => false,
|
||||
hasGenericAddressLookupSignal: (text: string) => text.includes("покажи")
|
||||
};
|
||||
|
||||
describe("address counterparty utf8 regression", () => {
|
||||
it("classifies direct documents-by-counterparty wording with a loose human anchor", () => {
|
||||
const result = resolveCounterpartyAddressIntent("покажи все документы по чепурнову", utf8Deps);
|
||||
|
||||
expect(result?.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.reasons).toContain("documents_by_counterparty_signal_detected");
|
||||
});
|
||||
|
||||
it("keeps the main resolver in the supported contour for direct documents-by-counterparty wording", () => {
|
||||
const result = resolveAddressIntent("покажи все документы по чепурнову");
|
||||
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,13 @@ import { resolveInventoryAddressIntent } from "../src/services/addressInventoryI
|
||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||
|
||||
describe("addressInventoryIntentSignals", () => {
|
||||
it("classifies plain Russian stock wording from the agent replay as inventory on hand", () => {
|
||||
const result = resolveAddressIntent("какие остатки на складе на март 2021");
|
||||
|
||||
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result.reasons).toContain("inventory_on_hand_signal_detected");
|
||||
});
|
||||
|
||||
it("classifies warehouse snapshot wording through the extracted inventory owner", () => {
|
||||
const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15");
|
||||
|
||||
|
||||
@@ -12,6 +12,48 @@ vi.mock("../src/services/addressMcpClient", async () => {
|
||||
...actual,
|
||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
||||
};
|
||||
it("keeps plain March stock wording in the inventory contour and asks to choose the company", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2021-03-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 6490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Пуф арий",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
},
|
||||
{
|
||||
Period: "2021-03-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 34490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Диван трехместный",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Лайсвуд"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("какие остатки на складе на март 2021");
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
|
||||
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
|
||||
expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]);
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
@@ -143,4 +185,52 @@ describe("inventory organization scope grounding", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defers company clarification for item-focused inventory queries and grounds the company from observed rows", async () => {
|
||||
const item = "Столешница 600*3050*26 дуб ниагара";
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2019-02-11T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2019 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 3724.17,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: item,
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз МСК\\",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи документы по этой позиции", {
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"],
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item,
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||||
expect(result?.debug.reasons).toContain("organization_grounded_from_observed_rows");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -549,6 +549,61 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077");
|
||||
});
|
||||
|
||||
it("does not let carried counterparty scope steal selected-item document follow-up into open items", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2019-02-12T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 3690,
|
||||
SubcontoDt1: "Столешница 600*3050*26 альмандин",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи документы по этой позиции", {
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item: "Столешница 600*3050*26 альмандин",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
counterparty: "Альтернатива Плюс, ООО",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ООО \\Альтернатива Плюс\\",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
root_filters: {
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
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.capability_id).toBe("inventory_inventory_purchase_documents_for_item");
|
||||
expect(result?.debug.reasons).not.toContain("open_items_from_followup_context");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003");
|
||||
});
|
||||
|
||||
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: "Подтвержден адресный ответ.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_SUMMARY",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
reasons: ["address_action_detected", "address_entity_detected"],
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
},
|
||||
...(overrides ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
function buildAddressLimitedLaneResult(overrides?: Record<string, unknown>): any {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: "Нужно уточнить организацию.",
|
||||
reply_type: "partial_coverage",
|
||||
response_type: "LIMITED_WITH_REASON",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: null,
|
||||
limited_reason_category: "missing_anchor",
|
||||
organization_candidates: ['ООО "Альтернатива Плюс"', 'ООО "Лайсвуд"'],
|
||||
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
|
||||
},
|
||||
...(overrides ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent semantic inventory regressions", () => {
|
||||
it("continues the stock query after exact clarification phrase 'давай по Альтернативе Плюс'", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "какие остатки на складе на март 2021";
|
||||
const secondMessage = "давай по Альтернативе Плюс";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === firstMessage) {
|
||||
return buildAddressLimitedLaneResult();
|
||||
}
|
||||
if (
|
||||
message === secondMessage &&
|
||||
options?.activeOrganization === organization &&
|
||||
options?.followupContext?.previous_intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
|
||||
}
|
||||
});
|
||||
}
|
||||
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 = `agent-semantic-org-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("partial_coverage");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: secondMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].options?.activeOrganization).toBe(organization);
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores root stock slice after selected-object drilldown and does not rerun address lane for memory recap", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const restatementMessage = "покажи еще раз остатки на эту же дату";
|
||||
const recapMessage = "а что мы уже выяснили по этой позиции?";
|
||||
const item = "Столешница 600*3050*26 альмандин";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (
|
||||
message === restatementMessage &&
|
||||
options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" &&
|
||||
options?.followupContext?.root_filters?.as_of_date === "2021-03-31" &&
|
||||
options?.followupContext?.root_filters?.period_from === "2021-03-01" &&
|
||||
options?.followupContext?.root_filters?.period_to === "2021-03-31"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской остаток.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"]
|
||||
}
|
||||
});
|
||||
}
|
||||
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 = `agent-semantic-inventory-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:37:39.000Z",
|
||||
trace_id: "address-root-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-provenance",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:37:52.000Z",
|
||||
trace_id: "address-provenance-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-docs",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} найден документ закупки.`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:38:16.000Z",
|
||||
trace_id: "address-docs-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_documents_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const restatement = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: restatementMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(restatement.ok).toBe(true);
|
||||
expect(restatement.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
|
||||
expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01");
|
||||
expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31");
|
||||
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
|
||||
|
||||
const recap = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: recapMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(recap.ok).toBe(true);
|
||||
expect(recap.reply_type).toBe("factual_with_explanation");
|
||||
expect(recap.debug?.tool_gate_reason).toBe("memory_recap_followup_detected");
|
||||
expect(recap.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract");
|
||||
expect(String(recap.assistant_reply ?? "")).toContain(item);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores inventory root for bare 'остатки по складу на эту же дату' after receivables drift", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const message = "остатки по складу на эту же дату";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (
|
||||
requestMessage === message &&
|
||||
options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" &&
|
||||
options?.followupContext?.root_filters?.as_of_date === "2020-03-31"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
as_of_date: "2020-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"]
|
||||
}
|
||||
});
|
||||
}
|
||||
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 = `agent-semantic-same-date-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root-stock",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:20:00.000Z",
|
||||
trace_id: "address-root-stock-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
as_of_date: "2020-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-receivables",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Итого подтвержденная дебиторская задолженность на 31.03.2020: 15 404 897,08 ₽.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:25:00.000Z",
|
||||
trace_id: "address-receivables-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "receivables_confirmed_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
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_context_only).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps selected-object document follow-up in inventory contour after an intermediate capability chat turn", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const item = "Столешница 600*3050*26 альмандин";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
const message = `По выбранному объекту "${item}": покажи документы по этой позиции`;
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (
|
||||
requestMessage === message &&
|
||||
options?.followupContext?.previous_intent === "inventory_purchase_provenance_for_item" &&
|
||||
options?.followupContext?.previous_filters?.item === item &&
|
||||
options?.followupContext?.previous_filters?.organization === organization
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: `По позиции ${item} найден подтвержденный документ закупки.`,
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_adjusted_to_inventory_followup_context"]
|
||||
}
|
||||
});
|
||||
}
|
||||
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 = `agent-semantic-meta-docs-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root-inventory",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:29:00.000Z",
|
||||
trace_id: "address-root-seed-2",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-item-provenance",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:30:00.000Z",
|
||||
trace_id: "address-provenance-seed-2",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-capability-chat",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Могу работать с остатками, документами и взаиморасчетами.",
|
||||
reply_type: "factual_with_explanation",
|
||||
created_at: "2026-04-17T17:31:00.000Z",
|
||||
trace_id: "chat-capability-seed",
|
||||
debug: {
|
||||
execution_lane: "living_chat",
|
||||
living_chat_response_source: "deterministic_capability_contract"
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(item);
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses grounded organization from the last factual address answer instead of re-asking for company", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
const message = "какие остатки на складе на март 2021";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (requestMessage === message && options?.activeOrganization === organization) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["organization_scope_restored_from_grounded_address_history"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLimitedLaneResult();
|
||||
})
|
||||
} 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 = `agent-semantic-grounded-org-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-counterparty-seed",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "По Чапурнову подтверждены документы по ООО \"Альтернатива Плюс\".",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T18:05:00.000Z",
|
||||
trace_id: "address-counterparty-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
counterparty: "Чапурнов",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_list_documents_by_counterparty_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.activeOrganization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,10 @@ describe("assistantBoundaryPolicy", () => {
|
||||
organizations: ["ООО Альтернатива Плюс"]
|
||||
});
|
||||
|
||||
expect(reply).toContain("MCP-канале `finance`");
|
||||
expect(reply).toContain("Сейчас доступна организация");
|
||||
expect(reply).toContain("ООО Альтернатива Плюс");
|
||||
expect(reply.toLowerCase()).toContain("read-only");
|
||||
expect(reply).not.toContain("MCP");
|
||||
expect(reply.toLowerCase()).not.toContain("read-only");
|
||||
});
|
||||
|
||||
it("strips unexpected CJK fragments from live chat reply", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("assistant living chat mode", () => {
|
||||
const items = [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
||||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||
debug: {
|
||||
trace_id: "chat-org-scope",
|
||||
living_chat_data_scope_probe_status: "resolved",
|
||||
@@ -91,7 +91,7 @@ describe("assistant living chat mode", () => {
|
||||
message_id: "msg-seed-org-scope",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
||||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||
reply_type: "factual_with_explanation",
|
||||
created_at: new Date().toISOString(),
|
||||
trace_id: "chat-seed-org-scope",
|
||||
@@ -780,7 +780,8 @@ describe("assistant living chat mode", () => {
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("организаций");
|
||||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("mcp");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
@@ -827,7 +828,8 @@ describe("assistant living chat mode", () => {
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("read-only");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("по какой смотреть данные");
|
||||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("read-only");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -42,6 +42,12 @@ describe("assistantLivingModePolicy", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects bare recap wording without 'помнишь' as memory signal", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true);
|
||||
});
|
||||
|
||||
it("routes casual small-talk to chat mode", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
|
||||
@@ -1169,6 +1169,55 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps exact open-items lookup in address lane even when semantic guard overflags deep investigation", () => {
|
||||
const rawUserMessage = "хвосты покажи по счету 60 на август 2022";
|
||||
const effectiveAddressUserMessage = "хвосты по счету 60 на август 2022";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
semanticHints: {
|
||||
exact_data_request_detected: true,
|
||||
account_scope_kind: "explicit",
|
||||
account_scope_text: "60",
|
||||
date_scope_kind: "explicit_period",
|
||||
date_scope_text: "август 2022"
|
||||
}
|
||||
});
|
||||
const semanticExtractionContract = buildAddressSemanticExtractionContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
predecomposeContract
|
||||
});
|
||||
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract,
|
||||
semanticExtractionContract: {
|
||||
...semanticExtractionContract,
|
||||
guard_hints: {
|
||||
...(semanticExtractionContract.guard_hints ?? {}),
|
||||
deep_investigation_signal_detected: true
|
||||
}
|
||||
}
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
|
||||
expect(
|
||||
decision.orchestrationContract?.semantic_route_arbitration?.exact_address_intent_protected_from_semantic_deep_hint
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
|
||||
|
||||
@@ -48,7 +48,19 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
strongDataSignal: false,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true
|
||||
hasPriorAddressDebug: true,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "list_documents_by_counterparty"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||
@@ -67,14 +79,60 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
strongDataSignal: true,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true
|
||||
hasPriorAddressDebug: true,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("builds deterministic recap from prior selected object context", () => {
|
||||
it("does not trigger recap from ungrounded address history", () => {
|
||||
const signals = policy.resolveRouteMemorySignals({
|
||||
rawUserMessage: "а ты помнишь что мы обсуждали?",
|
||||
repairedRawUserMessage: "",
|
||||
effectiveAddressUserMessage: "",
|
||||
repairedEffectiveAddressUserMessage: "",
|
||||
dataScopeMetaQuery: false,
|
||||
capabilityMetaQuery: false,
|
||||
dataRetrievalSignal: false,
|
||||
strongDataSignal: false,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(false);
|
||||
});
|
||||
|
||||
it("builds deterministic recap summary from recent selected-object facts", () => {
|
||||
const context = resolveAssistantLivingChatMemoryContext({
|
||||
modeDecisionReason: "memory_recap_followup_detected",
|
||||
sessionItems: [
|
||||
@@ -82,6 +140,9 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_resolved: "Рабочая станция",
|
||||
extracted_filters: {
|
||||
@@ -89,6 +150,34 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -96,6 +185,50 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
const reply = buildAddressMemoryRecapReply({
|
||||
organization: null,
|
||||
addressDebug: context.lastMemoryAddressDebug,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
toNonEmptyString: (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.length > 0 ? text : null;
|
||||
@@ -104,6 +237,8 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
|
||||
expect(context.contextualMemoryRecapFollowup).toBe(true);
|
||||
expect(reply).toContain("Рабочая станция");
|
||||
expect(reply).toContain("28.02.2022");
|
||||
expect(reply).toContain("мы уже выяснили");
|
||||
expect(reply).toContain("разобрали, кто поставлял");
|
||||
expect(reply).toContain("подняли документы закупки");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,4 +294,27 @@ describe("assistantRoutePolicy", () => {
|
||||
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
||||
expect(decision.orchestrationContract?.provider_execution?.llm_runtime_unavailable_detected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify colloquial VAT root query as non-domain when L0 address gate is positive", () => {
|
||||
const policy = buildPolicy({
|
||||
hasStrongDataIntentSignal: () => true,
|
||||
resolveAddressToolGateDecision: () => ({
|
||||
runAddressLane: true,
|
||||
decision: "run_address_lane",
|
||||
reason: "address_signal_detected"
|
||||
})
|
||||
});
|
||||
|
||||
const decision = policy.resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
|
||||
effectiveAddressUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: true
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateReason).toBe("address_signal_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveDomainPolarityGuard } from "../src/services/assistantRuntimeGuards";
|
||||
|
||||
describe("assistant runtime guards utf8 regression", () => {
|
||||
it("resolves supplier polarity for declined russian account wording", () => {
|
||||
const guard = resolveDomainPolarityGuard({
|
||||
userMessage: "хвосты покажи по счёту 60 на август 2022",
|
||||
focusDomainHint: "settlements_60_62"
|
||||
});
|
||||
|
||||
expect(guard.outcome).toBe("passed");
|
||||
expect(guard.polarity).toBe("supplier_payable");
|
||||
expect(guard.account_scope).toContain("60");
|
||||
expect(guard.resolved_account_anchors).toContain("60");
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,29 @@ describe("assistantTransitionPolicy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes same-date inventory restatement after drilldown into root-scoped carryover", () => {
|
||||
const policy = buildPolicy({
|
||||
hasInventoryRootTemporalFollowupSignal: () => false
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"покажи еще раз остатки на эту же дату",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_root_context");
|
||||
expect(carryover?.followupContext?.root_context_only).toBe(true);
|
||||
expect(carryover?.followupContext?.previous_intent).toBeUndefined();
|
||||
expect(carryover?.followupContext?.previous_filters).toEqual({
|
||||
as_of_date: "2020-03-31",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
});
|
||||
|
||||
it("builds continuation contract from extracted root carryover", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
@@ -146,4 +169,143 @@ describe("assistantTransitionPolicy", () => {
|
||||
expect(contract.anchor_type).toBe("item");
|
||||
expect(contract.anchor_value).toBe("Рабочая станция");
|
||||
});
|
||||
it("prefers carryover target intent over llm contract drift in continuation contract", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const contract = policy.buildAddressDialogContinuationContractV2(
|
||||
"покажи договор по гамме",
|
||||
"покажи договор по гамме",
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "customer_revenue_and_payments",
|
||||
target_intent: "list_contracts_by_counterparty",
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "Гамма-мебель, ООО"
|
||||
},
|
||||
previousSourceIntent: "customer_revenue_and_payments",
|
||||
previousAddressIntent: "customer_revenue_and_payments",
|
||||
followupSelectionMode: "carry_referenced_entity",
|
||||
hasImplicitContinuationSignal: false
|
||||
},
|
||||
{
|
||||
predecomposeContract: {
|
||||
intent: "unknown"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(contract.target_intent).toBe("list_contracts_by_counterparty");
|
||||
expect(contract.decision).toBe("continue_previous");
|
||||
});
|
||||
|
||||
it("drops stale carryover for a fresh standalone topic from another intent family", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Прогноз НДС на март 2020 собран.",
|
||||
debug: {
|
||||
detected_intent: "vat_payable_forecast",
|
||||
extracted_filters: {
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
}
|
||||
}
|
||||
}),
|
||||
hasAddressFollowupContextSignal: () => true,
|
||||
hasStandaloneAddressTopicSignal: () => true,
|
||||
resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date" }),
|
||||
resolveAddressIntentFamily: (intent: unknown) => {
|
||||
if (String(intent ?? "").startsWith("vat_")) return "vat";
|
||||
if (String(intent ?? "").startsWith("inventory_")) return "inventory";
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"остаток на складе за май 2020",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps document intent for short counterparty retarget wording with action verb", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Собран список документов по контрагенту Чапурнов.",
|
||||
debug: {
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
counterparty: "Чапурнов"
|
||||
},
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_resolved: "Чапурнов"
|
||||
}
|
||||
}),
|
||||
buildAddressFollowupOffer: () => ({
|
||||
enabled: true,
|
||||
source_intent: "list_documents_by_counterparty",
|
||||
suggested_intents: ["bank_operations_by_counterparty"]
|
||||
}),
|
||||
isImplicitAddressContinuationByLlm: () => true
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext("покажи по свк", [], null, null, null);
|
||||
|
||||
expect(carryover?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_previous_intent");
|
||||
});
|
||||
it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Собран sale trace по позиции.",
|
||||
debug: {
|
||||
detected_intent: "inventory_sale_trace_for_item",
|
||||
extracted_filters: {
|
||||
item: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_resolved: "Кромка с клеем 33 дуб ниагара 137 м"
|
||||
}
|
||||
}),
|
||||
hasAddressFollowupContextSignal: () => true,
|
||||
resolveAddressIntent: () => ({ intent: "vat_payable_confirmed_as_of_date" }),
|
||||
resolveAddressIntentFamily: (intent: unknown) => {
|
||||
if (String(intent ?? "").startsWith("vat_")) return "vat";
|
||||
if (String(intent ?? "").startsWith("inventory_")) return "inventory";
|
||||
return null;
|
||||
},
|
||||
hasForeignAccountingPivotOverInventoryMessage: () => true,
|
||||
findRecentInventoryRootFrame: () => ({
|
||||
intent: "inventory_on_hand_as_of_date",
|
||||
filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
anchorType: "organization",
|
||||
anchorValue: 'ООО "Альтернатива Плюс"'
|
||||
})
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext("а ндс?", [], null, null, null);
|
||||
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_root_context");
|
||||
expect(carryover?.followupContext?.root_context_only).toBe(true);
|
||||
expect(carryover?.followupContext?.previous_intent).toBeUndefined();
|
||||
expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(carryover?.followupContext?.previous_filters).toEqual({
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
|
||||
|
||||
describe("counterparty analytics reply builders", () => {
|
||||
it("keeps counterparty role split answers business-first", () => {
|
||||
const reply = composeFactualReply(
|
||||
"counterparty_population_and_roles",
|
||||
[
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_TOTAL",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 30,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 12,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_SUPPLIER_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 9,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_MIXED_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 4,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{ userMessage: "покажи роли контрагентов" }
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Распределение ролей по активности:");
|
||||
expect(reply.text).not.toContain("supplier-роль");
|
||||
expect(reply.text).not.toContain("customer-роль");
|
||||
});
|
||||
|
||||
it("formats value rankings without technical 'max single' label", () => {
|
||||
const reply = composeFactualReply(
|
||||
"customer_revenue_and_payments",
|
||||
[
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "Поступление 1",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 500,
|
||||
analytics: ["Клиент А", "Договор А-1"]
|
||||
},
|
||||
{
|
||||
period: "2020-03-02T00:00:00Z",
|
||||
registrator: "Поступление 2",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 1200,
|
||||
analytics: ["Клиент Б", "Договор Б-1"]
|
||||
}
|
||||
],
|
||||
{ userMessage: "с каких клиенктов самый высокий чек" }
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_LIST");
|
||||
expect(reply.text).toContain("максимальной сумме одной входящей операции");
|
||||
expect(reply.text).toContain("максимальная разовая сумма");
|
||||
expect(reply.text).not.toContain("max single");
|
||||
});
|
||||
|
||||
it("explains organization activity age as 1C activity rather than legal age", () => {
|
||||
const reply = composeFactualReply(
|
||||
"counterparty_activity_lifecycle",
|
||||
[
|
||||
{
|
||||
period: "2020-01-15T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVITY",
|
||||
account_dt: "62.01",
|
||||
account_kt: "90.01",
|
||||
amount: 12,
|
||||
analytics: ['ООО "Ромашка"']
|
||||
},
|
||||
{
|
||||
period: "2024-03-10T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVITY",
|
||||
account_dt: "62.01",
|
||||
account_kt: "90.01",
|
||||
amount: 4,
|
||||
analytics: ['ООО "Ландыш"']
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "сколько лет активности в базе 1с у нашей компании",
|
||||
organizationHint: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain('По активности организации ООО "Альтернатива Плюс" в базе 1С');
|
||||
expect(reply.text).toContain("Это возраст активности организации в 1С");
|
||||
});
|
||||
|
||||
it("renders contract usage overview with explicit confirmed wording", () => {
|
||||
const reply = composeFactualReply("contract_usage_overview", [
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CT_TOTAL",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 520,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CT_USED",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 148,
|
||||
analytics: []
|
||||
}
|
||||
]);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Профиль договорной базы собран по справочнику и подтвержденным операциям.");
|
||||
expect(reply.text).toContain("Использованных договоров с подтвержденной связью с операциями: 148.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user