АДРЕСНЫЙ РЕЖИМ - M2.3c тюнинг резолвинга и фильтров адресных запросов, поэтапная диагностика и аудит фильтрации по счету

This commit is contained in:
2026-03-29 21:51:09 +03:00
parent 2bf16de4ea
commit a2886faed6
37 changed files with 3050 additions and 151 deletions
@@ -58,22 +58,38 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
}
const rows: Array<Record<string, unknown>> = [];
const parseCsvLine = (line: string): string[] => {
const values: string[] = [];
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: string[] = [];
const matcher = /"([^"]*)"|([^,]+)/g;
let match: RegExpExecArray | null = 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;
@@ -7,6 +7,7 @@ import type {
AddressFilterSet,
AddressIntent,
AddressLimitedReasonCategory,
AddressMatchFailureStage,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResponseType,
@@ -28,6 +29,30 @@ interface NormalizedAddressRow {
analytics: string[];
}
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
"60": ["поставщик", "поставщиками", "подрядчиками", "расчеты с поставщиками"],
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@@ -48,12 +73,124 @@ function valueAsString(value: unknown): string {
return String(value);
}
function normalizeToken(value: string): string {
return String(value ?? "").trim().toLowerCase();
function transliterateCyrillicToLatin(value: string): string {
const map: Record<string, string> = {
а: "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: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function normalizeSearchText(value: string): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value: string): string[] {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
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: string): string {
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: string): string[] {
const result: string[] = [];
const matcher = /\b(\d{2})(?:[.,](\d{1,2}))?\b/g;
let hit: RegExpExecArray | null = 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: string, candidateToken: string): boolean {
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: string): string | null {
const normalized = normalizeAccountToken(value);
const match = normalized.match(/^(\d{2})/);
return match ? match[1] : null;
}
function uniqueStrings(values: string[]): string[] {
@@ -147,13 +284,27 @@ function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[])
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);
});
});
}
@@ -164,30 +315,55 @@ function applyAccountScopeFilter(rows: NormalizedAddressRow[], accountScope: str
return rows.filter((row) => rowMatchesAnyAccount(row, accountScope));
}
function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilterSet): NormalizedAddressRow[] {
interface AnchorFilterResult {
rows: NormalizedAddressRow[];
mismatchReason: string | null;
}
function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilterSet): AnchorFilterResult {
let filtered = [...rows];
let mismatchReason: string | null = 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: AddressIntent, rows: NormalizedAddressRow[]): NormalizedAddressRow[] {
@@ -261,6 +437,18 @@ interface RowStageDiagnostics {
| "unknown_row_shape";
}
interface AccountScopeAuditDebug {
accountTokenRaw: string | null;
accountTokenNormalized: string | null;
accountScopeFieldsChecked: string[];
accountScopeMatchStrategy: "account_code_regex_plus_alias_map_v1";
accountScopeDropReason:
| "not_applicable"
| "no_account_scope_requested"
| "no_rows_after_scope_filter"
| "rows_remaining_after_scope_filter";
}
function rowHasNonEmptyField(row: Record<string, unknown>, keys: string[]): boolean {
return keys.some((key) => String(row[key] ?? "").trim().length > 0);
}
@@ -303,11 +491,63 @@ function deriveRowStageDiagnostics(
return { rawRowKeysSample, materializationDropReason: "unknown_row_shape" };
}
function isAccountIntent(intent: AddressIntent): boolean {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function buildDefaultAccountScopeAudit(filters: AddressFilterSet): AccountScopeAuditDebug {
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: {
intent: AddressIntent;
filters: AddressFilterSet;
accountScope: string[];
rowsBeforeScope: number;
rowsAfterScope: number;
}): AccountScopeAuditDebug {
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: {
skipped?: boolean;
errored?: boolean;
rawRowsReceived: number;
rowsMaterialized: number;
rowsAnchorMatched: number;
rowsMatched: number;
}): AddressMcpCallStatus {
if (input.skipped) {
@@ -322,12 +562,24 @@ 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: AddressMcpCallStatus
): "skipped" | "error" | "no_raw_rows" | "raw_rows_received_but_not_materialized" | "materialized_but_not_matched" | "matched_non_empty" {
if (status === "materialized_but_not_anchor_matched" || status === "materialized_but_filtered_out_by_recipe") {
return "materialized_but_not_matched";
}
return status;
}
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
@@ -385,6 +637,42 @@ function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet):
};
}
function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
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: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
const heading =
category === "empty_match"
@@ -415,7 +703,10 @@ function buildLimitedExecutionResult(input: {
selectedRecipe: string | null;
accountScopeMode?: "strict" | "preferred";
accountScopeFallbackApplied?: boolean;
accountScopeAudit?: AccountScopeAuditDebug;
anchor?: AnchorResolutionDebug;
matchFailureStage?: AddressMatchFailureStage;
matchFailureReason?: string | null;
mcpCallStatus: AddressMcpCallStatus;
rowsFetched: number;
rawRowsReceived?: number;
@@ -437,6 +728,7 @@ function buildLimitedExecutionResult(input: {
nextStep?: string;
category: AddressLimitedReasonCategory;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
@@ -452,6 +744,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,
@@ -459,6 +752,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,
@@ -468,6 +763,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",
@@ -605,7 +905,7 @@ export class AddressQueryService {
const intent = resolveAddressIntent(userMessage);
const filters = extractAddressFilters(userMessage, intent.intent);
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
@@ -720,6 +1020,7 @@ export class AddressQueryService {
});
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({
mode,
shape,
@@ -733,8 +1034,10 @@ export 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,
@@ -759,16 +1062,44 @@ export 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: AddressFilterSet =
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: AddressMatchFailureStage =
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" && contractCandidatesFromRows(filteredRows).length === 0) {
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
@@ -778,7 +1109,10 @@ export 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,
@@ -803,6 +1137,38 @@ export class AddressQueryService {
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: AddressLimitedReasonCategory = 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,
@@ -812,7 +1178,10 @@ export 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,
@@ -822,18 +1191,10 @@ export 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
});
}
@@ -854,6 +1215,7 @@ export 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,
@@ -861,6 +1223,8 @@ export 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,
@@ -870,6 +1234,11 @@ export 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,
@@ -19,6 +19,32 @@ __WHERE_CLAUSE__
Движения.Период УБЫВ
`;
const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкПоступление.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
УПОРЯДОЧИТЬ ПО
Период УБЫВ
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_movements_payables_v1",
@@ -68,7 +94,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
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",
@@ -78,7 +105,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
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",
@@ -125,7 +153,7 @@ function toDateTimeExpr(isoDate: string, endOfDay: boolean): string | null {
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters: AddressFilterSet): string {
function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string {
const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
@@ -140,16 +168,16 @@ function buildWhereClause(filters: AddressFilterSet): string {
: 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 "";
@@ -194,11 +222,16 @@ export function buildAddressRecipePlan(
: [];
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,
@@ -1725,6 +1725,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,
@@ -1732,6 +1733,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,
@@ -1741,6 +1744,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,
@@ -1817,10 +1825,13 @@ export 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,
@@ -1829,6 +1840,11 @@ export 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,
@@ -42,11 +42,18 @@ export type AddressMcpCallStatus =
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe"
| "materialized_but_not_matched"
| "matched_non_empty";
export type AddressAccountScopeMode = "strict" | "preferred";
export type AddressMatchFailureStage =
| "none"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe";
export interface AddressModeDetection {
mode: AddressQuestionMode;
confidence: "high" | "medium" | "low";
@@ -90,6 +97,7 @@ export interface AddressRecipeDefinition {
recipe_id: string;
intent: Exclude<AddressIntent, "unknown">;
purpose: string;
query_template?: "movements" | "bank_docs";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;
@@ -120,6 +128,7 @@ export interface AddressExecutionDebug {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
selected_recipe: string | null;
mcp_call_status_legacy: Exclude<AddressMcpCallStatus, "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe">;
account_scope_mode: AddressAccountScopeMode;
account_scope_fallback_applied: boolean;
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
@@ -127,6 +136,8 @@ export interface AddressExecutionDebug {
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
ambiguity_count: number;
match_failure_stage: AddressMatchFailureStage;
match_failure_reason: string | null;
mcp_call_status: AddressMcpCallStatus;
rows_fetched: number;
raw_rows_received: number;
@@ -142,6 +153,15 @@ export interface AddressExecutionDebug {
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
account_token_raw: string | null;
account_token_normalized: string | null;
account_scope_fields_checked: string[];
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1";
account_scope_drop_reason:
| "not_applicable"
| "no_account_scope_requested"
| "no_rows_after_scope_filter"
| "rows_remaining_after_scope_filter";
runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType;
@@ -329,6 +329,7 @@ export interface AssistantDebugPayload {
extracted_filters?: Record<string, unknown>;
missing_required_filters?: string[];
selected_recipe?: string | null;
mcp_call_status_legacy?: "skipped" | "error" | "no_raw_rows" | "raw_rows_received_but_not_materialized" | "materialized_but_not_matched" | "matched_non_empty";
account_scope_mode?: "strict" | "preferred";
account_scope_fallback_applied?: boolean;
anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
@@ -336,11 +337,15 @@ export interface AssistantDebugPayload {
anchor_value_resolved?: string | null;
resolver_confidence?: "high" | "medium" | "low" | null;
ambiguity_count?: number;
match_failure_stage?: "none" | "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe";
match_failure_reason?: string | null;
mcp_call_status?:
| "skipped"
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe"
| "materialized_but_not_matched"
| "matched_non_empty";
rows_fetched?: number;
@@ -357,6 +362,11 @@ export interface AssistantDebugPayload {
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
account_token_raw?: string | null;
account_token_normalized?: string | null;
account_scope_fields_checked?: string[];
account_scope_match_strategy?: "account_code_regex_plus_alias_map_v1";
account_scope_drop_reason?: "not_applicable" | "no_account_scope_requested" | "no_rows_after_scope_filter" | "rows_remaining_after_scope_filter";
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";