АДРЕСНЫЙ РЕЖИМ - 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
+3 -1
View File
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@@ -45,6 +45,8 @@ exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = toBooleanFlag(process.env.FEATU
exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1, true);
exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1, true);
exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1, false);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 1200);
@@ -0,0 +1,179 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractAddressFilters = extractAddressFilters;
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 = /(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
function toIsoDate(year, month, day) {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function extractAsOfDate(text) {
if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) {
return new Date().toISOString().slice(0, 10);
}
const ymd = text.match(DATE_YMD_PATTERN);
if (ymd) {
const year = Number(ymd[1]);
const month = Number(ymd[2]);
const day = Number(ymd[3]);
return toIsoDate(year, month, day) ?? undefined;
}
const dmy = text.match(DATE_DMY_PATTERN);
if (dmy) {
const day = Number(dmy[1]);
const month = Number(dmy[2]);
const yearRaw = Number(dmy[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
return toIsoDate(year, month, day) ?? undefined;
}
return undefined;
}
function parseDateToken(token) {
const value = String(token ?? "").trim();
if (!value) {
return undefined;
}
const dmy = value.match(/^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})$/);
if (dmy) {
const day = Number(dmy[1]);
const month = Number(dmy[2]);
const yearRaw = Number(dmy[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
return toIsoDate(year, month, day) ?? undefined;
}
const ymd = value.match(/^(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})$/);
if (ymd) {
const year = Number(ymd[1]);
const month = Number(ymd[2]);
const day = Number(ymd[3]);
return toIsoDate(year, month, day) ?? undefined;
}
return undefined;
}
function extractPeriodRange(text) {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) {
return {};
}
const periodFrom = parseDateToken(String(directMatch[1] ?? ""));
const periodTo = parseDateToken(String(directMatch[2] ?? ""));
return {
...(periodFrom ? { period_from: periodFrom } : {}),
...(periodTo ? { period_to: periodTo } : {})
};
}
function cleanupAnchorValue(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
return "";
}
return normalized
.replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "")
.replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "")
.trim();
}
function shiftDaysIso(baseIso, deltaDays) {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
return date.toISOString().slice(0, 10);
}
function requiredFiltersByIntent(intent) {
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"];
}
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ["counterparty"];
}
return [];
}
function extractAddressFilters(userMessage, intent) {
const text = String(userMessage ?? "").trim();
const filters = {
sort: "period_desc",
limit: 20
};
const warnings = [];
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
filters.account = String(accountMatch[1]).replace(",", ".");
}
const limitMatch = text.match(LIMIT_PATTERN);
if (limitMatch) {
const parsed = Number(limitMatch[1]);
if (Number.isFinite(parsed) && parsed > 0) {
filters.limit = Math.min(200, Math.trunc(parsed));
}
}
const counterpartyMatch = text.match(COUNTERPARTY_PATTERN);
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
}
const periodRange = extractPeriodRange(text);
if (periodRange.period_from) {
filters.period_from = periodRange.period_from;
}
if (periodRange.period_to) {
filters.period_to = periodRange.period_to;
}
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
if (!filters.period_from && !filters.period_to) {
const asOfDate = extractAsOfDate(text);
if (asOfDate) {
filters.as_of_date = asOfDate;
}
}
// For document/bank lists we default to a short recent window if no explicit period was provided.
if ((intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") &&
!filters.period_from &&
!filters.period_to) {
const today = new Date().toISOString().slice(0, 10);
filters.period_to = today;
filters.period_from = shiftDaysIso(today, -90);
warnings.push("period_defaulted_last_90_days");
}
// For balance-style intents we force as_of_date deterministically:
// - explicit as_of has priority;
// - else use period_to boundary when provided;
// - else default to today.
if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance") && !filters.as_of_date) {
if (filters.period_to) {
filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to");
}
else {
filters.as_of_date = new Date().toISOString().slice(0, 10);
warnings.push("as_of_date_defaulted_today");
}
}
if (filters.counterparty && filters.counterparty.length < 2) {
warnings.push("counterparty_filter_too_short");
}
if (filters.contract && filters.contract.length < 2) {
warnings.push("contract_filter_too_short");
}
const required = requiredFiltersByIntent(intent);
const missingRequiredFilters = required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
return {
extracted_filters: filters,
missing_required_filters: missingRequiredFilters,
warnings
};
}
@@ -0,0 +1,150 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveAddressIntent = resolveAddressIntent;
const RECEIVABLES_STRONG = [
"кто должен нам",
"нам должны",
"who owes us",
"receivable",
"receivables",
"debtor",
"debtors",
"дебитор",
"дебиторск"
];
const PAYABLES_STRONG = [
"кому должны мы",
"мы должны",
"who we owe",
"payable",
"payables",
"creditor",
"creditors",
"кредитор",
"кредиторск"
];
const ACCOUNT_BALANCE_HINTS = [
"account balance",
"balance by account",
"saldo",
"остаток по счет",
"сальдо по счет",
"по счету"
];
const DOCUMENTS_FORMING_BALANCE_HINTS = [
"documents forming balance",
"documents form balance",
"balance documents",
"documents for balance",
"which documents form balance",
"из чего состоит остаток",
"какие документы формируют остаток",
"раскрой остаток по документам",
"документы под остатком"
];
const OPEN_CONTRACTS_HINTS = [
"open contracts",
"unclosed contracts",
"незакрыт",
"не закрыт",
"открыт",
"договор"
];
const OPEN_ITEMS_HINTS = [
"open items",
"unclosed items",
"хвост",
"висят",
"незакрыт",
"открыт",
"позици"
];
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"show documents by counterparty",
"list documents by counterparty",
"документ",
"по контрагент"
];
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"bank operations by counterparty",
"bank payments by counterparty",
"payment orders by counterparty",
"show bank operations by counterparty",
"банков",
"выписк",
"платеж"
];
function hasAny(text, patterns) {
return patterns.some((item) => text.includes(item));
}
function hasAccountNumberAnchor(text) {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
function resolveAddressIntent(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase();
if (hasAny(text, RECEIVABLES_STRONG)) {
return {
intent: "list_receivables_counterparties",
confidence: "high",
reasons: ["receivables_signal_detected"]
};
}
if (hasAny(text, PAYABLES_STRONG)) {
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
};
}
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS) && (hasAccountNumberAnchor(text) || text.includes("счет"))) {
return {
intent: "documents_forming_balance",
confidence: "high",
reasons: ["documents_forming_balance_signal_detected"]
};
}
if (hasAny(text, ACCOUNT_BALANCE_HINTS) || hasAccountNumberAnchor(text)) {
return {
intent: "account_balance_snapshot",
confidence: "high",
reasons: ["account_balance_signal_detected"]
};
}
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
return {
intent: "bank_operations_by_counterparty",
confidence: "medium",
reasons: ["bank_ops_by_counterparty_signal_detected"]
};
}
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
return {
intent: "list_documents_by_counterparty",
confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "medium",
reasons: ["open_items_signal_detected"]
};
}
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",
reasons: ["open_contract_signal_detected"]
};
}
return {
intent: "unknown",
confidence: "low",
reasons: ["intent_not_supported_in_v1"]
};
}
+208
View File
@@ -0,0 +1,208 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeAddressMcpQuery = executeAddressMcpQuery;
const config_1 = require("../config");
function toStringValue(value) {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function parseFiniteNumber(value) {
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 parseRowsFromTextTable(source) {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
const headerMatch = normalized.match(/\{([^}]*)\}:/);
if (!headerMatch) {
return [];
}
const columns = String(headerMatch[1] ?? "")
.split(",")
.map((item) => item.replace(/^"+|"+$/g, "").trim())
.filter(Boolean);
const body = normalized.slice((headerMatch.index ?? 0) + headerMatch[0].length).trim();
if (!body) {
return [];
}
const rows = [];
const lines = body
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const values = [];
const matcher = /"([^"]*)"|([^,]+)/g;
let match = null;
while ((match = matcher.exec(line)) !== null) {
const raw = match[1] !== undefined ? match[1] : match[2];
const value = String(raw ?? "").trim();
if (value.length > 0) {
values.push(value);
}
}
if (values.length === 0) {
continue;
}
const row = {};
for (let index = 0; index < columns.length; index += 1) {
const key = columns[index] ?? `column_${index + 1}`;
const raw = values[index] ?? "";
const parsed = parseFiniteNumber(raw);
row[key] = parsed ?? raw;
}
if (values[0])
row.Period = values[0];
if (values[1])
row.Registrator = values[1];
if (values[2])
row.AccountDt = values[2];
if (values[3])
row.AccountKt = values[3];
if (values[4])
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
rows.push(row);
}
return rows;
}
function parseExecutePayload(payload) {
if (!payload || typeof payload !== "object") {
return {
ok: false,
rows: [],
error: "MCP payload is empty or malformed"
};
}
const source = payload;
if (source.success !== true) {
return {
ok: false,
rows: [],
error: toStringValue(source.error).trim() || "MCP execute_query returned success=false"
};
}
if (Array.isArray(source.data)) {
const rows = source.data
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
return {
ok: true,
rows,
error: null
};
}
if (typeof source.data === "string") {
return {
ok: true,
rows: parseRowsFromTextTable(source.data),
error: null
};
}
if (source.data && typeof source.data === "object" && Array.isArray(source.data.rows)) {
const rows = (source.data.rows ?? [])
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
return {
ok: true,
rows,
error: null
};
}
return {
ok: true,
rows: [],
error: null
};
}
function buildMcpUrl(endpoint) {
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
const separator = normalizedEndpoint.includes("?") ? "&" : "?";
return `${config_1.ASSISTANT_MCP_PROXY_URL}${normalizedEndpoint}${separator}channel=${encodeURIComponent(config_1.ASSISTANT_MCP_CHANNEL)}`;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function filterRowsByAccountScope(rows, accountScope) {
if (accountScope.length === 0) {
return rows;
}
const matchers = accountScope.map((account) => new RegExp(`\\b${escapeRegExp(account)}(?:\\.\\d{1,2})?\\b`, "i"));
return rows.filter((row) => {
const searchable = Object.values(row)
.map((item) => String(item ?? ""))
.join(" ");
return matchers.some((matcher) => matcher.test(searchable));
});
}
async function executeAddressMcpQuery(input) {
const endpoint = buildMcpUrl("/api/execute_query");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS));
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify({
query: input.query,
limit: input.limit
}),
signal: controller.signal
});
const responseText = await response.text();
if (!response.ok) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
};
}
const payload = responseText.trim() ? JSON.parse(responseText) : {};
const parsed = parseExecutePayload(payload);
if (!parsed.ok) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: parsed.error
};
}
const filtered = filterRowsByAccountScope(parsed.rows, Array.isArray(input.account_scope) ? input.account_scope : []);
return {
fetched_rows: parsed.rows.length,
matched_rows: filtered.length,
raw_rows: parsed.rows,
rows: filtered,
error: null
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP fetch failed: ${message}`
};
}
finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,115 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectAddressQuestionMode = detectAddressQuestionMode;
const ADDRESS_ACTION_TOKENS = [
"show",
"list",
"find",
"get",
"lookup",
"open",
"balance",
"debt",
"owe",
"покажи",
"список",
"найди",
"выведи",
"кто",
"кому",
"какие",
"остаток",
"долг",
"задолж",
"хвост",
"незакрыт"
];
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"contract",
"contracts",
"account",
"accounts",
"document",
"documents",
"balance",
"payable",
"payables",
"receivable",
"receivables",
"owe",
"owes",
"owed",
"контрагент",
"договор",
"счет",
"счёт",
"документ",
"остаток",
"дебитор",
"кредитор",
"аванс",
"оплат",
"долг",
"должен",
"должны",
"должна"
];
const DEEP_REASONING_TOKENS = [
"why",
"because",
"root cause",
"mechanism",
"prove",
"chain",
"почему",
"причин",
"механизм",
"докажи",
"цепоч",
"разрыв",
"ошибк"
];
function hasAnyToken(text, tokens) {
return tokens.some((token) => text.includes(token));
}
function detectAddressQuestionMode(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return {
mode: "unsupported",
confidence: "low",
reasons: ["empty_message"]
};
}
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "high",
reasons: ["address_action_detected", "address_entity_detected"]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["address_entity_detected"]
};
}
if (hasDeepReasoning) {
return {
mode: "deep_analysis",
confidence: "high",
reasons: ["deep_reasoning_signal_detected"]
};
}
return {
mode: "unsupported",
confidence: "low",
reasons: ["no_address_or_deep_signal"]
};
}
@@ -0,0 +1,722 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressQueryService = void 0;
const config_1 = require("../config");
const addressQueryClassifier_1 = require("./addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("./addressQueryShapeClassifier");
const addressIntentResolver_1 = require("./addressIntentResolver");
const addressFilterExtractor_1 = require("./addressFilterExtractor");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const addressMcpClient_1 = require("./addressMcpClient");
function parseFiniteNumber(value) {
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) {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function normalizeToken(value) {
return String(value ?? "").trim().toLowerCase();
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function collectAnalyticsStrings(row) {
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 = [];
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) {
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) {
return [row.registrator, row.account_dt ?? "", row.account_kt ?? "", ...row.analytics].join(" ").toLowerCase();
}
function rowMatchesAnyAccount(row, accountScope) {
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, accountScope) {
if (accountScope.length === 0) {
return rows;
}
return rows.filter((row) => rowMatchesAnyAccount(row, accountScope));
}
function applyAddressFilters(rows, filters) {
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, rows) {
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, limit = 6) {
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) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}
function runtimeReadinessForLimitedCategory(category) {
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";
}
function rowHasNonEmptyField(row, keys) {
return keys.some((key) => String(row[key] ?? "").trim().length > 0);
}
function deriveRowStageDiagnostics(rawRows, rowsAfterAccountScope, rowsMaterialized) {
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) {
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, filters) {
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, reason, nextStep) {
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) {
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) {
const candidates = [];
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, rows) {
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")
};
}
class AddressQueryService {
async tryHandle(userMessage) {
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
if (intent.intent === "unknown") {
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 (!config_1.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 = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await (0, addressMcpClient_1.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
}
};
}
}
exports.AddressQueryService = AddressQueryService;
@@ -0,0 +1,143 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.classifyAddressQueryShape = classifyAddressQueryShape;
const EXPLAIN_PATTERNS = [
/why/iu,
/because/iu,
/root cause/iu,
/prove/iu,
/mechanism/iu,
/\u043f\u043e\u0447\u0435\u043c\u0443/iu,
/\u043f\u0440\u0438\u0447\u0438\u043d/iu,
/\u043e\u0448\u0438\u0431\u043a/iu,
/\u0434\u043e\u043a\u0430\u0436/iu,
/\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c/iu
];
const VERIFY_PATTERNS = [
/check/iu,
/verify/iu,
/is there/iu,
/was there/iu,
/\u043f\u0440\u043e\u0432\u0435\u0440/iu,
/\u0435\u0441\u0442\u044c\s+\u043b\u0438/iu,
/\u0431\u044b\u043b\u0438\s+\u043b\u0438/iu
];
const DRILLDOWN_PATTERNS = [
/drilldown/iu,
/breakdown/iu,
/forming balance/iu,
/\u0444\u043e\u0440\u043c\u0438\u0440\u0443\u044e\u0442/iu,
/\u0440\u0430\u0441\u043a\u0440\u043e\u0439/iu,
/\u0438\u0437\s+\u0447\u0435\u0433\u043e/iu
];
const DOCUMENT_PATTERNS = [
/document/iu,
/invoice/iu,
/payment/iu,
/bank operation/iu,
/\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442/iu,
/\u043f\u043b\u0430\u0442\u0435\u0436/iu,
/\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d/iu,
/\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446/iu,
/\u0432\u044b\u043f\u0438\u0441\u043a/iu
];
const AGGREGATE_PATTERNS = [
/who owes us/iu,
/who we owe/iu,
/balance/iu,
/receivable/iu,
/payable/iu,
/total/iu,
/\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d/iu,
/\u043a\u043e\u043c\u0443\s+\u0434\u043e\u043b\u0436\u043d\u044b/iu,
/\u043e\u0441\u0442\u0430\u0442\u043e\u043a/iu,
/\u0441\u0430\u043b\u044c\u0434\u043e/iu,
/\u043e\u0431\u043e\u0440\u043e\u0442/iu,
/\u0434\u043e\u043b\u0433/iu,
/\u0437\u0430\u0434\u043e\u043b\u0436/iu
];
const OBJECT_PATTERNS = [
/by counterparty/iu,
/by contract/iu,
/counterparty/iu,
/contract/iu,
/\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442/iu,
/\u043f\u043e\s+\u0434\u043e\u0433\u043e\u0432\u043e\u0440/iu,
/\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442/iu,
/\u0434\u043e\u0433\u043e\u0432\u043e\u0440/iu
];
function hasAnyPattern(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function hasCompoundSignal(text) {
const hasJoin = /(?:\s+\u0438\s+|\sand\s|;|,)/iu.test(text);
const hasActionVerb = /(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|\u043f\u043e\u043a\u0430\u0436\u0438|\u043d\u0430\u0439\u0434\u0438|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|who|show|find|list|check|verify)/iu.test(text);
if (hasJoin && hasActionVerb) {
return true;
}
return /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u0438\s+\u0435\u0449\u0435|\u0438\s+\u0442\u0430\u043a\u0436\u0435|and also|then)/iu.test(text);
}
function classifyAddressQueryShape(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return {
shape: "UNKNOWN",
confidence: "low",
reasons: ["empty_message"]
};
}
if (hasAnyPattern(text, EXPLAIN_PATTERNS)) {
return {
shape: "EXPLAIN_OR_REASON",
confidence: "high",
reasons: ["explain_signal_detected"]
};
}
if (hasCompoundSignal(text)) {
return {
shape: "COMPOUND_FACTUAL_QUERY",
confidence: "medium",
reasons: ["compound_signal_detected"]
};
}
if (hasAnyPattern(text, DRILLDOWN_PATTERNS)) {
return {
shape: "DRILLDOWN_REQUEST",
confidence: "high",
reasons: ["drilldown_signal_detected"]
};
}
if (hasAnyPattern(text, VERIFY_PATTERNS)) {
return {
shape: "VERIFY_FACTUAL",
confidence: "medium",
reasons: ["verify_signal_detected"]
};
}
if (hasAnyPattern(text, DOCUMENT_PATTERNS)) {
return {
shape: "DOCUMENT_LIST",
confidence: "medium",
reasons: ["document_list_signal_detected"]
};
}
if (hasAnyPattern(text, AGGREGATE_PATTERNS)) {
return {
shape: "AGGREGATE_LOOKUP",
confidence: "high",
reasons: ["aggregate_signal_detected"]
};
}
if (hasAnyPattern(text, OBJECT_PATTERNS)) {
return {
shape: "OBJECT_LOOKUP",
confidence: "medium",
reasons: ["object_signal_detected"]
};
}
return {
shape: "UNKNOWN",
confidence: "low",
reasons: ["shape_not_detected"]
};
}
@@ -0,0 +1,176 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.selectAddressRecipe = selectAddressRecipe;
exports.buildAddressRecipePlan = buildAddressRecipePlan;
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`;
const BASE_RECIPES = [
{
recipe_id: "address_movements_payables_v1",
intent: "list_payables_counterparties",
purpose: "List payable-related movements for accounts 60/76",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64,
account_scope: ["60", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties",
purpose: "List receivable-related movements for accounts 62/76",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64,
account_scope: ["62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_open_contracts_candidates_v1",
intent: "list_open_contracts",
purpose: "Collect contract candidates from 60/62/76 movements",
required_filters: [],
optional_filters: ["as_of_date", "organization", "limit"],
default_limit: 128,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_open_items_by_party_or_contract_v1",
intent: "open_items_by_counterparty_or_contract",
purpose: "Collect open movement items with counterparty/contract filters",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 96,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_documents_by_counterparty_v1",
intent: "list_documents_by_counterparty",
purpose: "Collect counterparty-related document lines from movements",
required_filters: ["counterparty"],
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"
},
{
recipe_id: "address_bank_operations_by_counterparty_v1",
intent: "bank_operations_by_counterparty",
purpose: "Collect bank operation candidates by counterparty from movement lines",
required_filters: ["counterparty"],
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["51", "52"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_documents_forming_balance_v1",
intent: "documents_forming_balance",
purpose: "Drilldown movements that form account balance as-of date",
required_filters: ["account", "as_of_date"],
optional_filters: ["organization", "counterparty", "contract", "period_from", "period_to", "limit", "sort"],
default_limit: 120,
account_scope_mode: "strict"
},
{
recipe_id: "address_movements_account_snapshot_v1",
intent: "account_balance_snapshot",
purpose: "Build account movement snapshot for explicit account",
required_filters: ["account"],
optional_filters: ["as_of_date", "period_from", "period_to", "limit"],
default_limit: 96,
account_scope_mode: "strict"
}
];
function toDateTimeExpr(isoDate, endOfDay) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const hour = endOfDay ? 23 : 0;
const minute = endOfDay ? 59 : 0;
const second = endOfDay ? 59 : 0;
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters) {
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
: null;
const periodToExpr = typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null;
const asOfExpr = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null;
if (periodFromExpr && periodToExpr) {
return `ГДЕ\n Движения.Период МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
}
if (periodFromExpr) {
return `ГДЕ\n Движения.Период >= ${periodFromExpr}`;
}
if (periodToExpr) {
return `ГДЕ\n Движения.Период <= ${periodToExpr}`;
}
if (asOfExpr) {
return `ГДЕ\n Движения.Период <= ${asOfExpr}`;
}
return "";
}
function selectAddressRecipe(intent, filters) {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
return {
selected_recipe: null,
missing_required_filters: [],
selection_reason: ["intent_recipe_not_implemented_in_v1"]
};
}
const missingRequiredFilters = recipe.required_filters.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
return {
selected_recipe: recipe,
missing_required_filters: missingRequiredFilters,
selection_reason: missingRequiredFilters.length > 0 ? ["missing_required_filters"] : ["recipe_selected"]
};
}
function buildAddressRecipePlan(recipe, filters) {
const resolvedLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
: recipe.default_limit;
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
? [String(filters.account)]
: Array.isArray(recipe.account_scope)
? [...recipe.account_scope]
: [];
const accountScopeMode = recipe.account_scope_mode ?? "strict";
const whereClause = buildWhereClause(filters);
const query = MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", whereClause);
return {
recipe,
query,
limit: resolvedLimit,
account_scope: accountScope,
account_scope_mode: accountScopeMode
};
}
+139 -1
View File
@@ -50,6 +50,7 @@ const questionTypeResolver_1 = __importStar(require("./questionTypeResolver"));
const companyAnchorResolver_1 = __importStar(require("./companyAnchorResolver"));
const assistantRuntimeGuards_1 = __importStar(require("./assistantRuntimeGuards"));
const assistantClaimBoundEvidence_1 = __importStar(require("./assistantClaimBoundEvidence"));
const addressQueryService_1 = __importStar(require("./addressQueryService"));
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
@@ -1720,16 +1721,85 @@ function cloneItems(items) {
debug: item.debug ? { ...item.debug } : null
}));
}
function buildAddressCoverageReport() {
return {
requirements_total: 0,
requirements_covered: 0,
requirements_uncovered: [],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
schema_version: "address_query_runtime_v1",
fallback_type: addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "none",
route_summary: null,
fragments: [],
requirements_extracted: [],
coverage_report: buildAddressCoverageReport(),
routes: [],
retrieval_status: [],
retrieval_results: [],
answer_grounding_check: {
status: grounded,
route_subject_match: true,
missing_requirements: [],
reasons: addressDebug.reasons ?? [],
why_included_summary: [],
selection_reason_summary: []
},
dropped_intent_segments: [],
detected_mode: addressDebug.detected_mode,
detected_mode_confidence: addressDebug.detected_mode_confidence,
query_shape: addressDebug.query_shape,
query_shape_confidence: addressDebug.query_shape_confidence,
detected_intent: addressDebug.detected_intent,
detected_intent_confidence: addressDebug.detected_intent_confidence,
extracted_filters: addressDebug.extracted_filters,
missing_required_filters: addressDebug.missing_required_filters,
selected_recipe: addressDebug.selected_recipe,
account_scope_mode: addressDebug.account_scope_mode,
account_scope_fallback_applied: addressDebug.account_scope_fallback_applied,
anchor_type: addressDebug.anchor_type,
anchor_value_raw: addressDebug.anchor_value_raw,
anchor_value_resolved: addressDebug.anchor_value_resolved,
resolver_confidence: addressDebug.resolver_confidence,
ambiguity_count: addressDebug.ambiguity_count,
mcp_call_status: addressDebug.mcp_call_status,
rows_fetched: addressDebug.rows_fetched,
raw_rows_received: addressDebug.raw_rows_received,
rows_after_account_scope: addressDebug.rows_after_account_scope,
rows_after_recipe_filter: addressDebug.rows_after_recipe_filter,
rows_materialized: addressDebug.rows_materialized,
rows_matched: addressDebug.rows_matched,
raw_row_keys_sample: addressDebug.raw_row_keys_sample,
materialization_drop_reason: addressDebug.materialization_drop_reason,
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
};
}
class AssistantService {
normalizerService;
sessions;
dataLayer;
sessionLogger;
constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger()) {
addressQueryService;
constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService()) {
this.normalizerService = normalizerService;
this.sessions = sessions;
this.dataLayer = dataLayer;
this.sessionLogger = sessionLogger;
this.addressQueryService = addressQueryService;
}
getSession(sessionId) {
return this.sessions.getSession(sessionId);
@@ -1749,6 +1819,74 @@ class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressLane = await this.addressQueryService.tryHandle(userMessage);
if (addressLane?.handled) {
const debug = buildAddressDebugPayload(addressLane.debug);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
detected_intent: addressLane.debug.detected_intent,
extracted_filters: addressLane.debug.extracted_filters,
selected_recipe: addressLane.debug.selected_recipe,
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,
mcp_call_status: addressLane.debug.mcp_call_status,
rows_fetched: addressLane.debug.rows_fetched,
raw_rows_received: addressLane.debug.raw_rows_received,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
rows_materialized: addressLane.debug.rows_materialized,
rows_matched: addressLane.debug.rows_matched,
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
runtime_readiness: addressLane.debug.runtime_readiness,
limited_reason_category: addressLane.debug.limited_reason_category,
response_type: addressLane.debug.response_type,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
};
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 &&
session.investigation_state
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+8
View File
@@ -91,6 +91,14 @@ export const FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1,
false
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
true
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true
);
export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(
/\/+$/,
""
@@ -0,0 +1,202 @@
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 =
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
function toIsoDate(year: number, month: number, day: number): string | null {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function extractAsOfDate(text: string): string | undefined {
if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) {
return new Date().toISOString().slice(0, 10);
}
const ymd = text.match(DATE_YMD_PATTERN);
if (ymd) {
const year = Number(ymd[1]);
const month = Number(ymd[2]);
const day = Number(ymd[3]);
return toIsoDate(year, month, day) ?? undefined;
}
const dmy = text.match(DATE_DMY_PATTERN);
if (dmy) {
const day = Number(dmy[1]);
const month = Number(dmy[2]);
const yearRaw = Number(dmy[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
return toIsoDate(year, month, day) ?? undefined;
}
return undefined;
}
function parseDateToken(token: string): string | undefined {
const value = String(token ?? "").trim();
if (!value) {
return undefined;
}
const dmy = value.match(/^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})$/);
if (dmy) {
const day = Number(dmy[1]);
const month = Number(dmy[2]);
const yearRaw = Number(dmy[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
return toIsoDate(year, month, day) ?? undefined;
}
const ymd = value.match(/^(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})$/);
if (ymd) {
const year = Number(ymd[1]);
const month = Number(ymd[2]);
const day = Number(ymd[3]);
return toIsoDate(year, month, day) ?? undefined;
}
return undefined;
}
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) {
return {};
}
const periodFrom = parseDateToken(String(directMatch[1] ?? ""));
const periodTo = parseDateToken(String(directMatch[2] ?? ""));
return {
...(periodFrom ? { period_from: periodFrom } : {}),
...(periodTo ? { period_to: periodTo } : {})
};
}
function cleanupAnchorValue(value: string): string {
const normalized = String(value ?? "").trim();
if (!normalized) {
return "";
}
return normalized
.replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "")
.replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "")
.trim();
}
function shiftDaysIso(baseIso: string, deltaDays: number): string {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
return date.toISOString().slice(0, 10);
}
function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilterSet> {
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"];
}
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ["counterparty"];
}
return [];
}
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
const text = String(userMessage ?? "").trim();
const filters: AddressFilterSet = {
sort: "period_desc",
limit: 20
};
const warnings: string[] = [];
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
filters.account = String(accountMatch[1]).replace(",", ".");
}
const limitMatch = text.match(LIMIT_PATTERN);
if (limitMatch) {
const parsed = Number(limitMatch[1]);
if (Number.isFinite(parsed) && parsed > 0) {
filters.limit = Math.min(200, Math.trunc(parsed));
}
}
const counterpartyMatch = text.match(COUNTERPARTY_PATTERN);
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
}
const periodRange = extractPeriodRange(text);
if (periodRange.period_from) {
filters.period_from = periodRange.period_from;
}
if (periodRange.period_to) {
filters.period_to = periodRange.period_to;
}
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
if (!filters.period_from && !filters.period_to) {
const asOfDate = extractAsOfDate(text);
if (asOfDate) {
filters.as_of_date = asOfDate;
}
}
// For document/bank lists we default to a short recent window if no explicit period was provided.
if (
(intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") &&
!filters.period_from &&
!filters.period_to
) {
const today = new Date().toISOString().slice(0, 10);
filters.period_to = today;
filters.period_from = shiftDaysIso(today, -90);
warnings.push("period_defaulted_last_90_days");
}
// For balance-style intents we force as_of_date deterministically:
// - explicit as_of has priority;
// - else use period_to boundary when provided;
// - else default to today.
if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance") && !filters.as_of_date) {
if (filters.period_to) {
filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to");
} else {
filters.as_of_date = new Date().toISOString().slice(0, 10);
warnings.push("as_of_date_defaulted_today");
}
}
if (filters.counterparty && filters.counterparty.length < 2) {
warnings.push("counterparty_filter_too_short");
}
if (filters.contract && filters.contract.length < 2) {
warnings.push("contract_filter_too_short");
}
const required = requiredFiltersByIntent(intent);
const missingRequiredFilters = required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
return {
extracted_filters: filters,
missing_required_filters: missingRequiredFilters,
warnings
};
}
@@ -0,0 +1,172 @@
import type { AddressIntentResolution } from "../types/addressQuery";
const RECEIVABLES_STRONG = [
"кто должен нам",
"нам должны",
"who owes us",
"receivable",
"receivables",
"debtor",
"debtors",
"дебитор",
"дебиторск"
];
const PAYABLES_STRONG = [
"кому должны мы",
"мы должны",
"who we owe",
"payable",
"payables",
"creditor",
"creditors",
"кредитор",
"кредиторск"
];
const ACCOUNT_BALANCE_HINTS = [
"account balance",
"balance by account",
"saldo",
"остаток по счет",
"сальдо по счет",
"по счету"
];
const DOCUMENTS_FORMING_BALANCE_HINTS = [
"documents forming balance",
"documents form balance",
"balance documents",
"documents for balance",
"which documents form balance",
"из чего состоит остаток",
"какие документы формируют остаток",
"раскрой остаток по документам",
"документы под остатком"
];
const OPEN_CONTRACTS_HINTS = [
"open contracts",
"unclosed contracts",
"незакрыт",
"не закрыт",
"открыт",
"договор"
];
const OPEN_ITEMS_HINTS = [
"open items",
"unclosed items",
"хвост",
"висят",
"незакрыт",
"открыт",
"позици"
];
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"show documents by counterparty",
"list documents by counterparty",
"документ",
"по контрагент"
];
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"bank operations by counterparty",
"bank payments by counterparty",
"payment orders by counterparty",
"show bank operations by counterparty",
"банков",
"выписк",
"платеж"
];
function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
}
function hasAccountNumberAnchor(text: string): boolean {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
const text = String(userMessage ?? "").trim().toLowerCase();
if (hasAny(text, RECEIVABLES_STRONG)) {
return {
intent: "list_receivables_counterparties",
confidence: "high",
reasons: ["receivables_signal_detected"]
};
}
if (hasAny(text, PAYABLES_STRONG)) {
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
};
}
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS) && (hasAccountNumberAnchor(text) || text.includes("счет"))) {
return {
intent: "documents_forming_balance",
confidence: "high",
reasons: ["documents_forming_balance_signal_detected"]
};
}
if (hasAny(text, ACCOUNT_BALANCE_HINTS) || hasAccountNumberAnchor(text)) {
return {
intent: "account_balance_snapshot",
confidence: "high",
reasons: ["account_balance_signal_detected"]
};
}
if (
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
) {
return {
intent: "bank_operations_by_counterparty",
confidence: "medium",
reasons: ["bank_ops_by_counterparty_signal_detected"]
};
}
if (
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
) {
return {
intent: "list_documents_by_counterparty",
confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "medium",
reasons: ["open_items_signal_detected"]
};
}
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",
reasons: ["open_contract_signal_detected"]
};
}
return {
intent: "unknown",
confidence: "low",
reasons: ["intent_not_supported_in_v1"]
};
}
@@ -0,0 +1,252 @@
import {
ASSISTANT_MCP_CHANNEL,
ASSISTANT_MCP_PROXY_URL,
ASSISTANT_MCP_TIMEOUT_MS
} from "../config";
interface McpExecuteQueryResponse {
success?: unknown;
data?: unknown;
error?: unknown;
}
export interface AddressMcpQueryResult {
ok: boolean;
rows: Array<Record<string, unknown>>;
error: string | null;
}
function toStringValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
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 parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
const headerMatch = normalized.match(/\{([^}]*)\}:/);
if (!headerMatch) {
return [];
}
const columns = String(headerMatch[1] ?? "")
.split(",")
.map((item) => item.replace(/^"+|"+$/g, "").trim())
.filter(Boolean);
const body = normalized.slice((headerMatch.index ?? 0) + headerMatch[0].length).trim();
if (!body) {
return [];
}
const rows: Array<Record<string, unknown>> = [];
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);
}
}
if (values.length === 0) {
continue;
}
const row: Record<string, unknown> = {};
for (let index = 0; index < columns.length; index += 1) {
const key = columns[index] ?? `column_${index + 1}`;
const raw = values[index] ?? "";
const parsed = parseFiniteNumber(raw);
row[key] = parsed ?? raw;
}
if (values[0]) row.Period = values[0];
if (values[1]) row.Registrator = values[1];
if (values[2]) row.AccountDt = values[2];
if (values[3]) row.AccountKt = values[3];
if (values[4]) row.Amount = parseFiniteNumber(values[4]) ?? values[4];
rows.push(row);
}
return rows;
}
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
if (!payload || typeof payload !== "object") {
return {
ok: false,
rows: [],
error: "MCP payload is empty or malformed"
};
}
const source = payload as McpExecuteQueryResponse;
if (source.success !== true) {
return {
ok: false,
rows: [],
error: toStringValue(source.error).trim() || "MCP execute_query returned success=false"
};
}
if (Array.isArray(source.data)) {
const rows = source.data
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null);
return {
ok: true,
rows,
error: null
};
}
if (typeof source.data === "string") {
return {
ok: true,
rows: parseRowsFromTextTable(source.data),
error: null
};
}
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
const rows = ((source.data as { rows: unknown[] }).rows ?? [])
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null);
return {
ok: true,
rows,
error: null
};
}
return {
ok: true,
rows: [],
error: null
};
}
function buildMcpUrl(endpoint: string): string {
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
const separator = normalizedEndpoint.includes("?") ? "&" : "?";
return `${ASSISTANT_MCP_PROXY_URL}${normalizedEndpoint}${separator}channel=${encodeURIComponent(ASSISTANT_MCP_CHANNEL)}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function filterRowsByAccountScope(
rows: Array<Record<string, unknown>>,
accountScope: string[]
): Array<Record<string, unknown>> {
if (accountScope.length === 0) {
return rows;
}
const matchers = accountScope.map((account) => new RegExp(`\\b${escapeRegExp(account)}(?:\\.\\d{1,2})?\\b`, "i"));
return rows.filter((row) => {
const searchable = Object.values(row)
.map((item) => String(item ?? ""))
.join(" ");
return matchers.some((matcher) => matcher.test(searchable));
});
}
export async function executeAddressMcpQuery(input: {
query: string;
limit: number;
account_scope?: string[];
}): Promise<{
fetched_rows: number;
matched_rows: number;
raw_rows: Array<Record<string, unknown>>;
rows: Array<Record<string, unknown>>;
error: string | null;
}> {
const endpoint = buildMcpUrl("/api/execute_query");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS));
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify({
query: input.query,
limit: input.limit
}),
signal: controller.signal
});
const responseText = await response.text();
if (!response.ok) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
};
}
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
const parsed = parseExecutePayload(payload);
if (!parsed.ok) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: parsed.error
};
}
const filtered = filterRowsByAccountScope(parsed.rows, Array.isArray(input.account_scope) ? input.account_scope : []);
return {
fetched_rows: parsed.rows.length,
matched_rows: filtered.length,
raw_rows: parsed.rows,
rows: filtered,
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP fetch failed: ${message}`
};
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,123 @@
import type { AddressModeDetection } from "../types/addressQuery";
const ADDRESS_ACTION_TOKENS = [
"show",
"list",
"find",
"get",
"lookup",
"open",
"balance",
"debt",
"owe",
"покажи",
"список",
"найди",
"выведи",
"кто",
"кому",
"какие",
"остаток",
"долг",
"задолж",
"хвост",
"незакрыт"
];
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"contract",
"contracts",
"account",
"accounts",
"document",
"documents",
"balance",
"payable",
"payables",
"receivable",
"receivables",
"owe",
"owes",
"owed",
"контрагент",
"договор",
"счет",
"счёт",
"документ",
"остаток",
"дебитор",
"кредитор",
"аванс",
"оплат",
"долг",
"должен",
"должны",
"должна"
];
const DEEP_REASONING_TOKENS = [
"why",
"because",
"root cause",
"mechanism",
"prove",
"chain",
"почему",
"причин",
"механизм",
"докажи",
"цепоч",
"разрыв",
"ошибк"
];
function hasAnyToken(text: string, tokens: string[]): boolean {
return tokens.some((token) => text.includes(token));
}
export function detectAddressQuestionMode(userMessage: string): AddressModeDetection {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return {
mode: "unsupported",
confidence: "low",
reasons: ["empty_message"]
};
}
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "high",
reasons: ["address_action_detected", "address_entity_detected"]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["address_entity_detected"]
};
}
if (hasDeepReasoning) {
return {
mode: "deep_analysis",
confidence: "high",
reasons: ["deep_reasoning_signal_detected"]
};
}
return {
mode: "unsupported",
confidence: "low",
reasons: ["no_address_or_deep_signal"]
};
}
@@ -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
}
};
}
}
@@ -0,0 +1,163 @@
import type { AddressQueryShapeDetection } from "../types/addressQuery";
const EXPLAIN_PATTERNS: RegExp[] = [
/why/iu,
/because/iu,
/root cause/iu,
/prove/iu,
/mechanism/iu,
/\u043f\u043e\u0447\u0435\u043c\u0443/iu,
/\u043f\u0440\u0438\u0447\u0438\u043d/iu,
/\u043e\u0448\u0438\u0431\u043a/iu,
/\u0434\u043e\u043a\u0430\u0436/iu,
/\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c/iu
];
const VERIFY_PATTERNS: RegExp[] = [
/check/iu,
/verify/iu,
/is there/iu,
/was there/iu,
/\u043f\u0440\u043e\u0432\u0435\u0440/iu,
/\u0435\u0441\u0442\u044c\s+\u043b\u0438/iu,
/\u0431\u044b\u043b\u0438\s+\u043b\u0438/iu
];
const DRILLDOWN_PATTERNS: RegExp[] = [
/drilldown/iu,
/breakdown/iu,
/forming balance/iu,
/\u0444\u043e\u0440\u043c\u0438\u0440\u0443\u044e\u0442/iu,
/\u0440\u0430\u0441\u043a\u0440\u043e\u0439/iu,
/\u0438\u0437\s+\u0447\u0435\u0433\u043e/iu
];
const DOCUMENT_PATTERNS: RegExp[] = [
/document/iu,
/invoice/iu,
/payment/iu,
/bank operation/iu,
/\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442/iu,
/\u043f\u043b\u0430\u0442\u0435\u0436/iu,
/\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d/iu,
/\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446/iu,
/\u0432\u044b\u043f\u0438\u0441\u043a/iu
];
const AGGREGATE_PATTERNS: RegExp[] = [
/who owes us/iu,
/who we owe/iu,
/balance/iu,
/receivable/iu,
/payable/iu,
/total/iu,
/\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d/iu,
/\u043a\u043e\u043c\u0443\s+\u0434\u043e\u043b\u0436\u043d\u044b/iu,
/\u043e\u0441\u0442\u0430\u0442\u043e\u043a/iu,
/\u0441\u0430\u043b\u044c\u0434\u043e/iu,
/\u043e\u0431\u043e\u0440\u043e\u0442/iu,
/\u0434\u043e\u043b\u0433/iu,
/\u0437\u0430\u0434\u043e\u043b\u0436/iu
];
const OBJECT_PATTERNS: RegExp[] = [
/by counterparty/iu,
/by contract/iu,
/counterparty/iu,
/contract/iu,
/\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442/iu,
/\u043f\u043e\s+\u0434\u043e\u0433\u043e\u0432\u043e\u0440/iu,
/\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442/iu,
/\u0434\u043e\u0433\u043e\u0432\u043e\u0440/iu
];
function hasAnyPattern(text: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(text));
}
function hasCompoundSignal(text: string): boolean {
const hasJoin = /(?:\s+\u0438\s+|\sand\s|;|,)/iu.test(text);
const hasActionVerb =
/(?:\u043a\u0442\u043e|\u043a\u043e\u043c\u0443|\u043f\u043e\u043a\u0430\u0436\u0438|\u043d\u0430\u0439\u0434\u0438|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|who|show|find|list|check|verify)/iu.test(
text
);
if (hasJoin && hasActionVerb) {
return true;
}
return /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u0438\s+\u0435\u0449\u0435|\u0438\s+\u0442\u0430\u043a\u0436\u0435|and also|then)/iu.test(
text
);
}
export function classifyAddressQueryShape(userMessage: string): AddressQueryShapeDetection {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return {
shape: "UNKNOWN",
confidence: "low",
reasons: ["empty_message"]
};
}
if (hasAnyPattern(text, EXPLAIN_PATTERNS)) {
return {
shape: "EXPLAIN_OR_REASON",
confidence: "high",
reasons: ["explain_signal_detected"]
};
}
if (hasCompoundSignal(text)) {
return {
shape: "COMPOUND_FACTUAL_QUERY",
confidence: "medium",
reasons: ["compound_signal_detected"]
};
}
if (hasAnyPattern(text, DRILLDOWN_PATTERNS)) {
return {
shape: "DRILLDOWN_REQUEST",
confidence: "high",
reasons: ["drilldown_signal_detected"]
};
}
if (hasAnyPattern(text, VERIFY_PATTERNS)) {
return {
shape: "VERIFY_FACTUAL",
confidence: "medium",
reasons: ["verify_signal_detected"]
};
}
if (hasAnyPattern(text, DOCUMENT_PATTERNS)) {
return {
shape: "DOCUMENT_LIST",
confidence: "medium",
reasons: ["document_list_signal_detected"]
};
}
if (hasAnyPattern(text, AGGREGATE_PATTERNS)) {
return {
shape: "AGGREGATE_LOOKUP",
confidence: "high",
reasons: ["aggregate_signal_detected"]
};
}
if (hasAnyPattern(text, OBJECT_PATTERNS)) {
return {
shape: "OBJECT_LOOKUP",
confidence: "medium",
reasons: ["object_signal_detected"]
};
}
return {
shape: "UNKNOWN",
confidence: "low",
reasons: ["shape_not_detected"]
};
}
@@ -0,0 +1,210 @@
import type {
AddressFilterSet,
AddressIntent,
AddressRecipeDefinition,
AddressRecipeSelection
} from "../types/addressQuery";
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_movements_payables_v1",
intent: "list_payables_counterparties",
purpose: "List payable-related movements for accounts 60/76",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64,
account_scope: ["60", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties",
purpose: "List receivable-related movements for accounts 62/76",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64,
account_scope: ["62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_open_contracts_candidates_v1",
intent: "list_open_contracts",
purpose: "Collect contract candidates from 60/62/76 movements",
required_filters: [],
optional_filters: ["as_of_date", "organization", "limit"],
default_limit: 128,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_open_items_by_party_or_contract_v1",
intent: "open_items_by_counterparty_or_contract",
purpose: "Collect open movement items with counterparty/contract filters",
required_filters: [],
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 96,
account_scope: ["60", "62", "76"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_documents_by_counterparty_v1",
intent: "list_documents_by_counterparty",
purpose: "Collect counterparty-related document lines from movements",
required_filters: ["counterparty"],
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"
},
{
recipe_id: "address_bank_operations_by_counterparty_v1",
intent: "bank_operations_by_counterparty",
purpose: "Collect bank operation candidates by counterparty from movement lines",
required_filters: ["counterparty"],
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["51", "52"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_documents_forming_balance_v1",
intent: "documents_forming_balance",
purpose: "Drilldown movements that form account balance as-of date",
required_filters: ["account", "as_of_date"],
optional_filters: ["organization", "counterparty", "contract", "period_from", "period_to", "limit", "sort"],
default_limit: 120,
account_scope_mode: "strict"
},
{
recipe_id: "address_movements_account_snapshot_v1",
intent: "account_balance_snapshot",
purpose: "Build account movement snapshot for explicit account",
required_filters: ["account"],
optional_filters: ["as_of_date", "period_from", "period_to", "limit"],
default_limit: 96,
account_scope_mode: "strict"
}
];
export interface AddressRecipeExecutionPlan {
recipe: AddressRecipeDefinition;
query: string;
limit: number;
account_scope: string[];
account_scope_mode: "strict" | "preferred";
}
function toDateTimeExpr(isoDate: string, endOfDay: boolean): string | null {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const hour = endOfDay ? 23 : 0;
const minute = endOfDay ? 59 : 0;
const second = endOfDay ? 59 : 0;
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters: AddressFilterSet): string {
const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
: null;
const periodToExpr =
typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null;
const asOfExpr =
typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null;
if (periodFromExpr && periodToExpr) {
return `ГДЕ\n Движения.Период МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
}
if (periodFromExpr) {
return `ГДЕ\n Движения.Период >= ${periodFromExpr}`;
}
if (periodToExpr) {
return `ГДЕ\n Движения.Период <= ${periodToExpr}`;
}
if (asOfExpr) {
return `ГДЕ\n Движения.Период <= ${asOfExpr}`;
}
return "";
}
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
return {
selected_recipe: null,
missing_required_filters: [],
selection_reason: ["intent_recipe_not_implemented_in_v1"]
};
}
const missingRequiredFilters = recipe.required_filters.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
return {
selected_recipe: recipe,
missing_required_filters: missingRequiredFilters,
selection_reason: missingRequiredFilters.length > 0 ? ["missing_required_filters"] : ["recipe_selected"]
};
}
export function buildAddressRecipePlan(
recipe: AddressRecipeDefinition,
filters: AddressFilterSet
): AddressRecipeExecutionPlan {
const resolvedLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
: recipe.default_limit;
const accountScope =
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
? [String(filters.account)]
: Array.isArray(recipe.account_scope)
? [...recipe.account_scope]
: [];
const accountScopeMode = recipe.account_scope_mode ?? "strict";
const whereClause = buildWhereClause(filters);
const query = MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
"__WHERE_CLAUSE__",
whereClause
);
return {
recipe,
query,
limit: resolvedLimit,
account_scope: accountScope,
account_scope_mode: accountScopeMode
};
}
@@ -12,6 +12,7 @@ import * as questionTypeResolver_1 from "./questionTypeResolver";
import * as companyAnchorResolver_1 from "./companyAnchorResolver";
import * as assistantRuntimeGuards_1 from "./assistantRuntimeGuards";
import * as assistantClaimBoundEvidence_1 from "./assistantClaimBoundEvidence";
import * as addressQueryService_1 from "./addressQueryService";
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
@@ -1682,16 +1683,85 @@ function cloneItems(items) {
debug: item.debug ? { ...item.debug } : null
}));
}
function buildAddressCoverageReport() {
return {
requirements_total: 0,
requirements_covered: 0,
requirements_uncovered: [],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
schema_version: "address_query_runtime_v1",
fallback_type: addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "none",
route_summary: null,
fragments: [],
requirements_extracted: [],
coverage_report: buildAddressCoverageReport(),
routes: [],
retrieval_status: [],
retrieval_results: [],
answer_grounding_check: {
status: grounded,
route_subject_match: true,
missing_requirements: [],
reasons: addressDebug.reasons ?? [],
why_included_summary: [],
selection_reason_summary: []
},
dropped_intent_segments: [],
detected_mode: addressDebug.detected_mode,
detected_mode_confidence: addressDebug.detected_mode_confidence,
query_shape: addressDebug.query_shape,
query_shape_confidence: addressDebug.query_shape_confidence,
detected_intent: addressDebug.detected_intent,
detected_intent_confidence: addressDebug.detected_intent_confidence,
extracted_filters: addressDebug.extracted_filters,
missing_required_filters: addressDebug.missing_required_filters,
selected_recipe: addressDebug.selected_recipe,
account_scope_mode: addressDebug.account_scope_mode,
account_scope_fallback_applied: addressDebug.account_scope_fallback_applied,
anchor_type: addressDebug.anchor_type,
anchor_value_raw: addressDebug.anchor_value_raw,
anchor_value_resolved: addressDebug.anchor_value_resolved,
resolver_confidence: addressDebug.resolver_confidence,
ambiguity_count: addressDebug.ambiguity_count,
mcp_call_status: addressDebug.mcp_call_status,
rows_fetched: addressDebug.rows_fetched,
raw_rows_received: addressDebug.raw_rows_received,
rows_after_account_scope: addressDebug.rows_after_account_scope,
rows_after_recipe_filter: addressDebug.rows_after_recipe_filter,
rows_materialized: addressDebug.rows_materialized,
rows_matched: addressDebug.rows_matched,
raw_row_keys_sample: addressDebug.raw_row_keys_sample,
materialization_drop_reason: addressDebug.materialization_drop_reason,
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
};
}
export class AssistantService {
normalizerService;
sessions;
dataLayer;
sessionLogger;
constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger()) {
addressQueryService;
constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService()) {
this.normalizerService = normalizerService;
this.sessions = sessions;
this.dataLayer = dataLayer;
this.sessionLogger = sessionLogger;
this.addressQueryService = addressQueryService;
}
getSession(sessionId) {
return this.sessions.getSession(sessionId);
@@ -1711,6 +1781,74 @@ export class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressLane = await this.addressQueryService.tryHandle(userMessage);
if (addressLane?.handled) {
const debug = buildAddressDebugPayload(addressLane.debug);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
detected_intent: addressLane.debug.detected_intent,
extracted_filters: addressLane.debug.extracted_filters,
selected_recipe: addressLane.debug.selected_recipe,
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,
mcp_call_status: addressLane.debug.mcp_call_status,
rows_fetched: addressLane.debug.rows_fetched,
raw_rows_received: addressLane.debug.raw_rows_received,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
rows_materialized: addressLane.debug.rows_materialized,
rows_matched: addressLane.debug.rows_matched,
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
runtime_readiness: addressLane.debug.runtime_readiness,
limited_reason_category: addressLane.debug.limited_reason_category,
response_type: addressLane.debug.response_type,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
};
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 &&
session.investigation_state
@@ -0,0 +1,158 @@
export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported";
export type AddressIntent =
| "list_open_contracts"
| "list_payables_counterparties"
| "list_receivables_counterparties"
| "account_balance_snapshot"
| "open_items_by_counterparty_or_contract"
| "list_documents_by_counterparty"
| "bank_operations_by_counterparty"
| "documents_forming_balance"
| "unknown";
export type AddressResponseType = "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
export type AddressQueryShape =
| "AGGREGATE_LOOKUP"
| "OBJECT_LOOKUP"
| "DOCUMENT_LIST"
| "DRILLDOWN_REQUEST"
| "COMPOUND_FACTUAL_QUERY"
| "VERIFY_FACTUAL"
| "EXPLAIN_OR_REASON"
| "UNKNOWN";
export type AddressLimitedReasonCategory =
| "empty_match"
| "missing_anchor"
| "recipe_visibility_gap"
| "execution_error"
| "unsupported";
export type AddressRuntimeReadiness =
| "LIVE_QUERYABLE"
| "LIVE_QUERYABLE_WITH_LIMITS"
| "REQUIRES_SPECIALIZED_RECIPE"
| "DEEP_ONLY"
| "UNKNOWN";
export type AddressMcpCallStatus =
| "skipped"
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_matched"
| "matched_non_empty";
export type AddressAccountScopeMode = "strict" | "preferred";
export interface AddressModeDetection {
mode: AddressQuestionMode;
confidence: "high" | "medium" | "low";
reasons: string[];
}
export interface AddressQueryShapeDetection {
shape: AddressQueryShape;
confidence: "high" | "medium" | "low";
reasons: string[];
}
export interface AddressIntentResolution {
intent: AddressIntent;
confidence: "high" | "medium" | "low";
reasons: string[];
}
export interface AddressFilterSet {
period_from?: string;
period_to?: string;
as_of_date?: string;
organization?: string;
counterparty?: string;
contract?: string;
account?: string;
document_type?: string;
document_ref?: string;
status?: string;
limit?: number;
sort?: "period_desc" | "period_asc";
}
export interface AddressFilterExtraction {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
warnings: string[];
}
export interface AddressRecipeDefinition {
recipe_id: string;
intent: Exclude<AddressIntent, "unknown">;
purpose: string;
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;
account_scope?: string[];
account_scope_mode?: AddressAccountScopeMode;
}
export interface AddressRecipeSelection {
selected_recipe: AddressRecipeDefinition | null;
missing_required_filters: string[];
selection_reason: string[];
}
export interface AddressLiveCallStatus {
ok: boolean;
error: string | null;
rows_fetched: number;
rows_matched: number;
}
export interface AddressExecutionDebug {
detected_mode: AddressQuestionMode;
detected_mode_confidence: "high" | "medium" | "low";
query_shape: AddressQueryShape;
query_shape_confidence: "high" | "medium" | "low";
detected_intent: AddressIntent;
detected_intent_confidence: "high" | "medium" | "low";
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
selected_recipe: string | null;
account_scope_mode: AddressAccountScopeMode;
account_scope_fallback_applied: boolean;
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;
mcp_call_status: AddressMcpCallStatus;
rows_fetched: number;
raw_rows_received: number;
rows_after_account_scope: number;
rows_after_recipe_filter: number;
rows_materialized: number;
rows_matched: number;
raw_row_keys_sample: string[];
materialization_drop_reason:
| "none"
| "dropped_by_account_scope_filter"
| "missing_period_and_registrator_fields"
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType;
limitations: string[];
reasons: string[];
}
export interface AddressExecutionResult {
handled: boolean;
reply_text: string;
reply_type: "factual" | "partial_coverage";
response_type: AddressResponseType;
debug: AddressExecutionDebug;
}
@@ -312,6 +312,54 @@ export interface AssistantDebugPayload {
retrieval_results: UnifiedRetrievalResult[];
answer_grounding_check: AnswerGroundingCheck;
dropped_intent_segments: string[];
detected_mode?: "address_query" | "deep_analysis" | "unsupported";
detected_mode_confidence?: "high" | "medium" | "low";
query_shape?:
| "AGGREGATE_LOOKUP"
| "OBJECT_LOOKUP"
| "DOCUMENT_LIST"
| "DRILLDOWN_REQUEST"
| "COMPOUND_FACTUAL_QUERY"
| "VERIFY_FACTUAL"
| "EXPLAIN_OR_REASON"
| "UNKNOWN";
query_shape_confidence?: "high" | "medium" | "low";
detected_intent?: string;
detected_intent_confidence?: "high" | "medium" | "low";
extracted_filters?: Record<string, unknown>;
missing_required_filters?: string[];
selected_recipe?: string | null;
account_scope_mode?: "strict" | "preferred";
account_scope_fallback_applied?: boolean;
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;
mcp_call_status?:
| "skipped"
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_matched"
| "matched_non_empty";
rows_fetched?: number;
raw_rows_received?: number;
rows_after_account_scope?: number;
rows_after_recipe_filter?: number;
rows_materialized?: number;
rows_matched?: number;
raw_row_keys_sample?: string[];
materialization_drop_reason?:
| "none"
| "dropped_by_account_scope_filter"
| "missing_period_and_registrator_fields"
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
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";
business_scope_raw?: string[];
business_scope_resolved?: string[];
company_grounding_applied?: boolean;
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService";
describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => {
const result = classifyAddressQueryShape("Why VAT chain does not match?");
expect(result.shape).toBe("EXPLAIN_OR_REASON");
expect(result.confidence).toBe("high");
});
it("classifies aggregate lookup question", () => {
const result = classifyAddressQueryShape("who owes us today?");
expect(result.shape).toBe("AGGREGATE_LOOKUP");
});
it("classifies compound factual question", () => {
const result = classifyAddressQueryShape("who owes us and who we owe today?");
expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
it("resolves documents by counterparty intent", () => {
const result = resolveAddressIntent("show documents by counterparty Alfa from 2020-07-01 to 2020-07-31");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves bank operations by counterparty intent", () => {
const result = resolveAddressIntent("show bank operations by counterparty Alfa");
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves documents forming balance intent", () => {
const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
});
describe("address filter extraction for balance drilldown", () => {
it("defaults as_of_date for documents_forming_balance when date is omitted", () => {
const result = extractAddressFilters("which documents form balance for account 62", "documents_forming_balance");
expect(result.extracted_filters.account).toBe("62");
expect(result.extracted_filters.as_of_date).toBeDefined();
expect(result.missing_required_filters).toEqual([]);
});
});
describe("address query limited taxonomy and stage diagnostics", () => {
it("returns missing_anchor for open items without concrete counterparty/contract anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show open items by contract");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
expect(result?.debug.mcp_call_status).toBe("skipped");
});
it("returns unsupported for not-implemented contract document list intent", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show documents by contract 15/24");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.limited_reason_category).toBe("unsupported");
expect(result?.debug.mcp_call_status).toBe("skipped");
});
it("includes resolver and row-stage diagnostics", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("which documents form balance for account 62 as of 2020-07-31");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.anchor_type).toBe("account");
expect(result?.debug.rows_fetched).toBeTypeOf("number");
expect(result?.debug.raw_rows_received).toBeTypeOf("number");
expect(result?.debug.rows_after_account_scope).toBeTypeOf("number");
expect(result?.debug.rows_materialized).toBeTypeOf("number");
expect(result?.debug.rows_after_recipe_filter).toBeTypeOf("number");
expect(result?.debug.rows_matched).toBeTypeOf("number");
expect(["strict", "preferred"]).toContain(result?.debug.account_scope_mode);
expect(result?.debug.account_scope_fallback_applied).toBeTypeOf("boolean");
expect([
"no_raw_rows",
"raw_rows_received_but_not_materialized",
"materialized_but_not_matched",
"matched_non_empty"
]).toContain(result?.debug.mcp_call_status);
expect(result?.debug.raw_row_keys_sample).toBeDefined();
expect(result?.debug.materialization_drop_reason).toBeDefined();
});
});