АДРЕСНЫЙ РЕЖИМ - M2.3c тюнинг резолвинга и фильтров адресных запросов, поэтапная диагностика и аудит фильтрации по счету
This commit is contained in:
+26
-10
@@ -38,21 +38,37 @@ function parseRowsFromTextTable(source) {
|
||||
return [];
|
||||
}
|
||||
const rows = [];
|
||||
const parseCsvLine = (line) => {
|
||||
const values = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[index + 1] === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
values.push(current.trim());
|
||||
return values;
|
||||
};
|
||||
const lines = body
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const values = [];
|
||||
const matcher = /"([^"]*)"|([^,]+)/g;
|
||||
let match = null;
|
||||
while ((match = matcher.exec(line)) !== null) {
|
||||
const raw = match[1] !== undefined ? match[1] : match[2];
|
||||
const value = String(raw ?? "").trim();
|
||||
if (value.length > 0) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
const values = parseCsvLine(line);
|
||||
if (values.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+353
-31
@@ -8,6 +8,29 @@ 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;
|
||||
@@ -26,11 +49,117 @@ function valueAsString(value) {
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
function normalizeToken(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
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 escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
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
|
||||
@@ -110,13 +239,27 @@ function rowMatchesAnyAccount(row, accountScope) {
|
||||
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 normalized = String(account ?? "").trim();
|
||||
if (!normalized) {
|
||||
const normalizedRequested = normalizeAccountToken(String(account ?? "").trim());
|
||||
if (!normalizedRequested) {
|
||||
return false;
|
||||
}
|
||||
const matcher = new RegExp(`\\b${escapeRegExp(normalized)}(?:\\.\\d{1,2})?\\b`, "i");
|
||||
return matcher.test(searchable);
|
||||
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) {
|
||||
@@ -127,23 +270,43 @@ function applyAccountScopeFilter(rows, 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 = normalizeToken(String(filters.counterparty));
|
||||
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
|
||||
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 = normalizeToken(String(filters.contract));
|
||||
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
|
||||
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 = normalizeToken(String(filters.document_ref));
|
||||
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
|
||||
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 filtered;
|
||||
return {
|
||||
rows: filtered,
|
||||
mismatchReason
|
||||
};
|
||||
}
|
||||
function applyIntentSpecificFilter(intent, rows) {
|
||||
if (intent === "bank_operations_by_counterparty") {
|
||||
@@ -217,6 +380,48 @@ function deriveRowStageDiagnostics(rawRows, rowsAfterAccountScope, rowsMateriali
|
||||
}
|
||||
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";
|
||||
@@ -230,11 +435,20 @@ function deriveMcpStageStatus(input) {
|
||||
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_not_matched";
|
||||
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() : "";
|
||||
@@ -286,6 +500,39 @@ function resolvePrimaryAnchor(intent, filters) {
|
||||
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-данных по текущему фильтру записи не найдены."
|
||||
@@ -306,6 +553,7 @@ function composeLimitedReply(category, reason, 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),
|
||||
@@ -321,6 +569,7 @@ function buildLimitedExecutionResult(input) {
|
||||
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,
|
||||
@@ -328,6 +577,8 @@ function buildLimitedExecutionResult(input) {
|
||||
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,
|
||||
@@ -337,6 +588,11 @@ function buildLimitedExecutionResult(input) {
|
||||
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",
|
||||
@@ -460,7 +716,7 @@ class AddressQueryService {
|
||||
}
|
||||
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
|
||||
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
|
||||
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
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") {
|
||||
@@ -566,6 +822,7 @@ class AddressQueryService {
|
||||
limit: plan.limit
|
||||
});
|
||||
if (mcp.error) {
|
||||
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
shape,
|
||||
@@ -579,8 +836,10 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -603,15 +862,40 @@ class AddressQueryService {
|
||||
normalizedRawRows.length > 0 &&
|
||||
scopedRows.length === 0;
|
||||
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
|
||||
const filterByAnchors = applyAddressFilters(normalizedRows, filters.extracted_filters);
|
||||
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
|
||||
});
|
||||
if (intent.intent === "list_open_contracts" && contractCandidatesFromRows(filteredRows).length === 0) {
|
||||
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,
|
||||
@@ -621,7 +905,10 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -644,6 +931,38 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -653,7 +972,10 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -663,18 +985,10 @@ class AddressQueryService {
|
||||
rowsMatched: 0,
|
||||
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
|
||||
materializationDropReason: rowDiagnostics.materializationDropReason,
|
||||
category: isVisibilityGapCandidate ? "recipe_visibility_gap" : "empty_match",
|
||||
reasonText: isVisibilityGapCandidate
|
||||
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
|
||||
: "по выбранным фильтрам в live-выборке нет строк",
|
||||
nextStep: isVisibilityGapCandidate
|
||||
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
|
||||
: "уточните период, контрагента, договор или снимите часть фильтров",
|
||||
limitations: [
|
||||
isVisibilityGapCandidate
|
||||
? "document_or_bank_visibility_gap_after_base_filter"
|
||||
: "no_rows_after_recipe_and_scope_filter"
|
||||
],
|
||||
category,
|
||||
reasonText,
|
||||
nextStep,
|
||||
limitations,
|
||||
reasons: baseReasons
|
||||
});
|
||||
}
|
||||
@@ -694,6 +1008,7 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -701,6 +1016,8 @@ class AddressQueryService {
|
||||
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,
|
||||
@@ -710,6 +1027,11 @@ class AddressQueryService {
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,31 @@ __WHERE_CLAUSE__
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Движения.Период УБЫВ
|
||||
`;
|
||||
const BANK_DOCS_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
БанкСписание.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(БанкСписание.Ссылка) КАК Регистратор,
|
||||
"" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
БанкСписание.СуммаДокумента КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
|
||||
ИЗ
|
||||
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
|
||||
__WHERE_OUT__
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
БанкПоступление.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Ссылка) КАК Регистратор,
|
||||
"" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
БанкПоступление.СуммаДокумента КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
|
||||
ИЗ
|
||||
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
|
||||
__WHERE_IN__
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Период УБЫВ
|
||||
`;
|
||||
const BASE_RECIPES = [
|
||||
{
|
||||
recipe_id: "address_movements_payables_v1",
|
||||
@@ -64,7 +89,8 @@ const BASE_RECIPES = [
|
||||
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
|
||||
default_limit: 100,
|
||||
account_scope: ["60", "62", "76", "51", "52"],
|
||||
account_scope_mode: "preferred"
|
||||
account_scope_mode: "preferred",
|
||||
query_template: "bank_docs"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_bank_operations_by_counterparty_v1",
|
||||
@@ -74,7 +100,8 @@ const BASE_RECIPES = [
|
||||
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
|
||||
default_limit: 100,
|
||||
account_scope: ["51", "52"],
|
||||
account_scope_mode: "preferred"
|
||||
account_scope_mode: "preferred",
|
||||
query_template: "bank_docs"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_documents_forming_balance_v1",
|
||||
@@ -111,7 +138,7 @@ function toDateTimeExpr(isoDate, endOfDay) {
|
||||
const second = endOfDay ? 59 : 0;
|
||||
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
|
||||
}
|
||||
function buildWhereClause(filters) {
|
||||
function buildWhereClause(filters, fieldPath) {
|
||||
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_from, false)
|
||||
: null;
|
||||
@@ -122,16 +149,16 @@ function buildWhereClause(filters) {
|
||||
? toDateTimeExpr(filters.as_of_date, true)
|
||||
: null;
|
||||
if (periodFromExpr && periodToExpr) {
|
||||
return `ГДЕ\n Движения.Период МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
|
||||
return `ГДЕ\n ${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
|
||||
}
|
||||
if (periodFromExpr) {
|
||||
return `ГДЕ\n Движения.Период >= ${periodFromExpr}`;
|
||||
return `ГДЕ\n ${fieldPath} >= ${periodFromExpr}`;
|
||||
}
|
||||
if (periodToExpr) {
|
||||
return `ГДЕ\n Движения.Период <= ${periodToExpr}`;
|
||||
return `ГДЕ\n ${fieldPath} <= ${periodToExpr}`;
|
||||
}
|
||||
if (asOfExpr) {
|
||||
return `ГДЕ\n Движения.Период <= ${asOfExpr}`;
|
||||
return `ГДЕ\n ${fieldPath} <= ${asOfExpr}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -164,8 +191,12 @@ function buildAddressRecipePlan(recipe, filters) {
|
||||
? [...recipe.account_scope]
|
||||
: [];
|
||||
const accountScopeMode = recipe.account_scope_mode ?? "strict";
|
||||
const whereClause = buildWhereClause(filters);
|
||||
const query = MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", whereClause);
|
||||
const query = recipe.query_template === "bank_docs"
|
||||
? BANK_DOCS_QUERY_TEMPLATE
|
||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
|
||||
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
|
||||
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период"));
|
||||
return {
|
||||
recipe,
|
||||
query,
|
||||
|
||||
@@ -1763,6 +1763,7 @@ function buildAddressDebugPayload(addressDebug) {
|
||||
extracted_filters: addressDebug.extracted_filters,
|
||||
missing_required_filters: addressDebug.missing_required_filters,
|
||||
selected_recipe: addressDebug.selected_recipe,
|
||||
mcp_call_status_legacy: addressDebug.mcp_call_status_legacy,
|
||||
account_scope_mode: addressDebug.account_scope_mode,
|
||||
account_scope_fallback_applied: addressDebug.account_scope_fallback_applied,
|
||||
anchor_type: addressDebug.anchor_type,
|
||||
@@ -1770,6 +1771,8 @@ function buildAddressDebugPayload(addressDebug) {
|
||||
anchor_value_resolved: addressDebug.anchor_value_resolved,
|
||||
resolver_confidence: addressDebug.resolver_confidence,
|
||||
ambiguity_count: addressDebug.ambiguity_count,
|
||||
match_failure_stage: addressDebug.match_failure_stage,
|
||||
match_failure_reason: addressDebug.match_failure_reason,
|
||||
mcp_call_status: addressDebug.mcp_call_status,
|
||||
rows_fetched: addressDebug.rows_fetched,
|
||||
raw_rows_received: addressDebug.raw_rows_received,
|
||||
@@ -1779,6 +1782,11 @@ function buildAddressDebugPayload(addressDebug) {
|
||||
rows_matched: addressDebug.rows_matched,
|
||||
raw_row_keys_sample: addressDebug.raw_row_keys_sample,
|
||||
materialization_drop_reason: addressDebug.materialization_drop_reason,
|
||||
account_token_raw: addressDebug.account_token_raw,
|
||||
account_token_normalized: addressDebug.account_token_normalized,
|
||||
account_scope_fields_checked: addressDebug.account_scope_fields_checked,
|
||||
account_scope_match_strategy: addressDebug.account_scope_match_strategy,
|
||||
account_scope_drop_reason: addressDebug.account_scope_drop_reason,
|
||||
runtime_readiness: addressDebug.runtime_readiness,
|
||||
limited_reason_category: addressDebug.limited_reason_category,
|
||||
response_type: addressDebug.response_type,
|
||||
@@ -1855,10 +1863,13 @@ class AssistantService {
|
||||
detected_intent: addressLane.debug.detected_intent,
|
||||
extracted_filters: addressLane.debug.extracted_filters,
|
||||
selected_recipe: addressLane.debug.selected_recipe,
|
||||
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
|
||||
account_scope_mode: addressLane.debug.account_scope_mode,
|
||||
account_scope_fallback_applied: addressLane.debug.account_scope_fallback_applied,
|
||||
anchor_type: addressLane.debug.anchor_type,
|
||||
resolver_confidence: addressLane.debug.resolver_confidence,
|
||||
match_failure_stage: addressLane.debug.match_failure_stage,
|
||||
match_failure_reason: addressLane.debug.match_failure_reason,
|
||||
mcp_call_status: addressLane.debug.mcp_call_status,
|
||||
rows_fetched: addressLane.debug.rows_fetched,
|
||||
raw_rows_received: addressLane.debug.raw_rows_received,
|
||||
@@ -1867,6 +1878,11 @@ class AssistantService {
|
||||
rows_materialized: addressLane.debug.rows_materialized,
|
||||
rows_matched: addressLane.debug.rows_matched,
|
||||
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
|
||||
account_token_raw: addressLane.debug.account_token_raw,
|
||||
account_token_normalized: addressLane.debug.account_token_normalized,
|
||||
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
|
||||
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
|
||||
account_scope_drop_reason: addressLane.debug.account_scope_drop_reason,
|
||||
runtime_readiness: addressLane.debug.runtime_readiness,
|
||||
limited_reason_category: addressLane.debug.limited_reason_category,
|
||||
response_type: addressLane.debug.response_type,
|
||||
|
||||
Reference in New Issue
Block a user