ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов

This commit is contained in:
dctouch 2026-04-17 02:08:02 +03:00
parent a3a61b3a0f
commit 44f1c1e11e
33 changed files with 22512 additions and 173 deletions

View File

@ -0,0 +1,2 @@
[trace-completeness] trace_id=s94Q97I27ZGMIP schema=v1 issues=missing_parsed_normalized_json
[trace-completeness] trace_id=cbWexcMb4f6HXF schema=v1 issues=missing_parsed_normalized_json

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,87 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_targeted_counterparty_tails",
"domain": "address_targeted_counterparty_tails",
"title": "Targeted live replay for counterparty documents, shipment items, inventory reset, and account-60 tails",
"description": "Strict short replay for the current targeted regressions without a full long-session autorun.",
"bindings": {},
"steps": [
{
"step_id": "step_01_documents_by_counterparty",
"title": "Counterparty documents with normalized name",
"question": "покажи все документы по чапурнову",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"required_direct_answer_patterns_any": [
"(?i)контрагент:",
"(?i)чапурнов"
]
},
{
"step_id": "step_02_counterparty_item_flow",
"title": "Counterparty shipment items",
"question": "что нам отгружал чапурнов? какой товар или услугу?",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"required_direct_answer_patterns_any": [
"(?i)контрагент:",
"(?i)позиции:",
"(?i)чапурнов"
],
"forbidden_direct_answer_patterns": [
"(?i)^по текущим условиям в доступном срезе данных совпадений не нашлось",
"(?i)^сейчас не дам прямой адресный ответ"
]
},
{
"step_id": "step_03_inventory_reset",
"title": "Inventory root after counterparty thread",
"question": "какие остатки на складе на сегодня?",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"required_direct_answer_patterns_any": [
"(?i)на складе",
"(?i)остат"
],
"forbidden_direct_answer_patterns": [
"(?i)чапурнов",
"(?i)контрагент:"
]
},
{
"step_id": "step_04_open_items_account_60",
"title": "Account 60 tails for August 2022",
"question": "хвосты покажи по счету 60 на август 2022",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"expected_intents": [
"open_items_by_counterparty_or_contract"
],
"required_filters": {
"account": "60",
"period_from": "2022-08-01",
"period_to": "2022-08-31",
"as_of_date": "2022-08-31"
},
"required_direct_answer_patterns_any": [
"(?i)счету 60",
"(?i)хвост"
]
}
]
}

View File

@ -432,6 +432,9 @@ function extractYearRangePeriod(text) {
}; };
} }
function cleanupAnchorValue(value) { function cleanupAnchorValue(value) {
const stripLeadingEntityRole = (text) => String(text ?? "")
.replace(/^(?:(?:по\s+)?(?:контрагент(?:ом|у|а|ы|ов)?|поставщик(?:ом|у|а|и|ов)?|клиент(?:ом|у|а|ы|ов)?|покупател(?:ем|ю|я|и|ей)|продав(?:цом|цу|ца|цы|цов)|заказчик(?:ом|у|а|и|ов)?|исполнител(?:ем|ю|я|и|ей)|подрядчик(?:ом|у|а|и|ов)?))\s+/iu, "")
.trim();
const stripOuterQuotes = (text) => String(text ?? "") const stripOuterQuotes = (text) => String(text ?? "")
.replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "") .replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "")
.trim(); .trim();
@ -439,6 +442,10 @@ function cleanupAnchorValue(value) {
if (!cleaned) { if (!cleaned) {
return ""; return "";
} }
cleaned = stripOuterQuotes(stripLeadingEntityRole(cleaned.replace(/[?!]+$/u, "").trim()));
if (!cleaned) {
return "";
}
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes: // Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31". // "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
const asOfTailPattern = /\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu; const asOfTailPattern = /\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
@ -471,7 +478,7 @@ function cleanupAnchorValue(value) {
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "") .replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "") .replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim(); .trim();
return stripOuterQuotes(cleaned); return stripOuterQuotes(stripLeadingEntityRole(cleaned));
} }
function cleanupContractAnchorValue(value) { function cleanupContractAnchorValue(value) {
let normalized = cleanupAnchorValue(value); let normalized = cleanupAnchorValue(value);
@ -568,6 +575,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (!value) { if (!value) {
return true; return true;
} }
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
return true;
}
const tokens = value const tokens = value
.split(/[^a-zа-я0-9]+/iu) .split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim()) .map((token) => token.trim())
@ -601,6 +611,16 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
"дата", "дата",
"конец", "конец",
"период", "период",
"весь",
"все",
"всё",
"всю",
"всех",
"всего",
"время",
"история",
"истории",
"срок",
"месяц", "месяц",
"году", "году",
"год", "год",
@ -800,6 +820,30 @@ function extractLeadingCounterpartyTokenHeuristic(text) {
} }
return undefined; return undefined;
} }
function extractShipmentCounterpartyValue(text) {
const hasShipmentCounterpartyTokenShape = (token) => {
const source = String(token ?? "").trim();
if (!source) {
return false;
}
if (hasStrongCounterpartyTokenShape(source)) {
return true;
}
return /^[\p{L}]{5,}$/u.test(source);
};
const match = String(text ?? "").match(/(?:что\s+нам\s+)?(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
const candidate = String(match[1] ?? "").trim();
if (!candidate || !hasShipmentCounterpartyTokenShape(candidate)) {
return undefined;
}
if (!isLikelyCounterpartyToken(candidate)) {
return undefined;
}
return candidate;
}
function hasExplicitAccountCue(text) { function hasExplicitAccountCue(text) {
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? "")); return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
} }
@ -1447,6 +1491,17 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("counterparty_anchor_derived_from_implicit_phrase"); warnings.push("counterparty_anchor_derived_from_implicit_phrase");
} }
} }
if (!filters.counterparty &&
allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")) {
const shipmentCounterparty = extractShipmentCounterpartyValue(text);
if (shipmentCounterparty) {
filters.counterparty = cleanupAnchorValue(shipmentCounterparty);
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
}
}
if (!filters.counterparty && if (!filters.counterparty &&
allowGenericCounterpartyAnchor && allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" || (intent === "list_documents_by_counterparty" ||

View File

@ -562,7 +562,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text) {
if (!hasVatLexeme) { if (!hasVatLexeme) {
return false; return false;
} }
const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text); const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(text);
if (!hasPaymentCue) { if (!hasPaymentCue) {
return false; return false;
} }
@ -587,11 +587,11 @@ function hasVatPayableConfirmedSignal(text) {
if (!hasVatLexeme) { if (!hasVatLexeme) {
return false; return false;
} }
const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text); const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(text);
if (!hasPaymentCue) { if (!hasPaymentCue) {
return false; return false;
} }
const hasDateOrPeriodCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text); const hasDateOrPeriodCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text);
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
} }
function hasPeriodCoverageProfileSignal(text) { function hasPeriodCoverageProfileSignal(text) {
@ -684,6 +684,9 @@ function hasCounterpartyDebtLongevitySignal(text) {
return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue; return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue;
} }
function hasCounterpartyActivityLifecycleSignal(text) { function hasCounterpartyActivityLifecycleSignal(text) {
if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) {
return false;
}
const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text); const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text);
if (hasPaymentRiskLexeme) { if (hasPaymentRiskLexeme) {
return false; return false;
@ -721,6 +724,19 @@ function hasCounterpartyActivityLifecycleSignal(text) {
} }
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb); return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
} }
function hasCounterpartyShipmentItemFlowSignal(text) {
const hasNamedTailAfterShipmentCue = /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(text);
const hasPartySignal = hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text);
if (!hasPartySignal && !hasNamedTailAfterShipmentCue) {
return false;
}
const hasInboundShipmentCue = /(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)|кто\s+нам\s+постав(?:лял|ил)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test(text);
const hasItemOrServiceCue = /(?:како(?:й|е|го|му)\s+товар|каки(?:е|х)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(text);
return hasInboundShipmentCue || hasItemOrServiceCue;
}
function hasContractUsageOverviewSignal(text) { function hasContractUsageOverviewSignal(text) {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) { if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
return true; return true;
@ -1652,7 +1668,9 @@ function resolveAddressIntent(userMessage) {
!hasInventoryProvenanceSignalV2(text) && !hasInventoryProvenanceSignalV2(text) &&
!hasInventoryPurchaseDocumentsSignalV2(text) && !hasInventoryPurchaseDocumentsSignalV2(text) &&
!hasInventorySaleTraceSignalV2(text) && !hasInventorySaleTraceSignalV2(text) &&
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text)) { (/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text) ||
hasAccountNumberAnchor(text) ||
hasCompactAccountCodeToken(text))) {
return { return {
intent: "open_items_by_counterparty_or_contract", intent: "open_items_by_counterparty_or_contract",
confidence: "medium", confidence: "medium",
@ -1760,15 +1778,20 @@ function resolveAddressIntent(userMessage) {
reasons: ["bank_ops_by_counterparty_signal_detected"] reasons: ["bank_ops_by_counterparty_signal_detected"]
}; };
} }
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) && if ((hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) &&
(hasPartyAnchorMention(text) || (hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) || hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) || hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text))) { hasHeuristicCounterpartyAnchor(text) ||
hasCounterpartyShipmentItemFlowSignal(text))) {
return { return {
intent: "list_documents_by_counterparty", intent: "list_documents_by_counterparty",
confidence: "medium", confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"] reasons: [
hasCounterpartyShipmentItemFlowSignal(text)
? "counterparty_item_flow_signal_detected"
: "documents_by_counterparty_signal_detected"
]
}; };
} }
if (hasAccountBalanceSignal(text)) { if (hasAccountBalanceSignal(text)) {

View File

@ -439,10 +439,19 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
const organizationScope = toNonEmptyString(filtersWithDerivedScope.organization); const organizationScope = toNonEmptyString(filtersWithDerivedScope.organization);
const nextResultSets = capResultSets([...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort((left, right) => left.created_from_turn - right.created_from_turn)); const nextResultSets = capResultSets([...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort((left, right) => left.created_from_turn - right.created_from_turn));
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]); const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
return { const nextSessionContext = action === "open"
...state, ? {
updated_at: createdAt, active_result_set_id: resultSetId,
session_context: { active_focus_object: focusObject ?? null,
last_confirmed_route: routeId ?? null,
date_scope: {
as_of_date: normalizedDateScope.as_of_date,
period_from: normalizedDateScope.period_from,
period_to: normalizedDateScope.period_to
},
organization_scope: organizationScope ?? state.session_context.organization_scope
}
: {
active_result_set_id: resultSetId, active_result_set_id: resultSetId,
active_focus_object: focusObject ?? state.session_context.active_focus_object, active_focus_object: focusObject ?? state.session_context.active_focus_object,
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route, last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
@ -452,7 +461,11 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
}, },
organization_scope: organizationScope ?? state.session_context.organization_scope organization_scope: organizationScope ?? state.session_context.organization_scope
}, };
return {
...state,
updated_at: createdAt,
session_context: nextSessionContext,
result_sets: nextResultSets, result_sets: nextResultSets,
navigation_history: nextEvents navigation_history: nextEvents
}; };

View File

@ -10,6 +10,8 @@ const composeStage_1 = require("./address_runtime/composeStage");
const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy"); const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy");
const addressRouteExpectations_1 = require("./addressRouteExpectations"); const addressRouteExpectations_1 = require("./addressRouteExpectations");
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher"); const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
const openaiResponsesClient_1 = require("./openaiResponsesClient");
const files_1 = require("../utils/files");
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"]; const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1"; const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
@ -115,6 +117,7 @@ const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
Справочник.Контрагенты КАК Контрагенты Справочник.Контрагенты КАК Контрагенты
`; `;
let counterpartyCatalogCache = null; let counterpartyCatalogCache = null;
const limitedReplyLlmClient = new openaiResponsesClient_1.OpenAIResponsesClient();
function parseFiniteNumber(value) { function parseFiniteNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
return value; return value;
@ -654,9 +657,37 @@ function anchorTokenVariants(token) {
} }
return Array.from(variants); return Array.from(variants);
} }
function normalizePartyTokenSkeleton(value) {
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
}
function fuzzyPartyTokenMatches(candidate, token) {
const normalizedCandidate = normalizeSearchText(candidate);
const normalizedToken = normalizeSearchText(token);
if (!normalizedCandidate || !normalizedToken) {
return false;
}
if (normalizedCandidate === normalizedToken) {
return true;
}
if (normalizedCandidate.length < 4 ||
normalizedToken.length < 4 ||
/\d/u.test(normalizedCandidate) ||
/\d/u.test(normalizedToken)) {
return false;
}
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
return false;
}
return (candidateSkeleton === tokenSkeleton ||
candidateSkeleton.startsWith(tokenSkeleton) ||
tokenSkeleton.startsWith(candidateSkeleton));
}
function matchesAnchorText(searchable, anchor) { function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable); const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const searchableTokens = tokenizeSearchableText(searchable);
const tokens = tokenizeAnchor(anchor); const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) { if (tokens.length === 0) {
const direct = normalizeSearchText(anchor); const direct = normalizeSearchText(anchor);
@ -669,7 +700,9 @@ function matchesAnchorText(searchable, anchor) {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); return (searchableNormalized.includes(variant) ||
searchableLatin.includes(tokenLatin) ||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant)));
}); });
}); });
} }
@ -791,6 +824,13 @@ function normalizeCounterpartyName(value) {
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
} }
function hasCounterpartyShipmentItemFlowSignal(userMessage) {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
return false;
}
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|кто\s+нам\s+постав|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(text);
}
function extractCounterpartyCatalogNames(rows) { function extractCounterpartyCatalogNames(rows) {
return uniqueStrings(rows return uniqueStrings(rows
.map((row) => { .map((row) => {
@ -807,6 +847,7 @@ function scoreCounterpartyCandidate(name, anchor) {
} }
const normalizedName = normalizeCounterpartyName(name); const normalizedName = normalizeCounterpartyName(name);
const normalizedAnchor = normalizeCounterpartyName(anchor); const normalizedAnchor = normalizeCounterpartyName(anchor);
const nameTokens = tokenizeSearchableText(name);
if (!normalizedName || !normalizedAnchor) { if (!normalizedName || !normalizedAnchor) {
return null; return null;
} }
@ -817,6 +858,9 @@ function scoreCounterpartyCandidate(name, anchor) {
else if (normalizedName.includes(normalizedAnchor)) { else if (normalizedName.includes(normalizedAnchor)) {
score += 5_000; score += 5_000;
} }
else if (fuzzyPartyTokenMatches(normalizedName, normalizedAnchor)) {
score += 3_500;
}
else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) { else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
score += 2_000; score += 2_000;
} }
@ -828,6 +872,9 @@ function scoreCounterpartyCandidate(name, anchor) {
if (normalizedName.includes(variant)) { if (normalizedName.includes(variant)) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20); tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20);
} }
else if (nameTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 12);
}
} }
if (tokenScore === 0) { if (tokenScore === 0) {
return null; return null;
@ -1019,7 +1066,7 @@ function toNormalizedRows(rows) {
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity);
const item = firstNonEmptyString(row.Номенклатура, row.Item, row.item, row.НоменклатураПредставление, row.SubcontoDt1, row.SubcontoDt2, row.SubcontoDt3, row.SubcontoKt1, row.SubcontoKt2, row.SubcontoKt3, row.СубконтоДт1, row.СубконтоДт2, row.СубконтоДт3, row.СубконтоКт1, row.СубконтоКт2, row.СубконтоКт3); const item = resolveInventoryItemFromRawRow(row, accountDt, accountKt);
const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление); const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление);
const analytics = collectAnalyticsStrings(row); const analytics = collectAnalyticsStrings(row);
@ -1038,6 +1085,150 @@ function toNormalizedRows(rows) {
}) })
.filter((item) => Boolean(item.period || item.registrator)); .filter((item) => Boolean(item.period || item.registrator));
} }
function hasInventoryAccountPrefix(account) {
const normalized = String(account ?? "").trim();
return /^41(?:\.01)?(?:$|[^\d])/u.test(normalized);
}
function resolveInventoryItemFromRawRow(row, accountDt, accountKt) {
const debitCandidates = [
row.SubcontoDt1,
row.SubcontoDt2,
row.SubcontoDt3,
row.СубконтоДт1,
row.СубконтоДт2,
row.СубконтоДт3
];
const creditCandidates = [
row.SubcontoKt1,
row.SubcontoKt2,
row.SubcontoKt3,
row.СубконтоКт1,
row.СубконтоКт2,
row.СубконтоКт3
];
if (hasInventoryAccountPrefix(accountDt) && !hasInventoryAccountPrefix(accountKt)) {
const debitSideItem = firstNonEmptyString(...debitCandidates, ...creditCandidates);
if (debitSideItem) {
return debitSideItem;
}
}
if (hasInventoryAccountPrefix(accountKt) && !hasInventoryAccountPrefix(accountDt)) {
const creditSideItem = firstNonEmptyString(...creditCandidates, ...debitCandidates);
if (creditSideItem) {
return creditSideItem;
}
}
const explicitItem = firstNonEmptyString(row.Номенклатура, row.Nomenclature, row.nomenclature, row.Item, row.item, row.НоменклатураПредставление);
if (explicitItem) {
return explicitItem;
}
return firstNonEmptyString(...debitCandidates, ...creditCandidates);
}
function formatMoneyRubForReply(value) {
return `${new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)} `;
}
function extractContractNameFromNormalizedRow(row) {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
continue;
}
if (/(?:договор|contract|дог\.)/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function hasBankAccountPrefix(account) {
const normalized = String(account ?? "").trim();
return /^5[12](?:$|[^\d])/u.test(normalized);
}
function hasPayablesAccountPrefix(account) {
const normalized = String(account ?? "").trim();
return /^60(?:$|[^\d])/u.test(normalized);
}
function isOutgoingSupplierPaymentRow(row) {
const registrator = normalizeSearchText(row.registrator);
return ((hasPayablesAccountPrefix(row.account_dt) && hasBankAccountPrefix(row.account_kt)) ||
registrator.includes("списание с расчетного счета"));
}
function isIncomingSupplierReturnRow(row) {
const registrator = normalizeSearchText(row.registrator);
return ((hasBankAccountPrefix(row.account_dt) && hasPayablesAccountPrefix(row.account_kt)) ||
registrator.includes("поступление на расчетный счет"));
}
function formatCounterpartyBankActivityRows(rows, limit = 5) {
return rows.slice(0, limit).map((row, index) => {
const parts = [`${index + 1}. ${row.registrator}`, `дата: ${row.period ?? "не указана"}`];
if (typeof row.amount === "number" && Number.isFinite(row.amount)) {
parts.push(`сумма: ${formatMoneyRubForReply(row.amount)}`);
}
const contract = extractContractNameFromNormalizedRow(row);
if (contract) {
parts.push(`договор: ${contract}`);
}
return parts.join(" | ");
});
}
function buildCounterpartyItemFlowBankFallbackReply(counterpartyLabel, bankRows) {
if (bankRows.length === 0) {
return null;
}
const outgoingPayments = bankRows.filter((row) => isOutgoingSupplierPaymentRow(row));
const incomingReturns = bankRows.filter((row) => isIncomingSupplierReturnRow(row));
const classifiedKeys = new Set([...outgoingPayments, ...incomingReturns].map((row) => `${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`));
const otherBankRows = bankRows.filter((row) => !classifiedKeys.has(`${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`));
const outgoingTotal = outgoingPayments.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const incomingTotal = incomingReturns.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const otherTotal = otherBankRows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const contracts = uniqueStrings(bankRows
.map((row) => extractContractNameFromNormalizedRow(row))
.filter((item) => Boolean(item)));
const summaryParts = [];
if (outgoingPayments.length > 0) {
summaryParts.push(`исходящих оплат поставщику: ${outgoingPayments.length} на ${formatMoneyRubForReply(outgoingTotal)}`);
}
if (incomingReturns.length > 0) {
summaryParts.push(`возвратов от поставщика: ${incomingReturns.length} на ${formatMoneyRubForReply(incomingTotal)}`);
}
if (otherBankRows.length > 0) {
summaryParts.push(`прочих банковских документов: ${otherBankRows.length} на ${formatMoneyRubForReply(otherTotal)}`);
}
if (summaryParts.length === 0) {
return null;
}
const lines = [`Контрагент: ${counterpartyLabel}. Подтвержденных поставок товаров или услуг не найдено.`];
lines.push("Позиции: подтвержденных поставок товаров или услуг не найдено.");
lines.push(`По связанным расчетам с контрагентом найдено ${summaryParts.join("; ")}.`);
if (outgoingPayments.length > 0 && incomingReturns.length > 0 && Math.abs(outgoingTotal - incomingTotal) < 0.005) {
lines.push("Сумма возврата совпадает с суммой исходящих оплат.");
}
if (contracts.length === 1) {
lines.push(`Договор: ${contracts[0]}.`);
}
else if (contracts.length > 1) {
lines.push(`Договоры: ${contracts.slice(0, 3).join("; ")}.`);
}
if (outgoingPayments.length > 0) {
lines.push("Оплаты поставщику:");
lines.push(...formatCounterpartyBankActivityRows(outgoingPayments));
}
if (incomingReturns.length > 0) {
lines.push("Возвраты от поставщика:");
lines.push(...formatCounterpartyBankActivityRows(incomingReturns));
}
if (otherBankRows.length > 0) {
lines.push("Прочие банковские документы:");
lines.push(...formatCounterpartyBankActivityRows(otherBankRows));
}
return lines.join("\n");
}
function rowSearchableText(row) { function rowSearchableText(row) {
return [ return [
row.registrator, row.registrator,
@ -2113,6 +2304,88 @@ function hasAggregateLimitedSignal(input) {
} }
return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(String(input.reason ?? "")); return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(String(input.reason ?? ""));
} }
function shouldUseLlmLimitedReply(category) {
return (category === "unsupported" ||
category === "recipe_visibility_gap" ||
category === "execution_error" ||
category === "missing_anchor" ||
category === "empty_match");
}
function loadSharedLlmRequestConfig() {
const record = (0, files_1.readJsonFile)(config_1.SHARED_LLM_CONNECTION_FILE, null);
const connection = record?.connection;
if (!connection || typeof connection !== "object") {
return null;
}
const llmProvider = connection.llmProvider === "local" ? "local" : "openai";
const model = String(connection.model ?? "").trim() || config_1.DEFAULT_MODEL;
const baseUrl = String(connection.baseUrl ?? "").trim() || config_1.DEFAULT_OPENAI_BASE_URL;
const temperature = typeof connection.temperature === "number" && Number.isFinite(connection.temperature)
? connection.temperature
: 0.2;
const maxOutputTokens = typeof connection.maxOutputTokens === "number" && Number.isFinite(connection.maxOutputTokens)
? Math.max(64, Math.trunc(connection.maxOutputTokens))
: config_1.DEFAULT_MAX_OUTPUT_TOKENS;
const apiKey = String(process.env.OPENAI_API_KEY ?? "").trim();
if (llmProvider === "openai" && apiKey.length === 0) {
return null;
}
return {
llmProvider,
apiKey,
model,
baseUrl,
temperature,
maxOutputTokens
};
}
async function tryComposeLlmLimitedReply(input) {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
const question = String(input.userMessage ?? "").trim();
if (!question) {
return null;
}
const config = loadSharedLlmRequestConfig();
if (!config) {
return null;
}
const contextPayload = {
question,
intent: input.intent,
category: input.category,
reason: normalizeLimitedReason(input.reasonText),
next_step: normalizeLimitedNextStep(input.nextStep ?? ""),
filters: input.filters,
anchor: input.anchor
? {
type: input.anchor.anchor_type,
raw: input.anchor.anchor_value_raw,
resolved: input.anchor.anchor_value_resolved
}
: null
};
try {
const response = await limitedReplyLlmClient.chat(config, {
systemPrompt: "Ты формулируешь мягкий, но предметный отказ для бухгалтерского ассистента 1С. Нельзя выдумывать факты и нельзя утверждать, что данные найдены, если это не подтверждено.",
developerPrompt: "Ответь по-русски, коротко и по делу. Дай 2-4 коротких абзаца. Первая фраза должна прямо сказать, что именно сейчас не удается надежно подтвердить по вопросу пользователя. Если есть контрагент, счет, договор, организация или период, упомяни их естественно. Коротко скажи, что уже проверено, и предложи 1-2 ближайших шага. Не используй техжаргон вроде address lane, partial_coverage, recipe, capability, unsupported.",
userMessage: `Контекст отказа:\n${JSON.stringify(contextPayload, null, 2)}`,
maxOutputTokens: Math.min(280, Math.max(160, config.maxOutputTokens ?? config_1.DEFAULT_MAX_OUTPUT_TOKENS)),
temperature: 0.2
});
const text = String(response.outputText ?? "")
.replace(/\r\n/g, "\n")
.trim();
return text.length > 0 ? text : null;
}
catch {
return null;
}
}
function composeLimitedReply(input) { function composeLimitedReply(input) {
const reason = normalizeLimitedReason(input.reason); const reason = normalizeLimitedReason(input.reason);
const filterSeed = buildLimitedVariantSeedFingerprint(input.filters); const filterSeed = buildLimitedVariantSeedFingerprint(input.filters);
@ -2511,6 +2784,7 @@ class AddressQueryService {
requestedResultMode, requestedResultMode,
filters: executionFilters filters: executionFilters
}); });
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
if ((0, addressCapabilityPolicy_1.isCapabilityRouteBlocked)(capabilityDecision)) { if ((0, addressCapabilityPolicy_1.isCapabilityRouteBlocked)(capabilityDecision)) {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -2537,6 +2811,17 @@ class AddressQueryService {
} }
const composeOptionsFromFilters = (filterSet, options = {}) => ({ const composeOptionsFromFilters = (filterSet, options = {}) => ({
userMessage, userMessage,
itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined,
counterpartyHint: typeof options.counterpartyHint === "string"
? options.counterpartyHint
: typeof filterSet.counterparty === "string"
? filterSet.counterparty
: undefined,
accountHint: typeof options.accountHint === "string"
? options.accountHint
: typeof filterSet.account === "string"
? filterSet.account
: undefined,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
@ -2545,8 +2830,39 @@ class AddressQueryService {
emphasizeNumbers: options.emphasizeNumbers ?? undefined, emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined useRubCurrency: options.useRubCurrency ?? undefined
}); });
const finalizeLimitedResult = async (input) => {
const result = buildLimitedExecutionResult(input);
const llmReply = await tryComposeLlmLimitedReply({
userMessage,
category: input.category,
intent: input.intent.intent,
reasonText: input.reasonText,
nextStep: input.nextStep,
filters: input.filters,
anchor: input.anchor
});
if (!llmReply) {
return result;
}
return {
...result,
reply_text: llmReply
};
};
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,
accountHint: typeof options.accountHint === "string"
? options.accountHint
: typeof filterSet.account === "string"
? filterSet.account
: undefined
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) && Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
@ -2596,7 +2912,7 @@ class AddressQueryService {
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
} }
if (intent.intent === "unknown") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -2618,7 +2934,7 @@ class AddressQueryService {
}); });
} }
if (recipeSelection.selected_recipe === null) { if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -2640,7 +2956,7 @@ class AddressQueryService {
}); });
} }
if (recipeSelection.missing_required_filters.length > 0) { if (recipeSelection.missing_required_filters.length > 0) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -2662,7 +2978,7 @@ class AddressQueryService {
}); });
} }
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) { if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -2687,6 +3003,14 @@ class AddressQueryService {
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) { if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor); const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
if (catalogResolution.resolvedValue) { if (catalogResolution.resolvedValue) {
filters.extracted_filters = {
...filters.extracted_filters,
counterparty: catalogResolution.resolvedValue
};
executionFilters = {
...executionFilters,
counterparty: catalogResolution.resolvedValue
};
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) { if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup"); filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
} }
@ -2723,6 +3047,42 @@ class AddressQueryService {
let composeIntent = intent.intent; let composeIntent = intent.intent;
let routeExpectationIntent = intent.intent; let routeExpectationIntent = intent.intent;
let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent); let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent);
const counterpartyItemFlowQuery = intent.intent === "list_documents_by_counterparty" &&
hasCounterpartyShipmentItemFlowSignal(userMessage) &&
typeof executionFilters.counterparty === "string" &&
executionFilters.counterparty.trim().length > 0;
const counterpartyItemFlowFilters = counterpartyItemFlowQuery && anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? {
...executionFilters,
counterparty: anchor.anchor_value_resolved
}
: executionFilters;
if (counterpartyItemFlowQuery) {
plan = {
...plan,
query: (0, addressRecipeCatalog_1.buildCounterpartyPurchaseDocumentQuery)(counterpartyItemFlowFilters, plan.limit),
account_scope: [],
account_scope_mode: "preferred"
};
if (!baseReasons.includes("counterparty_item_flow_query_override_to_purchase_documents")) {
baseReasons.push("counterparty_item_flow_query_override_to_purchase_documents");
}
}
const accountOnlyOpenItemsQuery = intent.intent === "open_items_by_counterparty_or_contract" &&
typeof executionFilters.account === "string" &&
executionFilters.account.trim().length > 0 &&
!(typeof executionFilters.counterparty === "string" && executionFilters.counterparty.trim().length > 0) &&
!(typeof executionFilters.contract === "string" && executionFilters.contract.trim().length > 0);
if (accountOnlyOpenItemsQuery) {
plan = {
...plan,
query: (0, addressRecipeCatalog_1.buildOpenItemsMovementQuery)(executionFilters, plan.limit),
account_scope_mode: "strict"
};
if (!baseReasons.includes("open_items_account_query_override_to_movements")) {
baseReasons.push("open_items_account_query_override_to_movements");
}
}
let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query, query: plan.query,
limit: plan.limit limit: plan.limit
@ -2823,7 +3183,7 @@ class AddressQueryService {
} }
if (mcp.error) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -2952,6 +3312,78 @@ class AddressQueryService {
: matchFailureStage === "materialized_but_filtered_out_by_recipe" : matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match" ? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null; : null;
const buildFactualNoRowsResult = (replyText, noRowsReason, extraLimitations = []) => {
const responseType = "FACTUAL_SUMMARY";
const semantics = mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
semanticFrame,
responseType,
rowsMatched: 0
}), undefined);
return {
handled: true,
reply_text: replyText,
reply_type: (0, composeStage_1.inferReplyType)(responseType),
response_type: responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: matchFailureStage,
match_failure_reason: matchFailureReason,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: 0,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: responseType,
...semantics,
limitations: [...filters.warnings, ...extraLimitations],
reasons: withConfirmedBalanceFallbackReason([...baseReasons, noRowsReason], requestedResultMode, undefined, semantics.result_mode),
...(capabilityAudit
? {
capability_id: capabilityAudit.capabilityId,
capability_layer: capabilityAudit.layer,
capability_route_mode: capabilityAudit.routeMode,
capability_route_enabled: capabilityAudit.enabled,
capability_route_reason: capabilityAudit.reason
}
: {}),
...(shadowRouteAudit
? {
shadow_route_status: shadowRouteAudit.status
}
: {})
}
};
};
if (organizationWarehouseRecoveryApplied) { if (organizationWarehouseRecoveryApplied) {
if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) { if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) {
baseReasons.push("organization_scope_live_grounding_recovered_rows"); baseReasons.push("organization_scope_live_grounding_recovered_rows");
@ -2996,7 +3428,7 @@ class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) { if (recoveredRows.length > 0) {
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters)); const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeRuntimeOptions(executionFilters));
const recoveryReason = recoveredBankRows.length > 0 const recoveryReason = recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback" ? "contract_docs_recovered_via_bank_fallback"
: "contract_docs_recovered_via_anchor_rows"; : "contract_docs_recovered_via_anchor_rows";
@ -3123,7 +3555,7 @@ class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length, rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length rowsMatched: expandedFilteredRows.length
}); });
const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows, composeOptionsFromFilters(expandedLimitFilters)); const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows, composeRuntimeOptions(expandedLimitFilters));
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`; const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"]; const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"]; const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
@ -3253,7 +3685,7 @@ class AddressQueryService {
}); });
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows); const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow); const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows, composeOptionsFromFilters(autoBroadenedFilters)); const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows, composeRuntimeOptions(autoBroadenedFilters));
const broadenedLimitations = [ const broadenedLimitations = [
...filters.warnings, ...filters.warnings,
...broadenedAdjustments, ...broadenedAdjustments,
@ -3340,6 +3772,7 @@ class AddressQueryService {
} }
} }
if (filteredRows.length === 0 && if (filteredRows.length === 0 &&
!counterpartyItemFlowQuery &&
isDocumentOrBankAnchorIntent(intent.intent) && isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) && !hasExplicitPeriodWindow(filters.extracted_filters) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) { (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) {
@ -3401,7 +3834,7 @@ class AddressQueryService {
rowsAnchorMatched: historicalRowsByAnchor.length, rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length rowsMatched: historicalFilteredRows.length
}); });
const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, composeOptionsFromFilters(historicalFilters)); const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, composeRuntimeOptions(historicalFilters));
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу."; const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion = intent.intent === "list_documents_by_counterparty" const historicalSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту." ? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
@ -3473,7 +3906,7 @@ class AddressQueryService {
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) { (stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows); const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) { if (documentBankFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(executionFilters)); const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeRuntimeOptions(executionFilters));
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы."; const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = intent.intent === "list_documents_by_counterparty" const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи." ? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
@ -3546,6 +3979,43 @@ class AddressQueryService {
!toNonEmptyFilterValue(filters.extracted_filters.contract) && !toNonEmptyFilterValue(filters.extracted_filters.contract) &&
!toNonEmptyFilterValue(filters.extracted_filters.document_ref); !toNonEmptyFilterValue(filters.extracted_filters.document_ref);
if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) { if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) {
if (stageStatus === "no_raw_rows" && counterpartyItemFlowQuery && anchor.anchor_type === "counterparty") {
const counterpartyLabel = String(anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? "указанный контрагент")
.trim()
.replace(/[.]+$/u, "");
const bankFallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("bank_operations_by_counterparty", counterpartyItemFlowFilters);
if (bankFallbackSelection.selected_recipe) {
const bankFallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(bankFallbackSelection.selected_recipe, counterpartyItemFlowFilters), "bank_operations_by_counterparty");
const bankFallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: bankFallbackPlan.query,
limit: bankFallbackPlan.limit
});
if (!bankFallbackMcp.error) {
const bankFallbackNormalizedRows = applyAccountScopeFilter(toNormalizedRows(bankFallbackMcp.raw_rows), bankFallbackPlan.account_scope);
const bankFallbackRows = applyIntentSpecificFilter("bank_operations_by_counterparty", applyAddressFilters(bankFallbackNormalizedRows, counterpartyItemFlowFilters).rows);
const bankFallbackReply = buildCounterpartyItemFlowBankFallbackReply(counterpartyLabel, bankFallbackRows);
if (bankFallbackReply) {
return buildFactualNoRowsResult(bankFallbackReply, "counterparty_item_flow_no_supply_but_bank_activity_explained", ["counterparty_item_flow_bank_activity_fallback_applied"]);
}
}
}
return buildFactualNoRowsResult(`Контрагент: ${counterpartyLabel}. Позиции: подтвержденных поступлений товаров или услуг не найдено.\nПроверил документы поступления и связанные движения по этому контрагенту: в доступном срезе базы строк с товарами или услугами не нашлось.`, "counterparty_item_flow_exact_negative_response");
}
if (stageStatus === "no_raw_rows" && accountOnlyOpenItemsQuery) {
const accountLabel = typeof executionFilters.account === "string" && executionFilters.account.trim().length > 0
? executionFilters.account.trim()
: "указанному счету";
const periodLabel = typeof executionFilters.period_from === "string" && typeof executionFilters.period_to === "string"
? `за период ${executionFilters.period_from}..${executionFilters.period_to}`
: typeof executionFilters.as_of_date === "string"
? `на дату ${executionFilters.as_of_date}`
: "";
const organizationLabel = typeof executionFilters.organization === "string" && executionFilters.organization.trim().length > 0
? executionFilters.organization.trim()
: "";
return buildFactualNoRowsResult(`По счету ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} открытых остатков не найдено.` +
(organizationLabel ? `\nОрганизация: ${organizationLabel}.` : ""), "open_items_account_exact_negative_response");
}
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate = hadBaseRows && const isVisibilityGapCandidate = hadBaseRows &&
@ -3623,7 +4093,7 @@ class AddressQueryService {
? "document_or_bank_visibility_gap_after_base_filter" ? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter" : "no_rows_after_recipe_and_scope_filter"
]; ];
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3665,7 +4135,7 @@ class AddressQueryService {
composeIntent === "payables_confirmed_as_of_date" || composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date"; composeIntent === "receivables_confirmed_as_of_date";
const shouldUseRubCurrency = composeIntent === "vat_payable_forecast" || composeIntent === "vat_liability_confirmed_for_tax_period"; const shouldUseRubCurrency = composeIntent === "vat_payable_forecast" || composeIntent === "vat_liability_confirmed_for_tax_period";
const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters, { const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeRuntimeOptions(executionFilters, {
vatDirectSourceProbe, vatDirectSourceProbe,
emphasizeNumbers: shouldEmphasizeNumbers, emphasizeNumbers: shouldEmphasizeNumbers,
useRubCurrency: shouldUseRubCurrency useRubCurrency: shouldUseRubCurrency
@ -3695,7 +4165,7 @@ class AddressQueryService {
resultMode: factualResultSemantics.result_mode resultMode: factualResultSemantics.result_mode
}); });
if (finalRouteExpectationAudit.status === "mismatch" && config_1.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) { if (finalRouteExpectationAudit.status === "mismatch" && config_1.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3741,7 +4211,7 @@ class AddressQueryService {
: intent.intent === "vat_liability_confirmed_for_tax_period" : intent.intent === "vat_liability_confirmed_for_tax_period"
? "vat_tax_period" ? "vat_tax_period"
: "vat_payable"; : "vat_payable";
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,

View File

@ -1,5 +1,7 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.buildCounterpartyPurchaseDocumentQuery = buildCounterpartyPurchaseDocumentQuery;
exports.buildOpenItemsMovementQuery = buildOpenItemsMovementQuery;
exports.selectAddressRecipe = selectAddressRecipe; exports.selectAddressRecipe = selectAddressRecipe;
exports.buildAddressRecipePlan = buildAddressRecipePlan; exports.buildAddressRecipePlan = buildAddressRecipePlan;
const config_1 = require("../config"); const config_1 = require("../config");
@ -78,6 +80,38 @@ __WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__ Товары.Ссылка.Дата __ORDER_DIRECTION__
`; `;
const COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
`;
const COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Услуги.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
Услуги.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Услуги.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Организация) КАК Организация,
0 КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Услуги КАК Услуги
__WHERE_CLAUSE__
`;
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период, __AS_OF_EXPR__ КАК Период,
@ -1149,6 +1183,31 @@ function buildInventoryItemReferenceCondition(filters, fieldPaths) {
} }
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
} }
function buildCounterpartyReferenceCondition(filters, fieldPaths) {
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
if (!counterparty) {
return null;
}
const counterpartyTokens = Array.from(new Set(counterparty
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 3)));
const tokens = counterpartyTokens.length > 0 ? counterpartyTokens : [counterparty];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`;
});
return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventorySaleDocumentQuery(filters, resolvedLimit) { function buildInventorySaleDocumentQuery(filters, resolvedLimit) {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
@ -1163,6 +1222,28 @@ function buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) {
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item)))) .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
} }
function buildCounterpartyPurchaseDocumentQuery(filters, resolvedLimit) {
const goodsCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Товары.Ссылка.Контрагент"]);
const servicesCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Услуги.Ссылка.Контрагент"]);
const goodsQuery = COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', goodsCounterpartyCondition].filter((item) => Boolean(item))));
const servicesQuery = COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Услуги.Ссылка.Дата", ['Услуги.Ссылка.Проведен = ИСТИНА', servicesCounterpartyCondition].filter((item) => Boolean(item))));
return `${goodsQuery}
ОБЪЕДИНИТЬ ВСЕ
${servicesQuery}
УПОРЯДОЧИТЬ ПО
Период ${resolveOrderDirection(filters.sort)}`;
}
function buildOpenItemsMovementQuery(filters, resolvedLimit) {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", extraConditions))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function shouldBoostLimitForAllTimeCounterparty(filters) { function shouldBoostLimitForAllTimeCounterparty(filters) {
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) || const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
(typeof filters.contract === "string" && filters.contract.trim().length > 0); (typeof filters.contract === "string" && filters.contract.trim().length > 0);

View File

@ -479,6 +479,12 @@ function detectValueRankingFocus(userMessage) {
if (!text) { if (!text) {
return "top_by_total"; return "top_by_total";
} }
const asksTotalMoneyEarned = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text) &&
!/(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart)/iu.test(text) &&
!/(?:топ|top|сам(?:ый|ая|ое|ые)|наибольш|больше\s+всего|максимальн)/iu.test(text);
if (asksTotalMoneyEarned) {
return "total_flow";
}
const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text); /(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
@ -575,6 +581,13 @@ function extractCounterpartyName(row) {
} }
return null; return null;
} }
function hasCounterpartyItemFlowQuestion(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return false;
}
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(text);
}
function extractInventoryItemName(row) { function extractInventoryItemName(row) {
const direct = String(row.item ?? "").trim(); const direct = String(row.item ?? "").trim();
if (direct) { if (direct) {
@ -752,10 +765,13 @@ function looksLikeInventoryPartyToken(value) {
} }
return normalized === normalized.toUpperCase() && normalized.length >= 4; return normalized === normalized.toUpperCase() && normalized.length >= 4;
} }
function extractInventoryCounterpartyCandidates(row) { function extractInventoryCounterpartyCandidates(row, excludedTokens = []) {
const itemToken = normalizeEntityToken(extractInventoryItemName(row)); const itemToken = normalizeEntityToken(extractInventoryItemName(row));
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row)); const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row)); const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
const excludedComparableTokens = excludedTokens
.map((token) => normalizeEntityToken(token))
.filter((token) => Boolean(token));
const candidates = []; const candidates = [];
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
@ -763,14 +779,18 @@ function extractInventoryCounterpartyCandidates(row) {
continue; continue;
} }
const comparable = normalizeEntityToken(normalized); const comparable = normalizeEntityToken(normalized);
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) { if (!comparable ||
comparable === itemToken ||
comparable === warehouseToken ||
comparable === organizationToken ||
excludedComparableTokens.includes(comparable)) {
continue; continue;
} }
candidates.push(normalized); candidates.push(normalized);
} }
return uniqueStrings(candidates); return uniqueStrings(candidates);
} }
function summarizeInventoryTraceRows(rows) { function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) {
const items = uniqueStrings(rows const items = uniqueStrings(rows
.map((row) => extractInventoryItemName(row)) .map((row) => extractInventoryItemName(row))
.filter((item) => Boolean(item))); .filter((item) => Boolean(item)));
@ -780,7 +800,7 @@ function summarizeInventoryTraceRows(rows) {
const organizations = uniqueStrings(rows const organizations = uniqueStrings(rows
.map((row) => extractInventoryOrganizationName(row)) .map((row) => extractInventoryOrganizationName(row))
.filter((item) => Boolean(item))); .filter((item) => Boolean(item)));
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row))); const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens)));
const documents = uniqueStrings(rows const documents = uniqueStrings(rows
.map((row) => String(row.registrator ?? "").trim()) .map((row) => String(row.registrator ?? "").trim())
.filter((item) => item.length > 0 && item !== "(без названия)")); .filter((item) => item.length > 0 && item !== "(без названия)"));
@ -800,9 +820,9 @@ function summarizeInventoryTraceRows(rows) {
totalAmount totalAmount
}; };
} }
function formatInventoryTraceRows(rows, limit = 10) { function formatInventoryTraceRows(rows, limit = 10, excludedCounterpartyTokens = []) {
return rows.slice(0, limit).map((row, index) => { return rows.slice(0, limit).map((row, index) => {
const parties = extractInventoryCounterpartyCandidates(row); const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
const warehouse = extractInventoryWarehouseName(row); const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row); const organization = extractInventoryOrganizationName(row);
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана"; const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
@ -823,6 +843,33 @@ function formatInventoryTraceRows(rows, limit = 10) {
return parts.join(" | "); return parts.join(" | ");
}); });
} }
function formatCounterpartyItemFlowRows(rows, limit = 12) {
return rows.slice(0, limit).map((row, index) => {
const item = extractInventoryItemName(row) ?? "позиция не указана";
const contract = extractContractName(row);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const quantity = extractInventoryQuantity(row);
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
const parts = [
`${index + 1}. ${item}`,
`договор: ${contract ?? "не указан"}`,
`документ: ${row.registrator}`,
`дата: ${inventoryTraceDateLabel(row.period)}`,
`сумма: ${amount}`
];
if (quantity !== null && quantity > 0) {
parts.push(`количество: ${formatNumberWithDots(quantity, 3)}`);
}
if (warehouse) {
parts.push(`склад: ${warehouse}`);
}
if (organization) {
parts.push(`организация: ${organization}`);
}
return parts.join(" | ");
});
}
function buildInventoryAgingByItemAggregate(rows, asOfDate) { function buildInventoryAgingByItemAggregate(rows, asOfDate) {
const byItem = new Map(); const byItem = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate); const asOfTimestamp = toUtcDayTimestamp(asOfDate);
@ -2575,6 +2622,8 @@ function composeFactualReply(intent, rows, options = {}) {
} }
const profileRows = Array.from(byCounterparty.values()); const profileRows = Array.from(byCounterparty.values());
const yearRows = Array.from(byYear.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 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 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 rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
@ -2606,6 +2655,31 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n") 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") { if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit); const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier const heading = isSupplier
@ -3344,8 +3418,11 @@ function composeFactualReply(intent, rows, options = {}) {
if (intent === "inventory_sale_trace_for_item") { if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options); const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row)); const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows); const requestedItemHint = String(options.itemHint ?? "").trim();
const itemLabel = summary.item ?? "товар не определен"; const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine = summary.counterparties.length === 1 const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1 : summary.counterparties.length > 1
@ -3367,7 +3444,7 @@ function composeFactualReply(intent, rows, options = {}) {
} }
lines.push("", "Документы выбытия:"); lines.push("", "Документы выбытия:");
if (saleRows.length > 0) { if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12)); lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
} }
else { else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
@ -3964,8 +4041,11 @@ function composeFactualReply(intent, rows, options = {}) {
} }
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows); const counterparties = buildCounterpartyRiskAggregate(rows);
const accountLead = typeof options.accountHint === "string" && options.accountHint.trim().length > 0
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
: "Собраны открытые позиции по взаиморасчетам.";
const lines = [ const lines = [
"Собраны открытые позиции по взаиморасчетам.", accountLead,
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.` `Контрагентов с сигналом: ${counterparties.length}.`
]; ];
@ -4019,10 +4099,63 @@ function composeFactualReply(intent, rows, options = {}) {
}; };
} }
if (intent === "list_documents_by_counterparty") { if (intent === "list_documents_by_counterparty") {
const lines = [ const resolvedCounterparty = (typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
`Найдено документов по контрагенту: ${rows.length}.`, ? options.counterpartyHint.trim()
...formatTopRows(rows, rows.length) : null) ??
]; (() => {
const counterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
return counterparties.length === 1 ? counterparties[0] : null;
})();
const counterpartyLabel = typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
: resolvedCounterparty
? `${resolvedCounterparty}.`
: null;
const counterpartyInline = typeof counterpartyLabel === "string" ? counterpartyLabel.replace(/[.]+$/u, "") : resolvedCounterparty;
const itemFlowQuestion = hasCounterpartyItemFlowQuestion(options.userMessage);
const items = uniqueStrings(rows
.map((row) => extractInventoryItemName(row))
.filter((item) => Boolean(item)));
const contracts = uniqueStrings(rows
.map((row) => extractContractName(row))
.filter((item) => Boolean(item)));
const lines = [];
if (itemFlowQuestion) {
lines.push(counterpartyInline
? `Контрагент: ${counterpartyInline}. Подтвержденных поставок товаров или услуг: ${rows.length}.`
: `Подтвержденных поставок товаров или услуг по запрошенному контрагенту: ${rows.length}.`);
}
else {
lines.push(counterpartyInline
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
: `Найдено документов по контрагенту: ${rows.length}.`);
}
if (counterpartyLabel) {
lines.push(`Контрагент: ${counterpartyLabel}`);
}
if (itemFlowQuestion) {
if (items.length > 0) {
lines.push(`Позиции: ${items.slice(0, 8).join("; ")}.`);
if (items.length > 8) {
lines.push(`Показаны первые 8 из ${items.length} позиций.`);
}
}
if (contracts.length === 1) {
lines.push(`Договор: ${contracts[0]}.`);
}
else if (contracts.length > 1) {
lines.push(`Договоры в выборке: ${contracts.slice(0, 3).join("; ")}.`);
}
lines.push(...formatCounterpartyItemFlowRows(rows));
if (rows.length > 12) {
lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
}
}
else {
lines.push(...formatTopRows(rows, rows.length));
}
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")

View File

@ -34,7 +34,7 @@ function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|дат[ауеы],?\s+котор(?:ую|ая)\s+(?:до\s+этого|раньше|ранее)\s+(?:рассматривали|смотрели)|дат[ауеы],?\s+которая\s+был[ао]?\s+ранее\s+рассмотрен[ао]?|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date|date\s+we\s+looked\s+at\s+before|previously\s+considered\s+date)/iu.test(String(text ?? "")); return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|дат[ауеы],?\s+котор(?:ую|ая)\s+(?:до\s+этого|раньше|ранее)\s+(?:рассматривали|смотрели)|дат[ауеы],?\s+которая\s+был[ао]?\s+ранее\s+рассмотрен[ао]?|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date|date\s+we\s+looked\s+at\s+before|previously\s+considered\s+date)/iu.test(String(text ?? ""));
} }
function hasSamePeriodHint(text) { function hasSamePeriodHint(text) {
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(String(text ?? "")); return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(String(text ?? ""));
} }
function hasExplicitPeriodLiteral(text) { function hasExplicitPeriodLiteral(text) {
return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? "")); return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? ""));
@ -521,6 +521,9 @@ function hasAddressFollowupContextSignal(text) {
if (hasSameDateHint(normalized)) { if (hasSameDateHint(normalized)) {
return true; return true;
} }
if (hasSamePeriodHint(normalized)) {
return true;
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length; const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if (tokenCount <= 12 && if (tokenCount <= 12 &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) && /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
@ -668,7 +671,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_aging_by_purchase_date" || intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") { intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period") {
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage); const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract); const currentContract = toNonEmptyString(merged.contract);
@ -739,6 +744,30 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (samePeriodRequested &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date")) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
reasons.push("period_from_from_followup_context");
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
reasons.push("period_to_from_followup_context");
}
if (intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (samePeriodRequested && if (samePeriodRequested &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")) { (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) { if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
@ -921,6 +950,15 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.period_to = previousPeriodTo; merged.period_to = previousPeriodTo;
} }
reasons.push("period_from_followup_context"); reasons.push("period_from_followup_context");
if (intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if ((intent === "list_open_contracts" || if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
@ -962,7 +1000,7 @@ function resolveMissingRequiredFilters(intent, filters) {
}); });
} }
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) { function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
if (!followupContext || !followupContext.previous_intent) { if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
return detectedIntent; return detectedIntent;
} }
const normalizedMessage = String(userMessage ?? ""); const normalizedMessage = String(userMessage ?? "");
@ -970,7 +1008,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (!hasFollowupSignal) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
const previousIntent = followupContext.previous_intent; const sourceIntent = followupContext.previous_intent ?? null;
const fallbackIntent = followupContext.target_intent ?? sourceIntent;
if (!sourceIntent && !fallbackIntent) {
return detectedIntent;
}
const previousFilters = followupContext.previous_filters ?? {}; const previousFilters = followupContext.previous_filters ?? {};
const previousContract = toNonEmptyString(previousFilters.contract); const previousContract = toNonEmptyString(previousFilters.contract);
const previousCounterparty = toNonEmptyString(previousFilters.counterparty); const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
@ -1000,7 +1042,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
reasons: [...detectedIntent.reasons, "open_items_from_followup_context"] reasons: [...detectedIntent.reasons, "open_items_from_followup_context"]
}; };
} }
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance"; const previousIsBalanceFamily = sourceIntent === "account_balance_snapshot" || sourceIntent === "documents_forming_balance";
if (previousIsBalanceFamily && if (previousIsBalanceFamily &&
hasAccountSignal(normalizedMessage) && hasAccountSignal(normalizedMessage) &&
(detectedIntent.intent === "unknown" || (detectedIntent.intent === "unknown" ||
@ -1015,7 +1057,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"] reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
}; };
} }
const previousIsInventoryFamily = isInventoryIntent(previousIntent); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) { if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" || if (detectedIntent.intent === "unknown" ||
@ -1024,7 +1066,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
detectedIntent.intent === "bank_operations_by_counterparty" || detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) { detectedIntent.intent === sourceIntent) {
return { return {
intent: "inventory_purchase_provenance_for_item", intent: "inventory_purchase_provenance_for_item",
confidence: "low", confidence: "low",
@ -1037,7 +1079,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
detectedIntent.intent === "list_documents_by_counterparty" || detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" || detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) { detectedIntent.intent === sourceIntent) {
return { return {
intent: "inventory_purchase_documents_for_item", intent: "inventory_purchase_documents_for_item",
confidence: "low", confidence: "low",
@ -1054,7 +1096,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === "inventory_sale_trace_for_item" || detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === previousIntent) { detectedIntent.intent === sourceIntent) {
return { return {
intent: "inventory_profitability_for_item", intent: "inventory_profitability_for_item",
confidence: "low", confidence: "low",
@ -1065,7 +1107,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" || if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === previousIntent || detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date") { detectedIntent.intent === "inventory_on_hand_as_of_date") {
return { return {
intent: "inventory_purchase_provenance_for_item", intent: "inventory_purchase_provenance_for_item",
@ -1078,7 +1120,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (detectedIntent.intent === "unknown" || if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) { detectedIntent.intent === sourceIntent) {
return { return {
intent: "inventory_sale_trace_for_item", intent: "inventory_sale_trace_for_item",
confidence: "low", confidence: "low",
@ -1090,7 +1132,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (detectedIntent.intent === "unknown" || if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_sale_trace_for_item" || detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) { detectedIntent.intent === sourceIntent) {
return { return {
intent: "inventory_purchase_to_sale_chain", intent: "inventory_purchase_to_sale_chain",
confidence: "low", confidence: "low",
@ -1101,7 +1143,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (previousIsInventoryFamily && if (previousIsInventoryFamily &&
hasFollowupSignal && hasFollowupSignal &&
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) && hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)) { (detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)) {
return { return {
intent: "inventory_purchase_provenance_for_item", intent: "inventory_purchase_provenance_for_item",
confidence: "low", confidence: "low",
@ -1158,7 +1200,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
return detectedIntent; return detectedIntent;
} }
return { return {
intent: previousIntent, intent: fallbackIntent ?? "unknown",
confidence: "low", confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"] reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
}; };

View File

@ -72,6 +72,12 @@ function tokenizeAnchor(value) {
.map((token) => token.trim()) .map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); .filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
} }
function tokenizeSearchableText(value) {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter(Boolean);
}
function anchorTokenVariants(token) { function anchorTokenVariants(token) {
const source = String(token ?? "").trim().toLowerCase(); const source = String(token ?? "").trim().toLowerCase();
if (!source) { if (!source) {
@ -90,9 +96,37 @@ function anchorTokenVariants(token) {
} }
return Array.from(variants); return Array.from(variants);
} }
function normalizePartyTokenSkeleton(value) {
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
}
function fuzzyPartyTokenMatches(candidate, token) {
const normalizedCandidate = normalizeSearchText(candidate);
const normalizedToken = normalizeSearchText(token);
if (!normalizedCandidate || !normalizedToken) {
return false;
}
if (normalizedCandidate === normalizedToken) {
return true;
}
if (normalizedCandidate.length < 4 ||
normalizedToken.length < 4 ||
/\d/u.test(normalizedCandidate) ||
/\d/u.test(normalizedToken)) {
return false;
}
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
return false;
}
return (candidateSkeleton === tokenSkeleton ||
candidateSkeleton.startsWith(tokenSkeleton) ||
tokenSkeleton.startsWith(candidateSkeleton));
}
function matchesAnchorText(searchable, anchor) { function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable); const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const searchableTokens = tokenizeSearchableText(searchable);
const tokens = tokenizeAnchor(anchor); const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) { if (tokens.length === 0) {
const direct = normalizeSearchText(anchor); const direct = normalizeSearchText(anchor);
@ -105,7 +139,9 @@ function matchesAnchorText(searchable, anchor) {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); return (searchableNormalized.includes(variant) ||
searchableLatin.includes(tokenLatin) ||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant)));
}); });
}); });
if (fullMatch) { if (fullMatch) {

View File

@ -2482,6 +2482,12 @@ function findRecentAddressFilterValue(items, key) {
if (!isAddressLaneDebugPayload(debug)) { if (!isAddressLaneDebugPayload(debug)) {
continue; continue;
} }
const replyType = toNonEmptyString(item.reply_type);
const limitedReasonCategory = toNonEmptyString(debug.limited_reason_category);
if ((replyType && replyType !== "factual" && replyType !== "factual_with_explanation") ||
limitedReasonCategory) {
continue;
}
const directFilterValue = readAddressFilterString(debug, key); const directFilterValue = readAddressFilterString(debug, key);
if (directFilterValue) { if (directFilterValue) {
return directFilterValue; return directFilterValue;
@ -2777,8 +2783,10 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
return false; return false;
} }
return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) || return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+рЅр°рј(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); /^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
} }
function isInventorySelectedObjectIntent(intent) { function isInventorySelectedObjectIntent(intent) {
@ -2896,6 +2904,7 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
} }
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) || const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) || /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized); /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized);
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) || const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) || /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
@ -2910,6 +2919,49 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
} }
return null; return null;
} }
function hasExplicitOrganizationScopeCue(text) {
return /(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку|муп|гуп|llc|ltd|inc|corp)(?=$|[\s"'«»„“()\\\/.,;:]))|организац|компан|контор|фирм/u.test(String(text ?? "").toLowerCase());
}
function looksLikeBareCounterpartyScopeTarget(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
if (hasExplicitOrganizationScopeCue(normalized)) {
return false;
}
if (/[0-9]/u.test(normalized)) {
return false;
}
const tokens = normalized.split(/\s+/u).filter(Boolean);
if (tokens.length === 0 || tokens.length > 4) {
return false;
}
return tokens.every((token) => /^[a-zа-яё._-]{2,}$/iu.test(token));
}
function shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText) {
const target = toNonEmptyString(scopeTargetText);
if (!target) {
return false;
}
if (hasExplicitOrganizationScopeCue(target) || hasExplicitOrganizationScopeCue(fragmentText)) {
return false;
}
return looksLikeBareCounterpartyScopeTarget(target);
}
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized || 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+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized);
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null; const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -3030,7 +3082,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0]) ? toNonEmptyString(followupOffer.suggested_intents[0])
: null; : null;
if (suggestedIntent) { const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
if (suggestedIntent && !keepPreviousIntent) {
previousIntent = suggestedIntent; previousIntent = suggestedIntent;
followupSelectionMode = "switch_to_suggested_intent"; followupSelectionMode = "switch_to_suggested_intent";
} }
@ -3128,13 +3181,19 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw } ? { ...previousFiltersRaw }
: {}; : {};
if (!toNonEmptyString(previousFilters.contract)) { const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" ||
sourceIntentHint === "list_documents_by_counterparty" ||
sourceIntentHint === "bank_operations_by_counterparty" ||
sourceIntentHint === "list_documents_by_contract" ||
sourceIntentHint === "bank_operations_by_contract" ||
sourceIntentHint === "open_items_by_counterparty_or_contract";
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.contract)) {
const historicalContract = findRecentAddressFilterValue(items, "contract"); const historicalContract = findRecentAddressFilterValue(items, "contract");
if (historicalContract) { if (historicalContract) {
previousFilters.contract = historicalContract; previousFilters.contract = historicalContract;
} }
} }
if (!toNonEmptyString(previousFilters.counterparty)) { if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.counterparty)) {
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty"); const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
if (historicalCounterparty) { if (historicalCounterparty) {
previousFilters.counterparty = historicalCounterparty; previousFilters.counterparty = historicalCounterparty;
@ -3180,7 +3239,10 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame && const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && (isInventorySelectedObjectIntent(sourceIntentHint) ||
isInventoryRootFrameIntent(sourceIntentHint) ||
currentFrameKind === "inventory_drilldown" ||
currentFrameKind === "inventory_root") &&
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); !hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
@ -3254,19 +3316,34 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null; return null;
} }
const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame &&
(rootScopedPivot ||
isInventoryRootFrameIntent(sourceIntentHint) ||
isInventorySelectedObjectIntent(sourceIntentHint) ||
hasNavigationInventoryItemFocusHint ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate));
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
: explicitIntent ?? previousIntent ?? undefined;
return { return {
followupContext: { followupContext: {
previous_intent: previousIntent ?? undefined, previous_intent: previousIntent ?? undefined,
target_intent: carryoverTargetIntent,
previous_filters: previousFilters, previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor, previous_anchor_value: previousAnchor,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootScopedPivot || undefined, root_context_only: rootScopedPivot || undefined,
root_intent: inventoryRootFrame?.intent ?? undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
root_filters: inventoryRootFrame?.filters ?? undefined, root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined,
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined, root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined,
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined, root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined,
current_frame_kind: currentFrameKind ?? undefined current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined
}, },
previousAddressIntent: previousIntent, previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor, previousAddressAnchor: previousAnchor,
@ -3340,7 +3417,7 @@ function isRetryableAddressLimitedResult(addressLane) {
return false; return false;
} }
const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase(); const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase();
return category === "missing_anchor" || category === "empty_match"; return category === "missing_anchor" || category === "empty_match" || category === "unsupported";
} }
function isAddressLlmPreDecomposeCandidate(userMessage) { function isAddressLlmPreDecomposeCandidate(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? "")); const repaired = repairAddressMojibake(String(userMessage ?? ""));
@ -3359,13 +3436,19 @@ function normalizeAddressSemanticHintsFromFragment(fragment) {
return null; return null;
} }
const scopeTargetKind = toNonEmptyString(hints.scope_target_kind); const scopeTargetKind = toNonEmptyString(hints.scope_target_kind);
const scopeTargetText = toNonEmptyString(hints.scope_target_text);
const fragmentText = toNonEmptyString(fragment.raw_fragment_text) ?? toNonEmptyString(fragment.normalized_fragment_text) ?? "";
const normalizedScopeTargetKind = scopeTargetKind === "organization" &&
shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText)
? "counterparty"
: scopeTargetKind;
const dateScopeKind = toNonEmptyString(hints.date_scope_kind); const dateScopeKind = toNonEmptyString(hints.date_scope_kind);
return { return {
scope_target_kind: scopeTargetKind ?? "none", scope_target_kind: normalizedScopeTargetKind ?? "none",
scope_target_text: toNonEmptyString(hints.scope_target_text), scope_target_text: scopeTargetText,
date_scope_kind: dateScopeKind ?? "missing", date_scope_kind: dateScopeKind ?? "missing",
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope", self_scope_detected: hints.self_scope_detected === true || normalizedScopeTargetKind === "self_scope",
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object" selected_object_scope_detected: hints.selected_object_scope_detected === true || normalizedScopeTargetKind === "selected_object"
}; };
} }
function extractAddressPredecomposeCandidateFromFragments(fragments) { function extractAddressPredecomposeCandidateFromFragments(fragments) {

View File

@ -471,6 +471,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
} }
function cleanupAnchorValue(value: string): string { function cleanupAnchorValue(value: string): string {
const stripLeadingEntityRole = (text: string): string =>
String(text ?? "")
.replace(
/^(?:(?:по\s+)?(?:контрагент(?:ом|у|а|ы|ов)?|поставщик(?:ом|у|а|и|ов)?|клиент(?:ом|у|а|ы|ов)?|покупател(?:ем|ю|я|и|ей)|продав(?:цом|цу|ца|цы|цов)|заказчик(?:ом|у|а|и|ов)?|исполнител(?:ем|ю|я|и|ей)|подрядчик(?:ом|у|а|и|ов)?))\s+/iu,
""
)
.trim();
const stripOuterQuotes = (text: string): string => const stripOuterQuotes = (text: string): string =>
String(text ?? "") String(text ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "") .replace(/^['"«»`]+|['"«»`]+$/gu, "")
@ -480,6 +487,10 @@ function cleanupAnchorValue(value: string): string {
if (!cleaned) { if (!cleaned) {
return ""; return "";
} }
cleaned = stripOuterQuotes(stripLeadingEntityRole(cleaned.replace(/[?!]+$/u, "").trim()));
if (!cleaned) {
return "";
}
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes: // Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31". // "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
@ -525,7 +536,7 @@ function cleanupAnchorValue(value: string): string {
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "") .replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim(); .trim();
return stripOuterQuotes(cleaned); return stripOuterQuotes(stripLeadingEntityRole(cleaned));
} }
function cleanupContractAnchorValue(value: string): string { function cleanupContractAnchorValue(value: string): string {
@ -638,6 +649,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (!value) { if (!value) {
return true; return true;
} }
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
return true;
}
const tokens = value const tokens = value
.split(/[^a-zа-я0-9]+/iu) .split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim()) .map((token) => token.trim())
@ -681,6 +695,16 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
"дата", "дата",
"конец", "конец",
"период", "период",
"весь",
"все",
"всё",
"всю",
"всех",
"всего",
"время",
"история",
"истории",
"срок",
"месяц", "месяц",
"году", "году",
"год", "год",
@ -906,6 +930,33 @@ function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefi
return undefined; return undefined;
} }
function extractShipmentCounterpartyValue(text: string): string | undefined {
const hasShipmentCounterpartyTokenShape = (token: string): boolean => {
const source = String(token ?? "").trim();
if (!source) {
return false;
}
if (hasStrongCounterpartyTokenShape(source)) {
return true;
}
return /^[\p{L}]{5,}$/u.test(source);
};
const match = String(text ?? "").match(
/(?:что\s+нам\s+)?(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu
);
if (!match) {
return undefined;
}
const candidate = String(match[1] ?? "").trim();
if (!candidate || !hasShipmentCounterpartyTokenShape(candidate)) {
return undefined;
}
if (!isLikelyCounterpartyToken(candidate)) {
return undefined;
}
return candidate;
}
function hasExplicitAccountCue(text: string): boolean { function hasExplicitAccountCue(text: string): boolean {
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? "")); return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
} }
@ -1666,6 +1717,19 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.push("counterparty_anchor_derived_from_implicit_phrase"); warnings.push("counterparty_anchor_derived_from_implicit_phrase");
} }
} }
if (
!filters.counterparty &&
allowGenericCounterpartyAnchor &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const shipmentCounterparty = extractShipmentCounterpartyValue(text);
if (shipmentCounterparty) {
filters.counterparty = cleanupAnchorValue(shipmentCounterparty);
warnings.push("counterparty_anchor_derived_from_shipment_phrase");
}
}
if ( if (
!filters.counterparty && !filters.counterparty &&
allowGenericCounterpartyAnchor && allowGenericCounterpartyAnchor &&

View File

@ -618,7 +618,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean {
return false; return false;
} }
const hasPaymentCue = const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test( /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
text text
); );
if (!hasPaymentCue) { if (!hasPaymentCue) {
@ -656,14 +656,14 @@ function hasVatPayableConfirmedSignal(text: string): boolean {
return false; return false;
} }
const hasPaymentCue = const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test( /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
text text
); );
if (!hasPaymentCue) { if (!hasPaymentCue) {
return false; return false;
} }
const hasDateOrPeriodCue = const hasDateOrPeriodCue =
/(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test( /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|на\s+(?:январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(
text text
); );
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
@ -784,6 +784,9 @@ function hasCounterpartyDebtLongevitySignal(text: string): boolean {
} }
function hasCounterpartyActivityLifecycleSignal(text: string): boolean { function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) {
return false;
}
const hasPaymentRiskLexeme = const hasPaymentRiskLexeme =
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test( /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(
text text
@ -837,6 +840,30 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb); return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
} }
function hasCounterpartyShipmentItemFlowSignal(text: string): boolean {
const hasNamedTailAfterShipmentCue =
/(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(
text
);
const hasPartySignal =
hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text);
if (!hasPartySignal && !hasNamedTailAfterShipmentCue) {
return false;
}
const hasInboundShipmentCue =
/(?:что\s+нам\s+(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)|кто\s+нам\s+постав(?:лял|ил)|что\s+постав(?:лял|или)\s+нам|что\s+нам\s+поставили)/iu.test(
text
);
const hasItemOrServiceCue =
/(?:како(?:й|е|го|му)\s+товар|каки(?:е|х)\s+товар|какую\s+услуг|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
text
);
return hasInboundShipmentCue || hasItemOrServiceCue;
}
function hasContractUsageOverviewSignal(text: string): boolean { function hasContractUsageOverviewSignal(text: string): boolean {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) { if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
return true; return true;
@ -2021,8 +2048,12 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
!hasInventoryProvenanceSignalV2(text) && !hasInventoryProvenanceSignalV2(text) &&
!hasInventoryPurchaseDocumentsSignalV2(text) && !hasInventoryPurchaseDocumentsSignalV2(text) &&
!hasInventorySaleTraceSignalV2(text) && !hasInventorySaleTraceSignalV2(text) &&
(
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test( /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(
text text
) ||
hasAccountNumberAnchor(text) ||
hasCompactAccountCodeToken(text)
) )
) { ) {
return { return {
@ -2164,16 +2195,21 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
} }
if ( if (
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) && (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) &&
(hasPartyAnchorMention(text) || (hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) || hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) || hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text)) hasHeuristicCounterpartyAnchor(text) ||
hasCounterpartyShipmentItemFlowSignal(text))
) { ) {
return { return {
intent: "list_documents_by_counterparty", intent: "list_documents_by_counterparty",
confidence: "medium", confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"] reasons: [
hasCounterpartyShipmentItemFlowSignal(text)
? "counterparty_item_flow_signal_detected"
: "documents_by_counterparty_signal_detected"
]
}; };
} }

View File

@ -499,10 +499,20 @@ export function evolveAddressNavigationStateWithAssistantItem(
) )
); );
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]); const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
return { const nextSessionContext =
...state, action === "open"
updated_at: createdAt, ? {
session_context: { active_result_set_id: resultSetId,
active_focus_object: focusObject ?? null,
last_confirmed_route: routeId ?? null,
date_scope: {
as_of_date: normalizedDateScope.as_of_date,
period_from: normalizedDateScope.period_from,
period_to: normalizedDateScope.period_to
},
organization_scope: organizationScope ?? state.session_context.organization_scope
}
: {
active_result_set_id: resultSetId, active_result_set_id: resultSetId,
active_focus_object: focusObject ?? state.session_context.active_focus_object, active_focus_object: focusObject ?? state.session_context.active_focus_object,
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route, last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
@ -512,7 +522,11 @@ export function evolveAddressNavigationStateWithAssistantItem(
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
}, },
organization_scope: organizationScope ?? state.session_context.organization_scope organization_scope: organizationScope ?? state.session_context.organization_scope
}, };
return {
...state,
updated_at: createdAt,
session_context: nextSessionContext,
result_sets: nextResultSets, result_sets: nextResultSets,
navigation_history: nextEvents navigation_history: nextEvents
}; };

View File

@ -1,9 +1,13 @@
import { import {
DEFAULT_MAX_OUTPUT_TOKENS,
DEFAULT_MODEL,
DEFAULT_OPENAI_BASE_URL,
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1, FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1, FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1,
FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1, FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_V1, FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
SHARED_LLM_CONNECTION_FILE
} from "../config"; } from "../config";
import type { import type {
AddressCapabilityLayer, AddressCapabilityLayer,
@ -28,6 +32,8 @@ import type {
} from "../types/addressQuery"; } from "../types/addressQuery";
import { import {
buildAddressRecipePlan, buildAddressRecipePlan,
buildCounterpartyPurchaseDocumentQuery,
buildOpenItemsMovementQuery,
selectAddressRecipe, selectAddressRecipe,
type AddressRecipeExecutionPlan type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog"; } from "./addressRecipeCatalog";
@ -57,6 +63,8 @@ import {
normalizeOrganizationScopeValue, normalizeOrganizationScopeValue,
resolveOrganizationSelectionFromMessage resolveOrganizationSelectionFromMessage
} from "./assistantOrganizationMatcher"; } from "./assistantOrganizationMatcher";
import { OpenAIResponsesClient, type OpenAIRequestConfig } from "./openaiResponsesClient";
import { readJsonFile } from "../utils/files";
interface NormalizedAddressRow { interface NormalizedAddressRow {
period: string | null; period: string | null;
@ -219,7 +227,20 @@ interface CounterpartyCatalogResolution {
ambiguityCount: number; ambiguityCount: number;
} }
interface SharedLlmConnectionRecord {
schema_version: "shared_llm_connection_v1";
updated_at: string;
connection: {
llmProvider: "openai" | "local";
model: string;
baseUrl: string;
temperature: number;
maxOutputTokens: number;
};
}
let counterpartyCatalogCache: { names: string[]; loadedAt: number } | null = null; let counterpartyCatalogCache: { names: string[]; loadedAt: number } | null = null;
const limitedReplyLlmClient = new OpenAIResponsesClient();
function parseFiniteNumber(value: unknown): number | null { function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
@ -836,9 +857,43 @@ function anchorTokenVariants(token: string): string[] {
return Array.from(variants); return Array.from(variants);
} }
function normalizePartyTokenSkeleton(value: string): string {
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
}
function fuzzyPartyTokenMatches(candidate: string, token: string): boolean {
const normalizedCandidate = normalizeSearchText(candidate);
const normalizedToken = normalizeSearchText(token);
if (!normalizedCandidate || !normalizedToken) {
return false;
}
if (normalizedCandidate === normalizedToken) {
return true;
}
if (
normalizedCandidate.length < 4 ||
normalizedToken.length < 4 ||
/\d/u.test(normalizedCandidate) ||
/\d/u.test(normalizedToken)
) {
return false;
}
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
return false;
}
return (
candidateSkeleton === tokenSkeleton ||
candidateSkeleton.startsWith(tokenSkeleton) ||
tokenSkeleton.startsWith(candidateSkeleton)
);
}
function matchesAnchorText(searchable: string, anchor: string): boolean { function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable); const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const searchableTokens = tokenizeSearchableText(searchable);
const tokens = tokenizeAnchor(anchor); const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) { if (tokens.length === 0) {
const direct = normalizeSearchText(anchor); const direct = normalizeSearchText(anchor);
@ -851,7 +906,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); return (
searchableNormalized.includes(variant) ||
searchableLatin.includes(tokenLatin) ||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))
);
}); });
}); });
} }
@ -989,6 +1048,16 @@ function normalizeCounterpartyName(value: string): string {
.trim(); .trim();
} }
function hasCounterpartyShipmentItemFlowSignal(userMessage: string): boolean {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
return false;
}
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|кто\s+нам\s+постав|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
text
);
}
function extractCounterpartyCatalogNames(rows: Array<Record<string, unknown>>): string[] { function extractCounterpartyCatalogNames(rows: Array<Record<string, unknown>>): string[] {
return uniqueStrings( return uniqueStrings(
rows rows
@ -1009,6 +1078,7 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
} }
const normalizedName = normalizeCounterpartyName(name); const normalizedName = normalizeCounterpartyName(name);
const normalizedAnchor = normalizeCounterpartyName(anchor); const normalizedAnchor = normalizeCounterpartyName(anchor);
const nameTokens = tokenizeSearchableText(name);
if (!normalizedName || !normalizedAnchor) { if (!normalizedName || !normalizedAnchor) {
return null; return null;
} }
@ -1018,6 +1088,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
score += 10_000; score += 10_000;
} else if (normalizedName.includes(normalizedAnchor)) { } else if (normalizedName.includes(normalizedAnchor)) {
score += 5_000; score += 5_000;
} else if (fuzzyPartyTokenMatches(normalizedName, normalizedAnchor)) {
score += 3_500;
} else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) { } else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
score += 2_000; score += 2_000;
} }
@ -1029,6 +1101,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
for (const variant of variants) { for (const variant of variants) {
if (normalizedName.includes(variant)) { if (normalizedName.includes(variant)) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20); tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20);
} else if (nameTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 12);
} }
} }
if (tokenScore === 0) { if (tokenScore === 0) {
@ -1241,24 +1315,7 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null; const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount); const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity); const quantity = firstFiniteNumber(row.Количество, row.quantity, row.Quantity);
const item = firstNonEmptyString( const item = resolveInventoryItemFromRawRow(row, accountDt, accountKt);
row.Номенклатура,
row.Item,
row.item,
row.НоменклатураПредставление,
row.SubcontoDt1,
row.SubcontoDt2,
row.SubcontoDt3,
row.SubcontoKt1,
row.SubcontoKt2,
row.SubcontoKt3,
row.СубконтоДт1,
row.СубконтоДт2,
row.СубконтоДт3,
row.СубконтоКт1,
row.СубконтоКт2,
row.СубконтоКт3
);
const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление);
const organization = firstNonEmptyString( const organization = firstNonEmptyString(
row.Организация, row.Организация,
@ -1285,6 +1342,191 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
.filter((item) => Boolean(item.period || item.registrator)); .filter((item) => Boolean(item.period || item.registrator));
} }
function hasInventoryAccountPrefix(account: string | null | undefined): boolean {
const normalized = String(account ?? "").trim();
return /^41(?:\.01)?(?:$|[^\d])/u.test(normalized);
}
function resolveInventoryItemFromRawRow(
row: Record<string, unknown>,
accountDt: string | null,
accountKt: string | null
): string | null {
const debitCandidates = [
row.SubcontoDt1,
row.SubcontoDt2,
row.SubcontoDt3,
row.СубконтоДт1,
row.СубконтоДт2,
row.СубконтоДт3
];
const creditCandidates = [
row.SubcontoKt1,
row.SubcontoKt2,
row.SubcontoKt3,
row.СубконтоКт1,
row.СубконтоКт2,
row.СубконтоКт3
];
if (hasInventoryAccountPrefix(accountDt) && !hasInventoryAccountPrefix(accountKt)) {
const debitSideItem = firstNonEmptyString(...debitCandidates, ...creditCandidates);
if (debitSideItem) {
return debitSideItem;
}
}
if (hasInventoryAccountPrefix(accountKt) && !hasInventoryAccountPrefix(accountDt)) {
const creditSideItem = firstNonEmptyString(...creditCandidates, ...debitCandidates);
if (creditSideItem) {
return creditSideItem;
}
}
const explicitItem = firstNonEmptyString(
row.Номенклатура,
row.Nomenclature,
row.nomenclature,
row.Item,
row.item,
row.НоменклатураПредставление
);
if (explicitItem) {
return explicitItem;
}
return firstNonEmptyString(...debitCandidates, ...creditCandidates);
}
function formatMoneyRubForReply(value: number): string {
return `${new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)} `;
}
function extractContractNameFromNormalizedRow(row: NormalizedAddressRow): string | null {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
continue;
}
if (/(?:договор|contract|дог\.)/iu.test(normalized)) {
return normalized;
}
}
return null;
}
function hasBankAccountPrefix(account: string | null | undefined): boolean {
const normalized = String(account ?? "").trim();
return /^5[12](?:$|[^\d])/u.test(normalized);
}
function hasPayablesAccountPrefix(account: string | null | undefined): boolean {
const normalized = String(account ?? "").trim();
return /^60(?:$|[^\d])/u.test(normalized);
}
function isOutgoingSupplierPaymentRow(row: NormalizedAddressRow): boolean {
const registrator = normalizeSearchText(row.registrator);
return (
(hasPayablesAccountPrefix(row.account_dt) && hasBankAccountPrefix(row.account_kt)) ||
registrator.includes("списание с расчетного счета")
);
}
function isIncomingSupplierReturnRow(row: NormalizedAddressRow): boolean {
const registrator = normalizeSearchText(row.registrator);
return (
(hasBankAccountPrefix(row.account_dt) && hasPayablesAccountPrefix(row.account_kt)) ||
registrator.includes("поступление на расчетный счет")
);
}
function formatCounterpartyBankActivityRows(rows: NormalizedAddressRow[], limit = 5): string[] {
return rows.slice(0, limit).map((row, index) => {
const parts = [`${index + 1}. ${row.registrator}`, `дата: ${row.period ?? "не указана"}`];
if (typeof row.amount === "number" && Number.isFinite(row.amount)) {
parts.push(`сумма: ${formatMoneyRubForReply(row.amount)}`);
}
const contract = extractContractNameFromNormalizedRow(row);
if (contract) {
parts.push(`договор: ${contract}`);
}
return parts.join(" | ");
});
}
function buildCounterpartyItemFlowBankFallbackReply(
counterpartyLabel: string,
bankRows: NormalizedAddressRow[]
): string | null {
if (bankRows.length === 0) {
return null;
}
const outgoingPayments = bankRows.filter((row) => isOutgoingSupplierPaymentRow(row));
const incomingReturns = bankRows.filter((row) => isIncomingSupplierReturnRow(row));
const classifiedKeys = new Set(
[...outgoingPayments, ...incomingReturns].map((row) => `${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`)
);
const otherBankRows = bankRows.filter(
(row) => !classifiedKeys.has(`${row.period ?? ""}|${row.registrator}|${row.amount ?? ""}`)
);
const outgoingTotal = outgoingPayments.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const incomingTotal = incomingReturns.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const otherTotal = otherBankRows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const contracts = uniqueStrings(
bankRows
.map((row) => extractContractNameFromNormalizedRow(row))
.filter((item): item is string => Boolean(item))
);
const summaryParts: string[] = [];
if (outgoingPayments.length > 0) {
summaryParts.push(
`исходящих оплат поставщику: ${outgoingPayments.length} на ${formatMoneyRubForReply(outgoingTotal)}`
);
}
if (incomingReturns.length > 0) {
summaryParts.push(
`возвратов от поставщика: ${incomingReturns.length} на ${formatMoneyRubForReply(incomingTotal)}`
);
}
if (otherBankRows.length > 0) {
summaryParts.push(`прочих банковских документов: ${otherBankRows.length} на ${formatMoneyRubForReply(otherTotal)}`);
}
if (summaryParts.length === 0) {
return null;
}
const lines = [`Контрагент: ${counterpartyLabel}. Подтвержденных поставок товаров или услуг не найдено.`];
lines.push("Позиции: подтвержденных поставок товаров или услуг не найдено.");
lines.push(`По связанным расчетам с контрагентом найдено ${summaryParts.join("; ")}.`);
if (outgoingPayments.length > 0 && incomingReturns.length > 0 && Math.abs(outgoingTotal - incomingTotal) < 0.005) {
lines.push("Сумма возврата совпадает с суммой исходящих оплат.");
}
if (contracts.length === 1) {
lines.push(`Договор: ${contracts[0]}.`);
} else if (contracts.length > 1) {
lines.push(`Договоры: ${contracts.slice(0, 3).join("; ")}.`);
}
if (outgoingPayments.length > 0) {
lines.push("Оплаты поставщику:");
lines.push(...formatCounterpartyBankActivityRows(outgoingPayments));
}
if (incomingReturns.length > 0) {
lines.push("Возвраты от поставщика:");
lines.push(...formatCounterpartyBankActivityRows(incomingReturns));
}
if (otherBankRows.length > 0) {
lines.push("Прочие банковские документы:");
lines.push(...formatCounterpartyBankActivityRows(otherBankRows));
}
return lines.join("\n");
}
function rowSearchableText(row: NormalizedAddressRow): string { function rowSearchableText(row: NormalizedAddressRow): string {
return [ return [
row.registrator, row.registrator,
@ -2630,6 +2872,104 @@ function hasAggregateLimitedSignal(input: {
); );
} }
function shouldUseLlmLimitedReply(category: AddressLimitedReasonCategory): boolean {
return (
category === "unsupported" ||
category === "recipe_visibility_gap" ||
category === "execution_error" ||
category === "missing_anchor" ||
category === "empty_match"
);
}
function loadSharedLlmRequestConfig(): OpenAIRequestConfig | null {
const record = readJsonFile<SharedLlmConnectionRecord | null>(SHARED_LLM_CONNECTION_FILE, null);
const connection = record?.connection;
if (!connection || typeof connection !== "object") {
return null;
}
const llmProvider = connection.llmProvider === "local" ? "local" : "openai";
const model = String(connection.model ?? "").trim() || DEFAULT_MODEL;
const baseUrl = String(connection.baseUrl ?? "").trim() || DEFAULT_OPENAI_BASE_URL;
const temperature =
typeof connection.temperature === "number" && Number.isFinite(connection.temperature)
? connection.temperature
: 0.2;
const maxOutputTokens =
typeof connection.maxOutputTokens === "number" && Number.isFinite(connection.maxOutputTokens)
? Math.max(64, Math.trunc(connection.maxOutputTokens))
: DEFAULT_MAX_OUTPUT_TOKENS;
const apiKey = String(process.env.OPENAI_API_KEY ?? "").trim();
if (llmProvider === "openai" && apiKey.length === 0) {
return null;
}
return {
llmProvider,
apiKey,
model,
baseUrl,
temperature,
maxOutputTokens
};
}
async function tryComposeLlmLimitedReply(input: {
userMessage: string;
category: AddressLimitedReasonCategory;
intent: AddressIntent;
reasonText: string;
nextStep?: string;
filters: AddressFilterSet;
anchor?: AnchorResolutionDebug;
}): Promise<string | null> {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
const question = String(input.userMessage ?? "").trim();
if (!question) {
return null;
}
const config = loadSharedLlmRequestConfig();
if (!config) {
return null;
}
const contextPayload = {
question,
intent: input.intent,
category: input.category,
reason: normalizeLimitedReason(input.reasonText),
next_step: normalizeLimitedNextStep(input.nextStep ?? ""),
filters: input.filters,
anchor: input.anchor
? {
type: input.anchor.anchor_type,
raw: input.anchor.anchor_value_raw,
resolved: input.anchor.anchor_value_resolved
}
: null
};
try {
const response = await limitedReplyLlmClient.chat(config, {
systemPrompt:
"Ты формулируешь мягкий, но предметный отказ для бухгалтерского ассистента 1С. Нельзя выдумывать факты и нельзя утверждать, что данные найдены, если это не подтверждено.",
developerPrompt:
"Ответь по-русски, коротко и по делу. Дай 2-4 коротких абзаца. Первая фраза должна прямо сказать, что именно сейчас не удается надежно подтвердить по вопросу пользователя. Если есть контрагент, счет, договор, организация или период, упомяни их естественно. Коротко скажи, что уже проверено, и предложи 1-2 ближайших шага. Не используй техжаргон вроде address lane, partial_coverage, recipe, capability, unsupported.",
userMessage: `Контекст отказа:\n${JSON.stringify(contextPayload, null, 2)}`,
maxOutputTokens: Math.min(280, Math.max(160, config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS)),
temperature: 0.2
});
const text = String(response.outputText ?? "")
.replace(/\r\n/g, "\n")
.trim();
return text.length > 0 ? text : null;
} catch {
return null;
}
}
function composeLimitedReply(input: { function composeLimitedReply(input: {
category: AddressLimitedReasonCategory; category: AddressLimitedReasonCategory;
reason: string; reason: string;
@ -3132,6 +3472,7 @@ export class AddressQueryService {
requestedResultMode, requestedResultMode,
filters: executionFilters filters: executionFilters
}); });
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
if (isCapabilityRouteBlocked(capabilityDecision)) { if (isCapabilityRouteBlocked(capabilityDecision)) {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -3162,9 +3503,24 @@ export class AddressQueryService {
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null; vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean; emphasizeNumbers?: boolean;
useRubCurrency?: boolean; useRubCurrency?: boolean;
counterpartyHint?: string;
accountHint?: string;
} = {} } = {}
) => ({ ) => ({
userMessage, userMessage,
itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined,
counterpartyHint:
typeof options.counterpartyHint === "string"
? options.counterpartyHint
: typeof filterSet.counterparty === "string"
? filterSet.counterparty
: undefined,
accountHint:
typeof options.accountHint === "string"
? options.accountHint
: typeof filterSet.account === "string"
? filterSet.account
: undefined,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
@ -3173,8 +3529,53 @@ export class AddressQueryService {
emphasizeNumbers: options.emphasizeNumbers ?? undefined, emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined useRubCurrency: options.useRubCurrency ?? undefined
}); });
const finalizeLimitedResult = async (
input: Parameters<typeof buildLimitedExecutionResult>[0]
): Promise<AddressExecutionResult> => {
const result = buildLimitedExecutionResult(input);
const llmReply = await tryComposeLlmLimitedReply({
userMessage,
category: input.category,
intent: input.intent.intent,
reasonText: input.reasonText,
nextStep: input.nextStep,
filters: input.filters,
anchor: input.anchor
});
if (!llmReply) {
return result;
}
return {
...result,
reply_text: llmReply
};
};
const composeRuntimeOptions = (
filterSet: AddressFilterSet,
options: {
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
counterpartyHint?: string;
accountHint?: string;
} = {}
) =>
composeOptionsFromFilters(filterSet, {
...options,
counterpartyHint:
typeof options.counterpartyHint === "string"
? options.counterpartyHint
: 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"
? filterSet.account
: undefined
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario = const debtLifecycleReceivablesScenario =
intent.intent === "list_receivables_counterparties" && intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) && Array.isArray(intent.reasons) &&
@ -3238,7 +3639,7 @@ export class AddressQueryService {
} }
if (intent.intent === "unknown") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3261,7 +3662,7 @@ export class AddressQueryService {
} }
if (recipeSelection.selected_recipe === null) { if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3284,7 +3685,7 @@ export class AddressQueryService {
} }
if (recipeSelection.missing_required_filters.length > 0) { if (recipeSelection.missing_required_filters.length > 0) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3307,7 +3708,7 @@ export class AddressQueryService {
} }
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) { if (!FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3334,6 +3735,14 @@ export class AddressQueryService {
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) { if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor); const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
if (catalogResolution.resolvedValue) { if (catalogResolution.resolvedValue) {
filters.extracted_filters = {
...filters.extracted_filters,
counterparty: catalogResolution.resolvedValue
};
executionFilters = {
...executionFilters,
counterparty: catalogResolution.resolvedValue
};
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) { if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup"); filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
} }
@ -3375,6 +3784,45 @@ export class AddressQueryService {
buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters), buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters),
intent.intent intent.intent
); );
const counterpartyItemFlowQuery =
intent.intent === "list_documents_by_counterparty" &&
hasCounterpartyShipmentItemFlowSignal(userMessage) &&
typeof executionFilters.counterparty === "string" &&
executionFilters.counterparty.trim().length > 0;
const counterpartyItemFlowFilters =
counterpartyItemFlowQuery && anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? {
...executionFilters,
counterparty: anchor.anchor_value_resolved
}
: executionFilters;
if (counterpartyItemFlowQuery) {
plan = {
...plan,
query: buildCounterpartyPurchaseDocumentQuery(counterpartyItemFlowFilters, plan.limit),
account_scope: [],
account_scope_mode: "preferred"
};
if (!baseReasons.includes("counterparty_item_flow_query_override_to_purchase_documents")) {
baseReasons.push("counterparty_item_flow_query_override_to_purchase_documents");
}
}
const accountOnlyOpenItemsQuery =
intent.intent === "open_items_by_counterparty_or_contract" &&
typeof executionFilters.account === "string" &&
executionFilters.account.trim().length > 0 &&
!(typeof executionFilters.counterparty === "string" && executionFilters.counterparty.trim().length > 0) &&
!(typeof executionFilters.contract === "string" && executionFilters.contract.trim().length > 0);
if (accountOnlyOpenItemsQuery) {
plan = {
...plan,
query: buildOpenItemsMovementQuery(executionFilters, plan.limit),
account_scope_mode: "strict"
};
if (!baseReasons.includes("open_items_account_query_override_to_movements")) {
baseReasons.push("open_items_account_query_override_to_movements");
}
}
let mcp = await executeAddressMcpQuery({ let mcp = await executeAddressMcpQuery({
query: plan.query, query: plan.query,
limit: plan.limit limit: plan.limit
@ -3479,7 +3927,7 @@ export class AddressQueryService {
if (mcp.error) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -3623,6 +4071,90 @@ export class AddressQueryService {
: matchFailureStage === "materialized_but_filtered_out_by_recipe" : matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match" ? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null; : null;
const buildFactualNoRowsResult = (
replyText: string,
noRowsReason: string,
extraLimitations: string[] = []
): AddressExecutionResult => {
const responseType: AddressResponseType = "FACTUAL_SUMMARY";
const semantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
semanticFrame,
responseType,
rowsMatched: 0
}),
undefined
);
return {
handled: true,
reply_text: replyText,
reply_type: inferReplyType(responseType),
response_type: responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: matchFailureStage,
match_failure_reason: matchFailureReason,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: 0,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: responseType,
...semantics,
limitations: [...filters.warnings, ...extraLimitations],
reasons: withConfirmedBalanceFallbackReason(
[...baseReasons, noRowsReason],
requestedResultMode,
undefined,
semantics.result_mode
),
...(capabilityAudit
? {
capability_id: capabilityAudit.capabilityId,
capability_layer: capabilityAudit.layer,
capability_route_mode: capabilityAudit.routeMode,
capability_route_enabled: capabilityAudit.enabled,
capability_route_reason: capabilityAudit.reason
}
: {}),
...(shadowRouteAudit
? {
shadow_route_status: shadowRouteAudit.status
}
: {})
}
};
};
if (organizationWarehouseRecoveryApplied) { if (organizationWarehouseRecoveryApplied) {
if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) { if (!baseReasons.includes("organization_scope_live_grounding_recovered_rows")) {
baseReasons.push("organization_scope_live_grounding_recovered_rows"); baseReasons.push("organization_scope_live_grounding_recovered_rows");
@ -3669,7 +4201,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) { if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters)); const factual = composeFactualReply(intent.intent, recoveredRows, composeRuntimeOptions(executionFilters));
const recoveryReason = const recoveryReason =
recoveredBankRows.length > 0 recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback" ? "contract_docs_recovered_via_bank_fallback"
@ -3822,7 +4354,7 @@ export class AddressQueryService {
const expandedFactual = composeFactualReply( const expandedFactual = composeFactualReply(
intent.intent, intent.intent,
expandedFilteredRows, expandedFilteredRows,
composeOptionsFromFilters(expandedLimitFilters) composeRuntimeOptions(expandedLimitFilters)
); );
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`; const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"]; const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
@ -3977,7 +4509,7 @@ export class AddressQueryService {
const broadenedFactual = composeFactualReply( const broadenedFactual = composeFactualReply(
intent.intent, intent.intent,
broadenedFilteredRows, broadenedFilteredRows,
composeOptionsFromFilters(autoBroadenedFilters) composeRuntimeOptions(autoBroadenedFilters)
); );
const broadenedLimitations = [ const broadenedLimitations = [
...filters.warnings, ...filters.warnings,
@ -4075,6 +4607,7 @@ export class AddressQueryService {
if ( if (
filteredRows.length === 0 && filteredRows.length === 0 &&
!counterpartyItemFlowQuery &&
isDocumentOrBankAnchorIntent(intent.intent) && isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) && !hasExplicitPeriodWindow(filters.extracted_filters) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
@ -4151,7 +4684,7 @@ export class AddressQueryService {
const historicalFactual = composeFactualReply( const historicalFactual = composeFactualReply(
intent.intent, intent.intent,
historicalFilteredRows, historicalFilteredRows,
composeOptionsFromFilters(historicalFilters) composeRuntimeOptions(historicalFilters)
); );
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу."; const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion = const historicalSuggestion =
@ -4238,7 +4771,7 @@ export class AddressQueryService {
const fallbackFactual = composeFactualReply( const fallbackFactual = composeFactualReply(
intent.intent, intent.intent,
documentBankFallbackRows, documentBankFallbackRows,
composeOptionsFromFilters(executionFilters) composeRuntimeOptions(executionFilters)
); );
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы."; const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = const fallbackSuggestion =
@ -4322,6 +4855,65 @@ export class AddressQueryService {
!toNonEmptyFilterValue(filters.extracted_filters.contract) && !toNonEmptyFilterValue(filters.extracted_filters.contract) &&
!toNonEmptyFilterValue(filters.extracted_filters.document_ref); !toNonEmptyFilterValue(filters.extracted_filters.document_ref);
if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) { if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) {
if (stageStatus === "no_raw_rows" && counterpartyItemFlowQuery && anchor.anchor_type === "counterparty") {
const counterpartyLabel = String(anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? "указанный контрагент")
.trim()
.replace(/[.]+$/u, "");
const bankFallbackSelection = selectAddressRecipe("bank_operations_by_counterparty", counterpartyItemFlowFilters);
if (bankFallbackSelection.selected_recipe) {
const bankFallbackPlan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(bankFallbackSelection.selected_recipe, counterpartyItemFlowFilters),
"bank_operations_by_counterparty"
);
const bankFallbackMcp = await executeAddressMcpQuery({
query: bankFallbackPlan.query,
limit: bankFallbackPlan.limit
});
if (!bankFallbackMcp.error) {
const bankFallbackNormalizedRows = applyAccountScopeFilter(
toNormalizedRows(bankFallbackMcp.raw_rows),
bankFallbackPlan.account_scope
);
const bankFallbackRows = applyIntentSpecificFilter(
"bank_operations_by_counterparty",
applyAddressFilters(bankFallbackNormalizedRows, counterpartyItemFlowFilters).rows
);
const bankFallbackReply = buildCounterpartyItemFlowBankFallbackReply(counterpartyLabel, bankFallbackRows);
if (bankFallbackReply) {
return buildFactualNoRowsResult(
bankFallbackReply,
"counterparty_item_flow_no_supply_but_bank_activity_explained",
["counterparty_item_flow_bank_activity_fallback_applied"]
);
}
}
}
return buildFactualNoRowsResult(
`Контрагент: ${counterpartyLabel}. Позиции: подтвержденных поступлений товаров или услуг не найдено.\роверил документы поступления и связанные движения по этому контрагенту: в доступном срезе базы строк с товарами или услугами не нашлось.`,
"counterparty_item_flow_exact_negative_response"
);
}
if (stageStatus === "no_raw_rows" && accountOnlyOpenItemsQuery) {
const accountLabel =
typeof executionFilters.account === "string" && executionFilters.account.trim().length > 0
? executionFilters.account.trim()
: "указанному счету";
const periodLabel =
typeof executionFilters.period_from === "string" && typeof executionFilters.period_to === "string"
? `за период ${executionFilters.period_from}..${executionFilters.period_to}`
: typeof executionFilters.as_of_date === "string"
? `на дату ${executionFilters.as_of_date}`
: "";
const organizationLabel =
typeof executionFilters.organization === "string" && executionFilters.organization.trim().length > 0
? executionFilters.organization.trim()
: "";
return buildFactualNoRowsResult(
`По счету ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} открытых остатков не найдено.` +
(organizationLabel ? `\nОрганизация: ${organizationLabel}.` : ""),
"open_items_account_exact_negative_response"
);
}
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate = const isVisibilityGapCandidate =
@ -4405,7 +4997,7 @@ export class AddressQueryService {
? "document_or_bank_visibility_gap_after_base_filter" ? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter" : "no_rows_after_recipe_and_scope_filter"
]; ];
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -4454,7 +5046,7 @@ export class AddressQueryService {
const factual = composeFactualReply( const factual = composeFactualReply(
composeIntent, composeIntent,
filteredRows, filteredRows,
composeOptionsFromFilters(executionFilters, { composeRuntimeOptions(executionFilters, {
vatDirectSourceProbe, vatDirectSourceProbe,
emphasizeNumbers: shouldEmphasizeNumbers, emphasizeNumbers: shouldEmphasizeNumbers,
useRubCurrency: shouldUseRubCurrency useRubCurrency: shouldUseRubCurrency
@ -4489,7 +5081,7 @@ export class AddressQueryService {
resultMode: factualResultSemantics.result_mode resultMode: factualResultSemantics.result_mode
}); });
if (finalRouteExpectationAudit.status === "mismatch" && FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) { if (finalRouteExpectationAudit.status === "mismatch" && FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,
@ -4537,7 +5129,7 @@ export class AddressQueryService {
: intent.intent === "vat_liability_confirmed_for_tax_period" : intent.intent === "vat_liability_confirmed_for_tax_period"
? "vat_tax_period" ? "vat_tax_period"
: "vat_payable"; : "vat_payable";
return buildLimitedExecutionResult({ return finalizeLimitedResult({
mode, mode,
shape, shape,
intent, intent,

View File

@ -85,6 +85,40 @@ __WHERE_CLAUSE__
Товары.Ссылка.Дата __ORDER_DIRECTION__ Товары.Ссылка.Дата __ORDER_DIRECTION__
`; `;
const COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
`;
const COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Услуги.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
Услуги.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Услуги.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Услуги.Ссылка.Организация) КАК Организация,
0 КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Услуги КАК Услуги
__WHERE_CLAUSE__
`;
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период, __AS_OF_EXPR__ КАК Период,
@ -1227,6 +1261,36 @@ function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPa
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
} }
function buildCounterpartyReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
if (!counterparty) {
return null;
}
const counterpartyTokens = Array.from(
new Set(
counterparty
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 3)
)
);
const tokens = counterpartyTokens.length > 0 ? counterpartyTokens : [counterparty];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`;
});
return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
@ -1257,6 +1321,44 @@ function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolved
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
} }
export function buildCounterpartyPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const goodsCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Товары.Ссылка.Контрагент"]);
const servicesCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Услуги.Ссылка.Контрагент"]);
const goodsQuery = COUNTERPARTY_PURCHASE_GOODS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Товары.Ссылка.Дата",
['Товары.Ссылка.Проведен = ИСТИНА', goodsCounterpartyCondition].filter((item): item is string => Boolean(item))
)
);
const servicesQuery = COUNTERPARTY_PURCHASE_SERVICES_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Услуги.Ссылка.Дата",
['Услуги.Ссылка.Проведен = ИСТИНА', servicesCounterpartyCondition].filter((item): item is string => Boolean(item))
)
);
return `${goodsQuery}
ОБЪЕДИНИТЬ ВСЕ
${servicesQuery}
УПОРЯДОЧИТЬ ПО
Период ${resolveOrderDirection(filters.sort)}`;
}
export function buildOpenItemsMovementQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const extraConditions: string[] = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", extraConditions))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean { function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasAnchor = const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) || (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||

View File

@ -40,6 +40,9 @@ export interface VatDirectSourceProbeSummary {
interface ComposeFactualReplyOptions { interface ComposeFactualReplyOptions {
userMessage?: string; userMessage?: string;
itemHint?: string;
counterpartyHint?: string;
accountHint?: string;
periodFrom?: string; periodFrom?: string;
periodTo?: string; periodTo?: string;
asOfDate?: string; asOfDate?: string;
@ -78,6 +81,7 @@ type CounterpartyProfileFocus =
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time"; type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
type ValueRankingFocus = type ValueRankingFocus =
| "top_by_total" | "top_by_total"
| "total_flow"
| "top_years_by_total" | "top_years_by_total"
| "top_by_ops" | "top_by_ops"
| "top_by_max_single" | "top_by_max_single"
@ -662,6 +666,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
if (!text) { if (!text) {
return "top_by_total"; return "top_by_total";
} }
const asksTotalMoneyEarned =
/(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text) &&
!/(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart)/iu.test(text) &&
!/(?:топ|top|сам(?:ый|ая|ое|ые)|наибольш|больше\s+всего|максимальн)/iu.test(text);
if (asksTotalMoneyEarned) {
return "total_flow";
}
const asksYearlyRevenueRanking = const asksYearlyRevenueRanking =
/(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
@ -767,6 +778,16 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return null; return null;
} }
function hasCounterpartyItemFlowQuestion(userMessage: string | undefined): boolean {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return false;
}
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
text
);
}
function extractInventoryItemName(row: ComposeStageRow): string | null { function extractInventoryItemName(row: ComposeStageRow): string | null {
const direct = String(row.item ?? "").trim(); const direct = String(row.item ?? "").trim();
if (direct) { if (direct) {
@ -988,10 +1009,13 @@ function looksLikeInventoryPartyToken(value: string): boolean {
return normalized === normalized.toUpperCase() && normalized.length >= 4; return normalized === normalized.toUpperCase() && normalized.length >= 4;
} }
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] { function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTokens: string[] = []): string[] {
const itemToken = normalizeEntityToken(extractInventoryItemName(row)); const itemToken = normalizeEntityToken(extractInventoryItemName(row));
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row)); const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row)); const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
const excludedComparableTokens = excludedTokens
.map((token) => normalizeEntityToken(token))
.filter((token): token is string => Boolean(token));
const candidates: string[] = []; const candidates: string[] = [];
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
@ -999,7 +1023,13 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[]
continue; continue;
} }
const comparable = normalizeEntityToken(normalized); const comparable = normalizeEntityToken(normalized);
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) { if (
!comparable ||
comparable === itemToken ||
comparable === warehouseToken ||
comparable === organizationToken ||
excludedComparableTokens.includes(comparable)
) {
continue; continue;
} }
candidates.push(normalized); candidates.push(normalized);
@ -1018,7 +1048,7 @@ interface InventoryTraceSummary {
totalAmount: number; totalAmount: number;
} }
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary { function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpartyTokens: string[] = []): InventoryTraceSummary {
const items = uniqueStrings( const items = uniqueStrings(
rows rows
.map((row) => extractInventoryItemName(row)) .map((row) => extractInventoryItemName(row))
@ -1034,7 +1064,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
.map((row) => extractInventoryOrganizationName(row)) .map((row) => extractInventoryOrganizationName(row))
.filter((item): item is string => Boolean(item)) .filter((item): item is string => Boolean(item))
); );
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row))); const counterparties = uniqueStrings(
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
);
const documents = uniqueStrings( const documents = uniqueStrings(
rows rows
.map((row) => String(row.registrator ?? "").trim()) .map((row) => String(row.registrator ?? "").trim())
@ -1057,9 +1089,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
}; };
} }
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] { function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10, excludedCounterpartyTokens: string[] = []): string[] {
return rows.slice(0, limit).map((row, index) => { return rows.slice(0, limit).map((row, index) => {
const parties = extractInventoryCounterpartyCandidates(row); const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
const warehouse = extractInventoryWarehouseName(row); const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row); const organization = extractInventoryOrganizationName(row);
const amount = const amount =
@ -1082,6 +1114,35 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[]
}); });
} }
function formatCounterpartyItemFlowRows(rows: ComposeStageRow[], limit = 12): string[] {
return rows.slice(0, limit).map((row, index) => {
const item = extractInventoryItemName(row) ?? "позиция не указана";
const contract = extractContractName(row);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const quantity = extractInventoryQuantity(row);
const amount =
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
const parts = [
`${index + 1}. ${item}`,
`договор: ${contract ?? "не указан"}`,
`документ: ${row.registrator}`,
`дата: ${inventoryTraceDateLabel(row.period)}`,
`сумма: ${amount}`
];
if (quantity !== null && quantity > 0) {
parts.push(`количество: ${formatNumberWithDots(quantity, 3)}`);
}
if (warehouse) {
parts.push(`склад: ${warehouse}`);
}
if (organization) {
parts.push(`организация: ${organization}`);
}
return parts.join(" | ");
});
}
interface InventoryAgingByItemAggregate { interface InventoryAgingByItemAggregate {
item: string; item: string;
warehouse: string | null; warehouse: string | null;
@ -3296,6 +3357,8 @@ export function composeFactualReply(
const profileRows = Array.from(byCounterparty.values()); const profileRows = Array.from(byCounterparty.values());
const yearRows = Array.from(byYear.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 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 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 rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
@ -3337,6 +3400,33 @@ export function composeFactualReply(
}; };
} }
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") { if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit); const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier const heading = isSupplier
@ -4274,8 +4364,11 @@ export function composeFactualReply(
if (intent === "inventory_sale_trace_for_item") { if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options); const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row)); const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows); const requestedItemHint = String(options.itemHint ?? "").trim();
const itemLabel = summary.item ?? "товар не определен"; const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine = const directAnswerLine =
summary.counterparties.length === 1 summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
@ -4296,7 +4389,7 @@ export function composeFactualReply(
} }
lines.push("", "Документы выбытия:"); lines.push("", "Документы выбытия:");
if (saleRows.length > 0) { if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12)); lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
} else { } else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
} }
@ -5023,8 +5116,12 @@ export function composeFactualReply(
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows); const counterparties = buildCounterpartyRiskAggregate(rows);
const accountLead =
typeof options.accountHint === "string" && options.accountHint.trim().length > 0
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
: "Собраны открытые позиции по взаиморасчетам.";
const lines = [ const lines = [
"Собраны открытые позиции по взаиморасчетам.", accountLead,
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.` `Контрагентов с сигналом: ${counterparties.length}.`
]; ];
@ -5090,10 +5187,73 @@ export function composeFactualReply(
} }
if (intent === "list_documents_by_counterparty") { if (intent === "list_documents_by_counterparty") {
const lines = [ const resolvedCounterparty =
`Найдено документов по контрагенту: ${rows.length}.`, (typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
...formatTopRows(rows, rows.length) ? 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 counterpartyLabel =
typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
: resolvedCounterparty
? `${resolvedCounterparty}.`
: null;
const counterpartyInline =
typeof counterpartyLabel === "string" ? counterpartyLabel.replace(/[.]+$/u, "") : resolvedCounterparty;
const itemFlowQuestion = hasCounterpartyItemFlowQuestion(options.userMessage);
const items = uniqueStrings(
rows
.map((row) => extractInventoryItemName(row))
.filter((item): item is string => Boolean(item))
);
const contracts = uniqueStrings(
rows
.map((row) => extractContractName(row))
.filter((item): item is string => Boolean(item))
);
const lines: string[] = [];
if (itemFlowQuestion) {
lines.push(
counterpartyInline
? `Контрагент: ${counterpartyInline}. Подтвержденных поставок товаров или услуг: ${rows.length}.`
: `Подтвержденных поставок товаров или услуг по запрошенному контрагенту: ${rows.length}.`
);
} else {
lines.push(
counterpartyInline
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
: `Найдено документов по контрагенту: ${rows.length}.`
);
}
if (counterpartyLabel) {
lines.push(`Контрагент: ${counterpartyLabel}`);
}
if (itemFlowQuestion) {
if (items.length > 0) {
lines.push(`Позиции: ${items.slice(0, 8).join("; ")}.`);
if (items.length > 8) {
lines.push(`Показаны первые 8 из ${items.length} позиций.`);
}
}
if (contracts.length === 1) {
lines.push(`Договор: ${contracts[0]}.`);
} else if (contracts.length > 1) {
lines.push(`Договоры в выборке: ${contracts.slice(0, 3).join("; ")}.`);
}
lines.push(...formatCounterpartyItemFlowRows(rows));
if (rows.length > 12) {
lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
}
} else {
lines.push(...formatTopRows(rows, rows.length));
}
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")

View File

@ -26,6 +26,7 @@ import type { AddressLlmSemanticHints } from "../../types/addressQuery";
export interface AddressFollowupContext { export interface AddressFollowupContext {
previous_intent?: AddressIntent; previous_intent?: AddressIntent;
target_intent?: AddressIntent;
previous_filters?: AddressFilterSet; previous_filters?: AddressFilterSet;
previous_anchor_type?: previous_anchor_type?:
| "account" | "account"
@ -98,7 +99,7 @@ function hasSameDateHint(text: string): boolean {
} }
function hasSamePeriodHint(text: string): boolean { function hasSamePeriodHint(text: string): boolean {
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test( return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(
String(text ?? "") String(text ?? "")
); );
} }
@ -672,6 +673,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
if (hasSameDateHint(normalized)) { if (hasSameDateHint(normalized)) {
return true; return true;
} }
if (hasSamePeriodHint(normalized)) {
return true;
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length; const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if ( if (
@ -853,7 +857,9 @@ function mergeFollowupFilters(
intent === "inventory_aging_by_purchase_date" || intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date" intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period"
) { ) {
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage); const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
@ -935,6 +941,34 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (
samePeriodRequested &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date")
) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
reasons.push("period_from_from_followup_context");
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
reasons.push("period_to_from_followup_context");
}
if (
intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if ( if (
samePeriodRequested && samePeriodRequested &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")
@ -1146,6 +1180,17 @@ function mergeFollowupFilters(
merged.period_to = previousPeriodTo; merged.period_to = previousPeriodTo;
} }
reasons.push("period_from_followup_context"); reasons.push("period_from_followup_context");
if (
intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if ( if (
@ -1199,7 +1244,7 @@ function deriveIntentWithFollowupContext(
userMessage: string, userMessage: string,
followupContext: AddressFollowupContext | null followupContext: AddressFollowupContext | null
): AddressIntentResolution { ): AddressIntentResolution {
if (!followupContext || !followupContext.previous_intent) { if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
return detectedIntent; return detectedIntent;
} }
@ -1208,7 +1253,11 @@ function deriveIntentWithFollowupContext(
if (!hasFollowupSignal) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
const previousIntent = followupContext.previous_intent; const sourceIntent = followupContext.previous_intent ?? null;
const fallbackIntent = followupContext.target_intent ?? sourceIntent;
if (!sourceIntent && !fallbackIntent) {
return detectedIntent;
}
const previousFilters = followupContext.previous_filters ?? {}; const previousFilters = followupContext.previous_filters ?? {};
const previousContract = toNonEmptyString(previousFilters.contract); const previousContract = toNonEmptyString(previousFilters.contract);
const previousCounterparty = toNonEmptyString(previousFilters.counterparty); const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
@ -1243,7 +1292,8 @@ function deriveIntentWithFollowupContext(
}; };
} }
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance"; const previousIsBalanceFamily =
sourceIntent === "account_balance_snapshot" || sourceIntent === "documents_forming_balance";
if ( if (
previousIsBalanceFamily && previousIsBalanceFamily &&
hasAccountSignal(normalizedMessage) && hasAccountSignal(normalizedMessage) &&
@ -1262,7 +1312,7 @@ function deriveIntentWithFollowupContext(
}; };
} }
const previousIsInventoryFamily = isInventoryIntent(previousIntent); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const inventorySelectedObjectFollowup = const inventorySelectedObjectFollowup =
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) { if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
@ -1273,7 +1323,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "bank_operations_by_counterparty" || detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent detectedIntent.intent === sourceIntent
) { ) {
return { return {
intent: "inventory_purchase_provenance_for_item", intent: "inventory_purchase_provenance_for_item",
@ -1289,7 +1339,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "list_documents_by_counterparty" || detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" || detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent detectedIntent.intent === sourceIntent
) { ) {
return { return {
intent: "inventory_purchase_documents_for_item", intent: "inventory_purchase_documents_for_item",
@ -1309,7 +1359,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === "inventory_sale_trace_for_item" || detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === previousIntent detectedIntent.intent === sourceIntent
) { ) {
return { return {
intent: "inventory_profitability_for_item", intent: "inventory_profitability_for_item",
@ -1323,7 +1373,7 @@ function deriveIntentWithFollowupContext(
if ( if (
detectedIntent.intent === "unknown" || detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === previousIntent || detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date" detectedIntent.intent === "inventory_on_hand_as_of_date"
) { ) {
return { return {
@ -1339,7 +1389,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "unknown" || detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent detectedIntent.intent === sourceIntent
) { ) {
return { return {
intent: "inventory_sale_trace_for_item", intent: "inventory_sale_trace_for_item",
@ -1354,7 +1404,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "unknown" || detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_sale_trace_for_item" || detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" || detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent detectedIntent.intent === sourceIntent
) { ) {
return { return {
intent: "inventory_purchase_to_sale_chain", intent: "inventory_purchase_to_sale_chain",
@ -1368,7 +1418,7 @@ function deriveIntentWithFollowupContext(
previousIsInventoryFamily && previousIsInventoryFamily &&
hasFollowupSignal && hasFollowupSignal &&
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) && hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent) (detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)
) { ) {
return { return {
intent: "inventory_purchase_provenance_for_item", intent: "inventory_purchase_provenance_for_item",
@ -1431,7 +1481,7 @@ function deriveIntentWithFollowupContext(
} }
return { return {
intent: previousIntent, intent: fallbackIntent ?? "unknown",
confidence: "low", confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"] reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
}; };

View File

@ -101,6 +101,13 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token)); .filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
} }
function tokenizeSearchableText(value: string): string[] {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter(Boolean);
}
function anchorTokenVariants(token: string): string[] { function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase(); const source = String(token ?? "").trim().toLowerCase();
if (!source) { if (!source) {
@ -123,9 +130,43 @@ function anchorTokenVariants(token: string): string[] {
return Array.from(variants); return Array.from(variants);
} }
function normalizePartyTokenSkeleton(value: string): string {
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
}
function fuzzyPartyTokenMatches(candidate: string, token: string): boolean {
const normalizedCandidate = normalizeSearchText(candidate);
const normalizedToken = normalizeSearchText(token);
if (!normalizedCandidate || !normalizedToken) {
return false;
}
if (normalizedCandidate === normalizedToken) {
return true;
}
if (
normalizedCandidate.length < 4 ||
normalizedToken.length < 4 ||
/\d/u.test(normalizedCandidate) ||
/\d/u.test(normalizedToken)
) {
return false;
}
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
return false;
}
return (
candidateSkeleton === tokenSkeleton ||
candidateSkeleton.startsWith(tokenSkeleton) ||
tokenSkeleton.startsWith(candidateSkeleton)
);
}
function matchesAnchorText(searchable: string, anchor: string): boolean { function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable); const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized); const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const searchableTokens = tokenizeSearchableText(searchable);
const tokens = tokenizeAnchor(anchor); const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) { if (tokens.length === 0) {
const direct = normalizeSearchText(anchor); const direct = normalizeSearchText(anchor);
@ -138,7 +179,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
const variants = anchorTokenVariants(token); const variants = anchorTokenVariants(token);
return variants.some((variant) => { return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant); const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin); return (
searchableNormalized.includes(variant) ||
searchableLatin.includes(tokenLatin) ||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))
);
}); });
}); });
if (fullMatch) { if (fullMatch) {

View File

@ -2440,6 +2440,12 @@ function findRecentAddressFilterValue(items, key) {
if (!isAddressLaneDebugPayload(debug)) { if (!isAddressLaneDebugPayload(debug)) {
continue; continue;
} }
const replyType = toNonEmptyString(item.reply_type);
const limitedReasonCategory = toNonEmptyString(debug.limited_reason_category);
if ((replyType && replyType !== "factual" && replyType !== "factual_with_explanation") ||
limitedReasonCategory) {
continue;
}
const directFilterValue = readAddressFilterString(debug, key); const directFilterValue = readAddressFilterString(debug, key);
if (directFilterValue) { if (directFilterValue) {
return directFilterValue; return directFilterValue;
@ -2735,8 +2741,10 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
return false; return false;
} }
return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) || return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+рЅр°рј(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); /^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
} }
function isInventorySelectedObjectIntent(intent) { function isInventorySelectedObjectIntent(intent) {
@ -2854,6 +2862,7 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
} }
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) || const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) || /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:а|a|и|i)\s+нам(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(normalized); /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(normalized);
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) || const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) || /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
@ -2868,6 +2877,49 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
} }
return null; return null;
} }
function hasExplicitOrganizationScopeCue(text) {
return /(?:(?:^|[\s"'«»()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку|муп|гуп|llc|ltd|inc|corp)(?=$|[\s"'«»()\\\/.,;:]))|организац|компан|контор|фирм/u.test(String(text ?? "").toLowerCase());
}
function looksLikeBareCounterpartyScopeTarget(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
if (hasExplicitOrganizationScopeCue(normalized)) {
return false;
}
if (/[0-9]/u.test(normalized)) {
return false;
}
const tokens = normalized.split(/\s+/u).filter(Boolean);
if (tokens.length === 0 || tokens.length > 4) {
return false;
}
return tokens.every((token) => /^[a-zа-яё._-]{2,}$/iu.test(token));
}
function shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText) {
const target = toNonEmptyString(scopeTargetText);
if (!target) {
return false;
}
if (hasExplicitOrganizationScopeCue(target) || hasExplicitOrganizationScopeCue(fragmentText)) {
return false;
}
return looksLikeBareCounterpartyScopeTarget(target);
}
function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized || 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+[a-zа-яё0-9._-]{2,}(?:\s+[a-zа-яё0-9._-]{2,})?$/iu.test(normalized);
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null; const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -2988,7 +3040,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0]) ? toNonEmptyString(followupOffer.suggested_intents[0])
: null; : null;
if (suggestedIntent) { const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
if (suggestedIntent && !keepPreviousIntent) {
previousIntent = suggestedIntent; previousIntent = suggestedIntent;
followupSelectionMode = "switch_to_suggested_intent"; followupSelectionMode = "switch_to_suggested_intent";
} }
@ -3086,13 +3139,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw } ? { ...previousFiltersRaw }
: {}; : {};
if (!toNonEmptyString(previousFilters.contract)) { const shouldBackfillHistoricalPartyAnchors =
sourceIntentHint === "list_contracts_by_counterparty" ||
sourceIntentHint === "list_documents_by_counterparty" ||
sourceIntentHint === "bank_operations_by_counterparty" ||
sourceIntentHint === "list_documents_by_contract" ||
sourceIntentHint === "bank_operations_by_contract" ||
sourceIntentHint === "open_items_by_counterparty_or_contract";
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.contract)) {
const historicalContract = findRecentAddressFilterValue(items, "contract"); const historicalContract = findRecentAddressFilterValue(items, "contract");
if (historicalContract) { if (historicalContract) {
previousFilters.contract = historicalContract; previousFilters.contract = historicalContract;
} }
} }
if (!toNonEmptyString(previousFilters.counterparty)) { if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.counterparty)) {
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty"); const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
if (historicalCounterparty) { if (historicalCounterparty) {
previousFilters.counterparty = historicalCounterparty; previousFilters.counterparty = historicalCounterparty;
@ -3138,7 +3198,10 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame && const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && (isInventorySelectedObjectIntent(sourceIntentHint) ||
isInventoryRootFrameIntent(sourceIntentHint) ||
currentFrameKind === "inventory_drilldown" ||
currentFrameKind === "inventory_root") &&
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); !hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
@ -3212,19 +3275,34 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null; return null;
} }
const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame &&
(rootScopedPivot ||
isInventoryRootFrameIntent(sourceIntentHint) ||
isInventorySelectedObjectIntent(sourceIntentHint) ||
hasNavigationInventoryItemFocusHint ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate));
const carryoverTargetIntent = followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined
: explicitIntent ?? previousIntent ?? undefined;
return { return {
followupContext: { followupContext: {
previous_intent: previousIntent ?? undefined, previous_intent: previousIntent ?? undefined,
target_intent: carryoverTargetIntent,
previous_filters: previousFilters, previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor, previous_anchor_value: previousAnchor,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootScopedPivot || undefined, root_context_only: rootScopedPivot || undefined,
root_intent: inventoryRootFrame?.intent ?? undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
root_filters: inventoryRootFrame?.filters ?? undefined, root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined,
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined, root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined,
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined, root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined,
current_frame_kind: currentFrameKind ?? undefined current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined
}, },
previousAddressIntent: previousIntent, previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor, previousAddressAnchor: previousAnchor,
@ -3298,7 +3376,7 @@ function isRetryableAddressLimitedResult(addressLane) {
return false; return false;
} }
const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase(); const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase();
return category === "missing_anchor" || category === "empty_match"; return category === "missing_anchor" || category === "empty_match" || category === "unsupported";
} }
function isAddressLlmPreDecomposeCandidate(userMessage) { function isAddressLlmPreDecomposeCandidate(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? "")); const repaired = repairAddressMojibake(String(userMessage ?? ""));
@ -3317,13 +3395,19 @@ function normalizeAddressSemanticHintsFromFragment(fragment) {
return null; return null;
} }
const scopeTargetKind = toNonEmptyString(hints.scope_target_kind); const scopeTargetKind = toNonEmptyString(hints.scope_target_kind);
const scopeTargetText = toNonEmptyString(hints.scope_target_text);
const fragmentText = toNonEmptyString(fragment.raw_fragment_text) ?? toNonEmptyString(fragment.normalized_fragment_text) ?? "";
const normalizedScopeTargetKind = scopeTargetKind === "organization" &&
shouldDowngradeOrganizationSemanticHint(scopeTargetText, fragmentText)
? "counterparty"
: scopeTargetKind;
const dateScopeKind = toNonEmptyString(hints.date_scope_kind); const dateScopeKind = toNonEmptyString(hints.date_scope_kind);
return { return {
scope_target_kind: scopeTargetKind ?? "none", scope_target_kind: normalizedScopeTargetKind ?? "none",
scope_target_text: toNonEmptyString(hints.scope_target_text), scope_target_text: scopeTargetText,
date_scope_kind: dateScopeKind ?? "missing", date_scope_kind: dateScopeKind ?? "missing",
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope", self_scope_detected: hints.self_scope_detected === true || normalizedScopeTargetKind === "self_scope",
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object" selected_object_scope_detected: hints.selected_object_scope_detected === true || normalizedScopeTargetKind === "selected_object"
}; };
} }
function extractAddressPredecomposeCandidateFromFragments(fragments) { function extractAddressPredecomposeCandidateFromFragments(fragments) {

View File

@ -0,0 +1,243 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
executeAddressMcpQueryMock: vi.fn()
}));
vi.mock("../src/services/addressMcpClient", async () => {
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
"../src/services/addressMcpClient"
);
return {
...actual,
executeAddressMcpQuery: executeAddressMcpQueryMock
};
});
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { AddressQueryService } from "../src/services/addressQueryService";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
vi.restoreAllMocks();
});
describe("counterparty shipment item flow and open-items routing", () => {
it("routes counterparty shipment item-flow wording to documents by counterparty", () => {
const result = resolveAddressIntent("что нам отгружал чепурнов? какой товар или услугу?");
expect(result.intent).toBe("list_documents_by_counterparty");
expect(result.reasons).toContain("counterparty_item_flow_signal_detected");
});
it("routes account 60 tails wording to open items intent", () => {
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
expect(result.intent).toBe("open_items_by_counterparty_or_contract");
});
it("includes resolved full counterparty name in document reply", () => {
const reply = composeFactualReply(
"list_documents_by_counterparty",
[
{
period: "2022-08-15T00:00:00Z",
registrator: "Поступление товаров и услуг 000000123 от 15.08.2022",
account_dt: "41.01",
account_kt: "60.01",
amount: 12500,
analytics: ["Чепурнов П.Д.", "Основной договор"],
item: "Кабель силовой",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
userMessage: "покажи все документы по чапурнову",
counterpartyHint: "Чепурнов П.Д."
}
);
expect(reply.text).toContain("Контрагент: Чепурнов П.Д.");
});
it("uses purchase document query for fuzzy counterparty item-flow wording", 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: "2020-03-10T00:00:00Z",
Registrator: "Поступление товаров и услуг 000000001 от 10.03.2020",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 12000,
Nomenclature: "Кабель силовой",
Counterparty: "Чепурнов П.Д.",
Contract: "Основной договор",
Quantity: 2,
Organization: 'ООО "Альтернатива Плюс"'
},
{
Period: "2020-03-14T00:00:00Z",
Registrator: "Поступление товаров и услуг 000000002 от 14.03.2020",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 5400,
Nomenclature: "Патч-корд",
Counterparty: "Чепурнов П.Д.",
Contract: "Основной договор",
Quantity: 10,
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"какие товары или услуги были отгружены нашей компании контрагентом чапурновым?"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Чепурнов П.Д.");
expect(String(result?.reply_text ?? "")).toContain("Позиции:");
expect(String(result?.reply_text ?? "")).toContain("Кабель силовой");
expect(String(result?.reply_text ?? "")).toContain("договор:");
expect(String(result?.reply_text ?? "")).toContain("дата:");
const query = String(executeAddressMcpQueryMock.mock.calls.at(-1)?.[0]?.query ?? "");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Услуги");
expect(query).toContain("Товары.Ссылка.Контрагент.Наименование ПОДОБНО");
expect(query).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 ?? "");
if (query.includes("Справочник.Контрагенты")) {
return {
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Чепурнов П.Д.",
Registrator: "Чепурнов П.Д."
}
],
rows: [],
error: null
};
}
if (query.includes("Документ.ПоступлениеТоваровУслуг.Товары") || query.includes("Документ.ПоступлениеТоваровУслуг.Услуги")) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: null
};
}
return {
fetched_rows: 3,
matched_rows: 3,
raw_rows: [
{
Period: "2021-06-11T12:00:01Z",
Registrator: "Списание с расчетного счета 00000000124 от 11.06.2021",
AccountDt: "60.02",
AccountKt: "51",
Amount: 119210,
SubcontoDt1: "Чепурнов П.Д.",
SubcontoDt2: "Договор № 11/1 от 25.11.2020",
SubcontoKt1: "ПАО СБЕРБАНК"
},
{
Period: "2021-05-18T12:00:00Z",
Registrator: "Списание с расчетного счета 00000000112 от 18.05.2021",
AccountDt: "60.02",
AccountKt: "51",
Amount: 180230,
SubcontoDt1: "Чепурнов П.Д.",
SubcontoDt2: "Договор № 11/1 от 25.11.2020",
SubcontoKt1: "ПАО СБЕРБАНК"
},
{
Period: "2022-01-20T12:00:03Z",
Registrator: "Поступление на расчетный счет 00000000001 от 20.01.2022",
AccountDt: "51",
AccountKt: "60.02",
Amount: 299440,
SubcontoKt1: "Чепурнов П.Д.",
SubcontoKt2: "Договор № 11/1 от 25.11.2020",
SubcontoDt1: "ПАО СБЕРБАНК"
}
],
rows: [],
error: null
};
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"какие товары или услуги были отгружены нашей компании контрагентом чапурновым?"
);
expect(result?.handled).toBe(true);
expect(String(result?.reply_text ?? "")).toContain("Подтвержденных поставок товаров или услуг не найдено");
expect(String(result?.reply_text ?? "")).toContain("исходящих оплат поставщику");
expect(String(result?.reply_text ?? "")).toContain("возвратов от поставщика");
expect(String(result?.reply_text ?? "")).toContain("Договор:");
expect(result?.debug.reasons).toContain("counterparty_item_flow_no_supply_but_bank_activity_explained");
expect(executeAddressMcpQueryMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("keeps account 60 tails in open-items route and mentions the account in reply", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2022-08-31T23:59:59Z",
Registrator: "Поступление на расчетный счет 000000001 от 31.08.2022",
AccountDt: "51",
AccountKt: "60.01",
Amount: 150000,
SubcontoKt1: "ООО Поставщик",
SubcontoKt2: "Договор поставки",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("хвосты покажи по счету 60 на август 2022");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(String(result?.reply_text ?? "")).toContain("счету 60");
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
expect(query).toContain("60%");
});
});

View File

@ -73,10 +73,48 @@ describe("counterparty lifecycle organization scope regressions", () => {
expect(result?.handled).toBe(true); expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("factual"); expect(result?.reply_type).toBe("factual");
expect(result?.response_type).toBe("FACTUAL_LIST"); expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle"); expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.extracted_filters?.organization).toBe('ООО "Альтернатива Плюс"'); expect(result?.debug.extracted_filters?.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
expect(result?.debug.match_failure_reason).toBeNull(); expect(result?.debug.match_failure_reason).toBeNull();
expect(result?.debug.rows_matched).toBe(3); expect(result?.debug.rows_matched).toBe(3);
expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"'); expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"');
}); });
it("does not turn 'за все время' into a bogus counterparty anchor for highest-value customer wording", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2020-01-15T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 120,
Counterparty: 'ООО "Ромашка"'
},
{
Period: "2020-02-20T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 80,
Counterparty: 'ООО "Ландыш"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("какой у нас самый доходный клиент за все время");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"');
});
}); });

View File

@ -42,6 +42,62 @@ describe("address follow-up temporal regressions", () => {
expect(result?.baseReasons).toContain("period_derived_from_followup_context_year"); expect(result?.baseReasons).toContain("period_derived_from_followup_context_year");
}); });
it("inherits the previous VAT period for 'за этот период' follow-up", () => {
const result = runAddressDecomposeStage("какой НДС мы должны примерно заплатить за этот период", {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2017-05-01",
period_to: "2017-05-31",
as_of_date: "2017-05-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31");
expect(result?.baseReasons).toContain("period_from_from_followup_context");
});
it("keeps inherited period when a follow-up retargets from receivables into VAT for the same period", () => {
const result = runAddressDecomposeStage("а какой НДС мы должны примерно заплатить за этот период", {
previous_intent: "receivables_confirmed_as_of_date",
target_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2017-05-01",
period_to: "2017-05-31",
as_of_date: "2017-05-31"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31");
});
it("uses follow-up target intent for short debt mirror like 'а нам?'", () => {
const result = runAddressDecomposeStage("а нам?", {
previous_intent: "payables_confirmed_as_of_date",
target_intent: "receivables_confirmed_as_of_date",
previous_filters: {
as_of_date: "2026-04-16"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2026-04-16");
});
it("keeps same-date inventory pivot anchored to the previous VAT date", () => { it("keeps same-date inventory pivot anchored to the previous VAT date", () => {
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", { const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
previous_intent: "vat_payable_confirmed_as_of_date", previous_intent: "vat_payable_confirmed_as_of_date",

View File

@ -81,6 +81,9 @@ describe("inventory sale trace movement route", () => {
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы"); expect(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы");
expect(String(result?.reply_text ?? "")).not.toMatch(
/Контрагент:\s*Рабочая станция универсального специалиста/i
);
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");

View File

@ -181,4 +181,65 @@ describe("address navigation state", () => {
expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс"); expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс");
expect(evolved.session_context.active_focus_object).toBeNull(); expect(evolved.session_context.active_focus_object).toBeNull();
}); });
it("resets stale focus and date scope on explicit new topic turn", () => {
const initial = normalizeAddressNavigationState(
{
schema_version: "address_navigation_state_v1",
session_id: "asst-6",
updated_at: "2026-04-12T10:00:00.000Z",
session_context: {
active_result_set_id: "rs-prev",
active_focus_object: {
object_type: "counterparty",
object_id: "counterparty:chapurnov",
label: "Чепурнов",
provenance_result_set_id: "rs-prev",
selected_at: "2026-04-12T09:59:00.000Z"
},
last_confirmed_route: "address_documents_by_counterparty_v1",
date_scope: {
as_of_date: null,
period_from: "2017-01-01",
period_to: "2017-12-31"
},
organization_scope: "ООО Альтернатива Плюс"
},
result_sets: [],
navigation_history: []
} as any,
"asst-6"
);
const assistantItem = {
message_id: "msg-a5",
session_id: "asst-6",
role: "assistant",
text: "На 16.04.2026 на складе подтверждено 11 позиций.",
reply_type: "factual",
created_at: "2026-04-12T10:05:00.000Z",
trace_id: "address-791",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
extracted_filters: {
as_of_date: "2026-04-16",
organization: "ООО Альтернатива Плюс"
},
anchor_type: "organization",
anchor_value_resolved: "ООО Альтернатива Плюс",
dialog_continuation_contract_v2: {
decision: "new_topic"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(initial, assistantItem, 5);
expect(evolved.session_context.active_result_set_id).toBe("rs-msg-a5");
expect(evolved.session_context.active_focus_object?.label).toBe("ООО Альтернатива Плюс");
expect(evolved.session_context.date_scope.as_of_date).toBe("2026-04-16");
expect(evolved.session_context.date_scope.period_from).toBeNull();
expect(evolved.session_context.date_scope.period_to).toBeNull();
expect(evolved.session_context.last_confirmed_route).toBe("address_inventory_on_hand_as_of_date_v1");
expect(evolved.navigation_history[0]?.action).toBe("open");
});
}); });

View File

@ -13,6 +13,12 @@ describe("vat payable confirmed as-of route", () => {
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected"); expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
}); });
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");
});
it("keeps VAT forecast intent when explicit forecast wording is used", () => { it("keeps VAT forecast intent when explicit forecast wording is used", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020"); const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
expect(result.intent).toBe("vat_payable_forecast"); expect(result.intent).toBe("vat_payable_forecast");
@ -62,5 +68,5 @@ describe("vat payable confirmed as-of route", () => {
expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.route_expectation_status).toBe("matched"); expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.limited_reason_category).not.toBe("unsupported"); expect(result?.debug.limited_reason_category).not.toBe("unsupported");
}); }, 15000);
}); });

View File

@ -2599,5 +2599,321 @@ describe("assistant address follow-up carryover", () => {
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31"); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled(); expect(normalizerService.normalize).not.toHaveBeenCalled();
}); });
it("treats short 'а нам?' as a receivables mirror follow-up after payables answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а нам?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return {
handled: true,
reply_text: "На ту же дату нам должны 125 000 руб.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "receivables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
reasons: ["address_action_detected", "address_followup_context_applied"]
}
};
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-a-nam-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-payables-seed",
session_id: sessionId,
role: "assistant",
text: "На сегодня мы должны поставщикам 87 000 руб.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-payables-seed",
debug: {
detected_mode: "address_query",
detected_intent: "payables_confirmed_as_of_date",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("receivables_confirmed_as_of_date");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2026-04-16");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps document intent for short counterparty retarget like 'а по свк'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а по свк";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту СВК.",
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-po-svk-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту Чапурнов.",
reply_type: "factual",
created_at: "2026-04-16T10:01:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "чапурнов",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "чапурнов",
anchor_value_resolved: "Чапурнов"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Чапурнов");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not backfill stale counterparty anchors into inventory root temporal follow-ups", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на июль 2019";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (options?.followupContext) {
return buildAddressLaneResult({
reply_text: "На 31.07.2019 на складе подтверждено 4 позиции.",
reply_type: "factual",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2019-07-01",
period_to: "2019-07-31",
as_of_date: "2019-07-31"
},
reasons: ["inventory_on_hand_signal_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-root-month-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту СВК.",
reply_type: "factual",
created_at: "2026-04-16T09:55:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "СЃРІРє",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "СЃРІРє",
anchor_value_resolved: "РЎР’Рљ"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: "На 16.04.2026 на складе подтверждено 11 позиций.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-inventory-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-16"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
anchor_type: "organization",
anchor_value_raw: 'ООО "Альтернатива Плюс"',
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
}
} as any);
sessions.setAddressNavigationState(sessionId, {
session_id: sessionId,
session_context: {
active_result_set_id: "rs-inventory-root",
active_focus_object: null,
last_confirmed_route: "address_inventory_on_hand_as_of_date_v1",
date_scope: {
as_of_date: "2026-04-16",
period_from: null,
period_to: null
},
organization_scope: 'ООО "Альтернатива Плюс"'
},
result_sets: [
{
result_set_id: "rs-inventory-root",
type: "inventory_snapshot",
route_id: "address_inventory_on_hand_as_of_date_v1",
filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-16"
},
entity_refs: [],
source_refs: [],
created_from_turn: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
],
navigation_history: [
{
event_id: "nav-inventory-root",
action: "open",
source_result_set_id: null,
target_object_id: null,
derived_result_set_id: "rs-inventory-root",
turn_index: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
]
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.counterparty).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe('ООО "Альтернатива Плюс"');
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined();
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
}); });

View File

@ -80,6 +80,34 @@ describe("assistant address lane runtime adapter", () => {
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3); expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3);
}); });
it("can retry with raw message after unsupported limited result", async () => {
const carryover: AssistantAddressFollowupCarryoverLike = { followupContext: { scope: "ctx" } };
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("unsupported"))
.mockResolvedValueOnce(limitedLane("unsupported"))
.mockResolvedValueOnce(factualLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "что нам отгружал чепурнов? какой товар или услугу?",
addressInputMessage: "Определить, что необходимо отгрузить контрагенту Чепурнов",
carryover,
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: true,
runAddressLaneAttempt,
isRetryableAddressLimitedResult: (lane) =>
["missing_anchor", "empty_match", "unsupported"].includes(
String(lane?.debug?.limited_reason_category ?? "").trim()
)
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("что нам отгружал чепурнов? какой товар или услугу?");
expect(result.retryAudit.attempted).toBe(true);
expect(result.retryAudit.initial_limited_category).toBe("unsupported");
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3);
});
it("returns pending limited result when retry is disabled", async () => { it("returns pending limited result when retry is disabled", async () => {
const runAddressLaneAttempt = vi const runAddressLaneAttempt = vi
.fn() .fn()

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,117 @@
{
"suite_id": "assistant_saved_session_gen-mo1t93wq-jy0453e",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-04-16T18:26:26.186Z",
"generation_id": "gen-mo1t93wq-jy0453e",
"mode": "saved_user_sessions",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "приветик - че как там дела"
},
{
"user_message": "расскажи что можешь интересного"
},
{
"user_message": "кайф - что там на складе по остаткам?"
},
{
"user_message": "а исторические остатки на другие даты умеешь?"
},
{
"user_message": "давай на июль 2017"
},
{
"user_message": "март 2016"
},
{
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
},
{
"user_message": "а кому продали?"
},
{
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
},
{
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
},
{
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
},
{
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
},
{
"user_message": "кто у нас самый доходный клиент за все время"
},
{
"user_message": "кто нам должен денег на май 2017"
},
{
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
},
{
"user_message": "мы должны комуто денег на сегодня?"
},
{
"user_message": "а нам?"
},
{
"user_message": "какой у нас самый доходный год"
},
{
"user_message": "а за 2017 мы скок заработали?"
},
{
"user_message": "сколько вообще денег мы заработали за все время?"
},
{
"user_message": "ты умеешь считать дельту по договорам?"
},
{
"user_message": "по чепурнову покажи все доки"
},
{
"user_message": "а по свк"
},
{
"user_message": "а сейчас у нас есть что на складе?"
},
{
"user_message": "что нам отгружать чепурнов? какой товар или услугу?"
},
{
"user_message": "какие остатки на складе на сегодня"
},
{
"user_message": "остатки на март 2016"
},
{
"user_message": "это по общей базе уже нужен вывод не по чепурнову"
},
{
"user_message": "хвосты покажи по счету 60 на август 2022"
},
{
"user_message": "Есть ли остатки товара, которые закупались очень давно"
},
{
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
}
]
}
]
}

View File

@ -0,0 +1,114 @@
{
"suite_id": "assistant_saved_session_runtime_job-efdquR6lxo",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_runtime_v0_1",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions_runtime",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "приветик - че как там дела"
},
{
"user_message": "расскажи что можешь интересного"
},
{
"user_message": "кайф - что там на складе по остаткам?"
},
{
"user_message": "а исторические остатки на другие даты умеешь?"
},
{
"user_message": "давай на июль 2017"
},
{
"user_message": "март 2016"
},
{
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
},
{
"user_message": "а кому продали?"
},
{
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
},
{
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
},
{
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
},
{
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
},
{
"user_message": "кто у нас самый доходный клиент за все время"
},
{
"user_message": "кто нам должен денег на май 2017"
},
{
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
},
{
"user_message": "мы должны комуто денег на сегодня?"
},
{
"user_message": "а нам?"
},
{
"user_message": "какой у нас самый доходный год"
},
{
"user_message": "а за 2017 мы скок заработали?"
},
{
"user_message": "сколько вообще денег мы заработали за все время?"
},
{
"user_message": "ты умеешь считать дельту по договорам?"
},
{
"user_message": "по чепурнову покажи все доки"
},
{
"user_message": "а по свк"
},
{
"user_message": "а сейчас у нас есть что на складе?"
},
{
"user_message": "что нам отгружать чепурнов? какой товар или услугу?"
},
{
"user_message": "какие остатки на складе на сегодня"
},
{
"user_message": "остатки на март 2016"
},
{
"user_message": "это по общей базе уже нужен вывод не по чепурнову"
},
{
"user_message": "хвосты покажи по счету 60 на август 2022"
},
{
"user_message": "Есть ли остатки товара, которые закупались очень давно"
},
{
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
}
]
}
]
}