ЮИ - Автопрогоны: доработать иконки и hover-состояния saved sessions

This commit is contained in:
2026-04-18 14:12:11 +03:00
parent 31cb4ccbbb
commit f58ef9ad55
32 changed files with 2214 additions and 132 deletions
@@ -1523,8 +1523,8 @@ function resolveAddressIntent(userMessage) {
const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase();
const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text;
const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text);
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text);
if (hasLooseVatPayableBridge) {
return {
intent: "vat_liability_confirmed_for_tax_period",
+87 -7
View File
@@ -826,6 +826,88 @@ function normalizeCounterpartyName(value) {
.replace(/\s+/g, " ")
.trim();
}
function resolvePreferredCounterpartyReplyLabel(...values) {
const candidates = Array.from(new Set(values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter((value) => value.length > 0)));
if (candidates.length === 0) {
return undefined;
}
const genericPattern = /^(?:группа|контрагент|компания|организация)$/iu;
candidates.sort((left, right) => {
const leftNormalized = normalizeCounterpartyName(left);
const rightNormalized = normalizeCounterpartyName(right);
const leftGeneric = genericPattern.test(leftNormalized);
const rightGeneric = genericPattern.test(rightNormalized);
if (leftGeneric !== rightGeneric) {
return leftGeneric ? 1 : -1;
}
if (leftNormalized && rightNormalized) {
if (leftNormalized.includes(rightNormalized) && leftNormalized !== rightNormalized) {
return -1;
}
if (rightNormalized.includes(leftNormalized) && leftNormalized !== rightNormalized) {
return 1;
}
}
return right.length - left.length;
});
return candidates[0] || undefined;
}
function repairCounterpartyReplyLabel(replyText, intent, extractedFilters) {
if (intent !== "list_documents_by_counterparty" && intent !== "bank_operations_by_counterparty") {
return replyText;
}
const requestedCounterparty = extractedFilters && typeof extractedFilters.counterparty === "string"
? extractedFilters.counterparty.trim()
: "";
if (!requestedCounterparty) {
return replyText;
}
const requestedNormalized = normalizeCounterpartyName(requestedCounterparty);
if (!requestedNormalized) {
return replyText;
}
let repaired = String(replyText ?? "");
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)(?=\s+Найдено\s+документов:)/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}`
: `Контрагент: ${label}`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+)([.\n])/gu, (match, label, suffix) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return match;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
const shouldReplace = labelLooksGeneric || requestedContainsLabel;
return shouldReplace ? `Контрагент: ${requestedCounterparty}${suffix}` : match;
});
repaired = repaired.replace(/Контрагент:\s*([^\n]+?)\.\.([^\n]*)(?=\n|$)/gu, (_match, left, right) => {
const leftPart = String(left ?? "").trim().replace(/[.]+$/u, "");
const rightPart = String(right ?? "").trim().replace(/^[.]+/u, "");
return rightPart.length > 0 ? `Контрагент: ${leftPart}. ${rightPart}` : `Контрагент: ${leftPart}.`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)\.\s+Найдено\s+документов:/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}. Найдено документов:`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}. Найдено документов:`
: `Контрагент: ${label}. Найдено документов:`;
});
return repaired;
}
function hasCounterpartyShipmentItemFlowSignal(userMessage) {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
@@ -2801,11 +2883,9 @@ class AddressQueryService {
};
const composeRuntimeOptions = (filterSet, options = {}) => composeOptionsFromFilters(filterSet, {
...options,
counterpartyHint: typeof options.counterpartyHint === "string"
? options.counterpartyHint
: anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined,
counterpartyHint: resolvePreferredCounterpartyReplyLabel(typeof options.counterpartyHint === "string" ? options.counterpartyHint : undefined, typeof filterSet.counterparty === "string" ? filterSet.counterparty : undefined, anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined),
accountHint: typeof options.accountHint === "string"
? options.accountHint
: typeof filterSet.account === "string"
@@ -3368,7 +3448,7 @@ class AddressQueryService {
});
return {
handled: true,
reply_text: replyText,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
reply_type: (0, composeStage_1.inferReplyType)(responseType),
response_type: responseType,
debug: debugPayload
@@ -3490,7 +3570,7 @@ class AddressQueryService {
});
return {
handled: true,
reply_text: input.replyText,
reply_text: repairCounterpartyReplyLabel(input.replyText, intent.intent, (input.extractedFilters ?? filters.extracted_filters)),
reply_type: (0, composeStage_1.inferReplyType)(input.responseType),
response_type: input.responseType,
debug: debugPayload
@@ -11,6 +11,30 @@ function uniqueStrings(values) {
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function normalizeCounterpartyDisplayLabel(value) {
const source = String(value ?? "").trim().replace(/[.]+$/u, "");
if (!source) {
return null;
}
return source.replace(/\s+/gu, " ");
}
function isGenericCounterpartyDisplayLabel(value) {
const normalized = normalizeCounterpartyDisplayLabel(value);
if (!normalized) {
return true;
}
return /^(?:группа|контрагент|компания|организация)$/iu.test(normalized);
}
function resolvePreferredCounterpartyDisplayLabel(requestedHint, rowLabels) {
const requested = normalizeCounterpartyDisplayLabel(requestedHint);
const resolvedFromRows = rowLabels.length === 1 ? normalizeCounterpartyDisplayLabel(rowLabels[0]) : null;
if (requested && resolvedFromRows) {
if (isGenericCounterpartyDisplayLabel(resolvedFromRows) || requested.includes(resolvedFromRows)) {
return requested;
}
}
return requested ?? resolvedFromRows;
}
function formatTopRows(rows, limit = 6) {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
@@ -3266,15 +3290,10 @@ function composeFactualReplyBody(intent, rows, options = {}) {
};
}
if (intent === "list_documents_by_counterparty") {
const resolvedCounterparty = (typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
? options.counterpartyHint.trim()
: null) ??
(() => {
const counterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
return counterparties.length === 1 ? counterparties[0] : null;
})();
const rowCounterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
const resolvedCounterparty = resolvePreferredCounterpartyDisplayLabel(typeof options.counterpartyHint === "string" ? options.counterpartyHint : null, rowCounterparties);
const counterpartyLabel = typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
: resolvedCounterparty
@@ -476,6 +476,9 @@ function isLikelyMojibakeToken(value) {
if (!token) {
return false;
}
if (/^[А-ЯЁ]{2,5}$/u.test(token)) {
return false;
}
if (MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)) {
return true;
}
@@ -240,7 +240,7 @@ async function runAssistantLivingChatRuntime(input) {
livingChatSource = "deterministic_memory_recap_contract";
}
else if (capabilityMetaQuery) {
chatText = input.buildAssistantCapabilityContractReply();
chatText = input.buildAssistantCapabilityContractReply(userMessage);
livingChatSource = "deterministic_capability_contract";
}
else {
@@ -235,6 +235,11 @@ function createAssistantLivingModePolicy(deps) {
if (hasScopeMetaSignal) {
return true;
}
const hasExplicitDeltaCapabilityMetaSignal = /(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(raw) ||
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(repaired);
if (hasExplicitDeltaCapabilityMetaSignal) {
return true;
}
const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
hasAssistantCapabilityQuestionSignal(repaired) ||
hasOperationalAdminActionRequestSignal(raw) ||
+36 -5
View File
@@ -3694,13 +3694,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
const repairedRawMessageForGate = repairAddressMojibake(rawMessageForGate);
const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedRawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
const rawCapabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedRawMessageForGate);
const capabilityMetaQuery = rawCapabilityMetaQuery ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
const rawDataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(repairedRawMessageForGate);
const dataRetrievalSignal = rawDataRetrievalSignal ||
hasDataRetrievalRequestSignal(repairedInputMessage);
if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
const rawCapabilityMetaOverride = rawCapabilityMetaQuery && !rawDataRetrievalSignal;
if (dataScopeMetaQuery || rawCapabilityMetaOverride || (capabilityMetaQuery && !dataRetrievalSignal)) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@@ -4107,6 +4114,9 @@ function hasAssistantCapabilityQuestionSignal(text) {
if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
return true;
}
if (/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(normalized)) {
return true;
}
const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
@@ -4135,8 +4145,27 @@ function shouldHandleAsAssistantCapabilityMetaQuery(text) {
function hasLivingChatSignal(text) {
return assistantLivingModePolicy.hasLivingChatSignal(text);
}
function buildAssistantCapabilityContractReply() {
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
function buildAssistantCapabilityContractReply(userMessage = "") {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()).replace(/ё/g, "е");
if (/(?:дельт|delta).*(?:договор|контракт)|(?:договор|контракт).*(?:дельт|delta)/iu.test(normalized)) {
return [
"По дельте по договорам отдельный подтвержденный маршрут в текущем контуре пока не включен.",
"То есть я не буду делать вид, что уже считаю ее точно.",
"Сейчас могу помочь с близкими вещами: показать договоры, документы, оплаты, выручку и хвосты по расчетам, чтобы подготовить основу под такой расчет.",
"Если хочешь, следующим проходом можем именно этот контур добить архитектурно."
].join("\n");
}
const registryReply = (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
const normalizedReply = String(registryReply ?? "")
.replace(/в режиме чтения/giu, "")
.replace(/read[_ -]?only/giu, "")
.replace(/По основным группам:/giu, "Основные направления:")
.replace(/Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу\./giu, "Если хотите, можно сразу задать конкретный вопрос по документам, остаткам, НДС, контрагенту или договору.")
.replace(/Что не делаю:\s*/giu, "Не делаю только административные действия: ")
.replace(/\s{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return normalizedReply || "Могу помогать с вопросами по данным 1С: НДС, контрагенты, долги, деньги, договоры и склад.";
}
const assistantProviderExecutionPolicy = (0, assistantProviderExecutionPolicy_1.createAssistantProviderExecutionPolicy)();
const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistantLivingModePolicy)({
@@ -4204,6 +4233,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan
compactWhitespace,
repairAddressMojibake,
countTokens,
shouldHandleAsAssistantCapabilityMetaQuery,
hasDataRetrievalRequestSignal,
findLastAddressAssistantItem,
findLastOrganizationClarificationAddressDebug,
mergeKnownOrganizations,
@@ -3,6 +3,46 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasSelectedObjectInventoryScopeSignal(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(normalized);
}
function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(text ?? ""));
const quotedMatch = repaired.match(/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu);
if (quotedMatch?.[1]) {
return deps.toNonEmptyString(quotedMatch[1]);
}
return null;
}
function inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage = null) {
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
if (samples.length === 0) {
return null;
}
if (samples.some((sample) => /(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test(sample))) {
return "inventory_purchase_provenance_for_item";
}
if (samples.some((sample) => /(?:документ|накладн|счет|сч[её]т|акт|поступл|покажи\s+док|все\s+документ|все\s+операц)/iu.test(sample))) {
return "inventory_purchase_documents_for_item";
}
if (samples.some((sample) => /(?:кому\s+продал|кому\s+ушл|куда\s+продал|кто\s+купил|реализац|отгруз|продаж)/iu.test(sample))) {
return "inventory_sale_trace_for_item";
}
if (samples.some((sample) => /(?:рентабел|маржин|прибыл|наценк|выгод)/iu.test(sample))) {
return "inventory_profitability_for_item";
}
if (samples.some((sample) => /(?:цепочк|от\s+закупк.*до\s+продаж|от\s+покупк.*до\s+продаж)/iu.test(sample))) {
return "inventory_purchase_to_sale_chain";
}
return null;
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@@ -83,6 +123,68 @@ function createAssistantTransitionPolicy(deps) {
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
}
function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") {
return false;
}
const executionLane = deps.toNonEmptyString(debug.execution_lane);
const detectedIntent = deps.toNonEmptyString(debug.detected_intent);
const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe);
const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
? debug.answer_grounding_check
: null;
const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status);
if (groundingStatus === "grounded") {
return true;
}
if (selectedRecipe) {
return true;
}
return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown");
}
function findRecentUsableAddressAssistantItem(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
if (isUsableFollowupSourceDebug(item.debug)) {
return item;
}
}
return null;
}
function readAddressDebugItemHint(debug) {
if (!debug || typeof debug !== "object") {
return null;
}
const extractedFilters = debug.extracted_filters && typeof debug.extracted_filters === "object" ? debug.extracted_filters : null;
return (deps.toNonEmptyString(extractedFilters?.item) ??
(deps.toNonEmptyString(debug.anchor_type) === "item"
? deps.toNonEmptyString(debug.anchor_value_resolved) ?? deps.toNonEmptyString(debug.anchor_value_raw)
: null));
}
function findRecentInventoryPurchaseProvenanceItem(items, itemHint = null) {
const normalizedItemHint = deps.toNonEmptyString(itemHint);
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
if (deps.toNonEmptyString(debug.detected_intent) !== "inventory_purchase_provenance_for_item") {
continue;
}
if (!normalizedItemHint) {
return item;
}
const candidateItem = readAddressDebugItemHint(debug);
if (candidateItem && candidateItem === normalizedItemHint) {
return item;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint &&
@@ -222,7 +324,21 @@ function createAssistantTransitionPolicy(deps) {
return null;
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
const previousAddressItem = deps.findLastAddressAssistantItem(items);
const rawCapabilityMetaQuery = deps.shouldHandleAsAssistantCapabilityMetaQuery(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.shouldHandleAsAssistantCapabilityMetaQuery(String(alternateMessage ?? ""))
: false);
const rawDataRetrievalSignal = deps.hasDataRetrievalRequestSignal(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
return null;
}
const latestAddressItem = deps.findLastAddressAssistantItem(items);
const previousAddressItem = (latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug)
? latestAddressItem
: findRecentUsableAddressAssistantItem(items)) ?? latestAddressItem;
const previousAddressDebug = previousAddressItem?.debug ?? null;
const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
@@ -344,6 +460,7 @@ function createAssistantTransitionPolicy(deps) {
}
const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage)
? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent
@@ -467,10 +584,13 @@ function createAssistantTransitionPolicy(deps) {
? deps.hasFollowupMarker(String(alternateMessage ?? "")) ||
deps.hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? ""));
const hasSelectedObjectInventorySignalPrimary = llmSelectedObjectScopeDetected || hasSelectedObjectInventoryScopeSignal(userMessage);
const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage)
? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? ""))
? hasSelectedObjectInventoryScopeSignal(String(alternateMessage ?? ""))
: false;
const selectedObjectRetargetIntent = hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate
? inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage)
: null;
let inventoryRootFrame = deps.findRecentInventoryRootFrame(items);
if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) {
inventoryRootFrame = {
@@ -540,7 +660,13 @@ function createAssistantTransitionPolicy(deps) {
previousFilters.organization = organizationClarificationSelection;
}
if (inventoryPurchaseDateVatBridge) {
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState);
const purchaseBridgeItem = previousAddressItem &&
deps.toNonEmptyString(previousAddressDebug?.detected_intent) === "inventory_purchase_provenance_for_item"
? previousAddressItem
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
readAddressDebugItemHint(previousAddressDebug) ??
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
@@ -631,8 +757,6 @@ function createAssistantTransitionPolicy(deps) {
}
if (!rootScopedPivot &&
!deps.toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
@@ -642,10 +766,13 @@ function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "inventory_aging_by_purchase_date" ||
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)) {
previousFilters.item = navigationFocusObjectLabel;
if (!previousAnchor) {
const selectedObjectLabel = (navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ??
extractSelectedObjectLabel(userMessage) ??
(deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null);
if (selectedObjectLabel) {
previousFilters.item = selectedObjectLabel;
previousAnchorType = "item";
previousAnchor = navigationFocusObjectLabel;
previousAnchor = selectedObjectLabel;
}
}
if (organizationClarificationSelection && !previousAnchor) {
@@ -681,11 +808,16 @@ function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalAlternate));
const carryoverTargetIntent = inventoryPurchaseDateVatBridge
? "vat_liability_confirmed_for_tax_period"
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date"
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
: selectedObjectRetargetIntent &&
(explicitIntent === null ||
explicitIntent === "inventory_on_hand_as_of_date" ||
explicitIntent === sourceIntent)
? selectedObjectRetargetIntent
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date"
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
return {
followupContext: {
previous_intent: previousIntent ?? undefined,
@@ -1907,10 +1907,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
const hasLooseVatPayableBridge =
/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(
text
) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(
text
);
if (hasLooseVatPayableBridge) {
@@ -1056,6 +1056,99 @@ function normalizeCounterpartyName(value: string): string {
.trim();
}
function resolvePreferredCounterpartyReplyLabel(...values: Array<string | null | undefined>): string | undefined {
const candidates = Array.from(
new Set(
values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter((value) => value.length > 0)
)
);
if (candidates.length === 0) {
return undefined;
}
const genericPattern = /^(?:группа|контрагент|компания|организация)$/iu;
candidates.sort((left, right) => {
const leftNormalized = normalizeCounterpartyName(left);
const rightNormalized = normalizeCounterpartyName(right);
const leftGeneric = genericPattern.test(leftNormalized);
const rightGeneric = genericPattern.test(rightNormalized);
if (leftGeneric !== rightGeneric) {
return leftGeneric ? 1 : -1;
}
if (leftNormalized && rightNormalized) {
if (leftNormalized.includes(rightNormalized) && leftNormalized !== rightNormalized) {
return -1;
}
if (rightNormalized.includes(leftNormalized) && leftNormalized !== rightNormalized) {
return 1;
}
}
return right.length - left.length;
});
return candidates[0] || undefined;
}
function repairCounterpartyReplyLabel(
replyText: string,
intent: string,
extractedFilters: AddressFilterSet | null | undefined
): string {
if (intent !== "list_documents_by_counterparty" && intent !== "bank_operations_by_counterparty") {
return replyText;
}
const requestedCounterparty =
extractedFilters && typeof extractedFilters.counterparty === "string"
? extractedFilters.counterparty.trim()
: "";
if (!requestedCounterparty) {
return replyText;
}
const requestedNormalized = normalizeCounterpartyName(requestedCounterparty);
if (!requestedNormalized) {
return replyText;
}
let repaired = String(replyText ?? "");
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)(?=\s+Найдено\s+документов:)/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}`
: `Контрагент: ${label}`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+)([.\n])/gu, (match, label, suffix) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return match;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
const shouldReplace = labelLooksGeneric || requestedContainsLabel;
return shouldReplace ? `Контрагент: ${requestedCounterparty}${suffix}` : match;
});
repaired = repaired.replace(/Контрагент:\s*([^\n]+?)\.\.([^\n]*)(?=\n|$)/gu, (_match, left, right) => {
const leftPart = String(left ?? "").trim().replace(/[.]+$/u, "");
const rightPart = String(right ?? "").trim().replace(/^[.]+/u, "");
return rightPart.length > 0 ? `Контрагент: ${leftPart}. ${rightPart}` : `Контрагент: ${leftPart}.`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)\.\s+Найдено\s+документов:/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}. Найдено документов:`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}. Найдено документов:`
: `Контрагент: ${label}. Найдено документов:`;
});
return repaired;
}
function hasCounterpartyShipmentItemFlowSignal(userMessage: string): boolean {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
@@ -3497,12 +3590,13 @@ export class AddressQueryService {
) =>
composeOptionsFromFilters(filterSet, {
...options,
counterpartyHint:
typeof options.counterpartyHint === "string"
? options.counterpartyHint
: anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined,
counterpartyHint: resolvePreferredCounterpartyReplyLabel(
typeof options.counterpartyHint === "string" ? options.counterpartyHint : undefined,
typeof filterSet.counterparty === "string" ? filterSet.counterparty : undefined,
anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined
),
accountHint:
typeof options.accountHint === "string"
? options.accountHint
@@ -4127,7 +4221,7 @@ export class AddressQueryService {
);
return {
handled: true,
reply_text: replyText,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
reply_type: inferReplyType(responseType),
response_type: responseType,
debug: debugPayload
@@ -4294,7 +4388,11 @@ export class AddressQueryService {
);
return {
handled: true,
reply_text: input.replyText,
reply_text: repairCounterpartyReplyLabel(
input.replyText,
intent.intent,
(input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet
),
reply_type: inferReplyType(input.responseType),
response_type: input.responseType,
debug: debugPayload
@@ -100,6 +100,37 @@ function uniqueStrings(values: string[]): string[] {
);
}
function normalizeCounterpartyDisplayLabel(value: string | null | undefined): string | null {
const source = String(value ?? "").trim().replace(/[.]+$/u, "");
if (!source) {
return null;
}
return source.replace(/\s+/gu, " ");
}
function isGenericCounterpartyDisplayLabel(value: string | null | undefined): boolean {
const normalized = normalizeCounterpartyDisplayLabel(value);
if (!normalized) {
return true;
}
return /^(?:группа|контрагент|компания|организация)$/iu.test(normalized);
}
function resolvePreferredCounterpartyDisplayLabel(
requestedHint: string | null | undefined,
rowLabels: string[]
): string | null {
const requested = normalizeCounterpartyDisplayLabel(requestedHint);
const resolvedFromRows =
rowLabels.length === 1 ? normalizeCounterpartyDisplayLabel(rowLabels[0]) : null;
if (requested && resolvedFromRows) {
if (isGenericCounterpartyDisplayLabel(resolvedFromRows) || requested.includes(resolvedFromRows)) {
return requested;
}
}
return requested ?? resolvedFromRows;
}
function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
@@ -4181,18 +4212,15 @@ function composeFactualReplyBody(
}
if (intent === "list_documents_by_counterparty") {
const resolvedCounterparty =
(typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
? options.counterpartyHint.trim()
: null) ??
(() => {
const counterparties = uniqueStrings(
rows
.map((row) => extractCounterpartyName(row))
.filter((item): item is string => Boolean(item))
);
return counterparties.length === 1 ? counterparties[0] : null;
})();
const rowCounterparties = uniqueStrings(
rows
.map((row) => extractCounterpartyName(row))
.filter((item): item is string => Boolean(item))
);
const resolvedCounterparty = resolvePreferredCounterpartyDisplayLabel(
typeof options.counterpartyHint === "string" ? options.counterpartyHint : null,
rowCounterparties
);
const counterpartyLabel =
typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
@@ -567,6 +567,9 @@ function isLikelyMojibakeToken(value: string): boolean {
if (!token) {
return false;
}
if (/^[А-ЯЁ]{2,5}$/u.test(token)) {
return false;
}
if (MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)) {
return true;
}
@@ -56,7 +56,7 @@ export interface AssistantLivingChatRuntimeInput {
buildAssistantOrganizationFactBoundaryReply: (organization: string | null) => string;
buildAssistantDataScopeSelectionReply: (organization: string | null) => string;
buildAssistantOperationalBoundaryReply: () => string;
buildAssistantCapabilityContractReply: () => string;
buildAssistantCapabilityContractReply: (userMessage?: string) => string;
}
export interface AssistantLivingChatRuntimeOutput {
@@ -336,7 +336,7 @@ export async function runAssistantLivingChatRuntime(
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_memory_recap_contract";
} else if (capabilityMetaQuery) {
chatText = input.buildAssistantCapabilityContractReply();
chatText = input.buildAssistantCapabilityContractReply(userMessage);
livingChatSource = "deterministic_capability_contract";
} else {
chatText = await input.executeLlmChat();
@@ -304,6 +304,12 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
if (hasScopeMetaSignal) {
return true;
}
const hasExplicitDeltaCapabilityMetaSignal =
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(raw) ||
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(repaired);
if (hasExplicitDeltaCapabilityMetaSignal) {
return true;
}
const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
hasAssistantCapabilityQuestionSignal(repaired) ||
hasOperationalAdminActionRequestSignal(raw) ||
@@ -3649,13 +3649,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
const repairedRawMessageForGate = repairAddressMojibake(rawMessageForGate);
const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedRawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
const rawCapabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedRawMessageForGate);
const capabilityMetaQuery = rawCapabilityMetaQuery ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
const rawDataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(repairedRawMessageForGate);
const dataRetrievalSignal = rawDataRetrievalSignal ||
hasDataRetrievalRequestSignal(repairedInputMessage);
if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
const rawCapabilityMetaOverride = rawCapabilityMetaQuery && !rawDataRetrievalSignal;
if (dataScopeMetaQuery || rawCapabilityMetaOverride || (capabilityMetaQuery && !dataRetrievalSignal)) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@@ -4064,6 +4071,9 @@ function hasAssistantCapabilityQuestionSignal(text) {
if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
return true;
}
if (/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(normalized)) {
return true;
}
const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
@@ -4092,8 +4102,27 @@ function shouldHandleAsAssistantCapabilityMetaQuery(text) {
function hasLivingChatSignal(text) {
return assistantLivingModePolicy.hasLivingChatSignal(text);
}
function buildAssistantCapabilityContractReply() {
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
function buildAssistantCapabilityContractReply(userMessage = "") {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()).replace(/ё/g, "е");
if (/(?:дельт|delta).*(?:договор|контракт)|(?:договор|контракт).*(?:дельт|delta)/iu.test(normalized)) {
return [
"По дельте по договорам отдельный подтвержденный маршрут в текущем контуре пока не включен.",
"То есть я не буду делать вид, что уже считаю ее точно.",
"Сейчас могу помочь с близкими вещами: показать договоры, документы, оплаты, выручку и хвосты по расчетам, чтобы подготовить основу под такой расчет.",
"Если хочешь, следующим проходом можем именно этот контур добить архитектурно."
].join("\n");
}
const registryReply = (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
const normalizedReply = String(registryReply ?? "")
.replace(/в режиме чтения/giu, "")
.replace(/read[_ -]?only/giu, "")
.replace(/По основным группам:/giu, "Основные направления:")
.replace(/Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу\./giu, "Если хотите, можно сразу задать конкретный вопрос по документам, остаткам, НДС, контрагенту или договору.")
.replace(/Что не делаю:\s*/giu, "Не делаю только административные действия: ")
.replace(/\s{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return normalizedReply || "Могу помогать с вопросами по данным 1С: НДС, контрагенты, долги, деньги, договоры и склад.";
}
const assistantProviderExecutionPolicy = (0, assistantProviderExecutionPolicy_1.createAssistantProviderExecutionPolicy)();
const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistantLivingModePolicy)({
@@ -4161,6 +4190,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan
compactWhitespace,
repairAddressMojibake,
countTokens,
shouldHandleAsAssistantCapabilityMetaQuery,
hasDataRetrievalRequestSignal,
findLastAddressAssistantItem,
findLastOrganizationClarificationAddressDebug,
mergeKnownOrganizations,
@@ -1,6 +1,68 @@
// @ts-nocheck
export function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasSelectedObjectInventoryScopeSignal(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(
normalized
);
}
function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(text ?? ""));
const quotedMatch = repaired.match(
/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu
);
if (quotedMatch?.[1]) {
return deps.toNonEmptyString(quotedMatch[1]);
}
return null;
}
function inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage = null) {
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
if (samples.length === 0) {
return null;
}
if (
samples.some((sample) =>
/(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test(
sample
)
)
) {
return "inventory_purchase_provenance_for_item";
}
if (
samples.some((sample) =>
/(?:документ|накладн|счет|сч[её]т|акт|поступл|покажи\s+док|все\s+документ|все\s+операц)/iu.test(sample)
)
) {
return "inventory_purchase_documents_for_item";
}
if (
samples.some((sample) =>
/(?:кому\s+продал|кому\s+ушл|куда\s+продал|кто\s+купил|реализац|отгруз|продаж)/iu.test(sample)
)
) {
return "inventory_sale_trace_for_item";
}
if (samples.some((sample) => /(?:рентабел|маржин|прибыл|наценк|выгод)/iu.test(sample))) {
return "inventory_profitability_for_item";
}
if (samples.some((sample) => /(?:цепочк|от\s+закупк.*до\s+продаж|от\s+покупк.*до\s+продаж)/iu.test(sample))) {
return "inventory_purchase_to_sale_chain";
}
return null;
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@@ -90,6 +152,76 @@ export function createAssistantTransitionPolicy(deps) {
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
}
function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") {
return false;
}
const executionLane = deps.toNonEmptyString(debug.execution_lane);
const detectedIntent = deps.toNonEmptyString(debug.detected_intent);
const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe);
const answerGroundingCheck =
debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
? debug.answer_grounding_check
: null;
const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status);
if (groundingStatus === "grounded") {
return true;
}
if (selectedRecipe) {
return true;
}
return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown");
}
function findRecentUsableAddressAssistantItem(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
if (isUsableFollowupSourceDebug(item.debug)) {
return item;
}
}
return null;
}
function readAddressDebugItemHint(debug) {
if (!debug || typeof debug !== "object") {
return null;
}
const extractedFilters =
debug.extracted_filters && typeof debug.extracted_filters === "object" ? debug.extracted_filters : null;
return (
deps.toNonEmptyString(extractedFilters?.item) ??
(deps.toNonEmptyString(debug.anchor_type) === "item"
? deps.toNonEmptyString(debug.anchor_value_resolved) ?? deps.toNonEmptyString(debug.anchor_value_raw)
: null)
);
}
function findRecentInventoryPurchaseProvenanceItem(items, itemHint = null) {
const normalizedItemHint = deps.toNonEmptyString(itemHint);
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
if (deps.toNonEmptyString(debug.detected_intent) !== "inventory_purchase_provenance_for_item") {
continue;
}
if (!normalizedItemHint) {
return item;
}
const candidateItem = readAddressDebugItemHint(debug);
if (candidateItem && candidateItem === normalizedItemHint) {
return item;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if (
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
@@ -270,7 +402,24 @@ export function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta = null,
addressNavigationState = null
) {
const previousAddressItem = deps.findLastAddressAssistantItem(items);
const rawCapabilityMetaQuery =
deps.shouldHandleAsAssistantCapabilityMetaQuery(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.shouldHandleAsAssistantCapabilityMetaQuery(String(alternateMessage ?? ""))
: false);
const rawDataRetrievalSignal =
deps.hasDataRetrievalRequestSignal(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
return null;
}
const latestAddressItem = deps.findLastAddressAssistantItem(items);
const previousAddressItem =
(latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug)
? latestAddressItem
: findRecentUsableAddressAssistantItem(items)) ?? latestAddressItem;
const previousAddressDebug = previousAddressItem?.debug ?? null;
const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
@@ -430,6 +579,8 @@ export function createAssistantTransitionPolicy(deps) {
}
const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected =
llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage)
? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent
@@ -567,14 +718,15 @@ export function createAssistantTransitionPolicy(deps) {
? deps.hasFollowupMarker(String(alternateMessage ?? "")) ||
deps.hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:РїРѕ\s+РІСбранному\s+объекССѓ|РїРѕ\s+СЌСРѕР\s+РїРѕР·РёСРёРё|РїРѕ\s+СЌСРѕРјСѓ\s+Совару|selected\s+object)/iu.test(
String(userMessage ?? "")
);
const hasSelectedObjectInventorySignalPrimary =
llmSelectedObjectScopeDetected || hasSelectedObjectInventoryScopeSignal(userMessage);
const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage)
? /(?:РїРѕ\s+РІСбранному\s+объекССѓ|РїРѕ\s+СЌСРѕР\s+РїРѕР·РёСРёРё|РїРѕ\s+СЌСРѕРјСѓ\s+Совару|selected\s+object)/iu.test(
String(alternateMessage ?? "")
)
? hasSelectedObjectInventoryScopeSignal(String(alternateMessage ?? ""))
: false;
const selectedObjectRetargetIntent =
hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate
? inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage)
: null;
let inventoryRootFrame = deps.findRecentInventoryRootFrame(items);
if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) {
inventoryRootFrame = {
@@ -649,7 +801,17 @@ export function createAssistantTransitionPolicy(deps) {
previousFilters.organization = organizationClarificationSelection;
}
if (inventoryPurchaseDateVatBridge) {
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState);
const purchaseBridgeItem =
previousAddressItem &&
deps.toNonEmptyString(previousAddressDebug?.detected_intent) === "inventory_purchase_provenance_for_item"
? previousAddressItem
: findRecentInventoryPurchaseProvenanceItem(
items,
deps.toNonEmptyString(navigationFocusObjectLabel) ??
readAddressDebugItemHint(previousAddressDebug) ??
deps.toNonEmptyString(previousFilters.item)
) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
@@ -761,8 +923,6 @@ export function createAssistantTransitionPolicy(deps) {
if (
!rootScopedPivot &&
!deps.toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
@@ -773,10 +933,14 @@ export function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)
) {
previousFilters.item = navigationFocusObjectLabel;
if (!previousAnchor) {
const selectedObjectLabel =
(navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ??
extractSelectedObjectLabel(userMessage) ??
(deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null);
if (selectedObjectLabel) {
previousFilters.item = selectedObjectLabel;
previousAnchorType = "item";
previousAnchor = navigationFocusObjectLabel;
previousAnchor = selectedObjectLabel;
}
}
if (organizationClarificationSelection && !previousAnchor) {
@@ -817,6 +981,11 @@ export function createAssistantTransitionPolicy(deps) {
const carryoverTargetIntent =
inventoryPurchaseDateVatBridge
? "vat_liability_confirmed_for_tax_period"
: selectedObjectRetargetIntent &&
(explicitIntent === null ||
explicitIntent === "inventory_on_hand_as_of_date" ||
explicitIntent === sourceIntent)
? selectedObjectRetargetIntent
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot
@@ -130,6 +130,134 @@ describe("counterparty shipment item flow and open-items routing", () => {
expect(query).not.toContain("контрагентом");
});
it("keeps resolved counterparty group name in user-facing document reply for svk wording", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Группа СВК",
Registrator: "Группа СВК"
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2021-11-10T12:00:07Z",
Registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
AccountDt: "0",
AccountKt: "0",
Amount: 20000,
Counterparty: "Группа СВК",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("покажи документы по свк");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Группа СВК");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Группа Найдено");
});
it("prefers requested full counterparty label when document rows only expose a generic group label", () => {
const reply = composeFactualReply(
"list_documents_by_counterparty",
[
{
period: "2021-11-10T12:00:07Z",
registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
account_dt: "0",
account_kt: "0",
amount: 20000,
analytics: ["Группа", "Договор № 1-ПМ/2020 от 05.06.2020"],
organization: 'ООО "Альтернатива Плюс"'
}
],
{
userMessage: "а по свк",
counterpartyHint: "Группа СВК"
}
);
expect(reply.text).toContain("Контрагент: Группа СВК. Найдено документов: 1.");
expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов");
});
it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Группа СВК",
Registrator: "Группа СВК"
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2021-11-10T12:00:07Z",
Registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
AccountDt: "0",
AccountKt: "0",
Amount: 20000,
Counterparty: "Группа",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
},
{
Period: "2021-09-29T12:00:03Z",
Registrator: "Поступление на расчетный счет 00000000012 от 29.09.2021 12:00:03",
AccountDt: "0",
AccountKt: "0",
Amount: 50000,
Counterparty: "Группа",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("а по свк", {
followupContext: {
previous_intent: "list_documents_by_counterparty",
previous_filters: {
counterparty: "Чепурнов П.Д."
},
previous_anchor_type: "counterparty",
previous_anchor_value: "Чепурнов П.Д."
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Группа СВК");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Группа Найдено");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Чепурнов");
});
it("explains supplier payments and return when no supply rows are found", async () => {
executeAddressMcpQueryMock.mockImplementation(async (request?: { query?: string }) => {
const query = String(request?.query ?? "");
@@ -9,18 +9,27 @@ import { AddressQueryService } from "../src/services/addressQueryService";
describe("vat payable confirmed as-of route", () => {
it("routes VAT payable question into exact confirmed intent", () => {
const result = resolveAddressIntent("сколько НДС к уплате на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(
result.reasons.includes("vat_liability_confirmed_tax_period_signal_detected") ||
result.reasons.includes("vat_liability_colloquial_bridge_signal_detected")
).toBe(true);
});
it("treats colloquial 'сгрузить' wording as confirmed VAT payable intent", () => {
const result = resolveAddressIntent("какой НДС мы должны сгрузить на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_liability_colloquial_bridge_signal_detected");
});
it("treats colloquial VAT wording with month in prepositional case as confirmed intent", () => {
const result = resolveAddressIntent("Какой НДС необходимо сгрузить в марте 2020 года?");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_liability_colloquial_bridge_signal_detected");
});
it("keeps VAT forecast intent when explicit forecast wording is used", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
expect(result.intent).toBe("vat_payable_forecast");
expect(result.reasons).toContain("forecast_tax_signal_detected");
});
@@ -63,8 +72,8 @@ describe("vat payable confirmed as-of route", () => {
const service = new AddressQueryService();
const result = await service.tryHandle("сколько НДС к уплате на март 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.detected_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.debug.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
@@ -44,5 +44,17 @@ describe("answer composer user-facing formatting", () => {
expect(sanitized).not.toContain("technical_debug_payload_json");
expect(sanitized).not.toContain("trace_id");
});
});
it("keeps short uppercase Cyrillic counterparty acronyms in user-facing text", () => {
const source = [
"Контрагент: Группа СВК. Найдено документов: 2.",
"1. 2021-11-10 | Поступление на расчетный счет.",
"2. 2021-09-29 | Поступление на расчетный счет."
].join("\n");
const sanitized = sanitizeAssistantReplyForUserFacing(source);
expect(sanitized).toContain("Группа СВК");
expect(sanitized).toContain("Найдено документов: 2.");
});
});
@@ -410,7 +410,7 @@ 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("могу помочь");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
expect(String(response.assistant_reply)).not.toContain("vat_period_snapshot");
expect(String(response.assistant_reply)).not.toContain("inventory_on_hand_as_of_date");
@@ -608,7 +608,7 @@ 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("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
@@ -655,13 +655,165 @@ 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("режиме чтения");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("treats delta-by-contracts wording as capability meta instead of stale revenue follow-up", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-contract-delta-capability",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-contract-delta-capability";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-revenue-answer",
session_id: sessionId,
role: "assistant",
text: "Самый доходный клиент за все время — Гамма-мебель, ООО.",
reply_type: "factual",
created_at: new Date().toISOString(),
trace_id: "address-seed-revenue-answer",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_mode: "all_time"
}
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-contract-delta-capability-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "ты умеешь считать дельту по договорам?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("самый доходный клиент");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("keeps delta-by-contracts wording in capability meta mode after canonical rewrite", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: true,
trace_id: "norm-contract-delta-capability-rewrite",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: {
query: "проверить возможность расчета дельты по договорам"
},
validation: { passed: true, errors: [] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-contract-delta-capability-rewrite";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-vat-answer",
session_id: sessionId,
role: "assistant",
text: "Собран подтвержденный расчет НДС к уплате за февраль 2017.",
reply_type: "factual",
created_at: new Date().toISOString(),
trace_id: "address-seed-vat-answer",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "vat_liability_confirmed_for_tax_period",
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
extracted_filters: {
organization: "ООО Альтернатива Плюс",
as_of_date: "2017-02-28",
period_from: "2017-02-01",
period_to: "2017-02-28"
}
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-contract-delta-capability-rewrite-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "ты умеешь считать дельту по договорам?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
expect(String(response.assistant_reply)).not.toContain("февраль 2017");
expect(String(response.assistant_reply)).not.toContain("0,00");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("answers historical capability follow-up in current inventory context instead of generic capability contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
@@ -15,6 +15,8 @@ function buildPolicy(overrides: Record<string, unknown> = {}) {
compactWhitespace: (value: string) => String(value ?? "").replace(/\s+/g, " ").trim(),
repairAddressMojibake: (value: string) => value,
countTokens: (value: string) => String(value ?? "").split(/\s+/).filter(Boolean).length,
shouldHandleAsAssistantCapabilityMetaQuery: () => false,
hasDataRetrievalRequestSignal: () => false,
findLastAddressAssistantItem: () => ({
text: "1. Рабочая станция",
debug: {
@@ -381,6 +383,88 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
});
it("keeps purchase-date VAT bridge after unsupported verification interrupt", () => {
const item = "Рабочая станция универрсального специалиста";
const provenanceItem = {
role: "assistant",
text: [
`По позиции ${item} однозначный поставщик не подтвержден.`,
"Подтверждение:",
"- Первая найденная дата закупки: 05.02.2015."
].join("\n"),
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item,
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2016-03-31"
},
anchor_type: "item",
anchor_value_resolved: item
}
} as any;
const unsupportedInterrupt = {
role: "assistant",
text: "Пока не могу точно подтвердить, что именно это ты имеешь в виду.",
debug: {
execution_lane: "address_query",
detected_intent: "unknown",
answer_grounding_check: {
status: "unsupported"
}
}
} as any;
const policy = buildPolicy({
findLastAddressAssistantItem: () => unsupportedInterrupt,
hasAddressFollowupContextSignal: () => false,
findRecentInventoryRootFrame: () => null,
resolveAddressIntent: () => ({ intent: "unknown" })
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"ндс можешь прикинуть на дату покупки рабочей станции?",
[provenanceItem, unsupportedInterrupt],
null,
null,
{
session_context: {
active_focus_object: {
object_type: "item",
label: item,
provenance_result_set_id: "rs-provenance-interrupt"
},
active_result_set_id: "rs-provenance-interrupt"
},
result_sets: [
{
result_set_id: "rs-provenance-interrupt",
intent: "inventory_purchase_provenance_for_item",
entity_refs: [
{
index: 1,
entity_type: "item",
value: "Поступление товаров и услуг 00000000023 от 05.02.2015 0:00:00"
}
]
}
]
}
);
expect(carryover?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item");
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
item,
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28"
});
});
it("drops stale carryover for a fresh standalone topic from another intent family", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
@@ -520,4 +604,71 @@ describe("assistantTransitionPolicy", () => {
period_to: "2020-03-31"
});
});
it("does not attach address follow-up carryover to explicit capability-meta questions", () => {
const policy = buildPolicy({
shouldHandleAsAssistantCapabilityMetaQuery: (message: unknown) =>
/дельт[ауы]?\s+по\s+договорам/iu.test(String(message ?? "")),
hasAddressFollowupContextSignal: () => true
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"ты умеешь считать дельту по договорам?",
[],
"проверить возможность расчета дельты по договорам",
{
predecomposeContract: {
mode: "address_query",
intent: "unknown"
}
},
null
);
expect(carryover).toBeNull();
});
it("retargets selected-object provenance follow-up from inventory root when semantic scope is already detected", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
text: "На 31.03.2016 на складе подтверждено 2 позиции.",
debug: {
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2016-03-31",
period_from: "2016-03-01",
period_to: "2016-03-31"
},
anchor_type: "organization",
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
}
}),
hasAddressFollowupContextSignal: () => true
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где взяли это?',
[],
null,
{
predecomposeContract: {
mode: "address_query",
intent: "unknown",
semantics: {
selected_object_scope_detected: true
}
}
},
null
);
expect(carryover?.followupContext?.target_intent).toBe("inventory_purchase_provenance_for_item");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
organization: 'ООО "Альтернатива Плюс"',
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)"
});
expect(carryover?.followupContext?.previous_anchor_type).toBe("item");
});
});