АДРЕСНЫЙ РЕЖИМ - Batch-2 (Phase A/B): стабилизация LLM-маршрутизации жизненного цикла контрагентов и договоров + актуализация документации
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user