АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests

This commit is contained in:
2026-04-02 23:11:49 +03:00
parent 53716e548e
commit 88094c09f8
243 changed files with 94296 additions and 181 deletions
@@ -35,6 +35,8 @@ 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 COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@@ -85,6 +87,26 @@ const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Контрагент
ИЗ
Справочник.Контрагенты КАК Контрагенты
`;
interface CounterpartyCatalogResolution {
tried: boolean;
resolvedValue: string | null;
confidence: "high" | "medium" | "low" | null;
ambiguityCount: number;
}
let counterpartyCatalogCache: { names: string[]; loadedAt: number } | null = null;
function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -165,6 +187,28 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set<string>([source]);
if (/^[а-яё]+$/iu.test(source) && source.length >= 4) {
const withoutEnding = source.replace(
/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой|е|у|ы|а|я|и|ю)$/iu,
""
);
if (withoutEnding.length >= 3) {
variants.add(withoutEnding);
}
const withoutTrailingVowel = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutTrailingVowel.length >= 3) {
variants.add(withoutTrailingVowel);
}
}
return Array.from(variants);
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
@@ -177,8 +221,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
});
});
}
@@ -260,6 +307,172 @@ function uniqueStrings(values: string[]): string[] {
);
}
function normalizeCounterpartyName(value: string): string {
return normalizeSearchText(String(value ?? ""))
.replace(/\s+/g, " ")
.trim();
}
function extractCounterpartyCatalogNames(rows: Array<Record<string, unknown>>): string[] {
return uniqueStrings(
rows
.map((row) => {
const direct =
valueAsString(row.Контрагент ?? row.counterparty ?? row.Counterparty).trim() ||
valueAsString(row.Регистратор ?? row.registrator ?? row.Registrator).trim();
return direct;
})
.map((value) => value.trim())
.filter((value) => value.length >= 2)
);
}
function scoreCounterpartyCandidate(name: string, anchor: string): number | null {
if (!matchesAnchorText(name, anchor)) {
return null;
}
const normalizedName = normalizeCounterpartyName(name);
const normalizedAnchor = normalizeCounterpartyName(anchor);
if (!normalizedName || !normalizedAnchor) {
return null;
}
let score = 0;
if (normalizedName === normalizedAnchor) {
score += 10_000;
} else if (normalizedName.includes(normalizedAnchor)) {
score += 5_000;
} else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
score += 2_000;
}
const anchorTokens = tokenizeAnchor(anchor);
for (const token of anchorTokens) {
const variants = anchorTokenVariants(token);
let tokenScore = 0;
for (const variant of variants) {
if (normalizedName.includes(variant)) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20);
}
}
if (tokenScore === 0) {
return null;
}
score += tokenScore;
}
const lengthPenalty = Math.abs(normalizedName.length - normalizedAnchor.length);
score -= lengthPenalty;
return score;
}
function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filters: AddressFilterSet): boolean {
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
if (!counterparty || isLikelyLowQualityPartyAnchor(counterparty)) {
return false;
}
return (
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties"
);
}
async function resolveCounterpartyViaCatalog(anchorRaw: string): Promise<CounterpartyCatalogResolution> {
const requested = String(anchorRaw ?? "").trim();
if (!requested || isLikelyLowQualityPartyAnchor(requested)) {
return {
tried: false,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const now = Date.now();
const cacheFresh =
counterpartyCatalogCache !== null && now - counterpartyCatalogCache.loadedAt <= COUNTERPARTY_CATALOG_CACHE_TTL_MS;
let names: string[] = cacheFresh ? [...counterpartyCatalogCache!.names] : [];
if (!cacheFresh) {
const mcp = await executeAddressMcpQuery({
query: COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(COUNTERPARTY_CATALOG_LOOKUP_LIMIT)),
limit: COUNTERPARTY_CATALOG_LOOKUP_LIMIT
});
if (!mcp.error) {
names = extractCounterpartyCatalogNames(mcp.raw_rows);
if (names.length > 0) {
counterpartyCatalogCache = {
names: [...names],
loadedAt: now
};
}
} else if (counterpartyCatalogCache && counterpartyCatalogCache.names.length > 0) {
names = [...counterpartyCatalogCache.names];
} else {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
}
if (names.length === 0) {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const scored = names
.map((name) => {
const score = scoreCounterpartyCandidate(name, requested);
return score === null ? null : { name, score };
})
.filter((item): item is { name: string; score: number } => Boolean(item))
.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name, "ru"));
if (scored.length === 0) {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const topScore = scored[0].score;
const topCandidates = scored.filter((item) => item.score === topScore);
const bestCandidate = topCandidates[0];
const normalizedRequested = normalizeCounterpartyName(requested);
const normalizedBest = normalizeCounterpartyName(bestCandidate.name);
const isExact = normalizedBest === normalizedRequested;
const isStrongContains = normalizedBest.includes(normalizedRequested);
if (topCandidates.length > 1 && !isExact && !isStrongContains) {
return {
tried: true,
resolvedValue: null,
confidence: "low",
ambiguityCount: topCandidates.length - 1
};
}
return {
tried: true,
resolvedValue: bestCandidate.name,
confidence: isExact ? "high" : isStrongContains ? "medium" : topCandidates.length === 1 ? "medium" : "low",
ambiguityCount: topCandidates.length - 1
};
}
function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
const fixedKeys = [
"СубконтоДт1",
@@ -466,10 +679,15 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
);
}
function invertSort(sort: AddressFilterSet["sort"]): AddressFilterSet["sort"] {
return sort === "period_asc" ? "period_desc" : "period_asc";
}
function isAnchorRecoveryIntent(intent: AddressIntent): boolean {
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract" ||
intent === "open_items_by_counterparty_or_contract" ||
@@ -923,6 +1141,45 @@ export class AddressQueryService {
});
}
const rawCounterpartyAnchor =
typeof filters.extracted_filters.counterparty === "string" ? filters.extracted_filters.counterparty.trim() : "";
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
if (catalogResolution.resolvedValue) {
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
}
} else if (catalogResolution.tried) {
filters.warnings.push(
catalogResolution.ambiguityCount > 0
? "counterparty_anchor_catalog_lookup_ambiguous"
: "counterparty_anchor_catalog_lookup_no_match"
);
}
anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
if (anchor.anchor_type === "counterparty") {
anchor = {
...anchor,
anchor_value_raw: rawCounterpartyAnchor || anchor.anchor_value_raw
};
if (catalogResolution.resolvedValue) {
anchor = {
...anchor,
anchor_value_resolved: catalogResolution.resolvedValue,
resolver_confidence: catalogResolution.confidence ?? anchor.resolver_confidence,
ambiguity_count: Math.max(anchor.ambiguity_count, catalogResolution.ambiguityCount)
};
} else if (catalogResolution.ambiguityCount > 0) {
anchor = {
...anchor,
resolver_confidence: "low",
ambiguity_count: Math.max(anchor.ambiguity_count, catalogResolution.ambiguityCount)
};
}
}
}
const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await executeAddressMcpQuery({
query: plan.query,
@@ -1013,7 +1270,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows);
const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@@ -1132,7 +1389,7 @@ export class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows);
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
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"];
@@ -1241,7 +1498,7 @@ export class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@@ -1295,15 +1552,133 @@ export class AddressQueryService {
}
}
if (
filteredRows.length === 0 &&
isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
) {
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;
const historicalFilters: AddressFilterSet = {
...filters.extracted_filters,
sort: invertSort(filters.extracted_filters.sort),
limit: Math.max(currentLimit, ADDRESS_ANCHOR_RECOVERY_LIMIT)
};
const historicalSelection = selectAddressRecipe(intent.intent, historicalFilters);
if (historicalSelection.selected_recipe && historicalSelection.missing_required_filters.length === 0) {
const historicalPlan = buildAddressRecipePlan(historicalSelection.selected_recipe, historicalFilters);
const historicalMcp = await executeAddressMcpQuery({
query: historicalPlan.query,
limit: historicalPlan.limit
});
if (!historicalMcp.error) {
const historicalRawRows = toNormalizedRows(historicalMcp.raw_rows);
const historicalScopedRows = applyAccountScopeFilter(historicalRawRows, historicalPlan.account_scope);
const historicalAccountScopeFallbackApplied =
historicalPlan.account_scope_mode === "preferred" &&
historicalPlan.account_scope.length > 0 &&
historicalRawRows.length > 0 &&
historicalScopedRows.length === 0;
const historicalNormalizedRows = historicalAccountScopeFallbackApplied ? historicalRawRows : historicalScopedRows;
let historicalAnchor = resolvePrimaryAnchor(intent.intent, historicalFilters);
historicalAnchor = refineAnchorFromRows(historicalAnchor, historicalNormalizedRows);
const historicalFiltersForMatching: AddressFilterSet =
historicalAnchor.anchor_type === "counterparty" && historicalAnchor.anchor_value_resolved
? { ...historicalFilters, counterparty: historicalAnchor.anchor_value_resolved }
: historicalAnchor.anchor_type === "contract" && historicalAnchor.anchor_value_resolved
? { ...historicalFilters, contract: historicalAnchor.anchor_value_resolved }
: historicalFilters;
const historicalAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: historicalFiltersForMatching,
accountScope: historicalPlan.account_scope,
rowsBeforeScope: historicalRawRows.length,
rowsAfterScope: historicalNormalizedRows.length
});
const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching);
const historicalRowsByAnchor = historicalAnchorFilter.rows;
const historicalFilteredRows = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor);
if (historicalFilteredRows.length > 0) {
const historicalRowDiagnostics = deriveRowStageDiagnostics(
historicalMcp.raw_rows,
historicalNormalizedRows.length,
historicalNormalizedRows.length
);
const historicalStageStatus = deriveMcpStageStatus({
rawRowsReceived: historicalMcp.raw_rows.length,
rowsMaterialized: historicalNormalizedRows.length,
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
const historicalPrefix =
"В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
reply_type: inferReplyType(historicalFactual.responseType),
response_type: historicalFactual.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: historicalSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(historicalStageStatus),
account_scope_mode: historicalPlan.account_scope_mode,
account_scope_fallback_applied: historicalAccountScopeFallbackApplied,
anchor_type: historicalAnchor.anchor_type,
anchor_value_raw: historicalAnchor.anchor_value_raw,
anchor_value_resolved: historicalAnchor.anchor_value_resolved,
resolver_confidence: historicalAnchor.resolver_confidence,
ambiguity_count: historicalAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: historicalStageStatus,
rows_fetched: historicalMcp.fetched_rows,
raw_rows_received: historicalMcp.raw_rows.length,
rows_after_account_scope: historicalNormalizedRows.length,
rows_after_recipe_filter: historicalRowsByAnchor.length,
rows_materialized: historicalNormalizedRows.length,
rows_matched: historicalFilteredRows.length,
raw_row_keys_sample: historicalRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: historicalRowDiagnostics.materializationDropReason,
account_token_raw: historicalAccountScopeAudit.accountTokenRaw,
account_token_normalized: historicalAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: historicalAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: historicalAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: historicalAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
limitations: historicalLimitations,
reasons: historicalReasons
}
};
}
}
}
}
if (
filteredRows.length === 0 &&
isDocumentOrBankAnchorIntent(intent.intent) &&
normalizedRows.length > 0 &&
filterByAnchors.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 = composeFactualReply(intent.intent, documentBankFallbackRows);
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
@@ -1446,7 +1821,7 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
return {
handled: true,
reply_text: factual.text,