АДРЕСНЫЙ РЕЖИМ - Batch-2 (Phase A/B): стабилизация LLM-маршрутизации жизненного цикла контрагентов и договоров + актуализация документации

This commit is contained in:
2026-04-08 12:42:02 +03:00
parent eec5ef4fc0
commit 1f9bb886ee
28 changed files with 2281 additions and 128 deletions
@@ -236,12 +236,27 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"какие заказчики активны",
"какие клиенты работали",
"какие клиенты активны",
"какие контрагенты работали",
"какие поставщики работали",
"список заказчиков",
"список клиентов",
"список заказчиков за все время",
"список клиентов за все время",
"список активных заказчиков",
"список активных клиентов",
"новые заказчики",
"новые клиенты",
"новые контрагенты",
"впервые в",
"кто исчез",
"кто ушел",
"кто ушёл",
"только один раз",
"дольше всех",
"долгоживущие контрагенты",
"регулярные поставщики",
"эпизодические поставщики",
"давно не использовались поставщики",
"active customers",
"customer activity list",
"counterparty lifecycle"
@@ -255,6 +270,12 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [
"договоры total vs used",
"обзор договорной базы",
"договорная база total used",
"неиспользуемые договоры",
"давно не использовались договоры",
"мертвые договоры",
"мёртвые договоры",
"stale contracts",
"unused contracts",
"contracts total used",
"contract usage overview"
];
@@ -296,6 +317,11 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"активные договоры по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
"рабочие договоры",
"contracts by turnover",
"contracts by budget"
];
@@ -506,6 +532,9 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text) {
return false;
}
function hasCounterpartyPopulationAndRolesSignal(text) {
if (hasLifecycleSegmentationSignal(text)) {
return false;
}
if (hasAny(text, COUNTERPARTY_POPULATION_AND_ROLES_HINTS)) {
return true;
}
@@ -520,6 +549,9 @@ function hasCounterpartyPopulationAndRolesSignal(text) {
}
return false;
}
function hasLifecycleSegmentationSignal(text) {
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|только\s+один\s+раз|однораз|дольше\s+всех|долгожив|регулярн|эпизодич|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
}
function hasCounterpartyActivityLifecycleSignal(text) {
if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
return false;
@@ -530,19 +562,22 @@ function hasCounterpartyActivityLifecycleSignal(text) {
if (/(?:сколько|скока|скок)\s+/iu.test(text)) {
return false;
}
const hasCustomerLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text);
const hasActivityLexeme = /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active)/iu.test(text);
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(text);
const hasActivityLexeme = /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(text);
const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
const hasListWithWindow = hasCustomerLexeme && hasListVerb && hasTimeWindowLexeme;
const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme;
if (hasListWithWindow) {
return true;
}
if (hasCustomerLexeme && hasListVerb && hasRosterQualifier) {
if (hasCounterpartyLexeme && hasListVerb && hasRosterQualifier) {
return true;
}
return hasCustomerLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
if (hasCounterpartyLexeme && hasLifecycleSegmentationSignal(text)) {
return true;
}
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
}
function hasContractUsageOverviewSignal(text) {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
@@ -557,6 +592,9 @@ function hasContractUsageOverviewSignal(text) {
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
return true;
}
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
}
function hasCustomerRevenueAndPaymentsSignal(text) {
@@ -636,9 +674,10 @@ function hasContractUsageAndValueSignal(text) {
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(text);
const asksValue = /(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text);
const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text);
return asksValue || asksRank;
return asksStructure || asksValue || asksRank;
}
function hasContractListByCounterpartySignal(text) {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
@@ -22,7 +22,8 @@ const BANK_DOCS_QUERY_TEMPLATE = `
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -33,7 +34,8 @@ __WHERE_OUT__
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -460,7 +462,8 @@ const BASE_RECIPES = [
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 96,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_documents_by_counterparty_v1",
@@ -2771,6 +2771,41 @@ function isLowQualityPredecomposeCounterpartyAnchor(value) {
});
return meaningful.length === 0;
}
function normalizePredecomposeCounterpartyAnchorTokensForMatch(value) {
return normalizePredecomposeAnchorTokens(value).filter((token) => {
if (token.length < 2) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return !ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS.has(token);
});
}
function hasCounterpartyAnchorSubstitution(sourceValue, candidateValue) {
const sourceNormalized = String(sourceValue ?? "").trim().toLowerCase().replace(/ё/g, "е");
const candidateNormalized = String(candidateValue ?? "").trim().toLowerCase().replace(/ё/g, "е");
if (!sourceNormalized || !candidateNormalized) {
return false;
}
if (sourceNormalized === candidateNormalized) {
return false;
}
if (sourceNormalized.includes(candidateNormalized) || candidateNormalized.includes(sourceNormalized)) {
return false;
}
const sourceTokens = new Set(normalizePredecomposeCounterpartyAnchorTokensForMatch(sourceNormalized));
const candidateTokens = normalizePredecomposeCounterpartyAnchorTokensForMatch(candidateNormalized);
if (sourceTokens.size === 0 || candidateTokens.length === 0) {
return false;
}
for (const token of candidateTokens) {
if (sourceTokens.has(token)) {
return false;
}
}
return true;
}
function isLowQualityPredecomposeContractAnchor(value) {
const normalized = String(value ?? "").trim().toLowerCase().replace(/ё/g, "е");
if (!normalized) {
@@ -3003,6 +3038,27 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "");
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
@@ -247,12 +247,27 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"какие заказчики активны",
"какие клиенты работали",
"какие клиенты активны",
"какие контрагенты работали",
"какие поставщики работали",
"список заказчиков",
"список клиентов",
"список заказчиков за все время",
"список клиентов за все время",
"список активных заказчиков",
"список активных клиентов",
"новые заказчики",
"новые клиенты",
"новые контрагенты",
"впервые в",
"кто исчез",
"кто ушел",
"кто ушёл",
"только один раз",
"дольше всех",
"долгоживущие контрагенты",
"регулярные поставщики",
"эпизодические поставщики",
"давно не использовались поставщики",
"active customers",
"customer activity list",
"counterparty lifecycle"
@@ -267,6 +282,12 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [
"договоры total vs used",
"обзор договорной базы",
"договорная база total used",
"неиспользуемые договоры",
"давно не использовались договоры",
"мертвые договоры",
"мёртвые договоры",
"stale contracts",
"unused contracts",
"contracts total used",
"contract usage overview"
];
@@ -311,6 +332,11 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"активные договоры по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
"рабочие договоры",
"contracts by turnover",
"contracts by budget"
];
@@ -552,6 +578,9 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text: string): boolean {
}
function hasCounterpartyPopulationAndRolesSignal(text: string): boolean {
if (hasLifecycleSegmentationSignal(text)) {
return false;
}
if (hasAny(text, COUNTERPARTY_POPULATION_AND_ROLES_HINTS)) {
return true;
}
@@ -575,6 +604,12 @@ function hasCounterpartyPopulationAndRolesSignal(text: string): boolean {
return false;
}
function hasLifecycleSegmentationSignal(text: string): boolean {
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|только\s+один\s+раз|однораз|дольше\s+всех|долгожив|регулярн|эпизодич|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(
text
);
}
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
return false;
@@ -585,25 +620,31 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
if (/(?:сколько|скока|скок)\s+/iu.test(text)) {
return false;
}
const hasCustomerLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(
text
);
const hasActivityLexeme = /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active)/iu.test(text);
const hasActivityLexeme =
/(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(
text
);
const hasTimeWindowLexeme =
/(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(
text
);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
const hasListWithWindow = hasCustomerLexeme && hasListVerb && hasTimeWindowLexeme;
const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme;
if (hasListWithWindow) {
return true;
}
if (hasCustomerLexeme && hasListVerb && hasRosterQualifier) {
if (hasCounterpartyLexeme && hasListVerb && hasRosterQualifier) {
return true;
}
if (hasCounterpartyLexeme && hasLifecycleSegmentationSignal(text)) {
return true;
}
return hasCustomerLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
}
function hasContractUsageOverviewSignal(text: string): boolean {
@@ -625,6 +666,9 @@ function hasContractUsageOverviewSignal(text: string): boolean {
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
return true;
}
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
}
@@ -724,10 +768,13 @@ function hasContractUsageAndValueSignal(text: string): boolean {
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(
text
);
const asksValue =
/(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text);
const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text);
return asksValue || asksRank;
return asksStructure || asksValue || asksRank;
}
function hasContractListByCounterpartySignal(text: string): boolean {
@@ -26,7 +26,8 @@ const BANK_DOCS_QUERY_TEMPLATE = `
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
@@ -37,7 +38,8 @@ __WHERE_OUT__
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
@@ -474,7 +476,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 96,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_documents_by_counterparty_v1",
@@ -2732,6 +2732,41 @@ function isLowQualityPredecomposeCounterpartyAnchor(value) {
});
return meaningful.length === 0;
}
function normalizePredecomposeCounterpartyAnchorTokensForMatch(value) {
return normalizePredecomposeAnchorTokens(value).filter((token) => {
if (token.length < 2) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return !ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS.has(token);
});
}
function hasCounterpartyAnchorSubstitution(sourceValue, candidateValue) {
const sourceNormalized = String(sourceValue ?? "").trim().toLowerCase().replace(/ё/g, "е");
const candidateNormalized = String(candidateValue ?? "").trim().toLowerCase().replace(/ё/g, "е");
if (!sourceNormalized || !candidateNormalized) {
return false;
}
if (sourceNormalized === candidateNormalized) {
return false;
}
if (sourceNormalized.includes(candidateNormalized) || candidateNormalized.includes(sourceNormalized)) {
return false;
}
const sourceTokens = new Set(normalizePredecomposeCounterpartyAnchorTokensForMatch(sourceNormalized));
const candidateTokens = normalizePredecomposeCounterpartyAnchorTokensForMatch(candidateNormalized);
if (sourceTokens.size === 0 || candidateTokens.length === 0) {
return false;
}
for (const token of candidateTokens) {
if (sourceTokens.has(token)) {
return false;
}
}
return true;
}
function isLowQualityPredecomposeContractAnchor(value) {
const normalized = String(value ?? "").trim().toLowerCase().replace(/ё/g, "е");
if (!normalized) {
@@ -2964,6 +2999,27 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "");
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
@@ -1383,11 +1383,36 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves counterparty lifecycle intent for one-time counterparties wording", () => {
const result = resolveAddressIntent("Какие контрагенты работали с нами только один раз за все время?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves counterparty lifecycle intent for longest-running counterparties wording", () => {
const result = resolveAddressIntent("Какие контрагенты работают с нами дольше всех?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves supplier lifecycle segmentation wording into lifecycle intent", () => {
const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности.");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves stale suppliers wording into lifecycle intent", () => {
const result = resolveAddressIntent("Какие поставщики давно не использовались?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves contract usage overview intent", () => {
const result = resolveAddressIntent("Сколько всего договоров заведено и сколько из них реально использовались?");
expect(result.intent).toBe("contract_usage_overview");
});
it("resolves stale contracts wording into contract usage overview intent", () => {
const result = resolveAddressIntent("Какие договоры давно не использовались?");
expect(result.intent).toBe("contract_usage_overview");
});
it("resolves customer revenue/payout ranking intent", () => {
const result = resolveAddressIntent("какие клиенты самые доходные, выдай топ-20");
expect(result.intent).toBe("customer_revenue_and_payments");
@@ -1418,6 +1443,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves multi-contract counterparties wording into contract usage and value intent", () => {
const result = resolveAddressIntent("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves contracts-by-counterparty intent from list wording", () => {
const result = resolveAddressIntent("покажи договора все по жуковке 51");
expect(result.intent).toBe("list_contracts_by_counterparty");
@@ -2017,6 +2047,36 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes one-time counterparties wording into lifecycle aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие контрагенты работали с нами только один раз за все время?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes stale contracts wording into contract usage overview recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие договоры давно не использовались?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("contract_usage_overview");
expect(result?.debug.selected_recipe).toBe("address_contract_usage_overview_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes multi-contract counterparties wording into contract usage and value recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("contract_usage_and_value");
expect(result?.debug.selected_recipe).toBe("address_contract_usage_and_value_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("returns missing_anchor for open items without concrete counterparty/contract anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show open items by contract");
@@ -2510,6 +2570,22 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.limit).toBe(1000);
});
it("uses bank-doc profile with contract projection for open-items anchor matching", () => {
const selected = selectAddressRecipe("open_items_by_counterparty_or_contract", {
counterparty: "СВК",
as_of_date: "2020-12-31"
});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
counterparty: "СВК",
as_of_date: "2020-12-31"
});
expect(plan.query).toContain("Документ.СписаниеСРасчетногоСчета");
expect(plan.query).toContain("Документ.ПоступлениеНаРасчетныйСчет");
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор");
});
it("allows extended limit for open-contracts intent", () => {
const selected = selectAddressRecipe("list_open_contracts", {
as_of_date: "2020-12-31",
@@ -348,6 +348,100 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
});
it("rejects llm fragment when counterparty anchor is substituted by unrelated noun", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-anchor-substitution",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "свк доки за 20й",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "свк доки за 20й",
normalized_fragment_text: "сканирование документов за 20-й период",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: ["документы"],
register_hints: [],
time_scope: {
type: "explicit",
value: "2020",
confidence: "high"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "medium",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-anchor-substitution-${Date.now()}`,
user_message: "свк доки за 20й",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].message.toLowerCase()).toContain("свк");
expect(calls[0].message.toLowerCase()).not.toContain("сканирование");
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_substitution");
});
it("rejects follow-up intent injection when llm adds documents to same-date account prompt", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {