NODEDC_1C/llm_normalizer/backend/dist/services/addressQueryService.js

1045 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressQueryService = void 0;
const config_1 = require("../config");
const addressQueryClassifier_1 = require("./addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("./addressQueryShapeClassifier");
const addressIntentResolver_1 = require("./addressIntentResolver");
const addressFilterExtractor_1 = require("./addressFilterExtractor");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const addressMcpClient_1 = require("./addressMcpClient");
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
const ACCOUNT_ALIAS_MAP = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
"60": ["поставщик", "поставщиками", "подрядчиками", "расчеты с поставщиками"],
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
function parseFiniteNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value.replace(",", ".").trim());
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function valueAsString(value) {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function transliterateCyrillicToLatin(value) {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya"
};
let out = "";
for (const char of String(value ?? "").toLowerCase()) {
out += map[char] ?? char;
}
return out;
}
function normalizeSearchText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value) {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
if (!direct) {
return false;
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
}
function normalizeAccountToken(value) {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/(\d{2})(?:\.(\d{1,2}))?/);
if (!match) {
return source.toLowerCase();
}
const base = match[1];
if (!match[2]) {
return base;
}
const sub = String(Number(match[2]));
return `${base}.${sub}`;
}
function extractAccountTokens(searchable) {
const result = [];
const matcher = /\b(\d{2})(?:[.,](\d{1,2}))?\b/g;
let hit = null;
while ((hit = matcher.exec(searchable)) !== null) {
const base = hit[1];
const sub = hit[2] ? String(Number(hit[2])) : null;
result.push(sub ? `${base}.${sub}` : base);
}
return uniqueStrings(result);
}
function accountTokenMatches(requestedToken, candidateToken) {
const requested = normalizeAccountToken(requestedToken);
const candidate = normalizeAccountToken(candidateToken);
if (requested === candidate) {
return true;
}
if (!requested.includes(".")) {
return candidate.startsWith(`${requested}.`) || candidate === requested;
}
return false;
}
function baseAccountCode(value) {
const normalized = normalizeAccountToken(value);
const match = normalized.match(/^(\d{2})/);
return match ? match[1] : null;
}
function uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function collectAnalyticsStrings(row) {
const fixedKeys = [
"СубконтоДт1",
"СубконтоДт2",
"СубконтоДт3",
"СубконтоКт1",
"СубконтоКт2",
"СубконтоКт3",
"SubcontoDt1",
"SubcontoDt2",
"SubcontoDt3",
"SubcontoKt1",
"SubcontoKt2",
"SubcontoKt3",
"subconto_dt1",
"subconto_dt2",
"subconto_dt3",
"subconto_kt1",
"subconto_kt2",
"subconto_kt3",
"Counterparty",
"Контрагент",
"Contract",
"Договор"
];
const collected = [];
for (const key of fixedKeys) {
const value = valueAsString(row[key]).trim();
if (value) {
collected.push(value);
}
}
for (const [key, rawValue] of Object.entries(row)) {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("subconto") || lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор")) {
const value = valueAsString(rawValue).trim();
if (value) {
collected.push(value);
}
}
}
return uniqueStrings(collected);
}
function toNormalizedRows(rows) {
return rows
.map((row) => {
const period = valueAsString(row.Период ?? row.period ?? row.Period).trim() || null;
const registrator = valueAsString(row.Регистратор ?? row.registrator ?? row.Registrator).trim() ||
valueAsString(row.document ?? row.Recorder).trim() ||
"(без названия)";
const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null;
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const analytics = collectAnalyticsStrings(row);
return {
period,
registrator,
account_dt: accountDt,
account_kt: accountKt,
amount,
analytics
};
})
.filter((item) => Boolean(item.period || item.registrator));
}
function rowSearchableText(row) {
return [row.registrator, row.account_dt ?? "", row.account_kt ?? "", ...row.analytics].join(" ").toLowerCase();
}
function rowMatchesAnyAccount(row, accountScope) {
if (accountScope.length === 0) {
return true;
}
const searchable = [row.account_dt ?? "", row.account_kt ?? "", row.registrator, ...row.analytics].join(" ");
const extractedTokens = extractAccountTokens(searchable);
const normalizedSearch = normalizeSearchText(searchable);
const translitSearch = transliterateCyrillicToLatin(normalizedSearch);
return accountScope.some((account) => {
const normalizedRequested = normalizeAccountToken(String(account ?? "").trim());
if (!normalizedRequested) {
return false;
}
if (extractedTokens.some((candidate) => accountTokenMatches(normalizedRequested, candidate))) {
return true;
}
const base = baseAccountCode(normalizedRequested);
if (!base) {
return false;
}
const aliases = ACCOUNT_ALIAS_MAP[base] ?? [];
return aliases.some((alias) => {
const normalizedAlias = normalizeSearchText(alias);
const aliasLatin = transliterateCyrillicToLatin(normalizedAlias);
return normalizedSearch.includes(normalizedAlias) || translitSearch.includes(aliasLatin);
});
});
}
function applyAccountScopeFilter(rows, accountScope) {
if (accountScope.length === 0) {
return rows;
}
return rows.filter((row) => rowMatchesAnyAccount(row, accountScope));
}
function applyAddressFilters(rows, filters) {
let filtered = [...rows];
let mismatchReason = null;
if (filters.account && String(filters.account).trim()) {
const scopedAccount = String(filters.account).trim();
const before = filtered.length;
filtered = filtered.filter((row) => rowMatchesAnyAccount(row, [scopedAccount]));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "account_anchor_not_matched_in_materialized_rows";
}
}
if (filters.counterparty && String(filters.counterparty).trim()) {
const needle = String(filters.counterparty);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "counterparty_anchor_not_matched_in_materialized_rows";
}
}
if (filters.contract && String(filters.contract).trim()) {
const needle = String(filters.contract);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "contract_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = String(filters.document_ref);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "document_ref_anchor_not_matched_in_materialized_rows";
}
}
return {
rows: filtered,
mismatchReason
};
}
function applyIntentSpecificFilter(intent, rows) {
if (intent === "bank_operations_by_counterparty") {
const bankDocPattern = /(?:списаниесрасчетногосчета|поступлениенарасчетныйсчет|списание с расчетного счета|поступление на расчетный счет|bank|payment|wire|statement)/i;
return rows.filter((row) => bankDocPattern.test(row.registrator.toLowerCase()));
}
if (intent === "list_documents_by_counterparty") {
const documentPattern = /(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|payment|invoice|document|sale|purchase|bank)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
}
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);
}
return rows;
}
function formatTopRows(rows, limit = 6) {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
}
function inferReplyType(responseType) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}
function runtimeReadinessForLimitedCategory(category) {
if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS";
}
if (category === "recipe_visibility_gap") {
return "REQUIRES_SPECIALIZED_RECIPE";
}
if (category === "unsupported") {
return "DEEP_ONLY";
}
return "UNKNOWN";
}
function rowHasNonEmptyField(row, keys) {
return keys.some((key) => String(row[key] ?? "").trim().length > 0);
}
function deriveRowStageDiagnostics(rawRows, rowsAfterAccountScope, rowsMaterialized) {
if (rawRows.length === 0 || rowsMaterialized > 0) {
return {
rawRowKeysSample: rawRows.length > 0 ? Object.keys(rawRows[0] ?? {}).slice(0, 20) : [],
materializationDropReason: "none"
};
}
if (rawRows.length > 0 && rowsAfterAccountScope === 0) {
return {
rawRowKeysSample: Object.keys(rawRows[0] ?? {}).slice(0, 20),
materializationDropReason: "dropped_by_account_scope_filter"
};
}
const rawRowKeysSample = Object.keys(rawRows[0] ?? {}).slice(0, 20);
const hasPeriodField = rawRows.some((row) => rowHasNonEmptyField(row, ["Период", "period", "Period"]));
const hasRegistratorField = rawRows.some((row) => rowHasNonEmptyField(row, ["Регистратор", "registrator", "Registrator", "document", "Recorder"]));
if (!hasPeriodField && !hasRegistratorField) {
return { rawRowKeysSample, materializationDropReason: "missing_period_and_registrator_fields" };
}
if (!hasPeriodField) {
return { rawRowKeysSample, materializationDropReason: "missing_period_field" };
}
if (!hasRegistratorField) {
return { rawRowKeysSample, materializationDropReason: "missing_registrator_field" };
}
return { rawRowKeysSample, materializationDropReason: "unknown_row_shape" };
}
function isAccountIntent(intent) {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function buildDefaultAccountScopeAudit(filters) {
const tokenRaw = typeof filters.account === "string" && filters.account.trim().length > 0 ? filters.account.trim() : null;
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenRaw ? normalizeAccountToken(tokenRaw) : null,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
function buildAccountScopeAudit(input) {
const tokenRaw = typeof input.filters.account === "string" && input.filters.account.trim().length > 0 ? input.filters.account.trim() : null;
const tokenNormalized = tokenRaw ? normalizeAccountToken(tokenRaw) : null;
if (!isAccountIntent(input.intent)) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
if (input.accountScope.length === 0) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "no_account_scope_requested"
};
}
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: input.rowsBeforeScope > 0 && input.rowsAfterScope === 0 ? "no_rows_after_scope_filter" : "rows_remaining_after_scope_filter"
};
}
function deriveMcpStageStatus(input) {
if (input.skipped) {
return "skipped";
}
if (input.errored) {
return "error";
}
if (input.rawRowsReceived === 0) {
return "no_raw_rows";
}
if (input.rowsMaterialized === 0) {
return "raw_rows_received_but_not_materialized";
}
if (input.rowsAnchorMatched === 0) {
return "materialized_but_not_anchor_matched";
}
if (input.rowsMatched === 0) {
return "materialized_but_filtered_out_by_recipe";
}
return "matched_non_empty";
}
function toLegacyMcpStatus(status) {
if (status === "materialized_but_not_anchor_matched" || status === "materialized_but_filtered_out_by_recipe") {
return "materialized_but_not_matched";
}
return status;
}
function resolvePrimaryAnchor(intent, filters) {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
ambiguity_count: 0
};
}
function refineAnchorFromRows(anchor, rows) {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
function composeLimitedReply(category, reason, nextStep) {
const heading = category === "empty_match"
? "В live-данных по текущему фильтру записи не найдены."
: category === "missing_anchor"
? "Для точного адресного поиска не хватает обязательного якоря."
: category === "recipe_visibility_gap"
? "Текущий live recipe не дает нужную видимость данных для этого сценария."
: category === "unsupported"
? "Этот запрос не подходит под address_query V1."
: "Не удалось выполнить адресный live-запрос в V1.";
const lines = [
heading,
`Причина: ${reason}.`
];
if (nextStep) {
lines.push(`Что нужно уточнить: ${nextStep}.`);
}
return lines.join("\n");
}
function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
detected_mode: input.mode.mode,
detected_mode_confidence: input.mode.confidence,
query_shape: input.shape.shape,
query_shape_confidence: input.shape.confidence,
detected_intent: input.intent.intent,
detected_intent_confidence: input.intent.confidence,
extracted_filters: input.filters,
missing_required_filters: input.missingRequiredFilters,
selected_recipe: input.selectedRecipe,
mcp_call_status_legacy: toLegacyMcpStatus(input.mcpCallStatus),
account_scope_mode: input.accountScopeMode ?? "strict",
account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false,
anchor_type: input.anchor?.anchor_type ?? null,
anchor_value_raw: input.anchor?.anchor_value_raw ?? null,
anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null,
resolver_confidence: input.anchor?.resolver_confidence ?? null,
ambiguity_count: input.anchor?.ambiguity_count ?? 0,
match_failure_stage: input.matchFailureStage ?? "none",
match_failure_reason: input.matchFailureReason ?? null,
mcp_call_status: input.mcpCallStatus,
rows_fetched: input.rowsFetched,
raw_rows_received: input.rawRowsReceived ?? input.rowsFetched,
rows_after_account_scope: input.rowsAfterAccountScope ?? 0,
rows_after_recipe_filter: input.rowsAfterRecipeFilter ?? 0,
rows_materialized: input.rowsMaterialized ?? 0,
rows_matched: input.rowsMatched,
raw_row_keys_sample: input.rawRowKeysSample ?? [],
materialization_drop_reason: input.materializationDropReason ?? "none",
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
limitations: input.limitations,
reasons: input.reasons
}
};
}
function contractCandidatesFromRows(rows) {
const candidates = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows) {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title = intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
class AddressQueryService {
async tryHandle(userMessage) {
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: filters.missing_required_filters,
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "unsupported",
reasonText: "intent пока не поддержан в address V1",
nextStep: "переформулируйте вопрос как адресный lookup по счету/контрагенту/договору",
limitations: ["intent_not_supported_in_v1"],
reasons: baseReasons
});
}
if (intent.intent === "open_items_by_counterparty_or_contract" &&
!filters.extracted_filters.counterparty &&
!filters.extracted_filters.contract) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: ["counterparty_or_contract"],
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "missing_anchor",
reasonText: "для open_items нужен якорь контрагента или договора",
nextStep: "укажите контрагента или номер/название договора",
limitations: ["open_items_requires_counterparty_or_contract_filter"],
reasons: baseReasons
});
}
if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: recipeSelection.missing_required_filters,
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "recipe_visibility_gap",
reasonText: "для intent пока нет recipe в address V1",
nextStep: "выберите поддерживаемый P0 intent или переключите запрос в deep-analysis",
limitations: ["recipe_not_available"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
});
}
if (recipeSelection.missing_required_filters.length > 0) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: recipeSelection.missing_required_filters,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "missing_anchor",
reasonText: "не хватает обязательных фильтров",
nextStep: `уточните: ${recipeSelection.missing_required_filters.join(", ")}`,
limitations: ["missing_required_filters"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
});
}
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "execution_error",
reasonText: "live address lane выключен feature-флагом",
nextStep: "включите FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1",
limitations: ["address_live_lane_disabled"],
reasons: baseReasons
});
}
const plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query,
limit: plan.limit
});
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
anchor,
mcpCallStatus: deriveMcpStageStatus({
errored: true,
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: 0,
rowsAnchorMatched: 0,
rowsMatched: 0
}),
accountScopeAudit: errorScopeAudit,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: mcp.rows.length,
rowsAfterRecipeFilter: 0,
rowsMaterialized: 0,
rowsMatched: mcp.matched_rows,
rawRowKeysSample: [],
materializationDropReason: "none",
category: "execution_error",
reasonText: "live MCP вызов завершился ошибкой",
nextStep: mcp.error,
limitations: ["mcp_call_failed"],
reasons: [...baseReasons, mcp.error]
});
}
const normalizedRawRows = toNormalizedRows(mcp.raw_rows);
const scopedRows = applyAccountScopeFilter(normalizedRawRows, plan.account_scope);
const accountScopeFallbackApplied = plan.account_scope_mode === "preferred" &&
plan.account_scope.length > 0 &&
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters;
const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: filtersForMatching,
accountScope: plan.account_scope,
rowsBeforeScope: normalizedRawRows.length,
rowsAfterScope: normalizedRows.length
});
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows;
const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors);
const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
const stageStatus = deriveMcpStageStatus({
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: normalizedRows.length,
rowsAnchorMatched: filterByAnchors.length,
rowsMatched: filteredRows.length
});
const matchFailureStage = stageStatus === "materialized_but_not_anchor_matched"
? "materialized_but_not_anchor_matched"
: stageStatus === "materialized_but_filtered_out_by_recipe"
? "materialized_but_filtered_out_by_recipe"
: "none";
const matchFailureReason = matchFailureStage === "materialized_but_not_anchor_matched"
? anchorFilter.mismatchReason ?? "anchor_not_matched_after_materialization"
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null;
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: filteredRows.length,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
reasonText: "в live строках нет договорных якорей для уверенного списка незакрытых договоров",
nextStep: "сузьте запрос по контрагенту или добавьте номер договора",
limitations: ["no_contract_anchors_in_live_rows"],
reasons: baseReasons
});
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate = hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
const isAnchorMismatch = stageStatus === "materialized_but_not_anchor_matched";
const isRecipeFilteredOut = stageStatus === "materialized_but_filtered_out_by_recipe";
const category = isAnchorMismatch
? "missing_anchor"
: isRecipeFilteredOut
? "recipe_visibility_gap"
: isVisibilityGapCandidate
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? "якорь контрагента/договора не найден в материализованных live-строках"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? "уточните контрагента точным именем или добавьте ИНН/договор"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? ["anchor_not_matched_after_materialization"]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
: [
isVisibilityGapCandidate
? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter"
];
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: 0,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category,
reasonText,
nextStep,
limitations,
reasons: baseReasons
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
return {
handled: true,
reply_text: factual.text,
reply_type: inferReplyType(factual.responseType),
response_type: factual.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: recipeSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: filteredRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
limitations: filters.warnings,
reasons: baseReasons
}
};
}
}
exports.AddressQueryService = AddressQueryService;