АДРЕСНЫЙ РЕЖИМ - M2.3b тюнинг account-scope и диагностика стадий адресного рантайма

This commit is contained in:
2026-03-29 20:57:55 +03:00
parent c82ebd70b7
commit 2bf16de4ea
498 changed files with 2619075 additions and 3 deletions
@@ -0,0 +1,881 @@
import {
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
} from "../config";
import type {
AddressExecutionResult,
AddressFilterSet,
AddressIntent,
AddressLimitedReasonCategory,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResponseType,
AddressRuntimeReadiness
} from "../types/addressQuery";
import { detectAddressQuestionMode } from "./addressQueryClassifier";
import { classifyAddressQueryShape } from "./addressQueryShapeClassifier";
import { resolveAddressIntent } from "./addressIntentResolver";
import { extractAddressFilters } from "./addressFilterExtractor";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
interface NormalizedAddressRow {
period: string | null;
registrator: string;
account_dt: string | null;
account_kt: string | null;
amount: number | null;
analytics: string[];
}
function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value.replace(",", ".").trim());
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function valueAsString(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function normalizeToken(value: string): string {
return String(value ?? "").trim().toLowerCase();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
values
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
);
}
function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
const fixedKeys = [
"СубконтоДт1",
"СубконтоДт2",
"СубконтоДт3",
"СубконтоКт1",
"СубконтоКт2",
"СубконтоКт3",
"SubcontoDt1",
"SubcontoDt2",
"SubcontoDt3",
"SubcontoKt1",
"SubcontoKt2",
"SubcontoKt3",
"subconto_dt1",
"subconto_dt2",
"subconto_dt3",
"subconto_kt1",
"subconto_kt2",
"subconto_kt3",
"Counterparty",
"Контрагент",
"Contract",
"Договор"
];
const collected: string[] = [];
for (const key of fixedKeys) {
const value = valueAsString(row[key]).trim();
if (value) {
collected.push(value);
}
}
for (const [key, rawValue] of Object.entries(row)) {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("subconto") || lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор")) {
const value = valueAsString(rawValue).trim();
if (value) {
collected.push(value);
}
}
}
return uniqueStrings(collected);
}
function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddressRow[] {
return rows
.map((row) => {
const period = valueAsString(row.Период ?? row.period ?? row.Period).trim() || null;
const registrator =
valueAsString(row.Регистратор ?? row.registrator ?? row.Registrator).trim() ||
valueAsString(row.document ?? row.Recorder).trim() ||
"(без названия)";
const accountDt = valueAsString(row.СчетДт ?? row.account_dt ?? row.AccountDt).trim() || null;
const accountKt = valueAsString(row.СчетКт ?? row.account_kt ?? row.AccountKt).trim() || null;
const amount = parseFiniteNumber(row.Сумма ?? row.amount ?? row.Amount);
const analytics = collectAnalyticsStrings(row);
return {
period,
registrator,
account_dt: accountDt,
account_kt: accountKt,
amount,
analytics
};
})
.filter((item) => Boolean(item.period || item.registrator));
}
function rowSearchableText(row: NormalizedAddressRow): string {
return [row.registrator, row.account_dt ?? "", row.account_kt ?? "", ...row.analytics].join(" ").toLowerCase();
}
function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[]): boolean {
if (accountScope.length === 0) {
return true;
}
const searchable = [row.account_dt ?? "", row.account_kt ?? "", row.registrator, ...row.analytics].join(" ");
return accountScope.some((account) => {
const normalized = String(account ?? "").trim();
if (!normalized) {
return false;
}
const matcher = new RegExp(`\\b${escapeRegExp(normalized)}(?:\\.\\d{1,2})?\\b`, "i");
return matcher.test(searchable);
});
}
function applyAccountScopeFilter(rows: NormalizedAddressRow[], accountScope: string[]): NormalizedAddressRow[] {
if (accountScope.length === 0) {
return rows;
}
return rows.filter((row) => rowMatchesAnyAccount(row, accountScope));
}
function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilterSet): NormalizedAddressRow[] {
let filtered = [...rows];
if (filters.account && String(filters.account).trim()) {
const scopedAccount = String(filters.account).trim();
filtered = filtered.filter((row) => rowMatchesAnyAccount(row, [scopedAccount]));
}
if (filters.counterparty && String(filters.counterparty).trim()) {
const needle = normalizeToken(String(filters.counterparty));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
}
if (filters.contract && String(filters.contract).trim()) {
const needle = normalizeToken(String(filters.contract));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = normalizeToken(String(filters.document_ref));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
}
return filtered;
}
function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddressRow[]): NormalizedAddressRow[] {
if (intent === "bank_operations_by_counterparty") {
const bankDocPattern =
/(?:списаниесрасчетногосчета|поступлениенарасчетныйсчет|списание с расчетного счета|поступление на расчетный счет|bank|payment|wire|statement)/i;
return rows.filter((row) => bankDocPattern.test(row.registrator.toLowerCase()));
}
if (intent === "list_documents_by_counterparty") {
const documentPattern =
/(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|payment|invoice|document|sale|purchase|bank)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
}
if (intent === "documents_forming_balance") {
const documentPattern =
/(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|списаниесрасчетногосчета|поступлениенарасчетныйсчет|invoice|document|sale|purchase)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
}
return rows;
}
function formatTopRows(rows: NormalizedAddressRow[], limit = 6): string[] {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
}
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS";
}
if (category === "recipe_visibility_gap") {
return "REQUIRES_SPECIALIZED_RECIPE";
}
if (category === "unsupported") {
return "DEEP_ONLY";
}
return "UNKNOWN";
}
interface AnchorResolutionDebug {
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
anchor_value_raw: string | null;
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
ambiguity_count: number;
}
interface RowStageDiagnostics {
rawRowKeysSample: string[];
materializationDropReason:
| "none"
| "dropped_by_account_scope_filter"
| "missing_period_and_registrator_fields"
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
}
function rowHasNonEmptyField(row: Record<string, unknown>, keys: string[]): boolean {
return keys.some((key) => String(row[key] ?? "").trim().length > 0);
}
function deriveRowStageDiagnostics(
rawRows: Array<Record<string, unknown>>,
rowsAfterAccountScope: number,
rowsMaterialized: number
): RowStageDiagnostics {
if (rawRows.length === 0 || rowsMaterialized > 0) {
return {
rawRowKeysSample: rawRows.length > 0 ? Object.keys(rawRows[0] ?? {}).slice(0, 20) : [],
materializationDropReason: "none"
};
}
if (rawRows.length > 0 && rowsAfterAccountScope === 0) {
return {
rawRowKeysSample: Object.keys(rawRows[0] ?? {}).slice(0, 20),
materializationDropReason: "dropped_by_account_scope_filter"
};
}
const rawRowKeysSample = Object.keys(rawRows[0] ?? {}).slice(0, 20);
const hasPeriodField = rawRows.some((row) => rowHasNonEmptyField(row, ["Период", "period", "Period"]));
const hasRegistratorField = rawRows.some((row) =>
rowHasNonEmptyField(row, ["Регистратор", "registrator", "Registrator", "document", "Recorder"])
);
if (!hasPeriodField && !hasRegistratorField) {
return { rawRowKeysSample, materializationDropReason: "missing_period_and_registrator_fields" };
}
if (!hasPeriodField) {
return { rawRowKeysSample, materializationDropReason: "missing_period_field" };
}
if (!hasRegistratorField) {
return { rawRowKeysSample, materializationDropReason: "missing_registrator_field" };
}
return { rawRowKeysSample, materializationDropReason: "unknown_row_shape" };
}
function deriveMcpStageStatus(input: {
skipped?: boolean;
errored?: boolean;
rawRowsReceived: number;
rowsMaterialized: number;
rowsMatched: number;
}): AddressMcpCallStatus {
if (input.skipped) {
return "skipped";
}
if (input.errored) {
return "error";
}
if (input.rawRowsReceived === 0) {
return "no_raw_rows";
}
if (input.rowsMaterialized === 0) {
return "raw_rows_received_but_not_materialized";
}
if (input.rowsMatched === 0) {
return "materialized_but_not_matched";
}
return "matched_non_empty";
}
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() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
ambiguity_count: 0
};
}
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
const heading =
category === "empty_match"
? "В live-данных по текущему фильтру записи не найдены."
: category === "missing_anchor"
? "Для точного адресного поиска не хватает обязательного якоря."
: category === "recipe_visibility_gap"
? "Текущий live recipe не дает нужную видимость данных для этого сценария."
: category === "unsupported"
? "Этот запрос не подходит под address_query V1."
: "Не удалось выполнить адресный live-запрос в V1.";
const lines = [
heading,
`Причина: ${reason}.`
];
if (nextStep) {
lines.push(`Что нужно уточнить: ${nextStep}.`);
}
return lines.join("\n");
}
function buildLimitedExecutionResult(input: {
mode: { mode: "address_query" | "deep_analysis" | "unsupported"; confidence: "high" | "medium" | "low"; reasons: string[] };
shape: AddressQueryShapeDetection;
intent: { intent: AddressIntent; confidence: "high" | "medium" | "low"; reasons: string[] };
filters: AddressFilterSet;
missingRequiredFilters: string[];
selectedRecipe: string | null;
accountScopeMode?: "strict" | "preferred";
accountScopeFallbackApplied?: boolean;
anchor?: AnchorResolutionDebug;
mcpCallStatus: AddressMcpCallStatus;
rowsFetched: number;
rawRowsReceived?: number;
rowsAfterAccountScope?: number;
rowsAfterRecipeFilter?: number;
rowsMaterialized?: number;
rowsMatched: number;
rawRowKeysSample?: string[];
materializationDropReason?:
| "none"
| "dropped_by_account_scope_filter"
| "missing_period_and_registrator_fields"
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
limitations: string[];
reasons: string[];
reasonText: string;
nextStep?: string;
category: AddressLimitedReasonCategory;
}): AddressExecutionResult {
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
detected_mode: input.mode.mode,
detected_mode_confidence: input.mode.confidence,
query_shape: input.shape.shape,
query_shape_confidence: input.shape.confidence,
detected_intent: input.intent.intent,
detected_intent_confidence: input.intent.confidence,
extracted_filters: input.filters,
missing_required_filters: input.missingRequiredFilters,
selected_recipe: input.selectedRecipe,
account_scope_mode: input.accountScopeMode ?? "strict",
account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false,
anchor_type: input.anchor?.anchor_type ?? null,
anchor_value_raw: input.anchor?.anchor_value_raw ?? null,
anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null,
resolver_confidence: input.anchor?.resolver_confidence ?? null,
ambiguity_count: input.anchor?.ambiguity_count ?? 0,
mcp_call_status: input.mcpCallStatus,
rows_fetched: input.rowsFetched,
raw_rows_received: input.rawRowsReceived ?? input.rowsFetched,
rows_after_account_scope: input.rowsAfterAccountScope ?? 0,
rows_after_recipe_filter: input.rowsAfterRecipeFilter ?? 0,
rows_materialized: input.rowsMaterialized ?? 0,
rows_matched: input.rowsMatched,
raw_row_keys_sample: input.rawRowKeysSample ?? [],
materialization_drop_reason: input.materializationDropReason ?? "none",
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
limitations: input.limitations,
reasons: input.reasons
}
};
}
function contractCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
const candidates: string[] = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent: AddressIntent, rows: NormalizedAddressRow[]): { responseType: AddressResponseType; text: string } {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title =
intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
export class AddressQueryService {
public async tryHandle(userMessage: string): Promise<AddressExecutionResult | null> {
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = detectAddressQuestionMode(userMessage);
if (mode.mode !== "address_query") {
return null;
}
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const intent = resolveAddressIntent(userMessage);
const filters = extractAddressFilters(userMessage, intent.intent);
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: filters.missing_required_filters,
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "unsupported",
reasonText: "intent пока не поддержан в address V1",
nextStep: "переформулируйте вопрос как адресный lookup по счету/контрагенту/договору",
limitations: ["intent_not_supported_in_v1"],
reasons: baseReasons
});
}
if (
intent.intent === "open_items_by_counterparty_or_contract" &&
!filters.extracted_filters.counterparty &&
!filters.extracted_filters.contract
) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: ["counterparty_or_contract"],
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "missing_anchor",
reasonText: "для open_items нужен якорь контрагента или договора",
nextStep: "укажите контрагента или номер/название договора",
limitations: ["open_items_requires_counterparty_or_contract_filter"],
reasons: baseReasons
});
}
if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: recipeSelection.missing_required_filters,
selectedRecipe: null,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "recipe_visibility_gap",
reasonText: "для intent пока нет recipe в address V1",
nextStep: "выберите поддерживаемый P0 intent или переключите запрос в deep-analysis",
limitations: ["recipe_not_available"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
});
}
if (recipeSelection.missing_required_filters.length > 0) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: recipeSelection.missing_required_filters,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "missing_anchor",
reasonText: "не хватает обязательных фильтров",
nextStep: `уточните: ${recipeSelection.missing_required_filters.join(", ")}`,
limitations: ["missing_required_filters"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
});
}
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
anchor,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "execution_error",
reasonText: "live address lane выключен feature-флагом",
nextStep: "включите FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1",
limitations: ["address_live_lane_disabled"],
reasons: baseReasons
});
}
const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await executeAddressMcpQuery({
query: plan.query,
limit: plan.limit
});
if (mcp.error) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
anchor,
mcpCallStatus: deriveMcpStageStatus({
errored: true,
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: 0,
rowsMatched: 0
}),
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: mcp.rows.length,
rowsAfterRecipeFilter: 0,
rowsMaterialized: 0,
rowsMatched: mcp.matched_rows,
rawRowKeysSample: [],
materializationDropReason: "none",
category: "execution_error",
reasonText: "live MCP вызов завершился ошибкой",
nextStep: mcp.error,
limitations: ["mcp_call_failed"],
reasons: [...baseReasons, mcp.error]
});
}
const normalizedRawRows = toNormalizedRows(mcp.raw_rows);
const scopedRows = applyAccountScopeFilter(normalizedRawRows, plan.account_scope);
const accountScopeFallbackApplied =
plan.account_scope_mode === "preferred" &&
plan.account_scope.length > 0 &&
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
const filterByAnchors = applyAddressFilters(normalizedRows, filters.extracted_filters);
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,
rowsMatched: filteredRows.length
});
if (intent.intent === "list_open_contracts" && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
anchor,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: filteredRows.length,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
reasonText: "в live строках нет договорных якорей для уверенного списка незакрытых договоров",
nextStep: "сузьте запрос по контрагенту или добавьте номер договора",
limitations: ["no_contract_anchors_in_live_rows"],
reasons: baseReasons
});
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate =
hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
anchor,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: 0,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: 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"
],
reasons: baseReasons
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
return {
handled: true,
reply_text: factual.text,
reply_type: inferReplyType(factual.responseType),
response_type: factual.responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
anchor_value_raw: anchor.anchor_value_raw,
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
rows_after_account_scope: normalizedRows.length,
rows_after_recipe_filter: filterByAnchors.length,
rows_materialized: normalizedRows.length,
rows_matched: filteredRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
limitations: filters.warnings,
reasons: baseReasons
}
};
}
}