АДРЕСНЫЙ РЕЖИМ -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
@@ -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,