АДРЕСНЫЙ РЕЖИМ - M2.3c тюнинг резолвинга и фильтров адресных запросов, поэтапная диагностика и аудит фильтрации по счету
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user