1045 lines
46 KiB
JavaScript
1045 lines
46 KiB
JavaScript
"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;
|