АДРЕСНЫЙ РЕЖИМ - стабилизация LLM fallback якорей и периода
This commit is contained in:
@@ -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"]
|
||||
: [
|
||||
|
||||
Reference in New Issue
Block a user