АДРЕСНЫЙ РЕЖИМ - стабилизация LLM fallback якорей и периода

This commit is contained in:
2026-04-02 00:30:38 +03:00
parent 2d95ce6332
commit 7dd6607ded
19 changed files with 2727 additions and 49 deletions
@@ -34,6 +34,7 @@ interface AddressTryHandleOptions {
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@@ -48,6 +49,35 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
"по",
"by"
]);
const LOW_QUALITY_PARTY_ANCHOR_TOKENS = new Set([
"что",
"чо",
"были",
"был",
"была",
"было",
"ли",
"какие",
"какой",
"покажи",
"показать",
"выведи",
"списания",
"списание",
"поступления",
"поступление",
"доки",
"документ",
"документы",
"документов",
"банковские",
"операции",
"платежи",
"платеж",
"платежи",
"плс",
"please"
]);
const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
@@ -152,6 +182,30 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
});
}
function isLikelyLowQualityPartyAnchor(value: string | null | undefined): boolean {
const normalized = normalizeSearchText(String(value ?? ""));
if (!normalized) {
return true;
}
const tokens = normalized.split(" ").filter(Boolean);
if (tokens.length === 0) {
return true;
}
const meaningfulTokens = tokens.filter((token) => {
if (token.length < 2) {
return false;
}
if (PARTY_ANCHOR_STOPWORDS.has(token) || LOW_QUALITY_PARTY_ANCHOR_TOKENS.has(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return true;
});
return meaningfulTokens.length === 0;
}
function normalizeAccountToken(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/(\d{2})(?:\.(\d{1,2}))?/);
@@ -370,13 +424,13 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
}
function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddressRow[]): NormalizedAddressRow[] {
if (intent === "bank_operations_by_counterparty") {
if (intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_contract") {
const bankDocPattern =
/(?:списаниесрасчетногосчета|поступлениенарасчетныйсчет|списание с расчетного счета|поступление на расчетный счет|bank|payment|wire|statement)/i;
return rows.filter((row) => bankDocPattern.test(row.registrator.toLowerCase()));
}
if (intent === "list_documents_by_counterparty") {
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);
@@ -402,7 +456,21 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
);
}
function isDocumentOrBankIntent(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 {
@@ -959,6 +1027,124 @@ export class AddressQueryService {
});
}
if (
filteredRows.length === 0 &&
isDocumentOrBankIntent(intent.intent) &&
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
) {
const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
: plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters: AddressFilterSet = {
...filters.extracted_filters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
};
const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters);
if (expandedSelection.selected_recipe && expandedSelection.missing_required_filters.length === 0) {
const expandedPlan = buildAddressRecipePlan(expandedSelection.selected_recipe, expandedLimitFilters);
if (expandedPlan.limit > currentLimit) {
const expandedMcp = await executeAddressMcpQuery({
query: expandedPlan.query,
limit: expandedPlan.limit
});
if (!expandedMcp.error) {
const expandedRawRows = toNormalizedRows(expandedMcp.raw_rows);
const expandedScopedRows = applyAccountScopeFilter(expandedRawRows, expandedPlan.account_scope);
const expandedAccountScopeFallbackApplied =
expandedPlan.account_scope_mode === "preferred" &&
expandedPlan.account_scope.length > 0 &&
expandedRawRows.length > 0 &&
expandedScopedRows.length === 0;
const expandedNormalizedRows = expandedAccountScopeFallbackApplied ? expandedRawRows : expandedScopedRows;
let expandedAnchor = resolvePrimaryAnchor(intent.intent, expandedLimitFilters);
expandedAnchor = refineAnchorFromRows(expandedAnchor, expandedNormalizedRows);
const expandedFiltersForMatching: AddressFilterSet =
expandedAnchor.anchor_type === "counterparty" && expandedAnchor.anchor_value_resolved
? { ...expandedLimitFilters, counterparty: expandedAnchor.anchor_value_resolved }
: expandedAnchor.anchor_type === "contract" && expandedAnchor.anchor_value_resolved
? { ...expandedLimitFilters, contract: expandedAnchor.anchor_value_resolved }
: expandedLimitFilters;
const expandedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: expandedFiltersForMatching,
accountScope: expandedPlan.account_scope,
rowsBeforeScope: expandedRawRows.length,
rowsAfterScope: expandedNormalizedRows.length
});
const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching);
const expandedRowsByAnchor = expandedAnchorFilter.rows;
const expandedFilteredRows = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor);
if (expandedFilteredRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(
expandedMcp.raw_rows,
expandedNormalizedRows.length,
expandedNormalizedRows.length
);
const expandedStageStatus = deriveMcpStageStatus({
rawRowsReceived: expandedMcp.raw_rows.length,
rowsMaterialized: expandedNormalizedRows.length,
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows);
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
return {
handled: true,
reply_text: `${expandedPrefix}\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: toLegacyMcpStatus(expandedStageStatus),
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: "none",
match_failure_reason: null,
mcp_call_status: expandedStageStatus,
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: expandedFilteredRows.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
}
};
}
}
}
}
}
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
@@ -1071,32 +1257,52 @@ export class AddressQueryService {
const isVisibilityGapCandidate =
hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
(intent.intent === "list_documents_by_counterparty" ||
intent.intent === "bank_operations_by_counterparty" ||
intent.intent === "list_documents_by_contract" ||
intent.intent === "bank_operations_by_contract");
const isAnchorMismatch = stageStatus === "materialized_but_not_anchor_matched";
const isRecipeFilteredOut = stageStatus === "materialized_but_filtered_out_by_recipe";
const isFollowupAnchorCarryover =
Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
const isLowQualityPartyAnchor =
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
const anchorMismatchCategory: AddressLimitedReasonCategory =
isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? "missing_anchor"
? anchorMismatchCategory
: isRecipeFilteredOut
? "recipe_visibility_gap"
: isVisibilityGapCandidate
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? "якорь контрагента/договора не найден в материализованных live-строках"
? anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в материализованных live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? "уточните контрагента точным именем или добавьте ИНН/договор"
? anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? ["anchor_not_matched_after_materialization"]
? [
anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
: [