АДРЕСНЫЙ РЕЖИМ - M2.3b тюнинг account-scope и диагностика стадий адресного рантайма
This commit is contained in:
@@ -8,6 +8,8 @@ OPENAI_MAX_OUTPUT_TOKENS=700
|
||||
DATA_DIR=./data
|
||||
TZ_FALLBACK=Europe/Moscow
|
||||
FEATURE_ASSISTANT_MCP_RUNTIME_V1=0
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_V1=1
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1=1
|
||||
ASSISTANT_MCP_PROXY_URL=http://127.0.0.1:6003
|
||||
ASSISTANT_MCP_CHANNEL=default
|
||||
ASSISTANT_MCP_TIMEOUT_MS=1200
|
||||
|
||||
+3
-1
@@ -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"]
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,65 +0,0 @@
|
||||
# Run Folders
|
||||
|
||||
Эта папка используется для хранения артефактов каждой отдельной волны.
|
||||
|
||||
## Обязательный формат имени run-папки
|
||||
|
||||
- `docs/runs/YYYY-MM-DD_Stage_<NN>_Wave_<NN>_<short_topic>/`
|
||||
|
||||
Правило порядка строгое:
|
||||
- после даты всегда идет `Stage`;
|
||||
- после `Stage` всегда идет `Wave`;
|
||||
- затем краткая тема волны.
|
||||
|
||||
Пример:
|
||||
- `docs/runs/2026-03-26_Stage_04_Wave_01_Kickoff/`
|
||||
|
||||
## Обязательная структура внутри run-папки
|
||||
|
||||
- `README.md` — что проверяли и зачем;
|
||||
- `run_summary.json` — команды, результаты, ключевые ссылки;
|
||||
- `artifacts/` — отчеты прогонов (test/eval/acceptance/regression);
|
||||
- `prompt_dialogs/` — диалоги user/system/assistant и runtime-контекст.
|
||||
- `чат.txt` — контрольный прогон по 3 вопросам (основной пакет);
|
||||
- `чат_2q.txt` — короткий smoke-прогон по 2 вопросам.
|
||||
|
||||
## Обязательная структура `prompt_dialogs`
|
||||
|
||||
- `prompt_dialogs/index.json`
|
||||
- `prompt_dialogs/<suite>/<case_id>.json`
|
||||
- `prompt_dialogs/<suite>/<case_id>.md`
|
||||
|
||||
Минимум по каждому кейсу:
|
||||
- вопрос пользователя;
|
||||
- ответ системы (assistant reply);
|
||||
- технический контекст, доступный для анализа (debug/runtime/decomposition/grounding, если есть).
|
||||
|
||||
## Контрольные прогоны по вопросам
|
||||
|
||||
Для новых волн фиксируем два текстовых лога в run-папке:
|
||||
- `чат.txt` — полный прогон по 3 контрольным вопросам;
|
||||
- `чат_2q.txt` — короткий прогон по 2 вопросам для быстрого rerun-подтверждения.
|
||||
|
||||
Оба файла ведутся в одном стиле:
|
||||
- блоки `user` / `assistant`;
|
||||
- `reply_type`, `trace_id`;
|
||||
- при необходимости отдельный debug-блок.
|
||||
|
||||
## Важное правило по волнам
|
||||
|
||||
Артефакты разных волн нельзя смешивать в одной папке.
|
||||
Каждая волна должна иметь собственную run-папку и собственный набор `prompt_dialogs`.
|
||||
|
||||
## Архивация
|
||||
|
||||
```bash
|
||||
npm run artifacts:bundle
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run artifacts:bundle:clean
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run artifacts:bundle:clean -- --label stage4_wave1
|
||||
```
|
||||
@@ -1,75 +0,0 @@
|
||||
# API Contract
|
||||
|
||||
Base URL: `http://localhost:8787`
|
||||
|
||||
## POST `/api/normalize`
|
||||
|
||||
Core request fields:
|
||||
- `promptVersion` (e.g. `normalizer_v2_0_2`)
|
||||
- `schemaVersion` (e.g. `v2_0_2`)
|
||||
- `userQuestion`
|
||||
- model transport fields (`apiKey`, `model`, `baseUrl`, `temperature`, `maxOutputTokens`)
|
||||
|
||||
For v2.0.2, backend returns:
|
||||
- `schema_version: "v2_0_2"`
|
||||
- normalized payload with `normalized_query_v2_0_2`
|
||||
- deterministic `route_hint_summary`
|
||||
|
||||
Schema selection:
|
||||
- `promptVersion=normalizer_v2_0_2` or `schemaVersion=v2_0_2` -> `normalized_query_v2_0_2`
|
||||
- `promptVersion=normalizer_v2_0_1` or `schemaVersion=v2_0_1` -> `normalized_query_v2_0_1`
|
||||
- `promptVersion=normalizer_v2` or `schemaVersion=v2` -> `normalized_query_v2`
|
||||
- otherwise -> `normalized_query_v1`
|
||||
|
||||
## POST `/api/eval/run`
|
||||
|
||||
Supports v2 family (`v2`, `v2_0_1`, `v2_0_2`) with inline batch via `rawQuestions`.
|
||||
|
||||
Assistant Stage 1 eval target is additive and enabled only when `eval_target=assistant_stage1`.
|
||||
Legacy normalizer eval remains default when `eval_target` is omitted.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"mode": "single-pass-strict",
|
||||
"rawQuestions": "вопрос 1; вопрос 2; вопрос 3",
|
||||
"useMock": false,
|
||||
"normalizeConfig": {
|
||||
"promptVersion": "normalizer_v2_0_2",
|
||||
"schemaVersion": "v2_0_2",
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0,
|
||||
"maxOutputTokens": 900
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Assistant Stage 1 example:
|
||||
```json
|
||||
{
|
||||
"eval_target": "assistant_stage1",
|
||||
"mode": "single-pass-strict",
|
||||
"useMock": true,
|
||||
"caseSetFile": "assistant_stage1_canonical_v0_1.json",
|
||||
"compare_with_report_file": "assistant-stage1-baseline.json",
|
||||
"normalizeConfig": {
|
||||
"promptVersion": "normalizer_v2_0_2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
v2.0.2 eval metrics include:
|
||||
- `schema_validation_pass_rate`
|
||||
- `scope_detection_accuracy`
|
||||
- `route_resolution_accuracy`
|
||||
- `no_route_precision`
|
||||
- `false_no_route_rate`
|
||||
- `execution_state_consistency_rate`
|
||||
- `clarification_precision`
|
||||
- `clarification_recall`
|
||||
|
||||
## Presets and History
|
||||
- `GET /api/presets`
|
||||
- `POST /api/presets/save`
|
||||
- `GET /api/history`
|
||||
- `GET /api/history/:trace_id`
|
||||
@@ -1,33 +0,0 @@
|
||||
# Prompt System
|
||||
|
||||
Промпты лежат в корне проекта в каталоге `prompts/`.
|
||||
|
||||
## Supported Versions
|
||||
- `normalizer_v1`
|
||||
- `normalizer_v1_1`
|
||||
- `normalizer_v1_1_1`
|
||||
- `normalizer_v1_1_2`
|
||||
- `normalizer_v1_1_2_1`
|
||||
- `normalizer_v2`
|
||||
- `normalizer_v2_0_1`
|
||||
- `normalizer_v2_0_2`
|
||||
|
||||
## Main Files
|
||||
- `prompts/system/default.txt`
|
||||
- `prompts/domain/normalizer_domain_v1_1.txt`
|
||||
- `prompts/developer/normalizer_v2_0_2.txt`
|
||||
- `prompts/fewshot/normalizer_v2_0_2.txt`
|
||||
|
||||
## v2.0.2 Notes
|
||||
- Целевая схема: `normalized_query_v2_0_2`.
|
||||
- Требует fragment-level поля:
|
||||
- `execution_readiness`
|
||||
- `route_status`
|
||||
- `no_route_reason`
|
||||
- Добавляет дисциплину: routable in-scope fragments не должны оставаться в `no_route`.
|
||||
|
||||
## Prompt Manager
|
||||
`backend/src/services/promptBuilder.ts`:
|
||||
- Загружает builtin presets для всех версий, включая `normalizer_v2_0_2`.
|
||||
- Подставляет version-specific `developer/domain/fewshot`.
|
||||
- По умолчанию использует `DEFAULT_PROMPT_VERSION` из backend config.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Schema Contracts
|
||||
|
||||
## Supported Schemas
|
||||
- `normalized_query_v1`
|
||||
- file: `backend/src/schemas/normalized_query_v1.json`
|
||||
- `normalized_query_v2`
|
||||
- file: `backend/src/schemas/normalized_query_v2.json`
|
||||
- `normalized_query_v2_0_1`
|
||||
- file: `backend/src/schemas/normalized_query_v2_0_1.json`
|
||||
- `normalized_query_v2_0_2`
|
||||
- file: `backend/src/schemas/normalized_query_v2_0_2.json`
|
||||
|
||||
Root aliases in `/schemas`:
|
||||
- `schemas/normalized_query_v2.json`
|
||||
- `schemas/normalized_query_v2_0_1.json`
|
||||
- `schemas/normalized_query_v2_0_2.json`
|
||||
|
||||
## v2.0.2 Additions
|
||||
Fragment-level required fields:
|
||||
- `execution_readiness`
|
||||
- `route_status`
|
||||
- `no_route_reason`
|
||||
|
||||
Enums:
|
||||
- `execution_readiness`: `executable | executable_with_soft_assumptions | needs_clarification | no_route`
|
||||
- `route_status`: `routed | no_route`
|
||||
- `no_route_reason`: `out_of_scope | insufficient_specificity | missing_mapping | unsupported_fragment_type`
|
||||
|
||||
Consistency rules in schema:
|
||||
- If `route_status=no_route` then `no_route_reason` must be non-null enum value.
|
||||
- If `route_status=routed` then `no_route_reason` must be `null`.
|
||||
|
||||
## Validation API
|
||||
Backend validates via AJV:
|
||||
- `validateNormalized(payload, "v1")`
|
||||
- `validateNormalized(payload, "v2")`
|
||||
- `validateNormalized(payload, "v2_0_1")`
|
||||
- `validateNormalized(payload, "v2_0_2")`
|
||||
@@ -1,45 +0,0 @@
|
||||
# Assistant Mode Flow
|
||||
|
||||
## End-to-End
|
||||
1. User opens `Assistant` mode in GUI.
|
||||
2. User enters message and clicks `Send`.
|
||||
3. Frontend starts status ticker:
|
||||
- `Razbirayu zapros`
|
||||
- `Proveryayu kontur`
|
||||
- `Opredelyayu marshrut`
|
||||
- `Ishchu dannye`
|
||||
- `Sobirayu otvet`
|
||||
4. Backend stores user message in session history.
|
||||
5. Backend runs normalizer (`normalizer_v2_0_2`) with provided prompt/config.
|
||||
6. Backend derives deterministic route summary from normalized payload.
|
||||
7. Backend builds retrieval plan (current stage: stubbed/diagnostic).
|
||||
8. Backend composes human-readable assistant answer via `answer_composer`.
|
||||
9. Backend stores assistant reply in session history.
|
||||
10. Backend returns reply + debug payload + updated conversation snapshot.
|
||||
11. Frontend updates chat timeline and keeps debug expandable per assistant message.
|
||||
|
||||
## Fallback Behavior
|
||||
- `out_of_scope`: polite contour boundary response.
|
||||
- `clarification`: asks for concrete period/account/document/counterparty.
|
||||
- `partial`: reports available in-scope part and marks unavailable part.
|
||||
- `none`: reports routed fragments and planned execution path.
|
||||
|
||||
## Debug Drawer Data
|
||||
Each assistant reply can expose:
|
||||
- `trace_id`
|
||||
- normalized fragments
|
||||
- route summary and fallback type
|
||||
- retrieval plan payload
|
||||
- full normalized object snapshot
|
||||
|
||||
## Session Memory Rules
|
||||
- scope: current backend process memory only
|
||||
- key: `session_id`
|
||||
- message cap: bounded in-memory list per session
|
||||
- persistence: not durable across backend restart
|
||||
|
||||
## Why This Stage Exists
|
||||
This mode creates a usable operator loop before deeper field-hardening:
|
||||
- gather real dialog traces
|
||||
- identify where clarification/no-route appears in practice
|
||||
- evaluate answer quality and UX with real user input
|
||||
@@ -1,83 +0,0 @@
|
||||
# Assistant Mode Spec
|
||||
|
||||
## Goal
|
||||
Add a second UI mode (`Assistant`) on top of the existing decomposition pipeline, without removing current decomposition/debug capabilities.
|
||||
|
||||
## Scope Delivered
|
||||
- Keep existing `Decomposition` mode unchanged.
|
||||
- Add `Assistant` chat mode in frontend.
|
||||
- Add backend endpoint `POST /api/assistant/message`.
|
||||
- Add session-scoped chat memory (in-memory store).
|
||||
- Add `answer_composer` layer for human-readable output.
|
||||
- Add debug payload per assistant reply (expandable in UI).
|
||||
- Keep one shared backend normalization/routing pipeline.
|
||||
|
||||
## Backend Contract
|
||||
|
||||
### Endpoint
|
||||
`POST /api/assistant/message`
|
||||
|
||||
### Request
|
||||
- `session_id` (optional)
|
||||
- `user_message` (required)
|
||||
- connection settings: `apiKey`, `model`, `baseUrl`, `temperature`, `maxOutputTokens`
|
||||
- prompt settings: `promptVersion`, `systemPrompt`, `developerPrompt`, `domainPrompt`, `fewShotExamples`
|
||||
- optional `context` (`period_hint`, `business_context`)
|
||||
- `useMock` (optional)
|
||||
|
||||
### Response
|
||||
- `ok`
|
||||
- `session_id`
|
||||
- `assistant_reply`
|
||||
- `conversation_item` (assistant message)
|
||||
- `debug`:
|
||||
- `trace_id`
|
||||
- `fragments`
|
||||
- `fallback_type`
|
||||
- `route_summary`
|
||||
- `retrieval`
|
||||
- `normalized`
|
||||
- `conversation` (session items snapshot)
|
||||
|
||||
### Session Endpoint
|
||||
`GET /api/assistant/session/:session_id`
|
||||
|
||||
Returns current in-memory session transcript.
|
||||
|
||||
## Internal Pipeline
|
||||
`user_message -> normalizer_v2_0_2 -> deterministic route summary -> retrieval plan -> answer_composer -> assistant reply`
|
||||
|
||||
Notes:
|
||||
- retrieval layer is currently sandbox/stubbed in this stage;
|
||||
- answer is human-readable and not raw JSON;
|
||||
- debug JSON is still available per message.
|
||||
|
||||
## Logging Fields
|
||||
Each assistant message writes structured log entry with:
|
||||
- `session_id`
|
||||
- `message_id`
|
||||
- `user_message`
|
||||
- `normalizer_output`
|
||||
- `resolved_execution_state`
|
||||
- `routes`
|
||||
- `fallback_type`
|
||||
- `retrieval_payloads`
|
||||
- `assistant_reply`
|
||||
- `trace_id`
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
### Mode Switch
|
||||
Two explicit modes:
|
||||
- `Assistant`
|
||||
- `Decomposition`
|
||||
|
||||
### Assistant UI
|
||||
- chat feed (user/assistant messages)
|
||||
- message input + send
|
||||
- pipeline status text while processing
|
||||
- session reset button
|
||||
- optional debug section per assistant message (`details`)
|
||||
|
||||
### Decomposition UI
|
||||
All existing panels stay as-is: normalize/eval/history/runtime/debug tabs.
|
||||
@@ -1,146 +0,0 @@
|
||||
# Assistant Mode vNext Spec
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Перевести Assistant Mode из planner/debug shell в рабочий factual-режим:
|
||||
|
||||
- принять вопрос пользователя;
|
||||
- нормализовать и декомпозировать;
|
||||
- выбрать маршрут выполнения;
|
||||
- выполнить route-specific retrieval;
|
||||
- нормализовать retrieval results в единый контракт;
|
||||
- собрать один человекочитаемый ответ на русском;
|
||||
- отдать debug отдельно, через раскрываемый слой.
|
||||
|
||||
## 2. Реализованный контур
|
||||
|
||||
Текущий контур в backend:
|
||||
|
||||
`User message -> NormalizerService -> Route plan -> AssistantDataLayer (route executor) -> normalizeRetrievalResult -> composeAssistantAnswer -> Assistant API response`
|
||||
|
||||
Ключевые файлы:
|
||||
|
||||
- `backend/src/services/assistantService.ts`
|
||||
- `backend/src/services/assistantDataLayer.ts`
|
||||
- `backend/src/services/retrievalResultNormalizer.ts`
|
||||
- `backend/src/services/answerComposer.ts`
|
||||
- `backend/src/routes/assistant.ts`
|
||||
|
||||
## 3. API контракты
|
||||
|
||||
Endpoint: `POST /api/assistant/message`
|
||||
|
||||
Request (поддерживаются оба поля `user_message` и `message`):
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "asst-...",
|
||||
"mode": "assistant",
|
||||
"message": "Покажи риски по НДС за июнь 2020",
|
||||
"user_message": "Покажи риски по НДС за июнь 2020",
|
||||
"promptVersion": "normalizer_v2_0_2",
|
||||
"context": {
|
||||
"period_hint": "2020-06",
|
||||
"business_context": "buh_test"
|
||||
},
|
||||
"useMock": true
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"session_id": "asst-...",
|
||||
"assistant_reply": "Проверка выполнена...",
|
||||
"reply_type": "factual",
|
||||
"conversation_item": {},
|
||||
"debug": {
|
||||
"trace_id": "...",
|
||||
"fragments": [],
|
||||
"routes": [],
|
||||
"retrieval_status": [],
|
||||
"retrieval_results": []
|
||||
},
|
||||
"conversation": []
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Reply policy
|
||||
|
||||
Реализованы user-facing типы:
|
||||
|
||||
- `factual`
|
||||
- `empty`
|
||||
- `partial`
|
||||
- `clarification`
|
||||
- `out_of_scope`
|
||||
- `error`
|
||||
|
||||
Технические маркеры (`fallback_type`, route names, trace details) остаются в debug payload и не выводятся как основной ответ.
|
||||
|
||||
## 5. Debug payload
|
||||
|
||||
Debug отделён от user reply и содержит:
|
||||
|
||||
- `trace_id`
|
||||
- `route_summary`
|
||||
- `fragments`
|
||||
- `routes`
|
||||
- `retrieval_status`
|
||||
- `retrieval_results`
|
||||
- `normalized`
|
||||
|
||||
## 6. UI поведение
|
||||
|
||||
Frontend Assistant panel:
|
||||
|
||||
- показывает нормальный текст ответа;
|
||||
- показывает техразбор только внутри `details`-блока `Показать технический разбор`;
|
||||
- использует русские loading-состояния:
|
||||
- `Разбираю запрос`
|
||||
- `Ищу данные`
|
||||
- `Собираю ответ`
|
||||
|
||||
Ключевые файлы:
|
||||
|
||||
- `frontend/src/components/AssistantPanel.tsx`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/state/types.ts`
|
||||
|
||||
## 7. Логирование
|
||||
|
||||
В `assistant_loop` логируются:
|
||||
|
||||
- `session_id`, `message_id`, `user_message`
|
||||
- `normalizer_output`
|
||||
- `execution_plan`
|
||||
- `retrieval_calls`
|
||||
- `retrieval_results_raw`
|
||||
- `retrieval_results_normalized`
|
||||
- `assistant_reply`
|
||||
- `reply_type`
|
||||
- `trace_id`
|
||||
|
||||
Дополнительно введён session-level лог (один файл на `session_id`):
|
||||
|
||||
- каталог: `data/assistant_sessions`
|
||||
- формат: `assistant_session_log_v1`
|
||||
- модель записи: один JSON-файл `<session_id>.json`, который обновляется при каждом сообщении в рамках этой сессии.
|
||||
- внутри `turns[]` каждый закрытый контур хранит человекочитаемый блок:
|
||||
- `Вопрос`
|
||||
- `Понято как`
|
||||
- `Декомпозиция`
|
||||
- `Ответ`
|
||||
- ниже в `technical_json` остаётся полный технический JSON по этому же контуру.
|
||||
|
||||
## 8. Минимальная приёмка этапа
|
||||
|
||||
Этап считается выполненным:
|
||||
|
||||
1. Assistant возвращает русскоязычный пользовательский ответ, не route plan.
|
||||
2. Debug остаётся доступным отдельно.
|
||||
3. Работает factual retrieval loop через route executors.
|
||||
4. Отображаются `out_of_scope / clarification / partial / empty / error`.
|
||||
5. Decomposition-режим не сломан.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Clarification Policy (v2.0.1)
|
||||
|
||||
## Purpose
|
||||
|
||||
Prevent over-triggering clarification for in-scope operational accounting questions.
|
||||
|
||||
## Execution Readiness Levels
|
||||
|
||||
- `executable`: enough information to run safely.
|
||||
- `executable_with_soft_assumptions`: route is clear, missing details can be covered by safe context assumptions.
|
||||
- `needs_clarification`: missing information blocks reliable routing/execution.
|
||||
|
||||
## When Clarification Is Not Required
|
||||
|
||||
- In-scope query with recognizable accounting area and problem type.
|
||||
- Route can be selected deterministically.
|
||||
- Colloquial accounting language still maps to scan/review/anomaly/rule-check intent.
|
||||
- Period is missing but active period exists in session context.
|
||||
|
||||
## When Clarification Is Required
|
||||
|
||||
- Domain/scope unclear.
|
||||
- Accounting area/object cannot be identified.
|
||||
- Routing cannot be selected reliably.
|
||||
- Critical period-dependent task and period cannot be inferred from session context.
|
||||
- Conflicting mixed tasks that cannot be decomposed safely.
|
||||
|
||||
## Soft Assumptions
|
||||
|
||||
Allowed markers:
|
||||
- `period_from_session_context`
|
||||
- `company_scope_defaulted`
|
||||
- `problem_scan_mode_enabled`
|
||||
|
||||
These assumptions allow execution without forcing clarification when risk is acceptable.
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Domain Scope Policy
|
||||
|
||||
## Цель
|
||||
|
||||
Формализовать, какие запросы допускаются в бухгалтерский контур компании, а какие нет.
|
||||
|
||||
## In-Scope
|
||||
|
||||
`domain_relevance = in_scope` только если запрос относится к данным текущего предприятия и учетной онтологии:
|
||||
- документы, проводки, оплаты, взаиморасчеты;
|
||||
- остатки, хвосты, сальдо, аномалии;
|
||||
- контроль учетных правил в контуре предприятия;
|
||||
- риски закрытия периода в контексте конкретной базы.
|
||||
|
||||
`business_scope = company_specific_accounting`.
|
||||
|
||||
## Out-of-Scope
|
||||
|
||||
`domain_relevance = out_of_scope` если запрос:
|
||||
- про абстрактную бухгалтерию “вообще”;
|
||||
- про законы/ФСБУ/НК РФ без привязки к данным предприятия;
|
||||
- оффтоп/бытовой чат;
|
||||
- не связан с сущностями доступного учетного контура.
|
||||
|
||||
`business_scope`:
|
||||
- `generic_accounting` для общетеоретических бух-запросов;
|
||||
- `offtopic` для нерелевантного контента.
|
||||
|
||||
## Unclear
|
||||
|
||||
`domain_relevance = unclear`, если есть сигнал бухгалтерской темы, но не хватает контекста для уверенного доступа к данным.
|
||||
|
||||
`business_scope = unclear`.
|
||||
|
||||
## Обязательное правило выполнения
|
||||
|
||||
Фрагменты `out_of_scope`:
|
||||
- не отправляются в 1С/retrieval/analytics pipeline;
|
||||
- всегда ведут к fallback-поведеню.
|
||||
|
||||
Фрагменты `unclear`:
|
||||
- допускаются к уточнению;
|
||||
- не должны насильно эскалироваться в deep-route без дополнительных данных.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Fallback Policy
|
||||
|
||||
## Типы fallback
|
||||
|
||||
### 1) Out-of-scope fallback
|
||||
|
||||
Условие:
|
||||
- сообщение не имеет валидных in-scope фрагментов.
|
||||
|
||||
Шаблон:
|
||||
> Я работаю только с данными и бухгалтерским контуром текущей компании.
|
||||
> Запрос вне доступной предметной области.
|
||||
|
||||
### 2) Clarification fallback
|
||||
|
||||
Условие:
|
||||
- in-scope есть, но для исполнения не хватает критичного контекста (период, объект, участок учета).
|
||||
|
||||
Шаблон:
|
||||
> Могу проверить это в контуре компании, но нужно уточнить период, документ, счет или участок учета.
|
||||
|
||||
### 3) Partial fallback
|
||||
|
||||
Условие:
|
||||
- смешанное сообщение: часть in-scope, часть out-of-scope.
|
||||
|
||||
Шаблон:
|
||||
> Обработаю только ту часть запроса, которая относится к данным компании.
|
||||
> Остальное выходит за пределы доступного контура.
|
||||
|
||||
## Тональность
|
||||
|
||||
- профессионально и спокойно;
|
||||
- без канцелярита;
|
||||
- без грубости и оценочных формулировок;
|
||||
- без имитации “полного ответа”, если контур не позволяет.
|
||||
|
||||
## Техническое правило
|
||||
|
||||
Fallback выбирается после fragment-level domain gating, до исполнения маршрутов.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Final Answer Composer Spec
|
||||
|
||||
## 1. Назначение
|
||||
|
||||
`answerComposer` преобразует нормализованные retrieval results в один user-facing ответ на русском языке.
|
||||
|
||||
Реализация:
|
||||
|
||||
- `backend/src/services/answerComposer.ts`
|
||||
|
||||
Вход:
|
||||
|
||||
- `userMessage`
|
||||
- `routeSummary`
|
||||
- `retrievalResults[]` (уже в unified schema)
|
||||
|
||||
Выход:
|
||||
|
||||
- `assistant_reply` (текст для пользователя)
|
||||
- `reply_type`
|
||||
- `fallback_type`
|
||||
|
||||
## 2. Приоритеты ответа
|
||||
|
||||
Порядок разрешения:
|
||||
|
||||
1. `out_of_scope` fallback.
|
||||
2. `clarification` fallback (если factual результатов нет).
|
||||
3. `error`, если есть только ошибки retrieval.
|
||||
4. `empty`, если запрос валиден, но найдено 0 результатов.
|
||||
5. `partial`, если есть полезные данные, но покрытие неполное.
|
||||
6. `factual`, если есть корректные результаты без fallback-конфликта.
|
||||
|
||||
## 3. Типы ответов
|
||||
|
||||
### `factual`
|
||||
|
||||
- данные найдены;
|
||||
- даётся краткий итог + компактная сводка.
|
||||
|
||||
### `empty`
|
||||
|
||||
- корректный запрос, но выдача пустая.
|
||||
|
||||
### `partial`
|
||||
|
||||
- часть вопроса обработана;
|
||||
- часть недоступна/пустая/ошибочная.
|
||||
|
||||
### `clarification`
|
||||
|
||||
- нужно уточнение периода/документа/счёта/контрагента.
|
||||
|
||||
### `out_of_scope`
|
||||
|
||||
- запрос не относится к учётному контуру компании.
|
||||
|
||||
### `error`
|
||||
|
||||
- техническая ошибка retrieval.
|
||||
|
||||
## 4. Контентные правила
|
||||
|
||||
User-facing текст:
|
||||
|
||||
- только русский;
|
||||
- без route names;
|
||||
- без `trace`, `fallback_type`, `planned routes`;
|
||||
- без служебного planner/debug языка.
|
||||
|
||||
Технические детали живут только в `debug` payload.
|
||||
|
||||
## 5. Форматирование factual-ответа
|
||||
|
||||
Composer умеет кратко форматировать несколько типов retrieval:
|
||||
|
||||
- `chain`: акцент на цепочки контрагент/документы/операции.
|
||||
- `ranking`: топ/ранжирование.
|
||||
- `list`: список записей (в т.ч. риск-объекты).
|
||||
- `object/summary`: компактный факт-блок.
|
||||
|
||||
Если результатов много, выдаётся только верхняя часть (top-N), остальное остаётся в debug payload.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Fragment Execution Policy
|
||||
|
||||
## Назначение
|
||||
|
||||
Определяет, как система исполняет multi-intent сообщение после `normalized_query_v2`.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. Получить decomposition (`fragments`, `discarded_fragments`).
|
||||
2. Отфильтровать `out_of_scope` фрагменты.
|
||||
3. Оставшиеся `in_scope` прогнать через deterministic routing.
|
||||
4. Сгруппировать результаты в единый ответ.
|
||||
|
||||
## Группировка фрагментов
|
||||
|
||||
Рекомендуемая стратегия:
|
||||
- `live_mcp_drilldown` — отдельно (точечные задачи);
|
||||
- `hybrid_store_plus_live` — отдельно (цепочки и причинность);
|
||||
- `batch_refresh_then_store` — отдельно (обзор/топ/срез);
|
||||
- `store_feature_risk` и `store_canonical` можно агрегировать в один блок.
|
||||
|
||||
## Execution Planner Rules
|
||||
|
||||
- Не сводить насильно много задач к одному intent.
|
||||
- Не терять валидные in-scope задачи из-за соседнего шума.
|
||||
- При mixed-message обязательно возвращать partial fallback для out-of-scope части.
|
||||
- Если все in-scope фрагменты требуют уточнения — clarification fallback до выполнения.
|
||||
|
||||
## Evidence Safety
|
||||
|
||||
Если флаг `asks_for_evidence=true`:
|
||||
- ответ должен содержать ссылку на подтверждающий источник/объект после исполнения.
|
||||
|
||||
Если `asks_for_exact_object_trace=true`:
|
||||
- приоритет у точечного route `live_mcp_drilldown`.
|
||||
|
||||
## Наблюдаемость
|
||||
|
||||
Минимум логирования:
|
||||
- число фрагментов;
|
||||
- число discarded;
|
||||
- count in_scope/out_of_scope;
|
||||
- route decisions по fragment_id;
|
||||
- выбранный fallback type.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Known Limits Before Field Eval
|
||||
|
||||
## Current Stage Limits
|
||||
- Retrieval layer is stubbed in assistant mode:
|
||||
- system returns execution plan and routing trace,
|
||||
- not a full factual extraction from 1C/OData/MCP yet.
|
||||
- Session memory is in-memory only:
|
||||
- resets on backend restart,
|
||||
- no durable storage for long dialog continuity.
|
||||
- No production-grade orchestration:
|
||||
- no multi-agent planner,
|
||||
- no long-horizon tool chain.
|
||||
- Clarification policy is deterministic baseline:
|
||||
- adequate for sandbox,
|
||||
- will need tuning from real field traces.
|
||||
|
||||
## What Is Intentionally Deferred
|
||||
- Large conversation memory (100+ turns).
|
||||
- Automatic re-labeling and synthetic auto-eval expansion.
|
||||
- Full data retrieval hardening for every route.
|
||||
- Deep route fallback optimization beyond current deterministic policy.
|
||||
- Production SLO/SLA, auth, tenancy, and governance controls.
|
||||
|
||||
## Risks to Track
|
||||
- User may interpret planned route as final factual answer.
|
||||
- Partial fallback wording can still be too technical for non-debug users.
|
||||
- Out-of-scope vs clarification boundary may drift on ambiguous prompts.
|
||||
- Without field replay set (30-40 real questions), optimization remains speculative.
|
||||
|
||||
## Exit Criteria for Next Hardening Step
|
||||
- Collect real assistant-mode traces from live operators.
|
||||
- Build labeled set from real interactions (not synthetic-only).
|
||||
- Run targeted policy eval for:
|
||||
- clarification precision/recall,
|
||||
- false no-route rate,
|
||||
- partial fallback quality.
|
||||
- Connect at least one route to factual retrieval and validate answer-grounding.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Known Limits: Current Routes
|
||||
|
||||
## 1. Источник данных пока snapshot-based
|
||||
|
||||
Текущие route executors работают на экспортированных JSON из `docs/ARCH/2020экспорт`, а не на прямом online-query в 1С.
|
||||
|
||||
Следствие:
|
||||
|
||||
- ответы factual относительно snapshot-среза;
|
||||
- realtime-актуальность зависит от обновления экспорта.
|
||||
|
||||
## 2. Ограниченная семантика маршрутов
|
||||
|
||||
Маршруты покрывают базовые сценарии (chain/risk/ranking/canonical/drilldown), но без полного доменного покрытия бухгалтерского контура.
|
||||
|
||||
Следствие:
|
||||
|
||||
- часть сложных бухгалтерских формулировок уйдёт в `clarification` или `partial`;
|
||||
- не все аналитики и субконто-паттерны интерпретируются детерминированно.
|
||||
|
||||
## 3. No-route и уточнения
|
||||
|
||||
Для фрагментов `no_route` retrieval пропускается и возвращается skipped-result.
|
||||
|
||||
Следствие:
|
||||
|
||||
- пользователь получает корректный fallback-ответ;
|
||||
- но фактических данных по такому фрагменту не будет до уточнения.
|
||||
|
||||
## 4. Точечный drilldown требует идентификатор
|
||||
|
||||
`live_mcp_drilldown` в текущем контуре ожидает GUID-сигнал в тексте.
|
||||
|
||||
Следствие:
|
||||
|
||||
- без GUID маршрут вернёт `empty`;
|
||||
- это штатное поведение, не ошибка pipeline.
|
||||
|
||||
## 5. Формат user-facing ответа остаётся компактным
|
||||
|
||||
Composer отдаёт короткий итог и top-N фрагменты.
|
||||
|
||||
Следствие:
|
||||
|
||||
- полный массив фактов доступен через debug payload;
|
||||
- пользовательский пузырь не предназначен для полного аналитического досье.
|
||||
|
||||
## 6. Что нужно для следующего этапа
|
||||
|
||||
Рекомендуемое усиление:
|
||||
|
||||
1. Подключить live-data executor поверх MCP/FoxyLink вместо snapshot-only.
|
||||
2. Расширить route coverage по бухгалтерским кейсам (субконто/проводки/объяснение сальдо).
|
||||
3. Добавить интеграционные тесты factual retrieval на реальном контуре.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Forensic Audit v1.1 (NQ-004 / NQ-008 / NQ-009)
|
||||
|
||||
Источник baseline: `data/eval_cases/eval-YxrhL2dCcH.report.json` и trace-файлы `h5BLdC1oBO0wY6`, `iel5ScdccVZ4zT`, `1C9MATbKvo5FnF`.
|
||||
Новые API-вызовы на forensic этап: `0`.
|
||||
|
||||
| case_id | raw_question | expected.intent_class | actual.intent_class | expected.route_hint | actual.route_hint | expected.requires | actual.requires | какие признаки модель не увидела | какие признаки модель увидела лишние | предполагаемая причина ошибки | какая минимальная правка должна это исправить |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| NQ-004 | По 97 счету проверь, где возможна ошибка дат начала и окончания списания. | rule_based_account_control | anomaly_probe | store_feature_risk | store_feature_risk | `{cross=false, causal=false}` | `{cross=false, causal=true}` | Что это rule-based контроль по учетному правилу 97, а не поиск аномалий | Ложный causal (`needs_causal_chain=true`) и смещение в anomaly_probe | Лексика "ошибка" сработала как anomaly trigger без приоритета rule-based контроля | В developer v1.1 добавить приоритет: "ошибка дат/правил по счету" -> `rule_based_account_control`; запрет поднимать causal без явной цепочки |
|
||||
| NQ-008 | Покажи по банку документ №TRX-88 и связанную проводку по 51. | drilldown_explain | cross_entity | live_mcp_drilldown | live_mcp_drilldown | `{cross=false, causal=false}` | `{cross=true, causal=false}` | Что это точечный object trace с фокусом на конкретный документ | Лишний `needs_cross_entity_join=true`, из-за чего intent ушел в cross_entity | Слово "связанную" переоценено как cross-entity, хотя запрос точечный | В developer v1.1 закрепить правило: при точном doc/ref приоритет у `drilldown_explain`, cross_entity только если запрошен массовый разбор |
|
||||
| NQ-009 | Где у нас пахнет ручной ошибкой по июню? | ambiguous_human_query | anomaly_probe | batch_refresh_then_store | store_feature_risk | `{cross=false, causal=false}` | `{cross=false, causal=false}` | Что это широкий human-style запрос про периодный обзор без точного объекта | Лишний уход в risk-route (`store_feature_risk`) как будто это только anomaly probe | Комбинация "ручная ошибка" + короткая формулировка классифицирована как чистый risk-bucket | В developer/domain v1.1 добавить приоритеты: широкие human-form формулировки по периоду -> `batch_refresh_then_store`; `ambiguous_human_query` только контролируемый fallback |
|
||||
|
||||
## Итог forensic
|
||||
|
||||
- Основной baseline-дефект был в границах `intent_class`, а не в schema.
|
||||
- У `NQ-004` и `NQ-008` route был верный, но intent смещался в соседний класс.
|
||||
- У `NQ-009` одновременно сломались и intent, и route из-за перегруза risk-лексики.
|
||||
- Минимальные правки действительно лежат в prompt/few-shot таксономии и приоритетах, без переписывания бизнес-логики backend.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Normalizer v1.1.1 Patch Notes
|
||||
|
||||
## Что изменено
|
||||
|
||||
Сделан точечный `v1.1.1` patch поверх `v1.1` без пересборки архитектуры и без изменения schema:
|
||||
|
||||
- добавлен новый preset `normalizer_v1_1_1` в prompt manager;
|
||||
- добавлен developer prompt:
|
||||
- `prompts/developer/normalizer_v1_1_1.txt`
|
||||
- добавлен few-shot prompt:
|
||||
- `prompts/fewshot/normalizer_fewshot_v1_1_1.txt`
|
||||
- domain prompt намеренно не переписывался (используется `normalizer_domain_v1_1.txt`);
|
||||
- добавлены micro-eval артефакты:
|
||||
- `reports/normalizer_v1_1_1_micro_eval.json`
|
||||
- `reports/normalizer_v1_1_1_micro_eval.md`
|
||||
|
||||
## Какие 3 паттерна лечили
|
||||
|
||||
1. `period_close_risk` vs `heavy_analytical`
|
||||
- закреплен приоритет `period_close_risk` для лексики предзакрытия/последнего дня/сдачи отчетности.
|
||||
|
||||
2. Точечный drilldown и лишний `needs_cross_entity_join`
|
||||
- закреплено правило exact object trace:
|
||||
- `needs_exact_object_trace = true`
|
||||
- `needs_runtime_truth = true`
|
||||
- `needs_cross_entity_join = false` (если нет массового multi-entity анализа).
|
||||
|
||||
3. `anomaly_probe` и лишняя batch escalation
|
||||
- закреплено правило: если есть риск/аномалия без рейтинга и company-wide aggregation, route остается `store_feature_risk`.
|
||||
|
||||
## Почему изменения безопасны
|
||||
|
||||
- изменения локализованы только в developer/few-shot слоях `v1.1.1`;
|
||||
- сильные зоны `cross_entity`, causal chain и schema не переписывались;
|
||||
- schema и transport-контракт не изменены;
|
||||
- количество новых few-shot ограничено тремя целевыми примерами.
|
||||
|
||||
## Бюджет вызовов и ретраи
|
||||
|
||||
Текущий micro-run в этой среде выполнен в `use_mock=true`, так как `OPENAI_API_KEY` в shell не задан.
|
||||
|
||||
- внешние API-вызовы: `0`
|
||||
- ретраи: `0`
|
||||
- лимит этапа (`<=5`) не превышен.
|
||||
|
||||
## Что улучшилось
|
||||
|
||||
По micro-eval на целевых 5 кейсах:
|
||||
- `schema_validation_pass_rate = 100`
|
||||
- `intent_class_accuracy = 100`
|
||||
- `route_hint_accuracy = 100`
|
||||
- `causal_flag_accuracy = 100`
|
||||
- `high_confidence_error_rate = 0`
|
||||
|
||||
Покрытые кейсы:
|
||||
- `NQ-008`
|
||||
- `V11-DD-005`
|
||||
- `V11-OT-003`
|
||||
- `V11-OT-004`
|
||||
- `V11-OT-005`
|
||||
|
||||
## Что осталось как есть
|
||||
|
||||
- core логика `v1.1` по `cross_entity`/`heavy_analytical`/`rule_based_account_control` не расширялась;
|
||||
- не добавлялся большой eval sweep;
|
||||
- не трогались schema, parser и orchestration-контракты.
|
||||
|
||||
## Важно для финальной приемки
|
||||
|
||||
Micro-run зафиксирован корректно, но он mock-based (`use_mock=true`).
|
||||
Для production-приемки `v1.1.1` нужно один реальный strict micro-run на тех же 5 кейсах с `use_mock=false` и OpenAI API key.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Normalizer v1.1.2.1 Patch Notes
|
||||
|
||||
## Задача этапа
|
||||
|
||||
Сохранить стабильную prompt-логику `v1.1.2` и добавить новый 30-case набор
|
||||
из живых формулировок бух-ревью (`TZ_LLM_Normalizer_v1.1.2.1.md`) для отдельного контрольного прогона.
|
||||
|
||||
Ключевой принцип этапа:
|
||||
- не ломать работающий taxonomy/route baseline;
|
||||
- расширить покрытие на реальный язык бухгалтера.
|
||||
|
||||
## Что изменено
|
||||
|
||||
1. Добавлена версия prompt preset `normalizer_v1_1_2_1`.
|
||||
2. Добавлены новые prompt-файлы:
|
||||
- `prompts/developer/normalizer_v1_1_2_1.txt`
|
||||
- `prompts/fewshot/normalizer_fewshot_v1_1_2_1.txt`
|
||||
3. Добавлен новый eval dataset:
|
||||
- `eval_cases/normalizer_eval_v1_1_2_1_30cases.json`
|
||||
4. GUI переведен по умолчанию на:
|
||||
- `promptVersion: normalizer_v1_1_2_1`
|
||||
- `caseSetFile: normalizer_eval_v1_1_2_1_30cases.json`
|
||||
5. В `EvalService` добавлена автозапись артефактов для strict-run:
|
||||
- `reports/normalizer_v1_1_2_1_eval.json`
|
||||
- `reports/normalizer_v1_1_2_1_eval.md`
|
||||
6. Обновлены документы `docs/PROMPTS.md` и `docs/API.md`.
|
||||
|
||||
## Охват 30-case набора
|
||||
|
||||
В набор включены 7 предметных блоков:
|
||||
- поставщики/покупатели/взаиморасчеты;
|
||||
- реализация/неоплата/90+62;
|
||||
- банк/выписки/51;
|
||||
- товары/склад/41;
|
||||
- материалы/10;
|
||||
- РБП/97;
|
||||
- ОС/01-02.
|
||||
|
||||
## Совместимость и риск
|
||||
|
||||
Изменения в `v1.1.2.1` сделаны как controlled extension:
|
||||
- schema не менялась;
|
||||
- базовые route-policy и boundary-policy из `v1.1.2` сохранены;
|
||||
- добавлено только покрытие новых формулировок и новый eval-pack.
|
||||
|
||||
## Артефакты этапа
|
||||
|
||||
- `prompts/developer/normalizer_v1_1_2_1.txt`
|
||||
- `prompts/fewshot/normalizer_fewshot_v1_1_2_1.txt`
|
||||
- `eval_cases/normalizer_eval_v1_1_2_1_30cases.json`
|
||||
- `reports/normalizer_v1_1_2_1_eval.json` (после strict-run)
|
||||
- `reports/normalizer_v1_1_2_1_eval.md` (после strict-run)
|
||||
|
||||
## Результат контрольного прогона
|
||||
|
||||
Strict-run выполнен на `normalizer_eval_v1_1_2_1_30cases.json` в режиме `use_mock=true`.
|
||||
|
||||
- run_id: `eval-qfxRc9_xyJ`
|
||||
- cases_total: `30`
|
||||
- schema_validation_pass_rate: `100`
|
||||
- intent_class_accuracy: `83.33`
|
||||
- route_hint_accuracy: `100`
|
||||
- causal_flag_accuracy: `93.33`
|
||||
- high_confidence_error_rate: `0`
|
||||
|
||||
Комментарий:
|
||||
- mock-режим использует внутренние эвристики и не отражает финальное LLM-качество 1:1;
|
||||
- для production-приемки нужен повтор этого же strict-run в `use_mock=false`.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Normalizer v1.1.2 Patch Notes
|
||||
|
||||
## Проблема v1.1.1
|
||||
|
||||
На `v1.1.1` был локальный перекос taxonomy на границе:
|
||||
- `heavy_analytical` ↔ `period_close_risk`.
|
||||
|
||||
Симптом:
|
||||
- вопросы обзорного/рейтингового типа в контексте закрытия периода местами уезжали в `period_close_risk`;
|
||||
- часть пограничных кейсов получала излишне уверенную оценку.
|
||||
|
||||
Root cause:
|
||||
- правило для `period_close_risk` в `v1.1.1` было слишком жестким;
|
||||
- был сильный positive-trigger на close-context без симметричного противовеса для heavy-overview.
|
||||
|
||||
## Что изменено
|
||||
|
||||
### 1) Developer prompt (новый файл)
|
||||
- Добавлен `prompts/developer/normalizer_v1_1_2.txt`.
|
||||
- Переписан boundary-блок:
|
||||
- `period_close_risk` только если core вопроса про риск срыва/дестабилизации close-процесса.
|
||||
- `heavy_analytical` имеет приоритет, если цель вопроса: ranking/top/overview/summary/company-wide/prioritized analytical review, даже в close-контексте.
|
||||
|
||||
### 2) Few-shot (новый файл)
|
||||
- Добавлен `prompts/fewshot/normalizer_fewshot_v1_1_2.txt`.
|
||||
- Сохранен anchor-пример для `period_close_risk`.
|
||||
- Добавлены 2 симметричных heavy-counterexamples:
|
||||
- "Сделай рейтинг самых рисковых хвостов..."
|
||||
- "Дай обзорный риск-срез перед сдачей отчетности..."
|
||||
|
||||
### 3) Confidence guard
|
||||
- Добавлено явное правило в `developer v1.1.2`:
|
||||
- на boundary `heavy_analytical`/`period_close_risk` не ставить `confidence.overall=high` без однозначного close-failure core.
|
||||
|
||||
## Что не меняли (безопасность patch)
|
||||
|
||||
- schema не менялась;
|
||||
- route rules не переписывались;
|
||||
- cross-entity / drilldown / anomaly core-патчи не пересобирались;
|
||||
- domain prompt не расширяли массово.
|
||||
|
||||
Это intentional surgical patch только на одном taxonomy-boundary.
|
||||
|
||||
## Изменения в коде
|
||||
|
||||
- добавлена версия `normalizer_v1_1_2` в prompt manager и типы версий;
|
||||
- добавлен micro-eval авто-артефакт для кейсов:
|
||||
- `NQ-002, NQ-007, V11-HA-004, V11-OT-003, V11-OT-005`
|
||||
- файлы: `reports/normalizer_v1_1_2_micro_eval.json|md`;
|
||||
- UI и preset save по умолчанию переведены на `normalizer_v1_1_2`.
|
||||
|
||||
## Бюджет API
|
||||
|
||||
В этой среде `OPENAI_API_KEY` не задан, поэтому micro-run выполнен в `use_mock=true`.
|
||||
|
||||
- внешние API-вызовы: `0`
|
||||
- ретраи: `0`
|
||||
|
||||
Лимит этапа `<= 5` не превышен.
|
||||
|
||||
## Результаты micro-run
|
||||
|
||||
Отчет: `reports/normalizer_v1_1_2_micro_eval.json`
|
||||
|
||||
- `NQ-002` -> `heavy_analytical` (ok)
|
||||
- `NQ-007` -> `heavy_analytical` (ok)
|
||||
- `V11-HA-004` -> `heavy_analytical` (ok)
|
||||
- `V11-OT-003` -> `period_close_risk` (ok)
|
||||
- `V11-OT-005` -> `period_close_risk` (ok)
|
||||
|
||||
Итог метрик micro-run:
|
||||
- schema_validation_pass_rate = 100
|
||||
- intent_class_accuracy = 100
|
||||
- route_hint_accuracy = 100
|
||||
- causal_flag_accuracy = 100
|
||||
- high_confidence_error_rate = 0
|
||||
|
||||
## Что осталось на будущее
|
||||
|
||||
- Сделать один production micro-run (`use_mock=false`) на тех же 5 кейсах для финальной приемки по фактическому API-поведению модели.
|
||||
- После подтверждения можно закрывать `v1.1.2` и переходить к следующей пачке новых кейсов.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Changelog: LLM Normalizer v1.1
|
||||
|
||||
## Что изменено в prompt-слое
|
||||
|
||||
- Добавлен preset versioning: `normalizer_v1` и `normalizer_v1_1`.
|
||||
- Добавлены отдельные файлы:
|
||||
- `prompts/developer/normalizer_v1_1.txt`
|
||||
- `prompts/domain/normalizer_domain_v1_1.txt`
|
||||
- `prompts/fewshot/normalizer_fewshot_v1_1.txt`
|
||||
- В `developer` усилены приоритеты между классами:
|
||||
- `cross_entity` vs `anomaly_probe`
|
||||
- `cross_entity` vs `rule_based_account_control`
|
||||
- `drilldown_explain` для точечного object trace
|
||||
- контроль fallback в `ambiguous_human_query`
|
||||
- Добавлена confidence-policy v1.1: при ambiguity/сложном вопросе/high-uncertainty запрещено ставить `high` без оснований.
|
||||
|
||||
## Какие linguistic patterns добавлены
|
||||
|
||||
- "не сходится", "не видно", "не собралось", "повисло", "что пошло криво".
|
||||
- "разложи по документам/оплатам/закрывающим".
|
||||
- "чем подтверждается", "где ошибка в цепочке".
|
||||
- Отдельно выделены паттерны точечного drilldown: ref, номер документа, конкретная строка проводки.
|
||||
|
||||
## Какие few-shot кейсы добавлены
|
||||
|
||||
В `normalizer_fewshot_v1_1.txt` добавлено 7 коротких примеров, покрывающих:
|
||||
- `cross_entity` vs `anomaly_probe`
|
||||
- `cross_entity` vs `rule_based_account_control`
|
||||
- `cross_entity` (массовый explain) vs `drilldown_explain` (точечный trace)
|
||||
- causal language + risk words
|
||||
- ambiguous human wording
|
||||
- rule-based контроль без causal chain
|
||||
- heavy overview без точечного explain
|
||||
|
||||
## Какие кейсы были проблемными в baseline
|
||||
|
||||
- `NQ-004`: intent ушел в `anomaly_probe` вместо `rule_based_account_control`.
|
||||
- `NQ-008`: intent ушел в `cross_entity` вместо `drilldown_explain`.
|
||||
- `NQ-009`: intent и route ушли в risk bucket вместо широкого human-style обзорного маршрута.
|
||||
|
||||
Подробный forensic: `docs/normalizer_forensic_audit_v1_1.md`.
|
||||
|
||||
## Сколько API-вызовов потрачено
|
||||
|
||||
- Forensic этап: `0` новых внешних вызовов (использованы существующие traces).
|
||||
- Финальный контрольный run: `0` внешних вызовов, так как запуск выполнен в `useMock=true` режиме.
|
||||
- Итого этап: `0` внешних API-вызовов.
|
||||
|
||||
## Итоговые метрики до/после
|
||||
|
||||
Baseline (из `eval-YxrhL2dCcH.report.json`):
|
||||
- schema_validation_pass_rate: `100`
|
||||
- intent_class_accuracy: `72.73`
|
||||
- route_hint_accuracy: `90.91`
|
||||
- causal_flag_accuracy: `81.82`
|
||||
- high_confidence_error_rate: `9.09`
|
||||
|
||||
v1.1 strict run (`reports/normalizer_eval_v1_1_run.json`, mock):
|
||||
- schema_validation_pass_rate: `100`
|
||||
- intent_class_accuracy: `83.33`
|
||||
- route_hint_accuracy: `100`
|
||||
- causal_flag_accuracy: `100`
|
||||
- high_confidence_error_rate: `0`
|
||||
|
||||
## Что осталось проблемным после тюнинга
|
||||
|
||||
- В mock-run остались mismatch по классам `anomaly_probe`, `ambiguous_human_query`, `period_close_risk`.
|
||||
- Это ожидаемо для текущей mock-эвристики (она route-driven и не отражает полноценно LLM taxonomy).
|
||||
- Следующий шаг для честной приемки: один реальный `single-pass-strict` запуск на тех же 30 кейсах с OpenAI API key и фиксация финальных production-метрик.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Normalizer v2.0.1 Spec
|
||||
|
||||
## Goal
|
||||
|
||||
`v2.0.1` keeps decomposition-first architecture but reduces unnecessary clarification on one-step in-scope accounting queries.
|
||||
|
||||
Core target:
|
||||
- keep schema/scope stability;
|
||||
- keep deterministic routing in code;
|
||||
- lower false clarification behavior.
|
||||
|
||||
## Key Contract Changes
|
||||
|
||||
Schema: `normalized_query_v2_0_1`
|
||||
|
||||
Fragment-level fields added:
|
||||
- `execution_readiness`: `executable | executable_with_soft_assumptions | needs_clarification`
|
||||
- `clarification_reason`: `string | null`
|
||||
- `soft_assumption_used`: `period_from_session_context | company_scope_defaulted | problem_scan_mode_enabled` (array)
|
||||
|
||||
## Readiness Policy
|
||||
|
||||
Policy is applied in code (post-check), not only in prompt:
|
||||
|
||||
`decide_fragment_execution_policy(fragment, session_context)`
|
||||
|
||||
Rules:
|
||||
1. `out_of_scope` or `unclear` -> `needs_clarification`.
|
||||
2. In-scope but business area/route cannot be identified -> `needs_clarification`.
|
||||
3. In-scope and operationally clear -> `executable` or `executable_with_soft_assumptions`.
|
||||
4. Missing period does not force clarification if session context provides active period.
|
||||
5. Scan/review/anomaly/rule-check colloquial requests are executable if accounting area is understandable.
|
||||
|
||||
## Global Clarification Rule
|
||||
|
||||
`global_notes.needs_clarification = true` only when **all** in-scope fragments are blocked by clarification.
|
||||
|
||||
If at least one in-scope fragment is executable, clarification is not global fallback.
|
||||
|
||||
## Routing Compatibility
|
||||
|
||||
Routing remains deterministic (`routeHintAdapter`), but:
|
||||
- fragments with `execution_readiness=needs_clarification` get `no_route`;
|
||||
- soft assumptions do not block routing.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# Normalizer v2 Spec
|
||||
|
||||
## Назначение
|
||||
|
||||
`Normalizer v2` переводит слой нормализации с single-intent модели на decomposition-first:
|
||||
|
||||
`raw message -> fragments + scope + flags -> deterministic routing in code`
|
||||
|
||||
LLM в v2 не выдает финальное route-решение как источник истины.
|
||||
LLM возвращает структурированную семантику, а маршрут выбирается правилами в коде.
|
||||
|
||||
## Вход
|
||||
|
||||
- Сырое сообщение пользователя (в т.ч. длинное, multi-intent, шумное).
|
||||
|
||||
## Выход
|
||||
|
||||
- JSON по схеме `normalized_query_v2`.
|
||||
- Ключевые части:
|
||||
- `message_in_scope`, `scope_confidence`
|
||||
- `fragments[]`
|
||||
- `discarded_fragments[]`
|
||||
- `global_notes`
|
||||
|
||||
## Fragment Contract
|
||||
|
||||
Каждый фрагмент содержит:
|
||||
- domain gating (`domain_relevance`, `business_scope`);
|
||||
- semantic hints (`entity_hints`, `account_hints`, `document_hints`, `register_hints`);
|
||||
- `time_scope`;
|
||||
- route-critical flags;
|
||||
- `candidate_labels` (multi-label, без жесткого single-intent);
|
||||
- `confidence`.
|
||||
|
||||
## Deterministic Routing
|
||||
|
||||
Код применяет правила к `flags`:
|
||||
|
||||
1. `asks_for_exact_object_trace=true` -> `live_mcp_drilldown`
|
||||
2. `asks_for_ranking_or_top=true` или `asks_for_period_summary=true` -> `batch_refresh_then_store`
|
||||
3. `has_multi_entity_scope=true` и `asks_for_chain_explanation=true` -> `hybrid_store_plus_live`
|
||||
4. `asks_for_rule_check=true` и нет causal-chain -> `store_feature_risk`
|
||||
5. `asks_for_anomaly_scan=true` без heavy/causal -> `store_feature_risk`
|
||||
6. fragment `out_of_scope` -> `no_route`
|
||||
7. остальное in-scope -> `store_canonical`
|
||||
|
||||
## Совместимость
|
||||
|
||||
- `v1` и `v2` поддерживаются параллельно.
|
||||
- Выбор схемы:
|
||||
- `promptVersion=normalizer_v2` (или `schemaVersion=v2`) -> `normalized_query_v2`
|
||||
- иначе -> `normalized_query_v1`
|
||||
|
||||
## UI-диагностика v2
|
||||
|
||||
Добавлены диагностические представления:
|
||||
- Fragment View
|
||||
- Scope View
|
||||
- Flags View
|
||||
- Route Simulation
|
||||
|
||||
## Ограничения этапа
|
||||
|
||||
- Полноценный quality-eval v2 отдельно планируется (см. `reports/v2_pilot_eval_plan.md`).
|
||||
- Старый eval runner метрик `intent/route` ориентирован на v1 и не является основным приемочным контуром для v2.
|
||||
@@ -1,116 +0,0 @@
|
||||
# Route Executor Contracts
|
||||
|
||||
## 1. Общий контракт
|
||||
|
||||
Каждый route executor возвращает результат в unified-формате:
|
||||
|
||||
```json
|
||||
{
|
||||
"fragment_id": "F1",
|
||||
"route": "store_feature_risk",
|
||||
"status": "ok | empty | partial | error",
|
||||
"result_type": "list | summary | object | chain | ranking",
|
||||
"items": [],
|
||||
"summary": {},
|
||||
"evidence": [],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
Реализация:
|
||||
|
||||
- raw execution: `backend/src/services/assistantDataLayer.ts`
|
||||
- normalizer: `backend/src/services/retrievalResultNormalizer.ts`
|
||||
|
||||
## 2. Вход executor’ов
|
||||
|
||||
Вызов выполняется через:
|
||||
|
||||
`executeRoute(route: string, fragmentText: string)`
|
||||
|
||||
Входы:
|
||||
|
||||
- `route`: выбранный маршрут из route planner.
|
||||
- `fragmentText`: текст фрагмента после нормализации/декомпозиции.
|
||||
|
||||
Источник данных текущего MVP:
|
||||
|
||||
- snapshot-пакет `docs/ARCH/2020экспорт/*.json`
|
||||
|
||||
## 3. Route: `hybrid_store_plus_live`
|
||||
|
||||
Назначение:
|
||||
|
||||
- causal/cross-entity цепочки;
|
||||
- связь документов с контрагентами;
|
||||
- поиск разрывов связности.
|
||||
|
||||
Result:
|
||||
|
||||
- `result_type = "chain"`
|
||||
- `items`: агрегаты по контрагенту (`operations_count`, `document_refs_count`, `relation_types`, `samples`)
|
||||
- `summary`: `checked_records`, `matched_counterparties`, `route_focus`
|
||||
|
||||
## 4. Route: `store_feature_risk`
|
||||
|
||||
Назначение:
|
||||
|
||||
- anomaly/rule-check;
|
||||
- риск-признаки в проблемных записях.
|
||||
|
||||
Result:
|
||||
|
||||
- `result_type = "list"`
|
||||
- `items`: записи с `risk_score`, `reasons`, техническими индикаторами
|
||||
- `summary`: `checked_records`, `risky_records`, `average_risk_score`
|
||||
|
||||
## 5. Route: `batch_refresh_then_store`
|
||||
|
||||
Назначение:
|
||||
|
||||
- обзорные и ranking-задачи;
|
||||
- top/приоритизация проверки.
|
||||
|
||||
Result:
|
||||
|
||||
- `result_type = "ranking"`
|
||||
- `items`: `rank`, `entity`, `records_count`
|
||||
- `summary`: `checked_records`, `ranked_entities`
|
||||
|
||||
## 6. Route: `store_canonical`
|
||||
|
||||
Назначение:
|
||||
|
||||
- канонический factual path по документам.
|
||||
|
||||
Result:
|
||||
|
||||
- `result_type = "list"`
|
||||
- `items`: документные записи (`source_entity`, `source_id`, `period`, `counterparty_id`, `recorder`)
|
||||
- `summary`: `checked_records`, `returned_records`
|
||||
|
||||
## 7. Route: `live_mcp_drilldown`
|
||||
|
||||
Назначение:
|
||||
|
||||
- точечный drilldown по GUID/объекту.
|
||||
|
||||
Result:
|
||||
|
||||
- `result_type = "object"`
|
||||
- `items`: найденные совпадения по GUID
|
||||
- `summary`: `query_guids`, `matched_records`
|
||||
|
||||
## 8. Ошибки и no-route
|
||||
|
||||
Если fragment получает `route=no_route`, backend не вызывает executor, а формирует skipped-result:
|
||||
|
||||
- `status = "empty"`
|
||||
- `result_type = "summary"`
|
||||
- `summary.skipped = true`
|
||||
- `summary.no_route_reason`
|
||||
|
||||
Если executor падает:
|
||||
|
||||
- `status = "error"`
|
||||
- `errors[]` содержит текст ошибки.
|
||||
@@ -1,79 +0,0 @@
|
||||
# v2.0.2 Execution State Machine
|
||||
|
||||
## Goal
|
||||
Synchronize fragment-level states so there are no gray zones between:
|
||||
- domain scope
|
||||
- execution readiness
|
||||
- routing
|
||||
- fallback behavior
|
||||
|
||||
## Canonical Fragment Contract (v2.0.2)
|
||||
- `execution_readiness`: `executable | executable_with_soft_assumptions | needs_clarification | no_route`
|
||||
- `route_status`: `routed | no_route`
|
||||
- `no_route_reason`: `out_of_scope | insufficient_specificity | missing_mapping | unsupported_fragment_type | null`
|
||||
|
||||
## Resolver Layer
|
||||
`resolveFragmentExecutionStateV202(fragment, session_context)` runs after raw LLM output and before schema validation.
|
||||
|
||||
Resolver responsibilities:
|
||||
1. Normalize readiness.
|
||||
2. Normalize route status.
|
||||
3. Set explicit no-route reason.
|
||||
4. Enforce deterministic no-route guard.
|
||||
|
||||
## State Rules
|
||||
|
||||
### Rule A: out-of-scope
|
||||
- Condition: `domain_relevance=out_of_scope`
|
||||
- Result:
|
||||
- `execution_readiness=no_route`
|
||||
- `route_status=no_route`
|
||||
- `no_route_reason=out_of_scope`
|
||||
|
||||
### Rule B: insufficient specificity
|
||||
- Condition: readiness policy says clarification is required
|
||||
- Result:
|
||||
- `execution_readiness=needs_clarification`
|
||||
- `route_status=no_route`
|
||||
- `no_route_reason=insufficient_specificity`
|
||||
|
||||
### Rule C: missing mapping
|
||||
- Condition: in-scope fragment cannot be mapped by deterministic route selector
|
||||
- Result:
|
||||
- `execution_readiness=no_route`
|
||||
- `route_status=no_route`
|
||||
- `no_route_reason=missing_mapping`
|
||||
|
||||
### Rule D: routable fragment
|
||||
- Condition: in-scope, not clarification-blocked, route-selectable
|
||||
- Result:
|
||||
- `execution_readiness=executable | executable_with_soft_assumptions`
|
||||
- `route_status=routed`
|
||||
- `no_route_reason=null`
|
||||
|
||||
## Global Clarification Rule
|
||||
`global_notes.needs_clarification=true` only when all in-scope fragments are clarification-blocked.
|
||||
|
||||
## Deterministic Routing Consistency Checks
|
||||
|
||||
Decision is consistent when:
|
||||
- `route=no_route` -> readiness is not executable, and `no_route_reason` is present.
|
||||
- `route!=no_route` -> readiness is not clarification/no_route, and `no_route_reason` is null.
|
||||
|
||||
This consistency is now measured in eval as:
|
||||
- `execution_state_consistency_rate`
|
||||
|
||||
## Fallback Alignment
|
||||
- `out_of_scope`: no in-scope fragments
|
||||
- `clarification`: in-scope exists, but zero routable fragments due clarification
|
||||
- `partial`: mix of routed and no-route fragments
|
||||
- `none`: all in-scope fragments routed
|
||||
|
||||
## Trace Completeness Guard
|
||||
For each normalization run, service now validates trace has:
|
||||
- raw model output
|
||||
- parsed normalized payload
|
||||
- fragment execution state fields (for v2.0.1/v2.0.2)
|
||||
- deterministic route decisions per fragment
|
||||
|
||||
Missing elements are logged as system trace-completeness errors.
|
||||
@@ -1,42 +0,0 @@
|
||||
# v2.0.2 No-Route Audit
|
||||
|
||||
## Source Run
|
||||
- run_id: `eval-baY1nPi1rI`
|
||||
- no_route fragments in run: `6`
|
||||
|
||||
## Extracted No-Route Cases
|
||||
|
||||
| case_id | trace_id | domain_relevance | execution_readiness | old reason | classification |
|
||||
|---|---|---|---|---|---|
|
||||
| BQ-011 | HNAXW_JV6E_hp- | in_scope | needs_clarification | critical_period_missing | legit_no_route |
|
||||
| BQ-015 | mIR3Cru_dmM5PQ | in_scope | needs_clarification | critical_period_missing | legit_no_route |
|
||||
| BQ-017 | vuvha8oq0Y67kU | in_scope | needs_clarification | critical_period_missing | legit_no_route |
|
||||
| BQ-018 | dtTCTe9sMiGv2F | in_scope | needs_clarification | critical_period_missing | legit_no_route |
|
||||
| BQ-020 | 2VO4fwiW6_quNT | in_scope | needs_clarification | critical_period_missing | legit_no_route |
|
||||
| BQ-026 | dtXqHsYutlsp6q | out_of_scope | needs_clarification | fragment_out_of_scope | legit_no_route |
|
||||
|
||||
## Audit Result
|
||||
- `legit_no_route`: 6
|
||||
- `missing_mapping`: 0
|
||||
|
||||
Conclusion: historical no-route mass was mostly clarification/out-of-scope, not route-map holes.
|
||||
|
||||
## v2.0.2 Policy Mapping
|
||||
|
||||
Historical no-route reasons were normalized to explicit enum:
|
||||
- out-of-scope cases -> `no_route_reason=out_of_scope`
|
||||
- clarification/underspecified in-scope cases -> `no_route_reason=insufficient_specificity`
|
||||
|
||||
For real route-map gaps, v2.0.2 now reserves:
|
||||
- `no_route_reason=missing_mapping`
|
||||
- `execution_readiness=no_route`
|
||||
|
||||
## Deterministic Guard Added
|
||||
If fragment is:
|
||||
- `domain_relevance=in_scope`
|
||||
- not clarification-blocked
|
||||
- and route-selectable by deterministic policy
|
||||
|
||||
then `route_status=no_route` is forbidden and fragment is forced to routed state.
|
||||
|
||||
This prevents silent unresolved fragments.
|
||||
@@ -1,42 +0,0 @@
|
||||
# v2.0.2 Schema Forensic
|
||||
|
||||
## Source Run
|
||||
- run_id: `eval-baY1nPi1rI`
|
||||
- timestamp: `2026-03-23T18:59:54.829Z`
|
||||
- prompt_version: `normalizer_v2_0_1`
|
||||
- schema_validation_pass_rate: `96.15%`
|
||||
- failed case: `BQ-001`
|
||||
- trace_id: `SatgafwxwDR9BU`
|
||||
|
||||
## BQ-001 Failure Reconstruction
|
||||
1. Raw question was a long multi-intent pre-close analytical request.
|
||||
2. Model response arrived with:
|
||||
- `status: incomplete`
|
||||
- `incomplete_details.reason: max_output_tokens`
|
||||
3. The JSON body in `output_text` was truncated mid-fragment.
|
||||
4. Parser error after retry:
|
||||
- `JSON_PARSE_ERROR_AFTER_RETRY: Unterminated string in JSON at position 3506`
|
||||
5. `request_count_for_case = 2`, but retry used the same output budget, so truncation repeated.
|
||||
|
||||
## Root Cause
|
||||
Primary failure was not taxonomy misclassification.
|
||||
It was transport-level truncation due insufficient `max_output_tokens` for long structured output.
|
||||
|
||||
## Systemic Fix Implemented in v2.0.2
|
||||
1. Added adaptive retry output budget logic in normalizer service:
|
||||
- Detects `status=incomplete` + `reason=max_output_tokens`.
|
||||
- Escalates retry budget (`computeRetryMaxOutputTokens`) instead of repeating the same limit.
|
||||
- Hard cap: `2400` output tokens.
|
||||
2. Kept strict JSON schema validation unchanged (no relaxation).
|
||||
3. Added `normalized_query_v2_0_2` contract with explicit execution/route fields to reduce ambiguous partial outputs.
|
||||
|
||||
## Why This Is Not a Case-Specific Patch
|
||||
- Trigger condition is generic (`max_output_tokens` truncation), not tied to `BQ-001`.
|
||||
- Applies to any long, multi-fragment normalization.
|
||||
- Preserves strict schema discipline while improving completion reliability.
|
||||
|
||||
## Follow-up Validation
|
||||
- Run `single-pass-strict` on v2.0.2 labeled eval set.
|
||||
- Confirm:
|
||||
- `schema_validation_pass_rate = 100`
|
||||
- no parser failures caused by truncation in trace logs.
|
||||
Reference in New Issue
Block a user