ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов
This commit is contained in:
parent
a3a61b3a0f
commit
44f1c1e11e
|
|
@ -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
|
|
@ -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)хвост"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -432,6 +432,9 @@ function extractYearRangePeriod(text) {
|
|||
};
|
||||
}
|
||||
function cleanupAnchorValue(value) {
|
||||
const stripLeadingEntityRole = (text) => String(text ?? "")
|
||||
.replace(/^(?:(?:по\s+)?(?:контрагент(?:ом|у|а|ы|ов)?|поставщик(?:ом|у|а|и|ов)?|клиент(?:ом|у|а|ы|ов)?|покупател(?:ем|ю|я|и|ей)|продав(?:цом|цу|ца|цы|цов)|заказчик(?:ом|у|а|и|ов)?|исполнител(?:ем|ю|я|и|ей)|подрядчик(?:ом|у|а|и|ов)?))\s+/iu, "")
|
||||
.trim();
|
||||
const stripOuterQuotes = (text) => String(text ?? "")
|
||||
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||
.trim();
|
||||
|
|
@ -439,6 +442,10 @@ function cleanupAnchorValue(value) {
|
|||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
cleaned = stripOuterQuotes(stripLeadingEntityRole(cleaned.replace(/[?!]+$/u, "").trim()));
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
// 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".
|
||||
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+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
|
||||
.trim();
|
||||
return stripOuterQuotes(cleaned);
|
||||
return stripOuterQuotes(stripLeadingEntityRole(cleaned));
|
||||
}
|
||||
function cleanupContractAnchorValue(value) {
|
||||
let normalized = cleanupAnchorValue(value);
|
||||
|
|
@ -568,6 +575,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
|||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const tokens = value
|
||||
.split(/[^a-zа-я0-9]+/iu)
|
||||
.map((token) => token.trim())
|
||||
|
|
@ -601,6 +611,16 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
|
|||
"дата",
|
||||
"конец",
|
||||
"период",
|
||||
"весь",
|
||||
"все",
|
||||
"всё",
|
||||
"всю",
|
||||
"всех",
|
||||
"всего",
|
||||
"время",
|
||||
"история",
|
||||
"истории",
|
||||
"срок",
|
||||
"месяц",
|
||||
"году",
|
||||
"год",
|
||||
|
|
@ -800,6 +820,30 @@ function extractLeadingCounterpartyTokenHeuristic(text) {
|
|||
}
|
||||
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) {
|
||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||
}
|
||||
|
|
@ -1447,6 +1491,17 @@ function extractAddressFilters(userMessage, intent) {
|
|||
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 &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
|
|
|
|||
|
|
@ -562,7 +562,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text) {
|
|||
if (!hasVatLexeme) {
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -587,11 +587,11 @@ function hasVatPayableConfirmedSignal(text) {
|
|||
if (!hasVatLexeme) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
function hasPeriodCoverageProfileSignal(text) {
|
||||
|
|
@ -684,6 +684,9 @@ function hasCounterpartyDebtLongevitySignal(text) {
|
|||
return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue;
|
||||
}
|
||||
function hasCounterpartyActivityLifecycleSignal(text) {
|
||||
if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text);
|
||||
if (hasPaymentRiskLexeme) {
|
||||
return false;
|
||||
|
|
@ -721,6 +724,19 @@ function hasCounterpartyActivityLifecycleSignal(text) {
|
|||
}
|
||||
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) {
|
||||
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
|
||||
return true;
|
||||
|
|
@ -1652,7 +1668,9 @@ function resolveAddressIntent(userMessage) {
|
|||
!hasInventoryProvenanceSignalV2(text) &&
|
||||
!hasInventoryPurchaseDocumentsSignalV2(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 {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
|
|
@ -1760,15 +1778,20 @@ function resolveAddressIntent(userMessage) {
|
|||
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) ||
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text))) {
|
||||
hasHeuristicCounterpartyAnchor(text) ||
|
||||
hasCounterpartyShipmentItemFlowSignal(text))) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_counterparty_signal_detected"]
|
||||
reasons: [
|
||||
hasCounterpartyShipmentItemFlowSignal(text)
|
||||
? "counterparty_item_flow_signal_detected"
|
||||
: "documents_by_counterparty_signal_detected"
|
||||
]
|
||||
};
|
||||
}
|
||||
if (hasAccountBalanceSignal(text)) {
|
||||
|
|
|
|||
|
|
@ -439,10 +439,19 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
|
|||
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 nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
|
||||
return {
|
||||
...state,
|
||||
updated_at: createdAt,
|
||||
session_context: {
|
||||
const nextSessionContext = action === "open"
|
||||
? {
|
||||
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_focus_object: focusObject ?? state.session_context.active_focus_object,
|
||||
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
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
updated_at: createdAt,
|
||||
session_context: nextSessionContext,
|
||||
result_sets: nextResultSets,
|
||||
navigation_history: nextEvents
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const composeStage_1 = require("./address_runtime/composeStage");
|
|||
const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy");
|
||||
const addressRouteExpectations_1 = require("./addressRouteExpectations");
|
||||
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_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
|
||||
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
|
||||
|
|
@ -115,6 +117,7 @@ const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
|
|||
Справочник.Контрагенты КАК Контрагенты
|
||||
`;
|
||||
let counterpartyCatalogCache = null;
|
||||
const limitedReplyLlmClient = new openaiResponsesClient_1.OpenAIResponsesClient();
|
||||
function parseFiniteNumber(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
|
|
@ -654,9 +657,37 @@ function anchorTokenVariants(token) {
|
|||
}
|
||||
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) {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
|
|
@ -669,7 +700,9 @@ function matchesAnchorText(searchable, anchor) {
|
|||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((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, " ")
|
||||
.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) {
|
||||
return uniqueStrings(rows
|
||||
.map((row) => {
|
||||
|
|
@ -807,6 +847,7 @@ function scoreCounterpartyCandidate(name, anchor) {
|
|||
}
|
||||
const normalizedName = normalizeCounterpartyName(name);
|
||||
const normalizedAnchor = normalizeCounterpartyName(anchor);
|
||||
const nameTokens = tokenizeSearchableText(name);
|
||||
if (!normalizedName || !normalizedAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -817,6 +858,9 @@ function scoreCounterpartyCandidate(name, anchor) {
|
|||
else if (normalizedName.includes(normalizedAnchor)) {
|
||||
score += 5_000;
|
||||
}
|
||||
else if (fuzzyPartyTokenMatches(normalizedName, normalizedAnchor)) {
|
||||
score += 3_500;
|
||||
}
|
||||
else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
|
||||
score += 2_000;
|
||||
}
|
||||
|
|
@ -828,6 +872,9 @@ function scoreCounterpartyCandidate(name, anchor) {
|
|||
if (normalizedName.includes(variant)) {
|
||||
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) {
|
||||
return null;
|
||||
|
|
@ -1019,7 +1066,7 @@ function toNormalizedRows(rows) {
|
|||
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
|
||||
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
|
||||
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 organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление);
|
||||
const analytics = collectAnalyticsStrings(row);
|
||||
|
|
@ -1038,6 +1085,150 @@ function toNormalizedRows(rows) {
|
|||
})
|
||||
.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) {
|
||||
return [
|
||||
row.registrator,
|
||||
|
|
@ -2113,6 +2304,88 @@ function hasAggregateLimitedSignal(input) {
|
|||
}
|
||||
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) {
|
||||
const reason = normalizeLimitedReason(input.reason);
|
||||
const filterSeed = buildLimitedVariantSeedFingerprint(input.filters);
|
||||
|
|
@ -2511,6 +2784,7 @@ class AddressQueryService {
|
|||
requestedResultMode,
|
||||
filters: executionFilters
|
||||
});
|
||||
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
|
||||
if ((0, addressCapabilityPolicy_1.isCapabilityRouteBlocked)(capabilityDecision)) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
|
|
@ -2537,6 +2811,17 @@ class AddressQueryService {
|
|||
}
|
||||
const composeOptionsFromFilters = (filterSet, options = {}) => ({
|
||||
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,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
|
|
@ -2545,8 +2830,39 @@ class AddressQueryService {
|
|||
emphasizeNumbers: options.emphasizeNumbers ?? 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);
|
||||
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
|
||||
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
|
||||
Array.isArray(intent.reasons) &&
|
||||
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
|
||||
|
|
@ -2596,7 +2912,7 @@ class AddressQueryService {
|
|||
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
|
||||
}
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -2618,7 +2934,7 @@ class AddressQueryService {
|
|||
});
|
||||
}
|
||||
if (recipeSelection.selected_recipe === null) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -2640,7 +2956,7 @@ class AddressQueryService {
|
|||
});
|
||||
}
|
||||
if (recipeSelection.missing_required_filters.length > 0) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -2662,7 +2978,7 @@ class AddressQueryService {
|
|||
});
|
||||
}
|
||||
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -2687,6 +3003,14 @@ class AddressQueryService {
|
|||
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
|
||||
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
|
||||
if (catalogResolution.resolvedValue) {
|
||||
filters.extracted_filters = {
|
||||
...filters.extracted_filters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
executionFilters = {
|
||||
...executionFilters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
|
||||
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
|
||||
}
|
||||
|
|
@ -2723,6 +3047,42 @@ class AddressQueryService {
|
|||
let composeIntent = intent.intent;
|
||||
let routeExpectationIntent = 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)({
|
||||
query: plan.query,
|
||||
limit: plan.limit
|
||||
|
|
@ -2823,7 +3183,7 @@ class AddressQueryService {
|
|||
}
|
||||
if (mcp.error) {
|
||||
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -2952,6 +3312,78 @@ class AddressQueryService {
|
|||
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
|
||||
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
||||
: 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 (!baseReasons.includes("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 recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
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
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
: "contract_docs_recovered_via_anchor_rows";
|
||||
|
|
@ -3123,7 +3555,7 @@ class AddressQueryService {
|
|||
rowsAnchorMatched: expandedRowsByAnchor.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 expandedLimitations = [...filters.warnings, "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 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 = [
|
||||
...filters.warnings,
|
||||
...broadenedAdjustments,
|
||||
|
|
@ -3340,6 +3772,7 @@ class AddressQueryService {
|
|||
}
|
||||
}
|
||||
if (filteredRows.length === 0 &&
|
||||
!counterpartyItemFlowQuery &&
|
||||
isDocumentOrBankAnchorIntent(intent.intent) &&
|
||||
!hasExplicitPeriodWindow(filters.extracted_filters) &&
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) {
|
||||
|
|
@ -3401,7 +3834,7 @@ class AddressQueryService {
|
|||
rowsAnchorMatched: historicalRowsByAnchor.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 historicalSuggestion = intent.intent === "list_documents_by_counterparty"
|
||||
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
|
||||
|
|
@ -3473,7 +3906,7 @@ class AddressQueryService {
|
|||
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) {
|
||||
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
|
||||
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 fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
|
||||
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
|
||||
|
|
@ -3546,6 +3979,43 @@ class AddressQueryService {
|
|||
!toNonEmptyFilterValue(filters.extracted_filters.contract) &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.document_ref);
|
||||
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 hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||
const isVisibilityGapCandidate = hadBaseRows &&
|
||||
|
|
@ -3623,7 +4093,7 @@ class AddressQueryService {
|
|||
? "document_or_bank_visibility_gap_after_base_filter"
|
||||
: "no_rows_after_recipe_and_scope_filter"
|
||||
];
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3665,7 +4135,7 @@ class AddressQueryService {
|
|||
composeIntent === "payables_confirmed_as_of_date" ||
|
||||
composeIntent === "receivables_confirmed_as_of_date";
|
||||
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,
|
||||
emphasizeNumbers: shouldEmphasizeNumbers,
|
||||
useRubCurrency: shouldUseRubCurrency
|
||||
|
|
@ -3695,7 +4165,7 @@ class AddressQueryService {
|
|||
resultMode: factualResultSemantics.result_mode
|
||||
});
|
||||
if (finalRouteExpectationAudit.status === "mismatch" && config_1.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3741,7 +4211,7 @@ class AddressQueryService {
|
|||
: intent.intent === "vat_liability_confirmed_for_tax_period"
|
||||
? "vat_tax_period"
|
||||
: "vat_payable";
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildCounterpartyPurchaseDocumentQuery = buildCounterpartyPurchaseDocumentQuery;
|
||||
exports.buildOpenItemsMovementQuery = buildOpenItemsMovementQuery;
|
||||
exports.selectAddressRecipe = selectAddressRecipe;
|
||||
exports.buildAddressRecipePlan = buildAddressRecipePlan;
|
||||
const config_1 = require("../config");
|
||||
|
|
@ -78,6 +80,38 @@ __WHERE_CLAUSE__
|
|||
УПОРЯДОЧИТЬ ПО
|
||||
Товары.Ссылка.Дата __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 = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
__AS_OF_EXPR__ КАК Период,
|
||||
|
|
@ -1149,6 +1183,31 @@ function buildInventoryItemReferenceCondition(filters, fieldPaths) {
|
|||
}
|
||||
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) {
|
||||
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
|
||||
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
|
||||
|
|
@ -1163,6 +1222,28 @@ function buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) {
|
|||
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item))))
|
||||
.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) {
|
||||
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
|
||||
|
|
|
|||
|
|
@ -479,6 +479,12 @@ function detectValueRankingFocus(userMessage) {
|
|||
if (!text) {
|
||||
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) &&
|
||||
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
|
||||
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
|
||||
|
|
@ -575,6 +581,13 @@ function extractCounterpartyName(row) {
|
|||
}
|
||||
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) {
|
||||
const direct = String(row.item ?? "").trim();
|
||||
if (direct) {
|
||||
|
|
@ -752,10 +765,13 @@ function looksLikeInventoryPartyToken(value) {
|
|||
}
|
||||
return normalized === normalized.toUpperCase() && normalized.length >= 4;
|
||||
}
|
||||
function extractInventoryCounterpartyCandidates(row) {
|
||||
function extractInventoryCounterpartyCandidates(row, excludedTokens = []) {
|
||||
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
|
||||
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||
const excludedComparableTokens = excludedTokens
|
||||
.map((token) => normalizeEntityToken(token))
|
||||
.filter((token) => Boolean(token));
|
||||
const candidates = [];
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
|
|
@ -763,14 +779,18 @@ function extractInventoryCounterpartyCandidates(row) {
|
|||
continue;
|
||||
}
|
||||
const comparable = normalizeEntityToken(normalized);
|
||||
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
|
||||
if (!comparable ||
|
||||
comparable === itemToken ||
|
||||
comparable === warehouseToken ||
|
||||
comparable === organizationToken ||
|
||||
excludedComparableTokens.includes(comparable)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(normalized);
|
||||
}
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
function summarizeInventoryTraceRows(rows) {
|
||||
function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) {
|
||||
const items = uniqueStrings(rows
|
||||
.map((row) => extractInventoryItemName(row))
|
||||
.filter((item) => Boolean(item)));
|
||||
|
|
@ -780,7 +800,7 @@ function summarizeInventoryTraceRows(rows) {
|
|||
const organizations = uniqueStrings(rows
|
||||
.map((row) => extractInventoryOrganizationName(row))
|
||||
.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
|
||||
.map((row) => String(row.registrator ?? "").trim())
|
||||
.filter((item) => item.length > 0 && item !== "(без названия)"));
|
||||
|
|
@ -800,9 +820,9 @@ function summarizeInventoryTraceRows(rows) {
|
|||
totalAmount
|
||||
};
|
||||
}
|
||||
function formatInventoryTraceRows(rows, limit = 10) {
|
||||
function formatInventoryTraceRows(rows, limit = 10, excludedCounterpartyTokens = []) {
|
||||
return rows.slice(0, limit).map((row, index) => {
|
||||
const parties = extractInventoryCounterpartyCandidates(row);
|
||||
const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
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(" | ");
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const byItem = new Map();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
|
|
@ -2575,6 +2622,8 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
}
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
|
|
@ -2606,6 +2655,31 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "total_flow") {
|
||||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
`- Контрагентов в выборке: ${profileRows.length}.`
|
||||
];
|
||||
if (rankedByYearTotal.length > 0) {
|
||||
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
|
||||
}
|
||||
if (rankedByTotal.length > 0) {
|
||||
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: summaryLines.join("\n")
|
||||
};
|
||||
}
|
||||
if (focus === "top_years_by_total") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
|
|
@ -3344,8 +3418,11 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
if (intent === "inventory_sale_trace_for_item") {
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||
const summary = summarizeInventoryTraceRows(saleRows);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||
const excludedCounterpartyTokens = [itemLabel];
|
||||
const directAnswerLine = summary.counterparties.length === 1
|
||||
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
|
||||
: summary.counterparties.length > 1
|
||||
|
|
@ -3367,7 +3444,7 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
}
|
||||
lines.push("", "Документы выбытия:");
|
||||
if (saleRows.length > 0) {
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12));
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
|
||||
}
|
||||
else {
|
||||
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
|
||||
|
|
@ -3964,8 +4041,11 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
}
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const accountLead = typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
|
||||
: "Собраны открытые позиции по взаиморасчетам.";
|
||||
const lines = [
|
||||
"Собраны открытые позиции по взаиморасчетам.",
|
||||
accountLead,
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
|
|
@ -4019,10 +4099,63 @@ function composeFactualReply(intent, rows, options = {}) {
|
|||
};
|
||||
}
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
`Найдено документов по контрагенту: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
const resolvedCounterparty = (typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
|
||||
? options.counterpartyHint.trim()
|
||||
: null) ??
|
||||
(() => {
|
||||
const counterparties = uniqueStrings(rows
|
||||
.map((row) => extractCounterpartyName(row))
|
||||
.filter((item) => Boolean(item)));
|
||||
return counterparties.length === 1 ? counterparties[0] : null;
|
||||
})();
|
||||
const 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 {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
|
|
|
|||
|
|
@ -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 ?? ""));
|
||||
}
|
||||
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) {
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
if (hasSamePeriodHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
if (tokenCount <= 12 &&
|
||||
/(?:почему|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 === "payables_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 inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||
const currentContract = toNonEmptyString(merged.contract);
|
||||
|
|
@ -739,6 +744,30 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
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 &&
|
||||
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")) {
|
||||
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
|
||||
|
|
@ -921,6 +950,15 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
merged.period_to = previousPeriodTo;
|
||||
}
|
||||
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" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
|
|
@ -962,7 +1000,7 @@ function resolveMissingRequiredFilters(intent, filters) {
|
|||
});
|
||||
}
|
||||
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
|
||||
if (!followupContext || !followupContext.previous_intent) {
|
||||
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
|
|
@ -970,7 +1008,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (!hasFollowupSignal) {
|
||||
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 previousContract = toNonEmptyString(previousFilters.contract);
|
||||
const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
|
||||
|
|
@ -1000,7 +1042,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
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 &&
|
||||
hasAccountSignal(normalizedMessage) &&
|
||||
(detectedIntent.intent === "unknown" ||
|
||||
|
|
@ -1015,7 +1057,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
|
||||
};
|
||||
}
|
||||
const previousIsInventoryFamily = isInventoryIntent(previousIntent);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
|
|
@ -1024,7 +1066,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
detectedIntent.intent === "bank_operations_by_counterparty" ||
|
||||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
detectedIntent.intent === sourceIntent) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
|
|
@ -1037,7 +1079,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
detectedIntent.intent === sourceIntent) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
confidence: "low",
|
||||
|
|
@ -1054,7 +1096,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
detectedIntent.intent === sourceIntent) {
|
||||
return {
|
||||
intent: "inventory_profitability_for_item",
|
||||
confidence: "low",
|
||||
|
|
@ -1065,7 +1107,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === previousIntent ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
|
|
@ -1078,7 +1120,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
detectedIntent.intent === sourceIntent) {
|
||||
return {
|
||||
intent: "inventory_sale_trace_for_item",
|
||||
confidence: "low",
|
||||
|
|
@ -1090,7 +1132,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
detectedIntent.intent === sourceIntent) {
|
||||
return {
|
||||
intent: "inventory_purchase_to_sale_chain",
|
||||
confidence: "low",
|
||||
|
|
@ -1101,7 +1143,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (previousIsInventoryFamily &&
|
||||
hasFollowupSignal &&
|
||||
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)) {
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
confidence: "low",
|
||||
|
|
@ -1158,7 +1200,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
return detectedIntent;
|
||||
}
|
||||
return {
|
||||
intent: previousIntent,
|
||||
intent: fallbackIntent ?? "unknown",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ function tokenizeAnchor(value) {
|
|||
.map((token) => token.trim())
|
||||
.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) {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
|
|
@ -90,9 +96,37 @@ function anchorTokenVariants(token) {
|
|||
}
|
||||
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) {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
|
|
@ -105,7 +139,9 @@ function matchesAnchorText(searchable, anchor) {
|
|||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((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) {
|
||||
|
|
|
|||
|
|
@ -2482,6 +2482,12 @@ function findRecentAddressFilterValue(items, key) {
|
|||
if (!isAddressLaneDebugPayload(debug)) {
|
||||
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);
|
||||
if (directFilterValue) {
|
||||
return directFilterValue;
|
||||
|
|
@ -2777,8 +2783,10 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
|
|||
return false;
|
||||
}
|
||||
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,.;:!?])/iu.test(sample) ||
|
||||
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
|
||||
}
|
||||
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) ||
|
||||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
/^(?:а|a|и|i)\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) ||
|
||||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
|
|
@ -2910,6 +2919,49 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
|||
}
|
||||
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) {
|
||||
const previousAddressItem = findLastAddressAssistantItem(items);
|
||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||
|
|
@ -3030,7 +3082,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||
? toNonEmptyString(followupOffer.suggested_intents[0])
|
||||
: null;
|
||||
if (suggestedIntent) {
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
||||
if (suggestedIntent && !keepPreviousIntent) {
|
||||
previousIntent = suggestedIntent;
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
}
|
||||
|
|
@ -3128,13 +3181,19 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||
? { ...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");
|
||||
if (historicalContract) {
|
||||
previousFilters.contract = historicalContract;
|
||||
}
|
||||
}
|
||||
if (!toNonEmptyString(previousFilters.counterparty)) {
|
||||
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.counterparty)) {
|
||||
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
|
||||
if (historicalCounterparty) {
|
||||
previousFilters.counterparty = historicalCounterparty;
|
||||
|
|
@ -3180,7 +3239,10 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
currentFrameKind === "inventory_drilldown" ||
|
||||
currentFrameKind === "inventory_root") &&
|
||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
|
||||
|
|
@ -3254,19 +3316,34 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
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 {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
target_intent: carryoverTargetIntent,
|
||||
previous_filters: previousFilters,
|
||||
previous_anchor_type: previousAnchorType ?? undefined,
|
||||
previous_anchor_value: previousAnchor,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||
root_context_only: rootScopedPivot || undefined,
|
||||
root_intent: inventoryRootFrame?.intent ?? undefined,
|
||||
root_filters: inventoryRootFrame?.filters ?? undefined,
|
||||
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
||||
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined,
|
||||
current_frame_kind: currentFrameKind ?? undefined
|
||||
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
|
||||
root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined,
|
||||
root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined,
|
||||
root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined,
|
||||
current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined
|
||||
},
|
||||
previousAddressIntent: previousIntent,
|
||||
previousAddressAnchor: previousAnchor,
|
||||
|
|
@ -3340,7 +3417,7 @@ function isRetryableAddressLimitedResult(addressLane) {
|
|||
return false;
|
||||
}
|
||||
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) {
|
||||
const repaired = repairAddressMojibake(String(userMessage ?? ""));
|
||||
|
|
@ -3359,13 +3436,19 @@ function normalizeAddressSemanticHintsFromFragment(fragment) {
|
|||
return null;
|
||||
}
|
||||
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);
|
||||
return {
|
||||
scope_target_kind: scopeTargetKind ?? "none",
|
||||
scope_target_text: toNonEmptyString(hints.scope_target_text),
|
||||
scope_target_kind: normalizedScopeTargetKind ?? "none",
|
||||
scope_target_text: scopeTargetText,
|
||||
date_scope_kind: dateScopeKind ?? "missing",
|
||||
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
|
||||
self_scope_detected: hints.self_scope_detected === true || normalizedScopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || normalizedScopeTargetKind === "selected_object"
|
||||
};
|
||||
}
|
||||
function extractAddressPredecomposeCandidateFromFragments(fragments) {
|
||||
|
|
|
|||
|
|
@ -471,6 +471,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
|
|||
}
|
||||
|
||||
function cleanupAnchorValue(value: string): string {
|
||||
const stripLeadingEntityRole = (text: string): string =>
|
||||
String(text ?? "")
|
||||
.replace(
|
||||
/^(?:(?:по\s+)?(?:контрагент(?:ом|у|а|ы|ов)?|поставщик(?:ом|у|а|и|ов)?|клиент(?:ом|у|а|ы|ов)?|покупател(?:ем|ю|я|и|ей)|продав(?:цом|цу|ца|цы|цов)|заказчик(?:ом|у|а|и|ов)?|исполнител(?:ем|ю|я|и|ей)|подрядчик(?:ом|у|а|и|ов)?))\s+/iu,
|
||||
""
|
||||
)
|
||||
.trim();
|
||||
const stripOuterQuotes = (text: string): string =>
|
||||
String(text ?? "")
|
||||
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||
|
|
@ -480,6 +487,10 @@ function cleanupAnchorValue(value: string): string {
|
|||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
cleaned = stripOuterQuotes(stripLeadingEntityRole(cleaned.replace(/[?!]+$/u, "").trim()));
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 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".
|
||||
|
|
@ -525,7 +536,7 @@ function cleanupAnchorValue(value: string): string {
|
|||
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
|
||||
.trim();
|
||||
|
||||
return stripOuterQuotes(cleaned);
|
||||
return stripOuterQuotes(stripLeadingEntityRole(cleaned));
|
||||
}
|
||||
|
||||
function cleanupContractAnchorValue(value: string): string {
|
||||
|
|
@ -638,6 +649,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
|||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const tokens = value
|
||||
.split(/[^a-zа-я0-9]+/iu)
|
||||
.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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
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 &&
|
||||
allowGenericCounterpartyAnchor &&
|
||||
|
|
|
|||
|
|
@ -618,7 +618,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean {
|
|||
return false;
|
||||
}
|
||||
const hasPaymentCue =
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
|
||||
text
|
||||
);
|
||||
if (!hasPaymentCue) {
|
||||
|
|
@ -656,14 +656,14 @@ function hasVatPayableConfirmedSignal(text: string): boolean {
|
|||
return false;
|
||||
}
|
||||
const hasPaymentCue =
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
|
||||
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы|сгруз(?:ить|им|ишь|ите|ил|ила|или|ка))/iu.test(
|
||||
text
|
||||
);
|
||||
if (!hasPaymentCue) {
|
||||
return false;
|
||||
}
|
||||
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
|
||||
);
|
||||
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
|
||||
|
|
@ -784,6 +784,9 @@ function hasCounterpartyDebtLongevitySignal(text: string): boolean {
|
|||
}
|
||||
|
||||
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
if (hasCustomerRevenueAndPaymentsSignal(text) || hasSupplierPayoutsProfileSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const hasPaymentRiskLexeme =
|
||||
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(
|
||||
text
|
||||
|
|
@ -837,6 +840,30 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
|||
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 {
|
||||
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
|
||||
return true;
|
||||
|
|
@ -2021,8 +2048,12 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
|||
!hasInventoryProvenanceSignalV2(text) &&
|
||||
!hasInventoryPurchaseDocumentsSignalV2(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 {
|
||||
|
|
@ -2164,16 +2195,21 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
|||
}
|
||||
|
||||
if (
|
||||
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
||||
(hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) || hasCounterpartyShipmentItemFlowSignal(text)) &&
|
||||
(hasPartyAnchorMention(text) ||
|
||||
hasLooseByAnchorMention(text) ||
|
||||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||
hasHeuristicCounterpartyAnchor(text))
|
||||
hasHeuristicCounterpartyAnchor(text) ||
|
||||
hasCounterpartyShipmentItemFlowSignal(text))
|
||||
) {
|
||||
return {
|
||||
intent: "list_documents_by_counterparty",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_counterparty_signal_detected"]
|
||||
reasons: [
|
||||
hasCounterpartyShipmentItemFlowSignal(text)
|
||||
? "counterparty_item_flow_signal_detected"
|
||||
: "documents_by_counterparty_signal_detected"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -499,20 +499,34 @@ export function evolveAddressNavigationStateWithAssistantItem(
|
|||
)
|
||||
);
|
||||
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
|
||||
const nextSessionContext =
|
||||
action === "open"
|
||||
? {
|
||||
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_focus_object: focusObject ?? state.session_context.active_focus_object,
|
||||
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
|
||||
date_scope: {
|
||||
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
|
||||
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
|
||||
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
updated_at: createdAt,
|
||||
session_context: {
|
||||
active_result_set_id: resultSetId,
|
||||
active_focus_object: focusObject ?? state.session_context.active_focus_object,
|
||||
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
|
||||
date_scope: {
|
||||
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
|
||||
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
|
||||
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
|
||||
},
|
||||
organization_scope: organizationScope ?? state.session_context.organization_scope
|
||||
},
|
||||
session_context: nextSessionContext,
|
||||
result_sets: nextResultSets,
|
||||
navigation_history: nextEvents
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import {
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_OPENAI_BASE_URL,
|
||||
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||
SHARED_LLM_CONNECTION_FILE
|
||||
} from "../config";
|
||||
import type {
|
||||
AddressCapabilityLayer,
|
||||
|
|
@ -28,6 +32,8 @@ import type {
|
|||
} from "../types/addressQuery";
|
||||
import {
|
||||
buildAddressRecipePlan,
|
||||
buildCounterpartyPurchaseDocumentQuery,
|
||||
buildOpenItemsMovementQuery,
|
||||
selectAddressRecipe,
|
||||
type AddressRecipeExecutionPlan
|
||||
} from "./addressRecipeCatalog";
|
||||
|
|
@ -57,6 +63,8 @@ import {
|
|||
normalizeOrganizationScopeValue,
|
||||
resolveOrganizationSelectionFromMessage
|
||||
} from "./assistantOrganizationMatcher";
|
||||
import { OpenAIResponsesClient, type OpenAIRequestConfig } from "./openaiResponsesClient";
|
||||
import { readJsonFile } from "../utils/files";
|
||||
|
||||
interface NormalizedAddressRow {
|
||||
period: string | null;
|
||||
|
|
@ -219,7 +227,20 @@ interface CounterpartyCatalogResolution {
|
|||
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;
|
||||
const limitedReplyLlmClient = new OpenAIResponsesClient();
|
||||
|
||||
function parseFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
|
|
@ -836,9 +857,43 @@ function anchorTokenVariants(token: string): string[] {
|
|||
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 {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
|
|
@ -851,7 +906,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
|
|||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((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();
|
||||
}
|
||||
|
||||
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[] {
|
||||
return uniqueStrings(
|
||||
rows
|
||||
|
|
@ -1009,6 +1078,7 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
|||
}
|
||||
const normalizedName = normalizeCounterpartyName(name);
|
||||
const normalizedAnchor = normalizeCounterpartyName(anchor);
|
||||
const nameTokens = tokenizeSearchableText(name);
|
||||
if (!normalizedName || !normalizedAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1018,6 +1088,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
|||
score += 10_000;
|
||||
} else if (normalizedName.includes(normalizedAnchor)) {
|
||||
score += 5_000;
|
||||
} else if (fuzzyPartyTokenMatches(normalizedName, normalizedAnchor)) {
|
||||
score += 3_500;
|
||||
} else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
|
||||
score += 2_000;
|
||||
}
|
||||
|
|
@ -1029,6 +1101,8 @@ function scoreCounterpartyCandidate(name: string, anchor: string): number | null
|
|||
for (const variant of variants) {
|
||||
if (normalizedName.includes(variant)) {
|
||||
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) {
|
||||
|
|
@ -1241,24 +1315,7 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
|||
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
|
||||
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
|
||||
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 organization = firstNonEmptyString(
|
||||
row.Организация,
|
||||
|
|
@ -1285,6 +1342,191 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
|
|||
.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 {
|
||||
return [
|
||||
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: {
|
||||
category: AddressLimitedReasonCategory;
|
||||
reason: string;
|
||||
|
|
@ -3132,6 +3472,7 @@ export class AddressQueryService {
|
|||
requestedResultMode,
|
||||
filters: executionFilters
|
||||
});
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
if (isCapabilityRouteBlocked(capabilityDecision)) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
|
|
@ -3162,9 +3503,24 @@ export class AddressQueryService {
|
|||
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
|
||||
emphasizeNumbers?: boolean;
|
||||
useRubCurrency?: boolean;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
} = {}
|
||||
) => ({
|
||||
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,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
|
|
@ -3173,8 +3529,53 @@ export class AddressQueryService {
|
|||
emphasizeNumbers: options.emphasizeNumbers ?? 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);
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const debtLifecycleReceivablesScenario =
|
||||
intent.intent === "list_receivables_counterparties" &&
|
||||
Array.isArray(intent.reasons) &&
|
||||
|
|
@ -3238,7 +3639,7 @@ export class AddressQueryService {
|
|||
}
|
||||
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3261,7 +3662,7 @@ export class AddressQueryService {
|
|||
}
|
||||
|
||||
if (recipeSelection.selected_recipe === null) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3284,7 +3685,7 @@ export class AddressQueryService {
|
|||
}
|
||||
|
||||
if (recipeSelection.missing_required_filters.length > 0) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3307,7 +3708,7 @@ export class AddressQueryService {
|
|||
}
|
||||
|
||||
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3334,6 +3735,14 @@ export class AddressQueryService {
|
|||
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
|
||||
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
|
||||
if (catalogResolution.resolvedValue) {
|
||||
filters.extracted_filters = {
|
||||
...filters.extracted_filters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
executionFilters = {
|
||||
...executionFilters,
|
||||
counterparty: catalogResolution.resolvedValue
|
||||
};
|
||||
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
|
||||
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
|
||||
}
|
||||
|
|
@ -3375,6 +3784,45 @@ export class AddressQueryService {
|
|||
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: 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({
|
||||
query: plan.query,
|
||||
limit: plan.limit
|
||||
|
|
@ -3479,7 +3927,7 @@ export class AddressQueryService {
|
|||
|
||||
if (mcp.error) {
|
||||
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -3623,6 +4071,90 @@ export class AddressQueryService {
|
|||
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
|
||||
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
||||
: 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 (!baseReasons.includes("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 recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
if (recoveredRows.length > 0) {
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows, composeRuntimeOptions(executionFilters));
|
||||
const recoveryReason =
|
||||
recoveredBankRows.length > 0
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
|
|
@ -3822,7 +4354,7 @@ export class AddressQueryService {
|
|||
const expandedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
expandedFilteredRows,
|
||||
composeOptionsFromFilters(expandedLimitFilters)
|
||||
composeRuntimeOptions(expandedLimitFilters)
|
||||
);
|
||||
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
|
||||
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
|
||||
|
|
@ -3977,7 +4509,7 @@ export class AddressQueryService {
|
|||
const broadenedFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
broadenedFilteredRows,
|
||||
composeOptionsFromFilters(autoBroadenedFilters)
|
||||
composeRuntimeOptions(autoBroadenedFilters)
|
||||
);
|
||||
const broadenedLimitations = [
|
||||
...filters.warnings,
|
||||
|
|
@ -4075,6 +4607,7 @@ export class AddressQueryService {
|
|||
|
||||
if (
|
||||
filteredRows.length === 0 &&
|
||||
!counterpartyItemFlowQuery &&
|
||||
isDocumentOrBankAnchorIntent(intent.intent) &&
|
||||
!hasExplicitPeriodWindow(filters.extracted_filters) &&
|
||||
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
|
||||
|
|
@ -4151,7 +4684,7 @@ export class AddressQueryService {
|
|||
const historicalFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
historicalFilteredRows,
|
||||
composeOptionsFromFilters(historicalFilters)
|
||||
composeRuntimeOptions(historicalFilters)
|
||||
);
|
||||
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
|
||||
const historicalSuggestion =
|
||||
|
|
@ -4238,7 +4771,7 @@ export class AddressQueryService {
|
|||
const fallbackFactual = composeFactualReply(
|
||||
intent.intent,
|
||||
documentBankFallbackRows,
|
||||
composeOptionsFromFilters(executionFilters)
|
||||
composeRuntimeOptions(executionFilters)
|
||||
);
|
||||
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
|
||||
const fallbackSuggestion =
|
||||
|
|
@ -4322,6 +4855,65 @@ export class AddressQueryService {
|
|||
!toNonEmptyFilterValue(filters.extracted_filters.contract) &&
|
||||
!toNonEmptyFilterValue(filters.extracted_filters.document_ref);
|
||||
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}. Позиции: подтвержденных поступлений товаров или услуг не найдено.\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 hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||
const isVisibilityGapCandidate =
|
||||
|
|
@ -4405,7 +4997,7 @@ export class AddressQueryService {
|
|||
? "document_or_bank_visibility_gap_after_base_filter"
|
||||
: "no_rows_after_recipe_and_scope_filter"
|
||||
];
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -4454,7 +5046,7 @@ export class AddressQueryService {
|
|||
const factual = composeFactualReply(
|
||||
composeIntent,
|
||||
filteredRows,
|
||||
composeOptionsFromFilters(executionFilters, {
|
||||
composeRuntimeOptions(executionFilters, {
|
||||
vatDirectSourceProbe,
|
||||
emphasizeNumbers: shouldEmphasizeNumbers,
|
||||
useRubCurrency: shouldUseRubCurrency
|
||||
|
|
@ -4489,7 +5081,7 @@ export class AddressQueryService {
|
|||
resultMode: factualResultSemantics.result_mode
|
||||
});
|
||||
if (finalRouteExpectationAudit.status === "mismatch" && FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
@ -4537,7 +5129,7 @@ export class AddressQueryService {
|
|||
: intent.intent === "vat_liability_confirmed_for_tax_period"
|
||||
? "vat_tax_period"
|
||||
: "vat_payable";
|
||||
return buildLimitedExecutionResult({
|
||||
return finalizeLimitedResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,40 @@ __WHERE_CLAUSE__
|
|||
Товары.Ссылка.Дата __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 = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
__AS_OF_EXPR__ КАК Период,
|
||||
|
|
@ -1227,6 +1261,36 @@ function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPa
|
|||
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 {
|
||||
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
|
||||
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
|
||||
|
|
@ -1257,6 +1321,44 @@ function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolved
|
|||
.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 {
|
||||
const hasAnchor =
|
||||
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export interface VatDirectSourceProbeSummary {
|
|||
|
||||
interface ComposeFactualReplyOptions {
|
||||
userMessage?: string;
|
||||
itemHint?: string;
|
||||
counterpartyHint?: string;
|
||||
accountHint?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
asOfDate?: string;
|
||||
|
|
@ -78,6 +81,7 @@ type CounterpartyProfileFocus =
|
|||
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
|
||||
type ValueRankingFocus =
|
||||
| "top_by_total"
|
||||
| "total_flow"
|
||||
| "top_years_by_total"
|
||||
| "top_by_ops"
|
||||
| "top_by_max_single"
|
||||
|
|
@ -662,6 +666,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
|
|||
if (!text) {
|
||||
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) &&
|
||||
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
|
||||
|
|
@ -767,6 +778,16 @@ function extractCounterpartyName(row: ComposeStageRow): string | 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 {
|
||||
const direct = String(row.item ?? "").trim();
|
||||
if (direct) {
|
||||
|
|
@ -988,10 +1009,13 @@ function looksLikeInventoryPartyToken(value: string): boolean {
|
|||
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 warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||
const excludedComparableTokens = excludedTokens
|
||||
.map((token) => normalizeEntityToken(token))
|
||||
.filter((token): token is string => Boolean(token));
|
||||
const candidates: string[] = [];
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
|
|
@ -999,7 +1023,13 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[]
|
|||
continue;
|
||||
}
|
||||
const comparable = normalizeEntityToken(normalized);
|
||||
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
|
||||
if (
|
||||
!comparable ||
|
||||
comparable === itemToken ||
|
||||
comparable === warehouseToken ||
|
||||
comparable === organizationToken ||
|
||||
excludedComparableTokens.includes(comparable)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(normalized);
|
||||
|
|
@ -1018,7 +1048,7 @@ interface InventoryTraceSummary {
|
|||
totalAmount: number;
|
||||
}
|
||||
|
||||
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
|
||||
function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpartyTokens: string[] = []): InventoryTraceSummary {
|
||||
const items = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractInventoryItemName(row))
|
||||
|
|
@ -1034,7 +1064,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
|
|||
.map((row) => extractInventoryOrganizationName(row))
|
||||
.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(
|
||||
rows
|
||||
.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) => {
|
||||
const parties = extractInventoryCounterpartyCandidates(row);
|
||||
const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
|
||||
const warehouse = extractInventoryWarehouseName(row);
|
||||
const organization = extractInventoryOrganizationName(row);
|
||||
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 {
|
||||
item: string;
|
||||
warehouse: string | null;
|
||||
|
|
@ -3296,6 +3357,8 @@ export function composeFactualReply(
|
|||
|
||||
const profileRows = Array.from(byCounterparty.values());
|
||||
const yearRows = Array.from(byYear.values());
|
||||
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
|
||||
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
|
||||
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
|
||||
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
|
||||
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
|
||||
|
|
@ -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") {
|
||||
const visible = rankedByYearTotal.slice(0, limit);
|
||||
const heading = isSupplier
|
||||
|
|
@ -4274,8 +4364,11 @@ export function composeFactualReply(
|
|||
if (intent === "inventory_sale_trace_for_item") {
|
||||
const asOfDate = resolvePayablesAsOfDate(options);
|
||||
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||
const summary = summarizeInventoryTraceRows(saleRows);
|
||||
const itemLabel = summary.item ?? "товар не определен";
|
||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||
const excludedCounterpartyTokens = [itemLabel];
|
||||
const directAnswerLine =
|
||||
summary.counterparties.length === 1
|
||||
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
|
||||
|
|
@ -4296,7 +4389,7 @@ export function composeFactualReply(
|
|||
}
|
||||
lines.push("", "Документы выбытия:");
|
||||
if (saleRows.length > 0) {
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12));
|
||||
lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
|
||||
} else {
|
||||
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
|
||||
}
|
||||
|
|
@ -5023,8 +5116,12 @@ export function composeFactualReply(
|
|||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const accountLead =
|
||||
typeof options.accountHint === "string" && options.accountHint.trim().length > 0
|
||||
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
|
||||
: "Собраны открытые позиции по взаиморасчетам.";
|
||||
const lines = [
|
||||
"Собраны открытые позиции по взаиморасчетам.",
|
||||
accountLead,
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
|
|
@ -5090,10 +5187,73 @@ export function composeFactualReply(
|
|||
}
|
||||
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
const lines = [
|
||||
`Найдено документов по контрагенту: ${rows.length}.`,
|
||||
...formatTopRows(rows, rows.length)
|
||||
];
|
||||
const resolvedCounterparty =
|
||||
(typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
|
||||
? options.counterpartyHint.trim()
|
||||
: null) ??
|
||||
(() => {
|
||||
const counterparties = uniqueStrings(
|
||||
rows
|
||||
.map((row) => extractCounterpartyName(row))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
return counterparties.length === 1 ? counterparties[0] : null;
|
||||
})();
|
||||
const 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 {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { AddressLlmSemanticHints } from "../../types/addressQuery";
|
|||
|
||||
export interface AddressFollowupContext {
|
||||
previous_intent?: AddressIntent;
|
||||
target_intent?: AddressIntent;
|
||||
previous_filters?: AddressFilterSet;
|
||||
previous_anchor_type?:
|
||||
| "account"
|
||||
|
|
@ -98,7 +99,7 @@ function hasSameDateHint(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 ?? "")
|
||||
);
|
||||
}
|
||||
|
|
@ -672,6 +673,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
|||
if (hasSameDateHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (hasSamePeriodHint(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
if (
|
||||
|
|
@ -853,7 +857,9 @@ function mergeFollowupFilters(
|
|||
intent === "inventory_aging_by_purchase_date" ||
|
||||
intent === "payables_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 inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||
|
|
@ -935,6 +941,34 @@ function mergeFollowupFilters(
|
|||
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 &&
|
||||
(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;
|
||||
}
|
||||
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 (
|
||||
|
|
@ -1199,7 +1244,7 @@ function deriveIntentWithFollowupContext(
|
|||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressIntentResolution {
|
||||
if (!followupContext || !followupContext.previous_intent) {
|
||||
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
||||
|
|
@ -1208,7 +1253,11 @@ function deriveIntentWithFollowupContext(
|
|||
if (!hasFollowupSignal) {
|
||||
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 previousContract = toNonEmptyString(previousFilters.contract);
|
||||
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 (
|
||||
previousIsBalanceFamily &&
|
||||
hasAccountSignal(normalizedMessage) &&
|
||||
|
|
@ -1262,7 +1312,7 @@ function deriveIntentWithFollowupContext(
|
|||
};
|
||||
}
|
||||
|
||||
const previousIsInventoryFamily = isInventoryIntent(previousIntent);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const inventorySelectedObjectFollowup =
|
||||
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
|
|
@ -1273,7 +1323,7 @@ function deriveIntentWithFollowupContext(
|
|||
detectedIntent.intent === "bank_operations_by_counterparty" ||
|
||||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
|
|
@ -1289,7 +1339,7 @@ function deriveIntentWithFollowupContext(
|
|||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_documents_for_item",
|
||||
|
|
@ -1309,7 +1359,7 @@ function deriveIntentWithFollowupContext(
|
|||
detectedIntent.intent === "bank_operations_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_profitability_for_item",
|
||||
|
|
@ -1323,7 +1373,7 @@ function deriveIntentWithFollowupContext(
|
|||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === previousIntent ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return {
|
||||
|
|
@ -1339,7 +1389,7 @@ function deriveIntentWithFollowupContext(
|
|||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_sale_trace_for_item",
|
||||
|
|
@ -1354,7 +1404,7 @@ function deriveIntentWithFollowupContext(
|
|||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
detectedIntent.intent === sourceIntent
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_to_sale_chain",
|
||||
|
|
@ -1368,7 +1418,7 @@ function deriveIntentWithFollowupContext(
|
|||
previousIsInventoryFamily &&
|
||||
hasFollowupSignal &&
|
||||
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)
|
||||
(detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)
|
||||
) {
|
||||
return {
|
||||
intent: "inventory_purchase_provenance_for_item",
|
||||
|
|
@ -1431,7 +1481,7 @@ function deriveIntentWithFollowupContext(
|
|||
}
|
||||
|
||||
return {
|
||||
intent: previousIntent,
|
||||
intent: fallbackIntent ?? "unknown",
|
||||
confidence: "low",
|
||||
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ function tokenizeAnchor(value: string): string[] {
|
|||
.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[] {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
|
|
@ -123,9 +130,43 @@ function anchorTokenVariants(token: string): string[] {
|
|||
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 {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
const searchableTokens = tokenizeSearchableText(searchable);
|
||||
const tokens = tokenizeAnchor(anchor);
|
||||
if (tokens.length === 0) {
|
||||
const direct = normalizeSearchText(anchor);
|
||||
|
|
@ -138,7 +179,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
|
|||
const variants = anchorTokenVariants(token);
|
||||
return variants.some((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) {
|
||||
|
|
|
|||
|
|
@ -2440,6 +2440,12 @@ function findRecentAddressFilterValue(items, key) {
|
|||
if (!isAddressLaneDebugPayload(debug)) {
|
||||
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);
|
||||
if (directFilterValue) {
|
||||
return directFilterValue;
|
||||
|
|
@ -2735,8 +2741,10 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
|
|||
return false;
|
||||
}
|
||||
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,.;:!?])/iu.test(sample) ||
|
||||
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
|
||||
}
|
||||
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) ||
|
||||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
/^(?:а|a|и|i)\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) ||
|
||||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
|
||||
|
|
@ -2868,6 +2877,49 @@ function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
|||
}
|
||||
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) {
|
||||
const previousAddressItem = findLastAddressAssistantItem(items);
|
||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||
|
|
@ -2988,7 +3040,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
|
||||
? toNonEmptyString(followupOffer.suggested_intents[0])
|
||||
: null;
|
||||
if (suggestedIntent) {
|
||||
const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent);
|
||||
if (suggestedIntent && !keepPreviousIntent) {
|
||||
previousIntent = suggestedIntent;
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
}
|
||||
|
|
@ -3086,13 +3139,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||
? { ...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");
|
||||
if (historicalContract) {
|
||||
previousFilters.contract = historicalContract;
|
||||
}
|
||||
}
|
||||
if (!toNonEmptyString(previousFilters.counterparty)) {
|
||||
if (shouldBackfillHistoricalPartyAnchors && !toNonEmptyString(previousFilters.counterparty)) {
|
||||
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
|
||||
if (historicalCounterparty) {
|
||||
previousFilters.counterparty = historicalCounterparty;
|
||||
|
|
@ -3138,7 +3198,10 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
|
||||
(isInventorySelectedObjectIntent(sourceIntentHint) ||
|
||||
isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||
currentFrameKind === "inventory_drilldown" ||
|
||||
currentFrameKind === "inventory_root") &&
|
||||
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
|
||||
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
|
||||
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
|
||||
|
|
@ -3212,19 +3275,34 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
|||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
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 {
|
||||
followupContext: {
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
target_intent: carryoverTargetIntent,
|
||||
previous_filters: previousFilters,
|
||||
previous_anchor_type: previousAnchorType ?? undefined,
|
||||
previous_anchor_value: previousAnchor,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||
root_context_only: rootScopedPivot || undefined,
|
||||
root_intent: inventoryRootFrame?.intent ?? undefined,
|
||||
root_filters: inventoryRootFrame?.filters ?? undefined,
|
||||
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
||||
root_anchor_value: inventoryRootFrame?.anchorValue ?? undefined,
|
||||
current_frame_kind: currentFrameKind ?? undefined
|
||||
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,
|
||||
root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined,
|
||||
root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined,
|
||||
root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined,
|
||||
current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined
|
||||
},
|
||||
previousAddressIntent: previousIntent,
|
||||
previousAddressAnchor: previousAnchor,
|
||||
|
|
@ -3298,7 +3376,7 @@ function isRetryableAddressLimitedResult(addressLane) {
|
|||
return false;
|
||||
}
|
||||
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) {
|
||||
const repaired = repairAddressMojibake(String(userMessage ?? ""));
|
||||
|
|
@ -3317,13 +3395,19 @@ function normalizeAddressSemanticHintsFromFragment(fragment) {
|
|||
return null;
|
||||
}
|
||||
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);
|
||||
return {
|
||||
scope_target_kind: scopeTargetKind ?? "none",
|
||||
scope_target_text: toNonEmptyString(hints.scope_target_text),
|
||||
scope_target_kind: normalizedScopeTargetKind ?? "none",
|
||||
scope_target_text: scopeTargetText,
|
||||
date_scope_kind: dateScopeKind ?? "missing",
|
||||
self_scope_detected: hints.self_scope_detected === true || scopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
|
||||
self_scope_detected: hints.self_scope_detected === true || normalizedScopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected: hints.selected_object_scope_detected === true || normalizedScopeTargetKind === "selected_object"
|
||||
};
|
||||
}
|
||||
function extractAddressPredecomposeCandidateFromFragments(fragments) {
|
||||
|
|
|
|||
|
|
@ -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%");
|
||||
});
|
||||
});
|
||||
|
|
@ -73,10 +73,48 @@ describe("counterparty lifecycle organization scope regressions", () => {
|
|||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply_type).toBe("factual");
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
|
||||
expect(result?.debug.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?.counterparty).toBeUndefined();
|
||||
expect(result?.debug.match_failure_reason).toBeNull();
|
||||
expect(result?.debug.rows_matched).toBe(3);
|
||||
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('ООО "Ромашка"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,62 @@ describe("address follow-up temporal regressions", () => {
|
|||
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", () => {
|
||||
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
|
||||
previous_intent: "vat_payable_confirmed_as_of_date",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ describe("inventory sale trace movement route", () => {
|
|||
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(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы");
|
||||
expect(String(result?.reply_text ?? "")).not.toMatch(
|
||||
/Контрагент:\s*Рабочая станция универсального специалиста/i
|
||||
);
|
||||
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
|
|
|
|||
|
|
@ -181,4 +181,65 @@ describe("address navigation state", () => {
|
|||
expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ describe("vat payable confirmed as-of route", () => {
|
|||
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", () => {
|
||||
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
|
||||
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.route_expectation_status).toBe("matched");
|
||||
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,34 @@ describe("assistant address lane runtime adapter", () => {
|
|||
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 () => {
|
||||
const runAddressLaneAttempt = vi
|
||||
.fn()
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue