АДРЕСНЫЙ РЕЖИМ -ADDRESS: убран ложный factual fallback в open_items при неподтвержденном якоре + добавлен регресс-тест

This commit is contained in:
2026-04-02 13:39:08 +03:00
parent 4dff069ae3
commit cc7fcabf05
32 changed files with 72790 additions and 180 deletions
@@ -323,12 +323,27 @@ function cleanupAnchorValue(value) {
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
}
function cleanupContractAnchorValue(value) {
let normalized = cleanupAnchorValue(value);
if (!normalized) {
return normalized;
}
const yearQualifierTailPattern = /\s+(?:(?:за|for)\s+)?(?:year|г(?:од|ода)?\.?)\s*(?:20\d{2}|\d{2})(?:\s+|$)[\s\S]*$/iu;
if (yearQualifierTailPattern.test(normalized)) {
normalized = normalized.replace(yearQualifierTailPattern, "").trim();
}
const trailingSeparatedYearPattern = /\s+(?:20\d{2}|\d{2})\s*(?:г(?:од|ода)?\.?|year)?$/iu;
if (trailingSeparatedYearPattern.test(normalized) && /[\/\\-]/.test(normalized)) {
normalized = normalized.replace(trailingSeparatedYearPattern, "").trim();
}
return normalized;
}
function hasAllTimeHint(text) {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text) {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
const match = String(text ?? "").match(/(?:^|\s)по\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
@@ -471,14 +486,14 @@ function isLikelyCounterpartyToken(rawToken) {
}
function hasDocsOrBankSignal(text) {
const lowered = String(text ?? "").toLowerCase();
return new RegExp(`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`, "iu").test(lowered);
return new RegExp(`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|списан|поступлен|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`, "iu").test(lowered);
}
function extractCounterpartyFromFreeTextHeuristic(text) {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
@@ -531,7 +546,7 @@ function extractCounterpartyFromFreeTextHeuristic(text) {
}
function extractImplicitCounterpartyValue(text) {
const input = String(text ?? "");
const beforeDocsPattern = new RegExp(`(?:^|\\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\\s+${DOC_SIGNAL_PATTERN}(?=[\\s,.;:!?)]|$)`, "iu");
const beforeDocsPattern = new RegExp(`(?:^|\\s)([\\p{L}][\\p{L}\\p{N}._-]{1,})\\s+${DOC_SIGNAL_PATTERN}(?=[\\s,.;:!?)]|$)`, "iu");
const beforeDocsMatch = input.match(beforeDocsPattern);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
@@ -539,7 +554,7 @@ function extractImplicitCounterpartyValue(text) {
return candidate;
}
}
const afterDocsPattern = new RegExp(`${DOC_SIGNAL_PATTERN}\\s+(?:по\\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\\s,.;:!?)]|$)`, "iu");
const afterDocsPattern = new RegExp(`${DOC_SIGNAL_PATTERN}\\s+(?:по\\s+)?([\\p{L}][\\p{L}\\p{N}._-]{1,})(?=[\\s,.;:!?)]|$)`, "iu");
const afterDocsMatch = input.match(afterDocsPattern);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
@@ -549,6 +564,77 @@ function extractImplicitCounterpartyValue(text) {
}
return undefined;
}
function extractLeadingCounterpartyTokenHeuristic(text) {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens.slice(0, 3)) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$|^на$|^in$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function hasExplicitAccountCue(text) {
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
}
function extractAccountTokenHeuristic(text) {
const source = String(text ?? "");
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
if (dotted) {
return String(dotted[1]).replace(",", ".");
}
const contextual = source.match(/(?:^|[\s,.;:!?()\-])(?:по|на|for|account)\s+(\d{2})(?!\d)/iu);
if (contextual) {
return String(contextual[1]);
}
return undefined;
}
function shiftDaysIso(baseIso, deltaDays) {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
@@ -581,6 +667,13 @@ function extractAddressFilters(userMessage, intent) {
if (accountMatch) {
filters.account = String(accountMatch[1]).replace(",", ".");
}
if (!filters.account && (intent === "account_balance_snapshot" || intent === "documents_forming_balance")) {
const heuristicAccount = extractAccountTokenHeuristic(text);
if (heuristicAccount) {
filters.account = heuristicAccount;
warnings.push("account_anchor_derived_from_heuristic_token");
}
}
const limitMatch = text.match(LIMIT_PATTERN);
if (limitMatch) {
const parsed = Number(limitMatch[1]);
@@ -613,14 +706,21 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const leadingCounterparty = extractLeadingCounterpartyTokenHeuristic(text);
if (leadingCounterparty) {
filters.counterparty = cleanupAnchorValue(leadingCounterparty);
warnings.push("counterparty_anchor_derived_from_leading_token");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
filters.contract = cleanupContractAnchorValue(String(contractMatch[1]));
}
if (!filters.contract && (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract")) {
const heuristicContract = extractContractTokenHeuristic(text);
if (heuristicContract) {
filters.contract = cleanupAnchorValue(heuristicContract);
filters.contract = cleanupContractAnchorValue(heuristicContract);
warnings.push("contract_anchor_derived_from_heuristic_token");
}
}
@@ -690,6 +790,13 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("as_of_date_defaulted_today");
}
}
if (filters.account &&
intent !== "account_balance_snapshot" &&
intent !== "documents_forming_balance" &&
!hasExplicitAccountCue(text)) {
delete filters.account;
warnings.push("account_anchor_dropped_without_explicit_cue_for_non_account_intent");
}
if (filters.counterparty && filters.counterparty.length < 2) {
warnings.push("counterparty_filter_too_short");
}
+20 -79
View File
@@ -367,11 +367,13 @@ function applyIntentSpecificFilter(intent, rows) {
}
if (intent === "list_documents_by_counterparty" || intent === "list_documents_by_contract") {
const documentPattern = /(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|payment|invoice|document|sale|purchase|bank)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
const matched = rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
return matched.length > 0 ? matched : rows;
}
if (intent === "documents_forming_balance") {
const documentPattern = /(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|списаниесрасчетногосчета|поступлениенарасчетныйсчет|invoice|document|sale|purchase)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
const matched = rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
return matched.length > 0 ? matched : rows;
}
return rows;
}
@@ -396,6 +398,12 @@ function isAnchorRecoveryIntent(intent) {
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts");
}
function isDocumentOrBankAnchorIntent(intent) {
return (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract");
}
function toIsoDatePrefix(value) {
if (!value) {
return null;
@@ -967,74 +975,6 @@ class AddressQueryService {
}
};
}
if (intent.intent === "open_items_by_counterparty_or_contract" &&
expandedFilteredRows.length === 0 &&
expandedRowsByAnchor.length === 0 &&
expandedNormalizedRows.length > 0) {
const expandedFallbackRows = applyIntentSpecificFilter(intent.intent, expandedNormalizedRows);
if (expandedFallbackRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(expandedMcp.raw_rows, expandedNormalizedRows.length, expandedNormalizedRows.length);
const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFallbackRows);
const expandedLimitations = [
...filters.warnings,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
const expandedReasons = [
...baseReasons,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
return {
handled: true,
reply_text: `Точный якорь не подтвердился в live-строках даже после расширения до ${expandedPlan.limit}; ` +
"показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
expandedFactual.text,
reply_type: (0, composeStage_1.inferReplyType)(expandedFactual.responseType),
response_type: expandedFactual.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: expandedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: expandedPlan.account_scope_mode,
account_scope_fallback_applied: expandedAccountScopeFallbackApplied,
anchor_type: expandedAnchor.anchor_type,
anchor_value_raw: expandedAnchor.anchor_value_raw,
anchor_value_resolved: expandedAnchor.anchor_value_resolved,
resolver_confidence: expandedAnchor.resolver_confidence,
ambiguity_count: expandedAnchor.ambiguity_count,
match_failure_stage: "materialized_but_not_anchor_matched",
match_failure_reason: expandedAnchorFilter.mismatchReason ?? "anchor_not_matched_after_query_limit_expansion",
mcp_call_status: "matched_non_empty",
rows_fetched: expandedMcp.fetched_rows,
raw_rows_received: expandedMcp.raw_rows.length,
rows_after_account_scope: expandedNormalizedRows.length,
rows_after_recipe_filter: expandedRowsByAnchor.length,
rows_materialized: expandedNormalizedRows.length,
rows_matched: expandedFallbackRows.length,
raw_row_keys_sample: expandedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: expandedRowDiagnostics.materializationDropReason,
account_token_raw: expandedAccountScopeAudit.accountTokenRaw,
account_token_normalized: expandedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: expandedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: expandedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: expandedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
limitations: expandedLimitations,
reasons: expandedReasons
}
};
}
}
}
}
}
@@ -1140,16 +1080,17 @@ class AddressQueryService {
}
}
if (filteredRows.length === 0 &&
intent.intent === "open_items_by_counterparty_or_contract" &&
normalizedRows.length > 0) {
const openItemsFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (openItemsFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, openItemsFallbackRows);
const fallbackLimitations = [...filters.warnings, "open_items_anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "open_items_anchor_not_matched_fallback_rows"];
isDocumentOrBankAnchorIntent(intent.intent) &&
normalizedRows.length > 0 &&
(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);
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text: "Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
reply_text: "Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
fallbackFactual.text,
reply_type: (0, composeStage_1.inferReplyType)(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
@@ -1179,7 +1120,7 @@ class AddressQueryService {
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: openItemsFallbackRows.length,
rows_matched: documentBankFallbackRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
@@ -354,13 +354,33 @@ function cleanupAnchorValue(value: string): string {
.trim();
}
function cleanupContractAnchorValue(value: string): string {
let normalized = cleanupAnchorValue(value);
if (!normalized) {
return normalized;
}
const yearQualifierTailPattern =
/\s+(?:(?:за|for)\s+)?(?:year|г(?:од|ода)?\.?)\s*(?:20\d{2}|\d{2})(?:\s+|$)[\s\S]*$/iu;
if (yearQualifierTailPattern.test(normalized)) {
normalized = normalized.replace(yearQualifierTailPattern, "").trim();
}
const trailingSeparatedYearPattern = /\s+(?:20\d{2}|\d{2})\s*(?:г(?:од|ода)?\.?|year)?$/iu;
if (trailingSeparatedYearPattern.test(normalized) && /[\/\\-]/.test(normalized)) {
normalized = normalized.replace(trailingSeparatedYearPattern, "").trim();
}
return normalized;
}
function hasAllTimeHint(text: string): boolean {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text: string): string | undefined {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
const match = String(text ?? "").match(/(?:^|\s)по\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
@@ -508,7 +528,7 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
function hasDocsOrBankSignal(text: string): boolean {
const lowered = String(text ?? "").toLowerCase();
return new RegExp(
`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`,
`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|списан|поступлен|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`,
"iu"
).test(
lowered
@@ -521,7 +541,7 @@ function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefi
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
@@ -578,7 +598,7 @@ function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefi
function extractImplicitCounterpartyValue(text: string): string | undefined {
const input = String(text ?? "");
const beforeDocsPattern = new RegExp(
`(?:^|\\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\\s+${DOC_SIGNAL_PATTERN}(?=[\\s,.;:!?)]|$)`,
`(?:^|\\s)([\\p{L}][\\p{L}\\p{N}._-]{1,})\\s+${DOC_SIGNAL_PATTERN}(?=[\\s,.;:!?)]|$)`,
"iu"
);
const beforeDocsMatch = input.match(beforeDocsPattern);
@@ -590,7 +610,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
}
const afterDocsPattern = new RegExp(
`${DOC_SIGNAL_PATTERN}\\s+(?:по\\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\\s,.;:!?)]|$)`,
`${DOC_SIGNAL_PATTERN}\\s+(?:по\\s+)?([\\p{L}][\\p{L}\\p{N}._-]{1,})(?=[\\s,.;:!?)]|$)`,
"iu"
);
const afterDocsMatch = input.match(afterDocsPattern);
@@ -604,6 +624,81 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
return undefined;
}
function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefined {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens.slice(0, 3)) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$|^на$|^in$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function hasExplicitAccountCue(text: string): boolean {
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
}
function extractAccountTokenHeuristic(text: string): string | undefined {
const source = String(text ?? "");
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
if (dotted) {
return String(dotted[1]).replace(",", ".");
}
const contextual = source.match(/(?:^|[\s,.;:!?()\-])(?:по|на|for|account)\s+(\d{2})(?!\d)/iu);
if (contextual) {
return String(contextual[1]);
}
return undefined;
}
function shiftDaysIso(baseIso: string, deltaDays: number): string {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
@@ -640,6 +735,13 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (accountMatch) {
filters.account = String(accountMatch[1]).replace(",", ".");
}
if (!filters.account && (intent === "account_balance_snapshot" || intent === "documents_forming_balance")) {
const heuristicAccount = extractAccountTokenHeuristic(text);
if (heuristicAccount) {
filters.account = heuristicAccount;
warnings.push("account_anchor_derived_from_heuristic_token");
}
}
const limitMatch = text.match(LIMIT_PATTERN);
if (limitMatch) {
@@ -674,15 +776,22 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const leadingCounterparty = extractLeadingCounterpartyTokenHeuristic(text);
if (leadingCounterparty) {
filters.counterparty = cleanupAnchorValue(leadingCounterparty);
warnings.push("counterparty_anchor_derived_from_leading_token");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
filters.contract = cleanupContractAnchorValue(String(contractMatch[1]));
}
if (!filters.contract && (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract")) {
const heuristicContract = extractContractTokenHeuristic(text);
if (heuristicContract) {
filters.contract = cleanupAnchorValue(heuristicContract);
filters.contract = cleanupContractAnchorValue(heuristicContract);
warnings.push("contract_anchor_derived_from_heuristic_token");
}
}
@@ -761,6 +870,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
if (
filters.account &&
intent !== "account_balance_snapshot" &&
intent !== "documents_forming_balance" &&
!hasExplicitAccountCue(text)
) {
delete filters.account;
warnings.push("account_anchor_dropped_without_explicit_cue_for_non_account_intent");
}
if (filters.counterparty && filters.counterparty.length < 2) {
warnings.push("counterparty_filter_too_short");
}
@@ -433,13 +433,15 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
if (intent === "list_documents_by_counterparty" || intent === "list_documents_by_contract") {
const documentPattern =
/(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|payment|invoice|document|sale|purchase|bank)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
const matched = rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
return matched.length > 0 ? matched : rows;
}
if (intent === "documents_forming_balance") {
const documentPattern =
/(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|списаниесрасчетногосчета|поступлениенарасчетныйсчет|invoice|document|sale|purchase)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
const matched = rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
return matched.length > 0 ? matched : rows;
}
return rows;
@@ -475,6 +477,15 @@ function isAnchorRecoveryIntent(intent: AddressIntent): boolean {
);
}
function isDocumentOrBankAnchorIntent(intent: AddressIntent): boolean {
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
);
}
function toIsoDatePrefix(value: string | null): string | null {
if (!value) {
return null;
@@ -1172,82 +1183,6 @@ export class AddressQueryService {
}
};
}
if (
intent.intent === "open_items_by_counterparty_or_contract" &&
expandedFilteredRows.length === 0 &&
expandedRowsByAnchor.length === 0 &&
expandedNormalizedRows.length > 0
) {
const expandedFallbackRows = applyIntentSpecificFilter(intent.intent, expandedNormalizedRows);
if (expandedFallbackRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(
expandedMcp.raw_rows,
expandedNormalizedRows.length,
expandedNormalizedRows.length
);
const expandedFactual = composeFactualReply(intent.intent, expandedFallbackRows);
const expandedLimitations = [
...filters.warnings,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
const expandedReasons = [
...baseReasons,
"query_limit_auto_expanded_for_anchor_recovery",
"open_items_anchor_not_matched_fallback_rows"
];
return {
handled: true,
reply_text:
`Точный якорь не подтвердился в live-строках даже после расширения до ${expandedPlan.limit}; ` +
"показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
expandedFactual.text,
reply_type: inferReplyType(expandedFactual.responseType),
response_type: expandedFactual.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: expandedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: expandedPlan.account_scope_mode,
account_scope_fallback_applied: expandedAccountScopeFallbackApplied,
anchor_type: expandedAnchor.anchor_type,
anchor_value_raw: expandedAnchor.anchor_value_raw,
anchor_value_resolved: expandedAnchor.anchor_value_resolved,
resolver_confidence: expandedAnchor.resolver_confidence,
ambiguity_count: expandedAnchor.ambiguity_count,
match_failure_stage: "materialized_but_not_anchor_matched",
match_failure_reason:
expandedAnchorFilter.mismatchReason ?? "anchor_not_matched_after_query_limit_expansion",
mcp_call_status: "matched_non_empty",
rows_fetched: expandedMcp.fetched_rows,
raw_rows_received: expandedMcp.raw_rows.length,
rows_after_account_scope: expandedNormalizedRows.length,
rows_after_recipe_filter: expandedRowsByAnchor.length,
rows_materialized: expandedNormalizedRows.length,
rows_matched: expandedFallbackRows.length,
raw_row_keys_sample: expandedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: expandedRowDiagnostics.materializationDropReason,
account_token_raw: expandedAccountScopeAudit.accountTokenRaw,
account_token_normalized: expandedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: expandedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: expandedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: expandedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
limitations: expandedLimitations,
reasons: expandedReasons
}
};
}
}
}
}
}
@@ -1362,18 +1297,19 @@ export class AddressQueryService {
if (
filteredRows.length === 0 &&
intent.intent === "open_items_by_counterparty_or_contract" &&
normalizedRows.length > 0
isDocumentOrBankAnchorIntent(intent.intent) &&
normalizedRows.length > 0 &&
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
) {
const openItemsFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (openItemsFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, openItemsFallbackRows);
const fallbackLimitations = [...filters.warnings, "open_items_anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "open_items_anchor_not_matched_fallback_rows"];
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows);
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text:
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные позиции по счетам 60/62/76.\n" +
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
fallbackFactual.text,
reply_type: inferReplyType(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
@@ -1403,7 +1339,7 @@ export class AddressQueryService {
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: openItemsFallbackRows.length,
rows_matched: documentBankFallbackRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
@@ -315,6 +315,37 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("as_of_date_derived_from_period_to");
});
it("extracts dotted account by heuristic for docs-forming phrasing without 'счет' keyword", () => {
const result = extractAddressFilters(
"раскрой остаток 60.01 по документам на конец июля 2020",
"documents_forming_balance"
);
expect(result.extracted_filters.account).toBe("60.01");
expect(result.extracted_filters.as_of_date).toBe("2020-07-31");
expect(result.warnings).toContain("account_anchor_derived_from_heuristic_token");
});
it("extracts dotted account by heuristic for short balance slang", () => {
const result = extractAddressFilters("скока по 60.02 на конец 2020-12", "account_balance_snapshot");
expect(result.extracted_filters.account).toBe("60.02");
expect(result.extracted_filters.as_of_date).toBe("2020-12-31");
expect(result.warnings).toContain("account_anchor_derived_from_heuristic_token");
});
it("drops accidental account for non-account intent without explicit account cue", () => {
const result = extractAddressFilters("покажи банк операции по свк за 2020", "bank_operations_by_counterparty");
expect(result.extracted_filters.account).toBeUndefined();
});
it("extracts leading counterparty token for short bank phrase", () => {
const result = extractAddressFilters("свк списания/поступления за 2020", "bank_operations_by_counterparty");
expect(result.extracted_filters.counterparty).toBe("свк");
expect(
result.warnings.includes("counterparty_anchor_derived_from_leading_token") ||
result.warnings.includes("counterparty_anchor_derived_from_free_text_heuristic")
).toBe(true);
});
it("treats 'за весь период' as all-time hint and does not force 90-day default", () => {
const result = extractAddressFilters(
"Покажи банковские операции по клиенту Бета за весь период",
@@ -467,6 +498,26 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("trims english year tail from contract anchor", () => {
const result = extractAddressFilters(
"docs by contract 15/24 year 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("trims trailing separated year from contract anchor", () => {
const result = extractAddressFilters(
"docs by contract 15/24 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("extracts multiline year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025\n год?",
@@ -514,6 +565,18 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.mcp_call_status).toBe("skipped");
});
it("does not return fallback factual rows for unmatched open-items contract anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи открытые позиции по договору 15/24");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.reply_type).toBe("partial_coverage");
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(result?.debug.rows_matched).toBe(0);
expect(["empty_match", "missing_anchor"]).toContain(result?.debug.limited_reason_category);
expect(String(result?.assistant_reply ?? "")).not.toContain("Собраны открытые позиции");
});
it("routes contract document list intent into address recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show documents by contract 15/24");