АДРЕСНЫЙ РЕЖИМ - M2.3b тюнинг account-scope и диагностика стадий адресного рантайма
This commit is contained in:
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user