АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе

This commit is contained in:
2026-04-01 17:55:02 +03:00
parent 4060a5e575
commit 4d59672576
90 changed files with 19595 additions and 785 deletions
+2 -1
View File
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_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;
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_LLM_PREDECOMPOSE_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, "..");
@@ -46,6 +46,7 @@ exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = toBooleanFlag(process.env.FEATUR
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_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_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";
+56 -5
View File
@@ -6,23 +6,74 @@ const config_1 = require("../config");
const http_1 = require("../utils/http");
function buildTestConnectionRouter(client) {
const router = (0, express_1.Router)();
router.post("/api/openai/test-connection", async (req, res, next) => {
const handler = async (req, res, next) => {
try {
const body = (req.body ?? {});
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
const model = String(body.model ?? config_1.DEFAULT_MODEL);
const baseUrl = String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL);
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
const result = await client.testConnection({
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? config_1.DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
llmProvider,
apiKey,
model,
baseUrl
});
let modelFound = null;
let modelsCount = null;
if (llmProvider === "local") {
try {
const models = await client.listModels({
llmProvider,
apiKey,
model,
baseUrl
});
modelsCount = models.length;
modelFound = models.includes(model);
}
catch {
modelFound = null;
modelsCount = null;
}
}
(0, http_1.ok)(res, {
ok: true,
provider: llmProvider,
model: result.model,
model_found: modelFound,
models_count: modelsCount,
timestamp: new Date().toISOString()
});
}
catch (error) {
next(error);
}
});
};
const listModelsHandler = async (req, res, next) => {
try {
const body = (req.body ?? {});
const models = await client.listModels({
llmProvider: body.llmProvider === "local" ? "local" : "openai",
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? config_1.DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
});
(0, http_1.ok)(res, {
ok: true,
models,
count: models.length,
timestamp: new Date().toISOString()
});
}
catch (error) {
next(error);
}
};
router.post("/api/llm/test-connection", handler);
router.post("/api/llm/models", listModelsHandler);
// Backward-compatible route for old frontend builds.
router.post("/api/openai/test-connection", handler);
router.post("/api/openai/models", listModelsHandler);
return router;
}
@@ -3,12 +3,19 @@ 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 COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
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;
const YEAR_RANGE_PATTERN = /(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
const YEAR_PERIOD_PATTERN = /(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_ANY_PATTERN = /(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
function toIsoDate(year, month, day) {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
@@ -61,6 +68,64 @@ function parseDateToken(token) {
}
return undefined;
}
function resolveMonthByName(rawMonthName) {
const token = String(rawMonthName ?? "").trim().toLowerCase();
if (!token) {
return undefined;
}
if (/^янв|^january|^jan/.test(token))
return 1;
if (/^фев|^february|^feb/.test(token))
return 2;
if (/^мар|^march|^mar/.test(token))
return 3;
if (/^апр|^april|^apr/.test(token))
return 4;
if (/^ма[йя]|^may/.test(token))
return 5;
if (/^июн|^june|^jun/.test(token))
return 6;
if (/^июл|^july|^jul/.test(token))
return 7;
if (/^авг|^august|^aug/.test(token))
return 8;
if (/^сен|^сент|^september|^sep/.test(token))
return 9;
if (/^окт|^october|^oct/.test(token))
return 10;
if (/^ноя|^november|^nov/.test(token))
return 11;
if (/^дек|^december|^dec/.test(token))
return 12;
return undefined;
}
function extractMonthPeriod(text) {
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
if (numericMatch) {
const month = Number(numericMatch[1]);
const year = Number(numericMatch[2]);
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
if (byNameMatch) {
const month = resolveMonthByName(String(byNameMatch[1]));
const year = Number(byNameMatch[2]);
if (month && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
return {};
}
function extractPeriodRange(text) {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) {
@@ -73,6 +138,64 @@ function extractPeriodRange(text) {
...(periodTo ? { period_to: periodTo } : {})
};
}
function extractYearPeriod(text) {
const match = text.match(YEAR_PERIOD_PATTERN);
if (match) {
const year = Number(match[1]);
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
return {};
}
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
if (relaxedYearMatch) {
const year = Number(relaxedYearMatch[1]);
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
}
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
if (!shortYearMatch) {
return {};
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return {};
}
const year = 2000 + shortYear;
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
function extractYearRangePeriod(text) {
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
if (!match) {
return {};
}
const leftYear = Number(match[1]);
const rightYear = Number(match[2]);
if (!Number.isFinite(leftYear) ||
!Number.isFinite(rightYear) ||
leftYear < 2000 ||
leftYear > 2099 ||
rightYear < 2000 ||
rightYear > 2099) {
return {};
}
const fromYear = Math.min(leftYear, rightYear);
const toYear = Math.max(leftYear, rightYear);
return {
period_from: `${fromYear}-01-01`,
period_to: `${toYear}-12-31`
};
}
function cleanupAnchorValue(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
@@ -84,11 +207,11 @@ function cleanupAnchorValue(value) {
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPattern = /\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
@@ -99,7 +222,186 @@ function cleanupAnchorValue(value) {
}
function hasAllTimeHint(text) {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text) {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
const token = String(match[1] ?? "").trim();
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
if (stopWords.has(lowered)) {
return undefined;
}
return token;
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"покеж",
"покажи",
"выведи"
]);
return !stopWords.has(lowered);
}
function hasDocsOrBankSignal(text) {
const lowered = String(text ?? "").toLowerCase();
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(lowered);
}
function extractCounterpartyFromFreeTextHeuristic(text) {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function extractImplicitCounterpartyValue(text) {
const input = String(text ?? "");
const beforeDocsMatch = input.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
const afterDocsMatch = input.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
return undefined;
}
function shiftDaysIso(baseIso, deltaDays) {
const date = new Date(`${baseIso}T00:00:00.000Z`);
@@ -137,6 +439,27 @@ function extractAddressFilters(userMessage, intent) {
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const fallbackCounterparty = extractLooseByAnchorValue(text);
if (fallbackCounterparty) {
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const implicitCounterparty = extractImplicitCounterpartyValue(text);
if (implicitCounterparty) {
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
@@ -148,6 +471,30 @@ function extractAddressFilters(userMessage, intent) {
if (periodRange.period_to) {
filters.period_to = periodRange.period_to;
}
if (!filters.period_from && !filters.period_to) {
const monthPeriod = extractMonthPeriod(text);
if (monthPeriod.period_from && monthPeriod.period_to) {
filters.period_from = monthPeriod.period_from;
filters.period_to = monthPeriod.period_to;
warnings.push("period_derived_from_month_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearRangePeriod = extractYearRangePeriod(text);
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
filters.period_from = yearRangePeriod.period_from;
filters.period_to = yearRangePeriod.period_to;
warnings.push("period_derived_from_year_range_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearPeriod = extractYearPeriod(text);
if (yearPeriod.period_from && yearPeriod.period_to) {
filters.period_from = yearPeriod.period_from;
filters.period_to = yearPeriod.period_to;
warnings.push("period_derived_from_year_phrase");
}
}
// 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);
+191 -3
View File
@@ -62,23 +62,201 @@ const OPEN_ITEMS_HINTS = [
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"documents by company",
"documents by supplier",
"documents by customer",
"documents by client",
"documents by partner",
"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",
"bank operations by company",
"bank operations by supplier",
"bank operations by customer",
"show bank operations by counterparty",
"bank ops",
"transactions by counterparty",
"банков",
"выписк",
"платеж"
"платеж",
"платёж",
"оплат",
"списан",
"поступлен",
"движени"
];
function hasAny(text, patterns) {
return patterns.some((item) => text.includes(item));
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"доки?",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй"
]);
return !stopWords.has(token);
}
function hasPartyAnchorMention(text) {
return (text.includes("контраг") ||
text.includes("контра") ||
text.includes("counterparty") ||
text.includes("компан") ||
text.includes("company") ||
text.includes("организац") ||
text.includes("supplier") ||
text.includes("vendor") ||
text.includes("customer") ||
text.includes("client") ||
text.includes("partner") ||
text.includes("поставщик") ||
text.includes("клиент") ||
text.includes("покупател") ||
text.includes("партнер"));
}
function hasLooseByAnchorMention(text) {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам"
]);
return !stopWords.has(token);
}
function hasImplicitCounterpartyAnchorAroundDocs(text) {
const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
return true;
}
const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
return true;
}
return false;
}
function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
}
function hasHeuristicCounterpartyAnchor(text) {
if (!hasDocsOrBankSignal(text)) {
return false;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return true;
}
return false;
}
function hasGenericAddressLookupSignal(text) {
return (/\bесть\b/iu.test(text) ||
/\bпокажи\b/iu.test(text) ||
/\bвыведи\b/iu.test(text) ||
/\bкакие\b/iu.test(text) ||
/\bчто(?:-|\s)?то\b/iu.test(text) ||
/за\s+любой\s+период/iu.test(text) ||
/за\s+вс[её]\s+время/iu.test(text) ||
/for\s+all\s+time/iu.test(text) ||
/all\s+time/iu.test(text));
}
function hasAccountNumberAnchor(text) {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
@@ -113,7 +291,7 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))) {
return {
intent: "bank_operations_by_counterparty",
confidence: "medium",
@@ -121,13 +299,23 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
(hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text))) {
return {
intent: "list_documents_by_counterparty",
confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"]
};
}
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
return {
intent: "list_documents_by_counterparty",
confidence: "low",
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
+78 -6
View File
@@ -1,7 +1,11 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeAddressMcpQuery = executeAddressMcpQuery;
const config_1 = require("../config");
const iconv_lite_1 = __importDefault(require("iconv-lite"));
function toStringValue(value) {
if (value === null || value === undefined) {
return "";
@@ -20,8 +24,76 @@ function parseFiniteNumber(value) {
}
return null;
}
function textMojibakeScore(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
}
function looksLikeMojibake(value) {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
}
function decodeUtf8FromWin1251Mojibake(value) {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const bytes = iconv_lite_1.default.encode(value, "win1251");
const decoded = bytes.toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
}
catch {
return value;
}
}
function decodeUtf8FromLatin1Mojibake(value) {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const decoded = Buffer.from(value, "latin1").toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
}
catch {
return value;
}
}
function normalizeMojibakeString(value) {
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
return decodeUtf8FromLatin1Mojibake(fromWin1251);
}
function normalizeMojibakeValue(value) {
if (typeof value === "string") {
return normalizeMojibakeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMojibakeValue(item));
}
if (value && typeof value === "object") {
const source = value;
const normalized = {};
for (const [key, raw] of Object.entries(source)) {
const repairedKey = normalizeMojibakeString(key);
normalized[repairedKey] = normalizeMojibakeValue(raw);
}
return normalized;
}
return value;
}
function normalizeMojibakeRows(rows) {
return rows.map((row) => normalizeMojibakeValue(row));
}
function parseRowsFromTextTable(source) {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
@@ -91,7 +163,7 @@ function parseRowsFromTextTable(source) {
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
rows.push(row);
}
return rows;
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload) {
if (!payload || typeof payload !== "object") {
@@ -110,9 +182,9 @@ function parseExecutePayload(payload) {
};
}
if (Array.isArray(source.data)) {
const rows = source.data
const rows = normalizeMojibakeRows(source.data
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
.filter((item) => item !== null));
return {
ok: true,
rows,
@@ -127,9 +199,9 @@ function parseExecutePayload(payload) {
};
}
if (source.data && typeof source.data === "object" && Array.isArray(source.data.rows)) {
const rows = (source.data.rows ?? [])
const rows = normalizeMojibakeRows((source.data.rows ?? [])
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
.filter((item) => item !== null));
return {
ok: true,
rows,
@@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"company",
"organization",
"supplier",
"vendor",
"customer",
"client",
"partner",
"contract",
"contracts",
"account",
@@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
"owes",
"owed",
"контрагент",
"контра",
"компан",
"организац",
"поставщик",
"клиент",
"покупател",
"партнер",
"банк",
"выписк",
"операц",
"договор",
"счет",
"счёт",
"документ",
"доки",
"док",
"остаток",
"дебитор",
"кредитор",
@@ -71,6 +90,54 @@ const DEEP_REASONING_TOKENS = [
"разрыв",
"ошибк"
];
function hasLooseByAnchorMention(text) {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
return !stopWords.has(token);
}
function hasAddressFollowupSignal(text) {
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
return true;
}
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
return true;
}
return false;
}
function hasAnyToken(text, tokens) {
return tokens.some((token) => text.includes(token));
}
@@ -86,6 +153,8 @@ function detectAddressQuestionMode(userMessage) {
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
const hasLooseByAnchor = hasLooseByAnchorMention(text);
const hasFollowupSignal = hasAddressFollowupSignal(text);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
@@ -93,6 +162,13 @@ function detectAddressQuestionMode(userMessage) {
reasons: ["address_action_detected", "address_entity_detected"]
};
}
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
+160 -215
View File
@@ -2,12 +2,11 @@
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");
const decomposeStage_1 = require("./address_runtime/decomposeStage");
const resolveStage_1 = require("./address_runtime/resolveStage");
const composeStage_1 = require("./address_runtime/composeStage");
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const PARTY_ANCHOR_STOPWORDS = new Set([
@@ -323,20 +322,56 @@ function applyIntentSpecificFilter(intent, rows) {
}
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 hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
}
function inferReplyType(responseType) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
function canAutoBroadenPeriodWindow(intent, filters) {
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return "partial_coverage";
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
}
function toIsoDatePrefix(value) {
if (!value) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) {
return match[1];
}
return null;
}
function deriveObservedPeriodWindow(rows) {
const dates = rows
.map((row) => toIsoDatePrefix(row.period))
.filter((item) => Boolean(item))
.sort();
if (dates.length === 0) {
return {
period_from: null,
period_to: null
};
}
return {
period_from: dates[0],
period_to: dates[dates.length - 1]
};
}
function composeAutoBroadenedPeriodPrefix(requested, observed) {
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
}
if (requestedFrom && requestedTo) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
}
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function runtimeReadinessForLimitedCategory(category) {
if (category === "empty_match" || category === "missing_anchor") {
@@ -449,90 +484,6 @@ function toLegacyMcpStatus(status) {
}
return status;
}
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 refineAnchorFromRows(anchor, rows) {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
function composeLimitedReply(category, reason, nextStep) {
const heading = category === "empty_match"
? "В live-данных по текущему фильтру записи не найдены."
@@ -601,124 +552,19 @@ function buildLimitedExecutionResult(input) {
}
};
}
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) {
async tryHandle(userMessage, options = {}) {
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
if (mode.mode !== "address_query") {
const followupContext = options.followupContext ?? null;
const decompose = (0, decomposeStage_1.runAddressDecomposeStage)(userMessage, followupContext);
if (!decompose) {
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);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const { mode, shape, intent, filters, baseReasons } = decompose;
let anchor = (0, resolveStage_1.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,
@@ -862,7 +708,7 @@ class AddressQueryService {
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = refineAnchorFromRows(anchor, normalizedRows);
anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows);
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
@@ -895,7 +741,7 @@ class AddressQueryService {
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null;
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && (0, composeStage_1.contractCandidatesFromRows)(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
@@ -925,6 +771,105 @@ class AddressQueryService {
reasons: baseReasons
});
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
const broadenedMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: broadenedPlan.query,
limit: broadenedPlan.limit
});
if (!broadenedMcp.error) {
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
const broadenedAccountScopeFallbackApplied = broadenedPlan.account_scope_mode === "preferred" &&
broadenedPlan.account_scope.length > 0 &&
broadenedRawRows.length > 0 &&
broadenedScopedRows.length === 0;
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
let broadenedAnchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, autoBroadenedFilters);
broadenedAnchor = (0, resolveStage_1.refineAnchorFromRows)(broadenedAnchor, broadenedNormalizedRows);
const broadenedFiltersForMatching = broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
: autoBroadenedFilters;
const broadenedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: broadenedFiltersForMatching,
accountScope: broadenedPlan.account_scope,
rowsBeforeScope: broadenedRawRows.length,
rowsAfterScope: broadenedNormalizedRows.length
});
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length);
const broadenedStageStatus = deriveMcpStageStatus({
rawRowsReceived: broadenedMcp.raw_rows.length,
rowsMaterialized: broadenedNormalizedRows.length,
rowsAnchorMatched: broadenedRowsByAnchor.length,
rowsMatched: broadenedFilteredRows.length
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
handled: true,
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
reply_type: (0, composeStage_1.inferReplyType)(broadenedFactual.responseType),
response_type: broadenedFactual.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: broadenedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
account_scope_mode: broadenedPlan.account_scope_mode,
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
anchor_type: broadenedAnchor.anchor_type,
anchor_value_raw: broadenedAnchor.anchor_value_raw,
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
resolver_confidence: broadenedAnchor.resolver_confidence,
ambiguity_count: broadenedAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: broadenedStageStatus,
rows_fetched: broadenedMcp.fetched_rows,
raw_rows_received: broadenedMcp.raw_rows.length,
rows_after_account_scope: broadenedNormalizedRows.length,
rows_after_recipe_filter: broadenedRowsByAnchor.length,
rows_materialized: broadenedNormalizedRows.length,
rows_matched: broadenedFilteredRows.length,
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
limitations: broadenedLimitations,
reasons: broadenedReasons
}
};
}
}
}
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
@@ -992,11 +937,11 @@ class AddressQueryService {
reasons: baseReasons
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows);
return {
handled: true,
reply_text: factual.text,
reply_type: inferReplyType(factual.responseType),
reply_type: (0, composeStage_1.inferReplyType)(factual.responseType),
response_type: factual.responseType,
debug: {
detected_mode: mode.mode,
@@ -122,6 +122,8 @@ const BASE_RECIPES = [
account_scope_mode: "strict"
}
];
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
function toDateTimeExpr(isoDate, endOfDay) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
@@ -172,6 +174,12 @@ function shouldBoostLimitForAllTimeCounterparty(filters) {
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0));
return !hasPeriod;
}
function maxLimitForIntent(intent) {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
return ADDRESS_MAX_LIMIT_DEFAULT;
}
function selectAddressRecipe(intent, filters) {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@@ -192,14 +200,19 @@ function selectAddressRecipe(intent, filters) {
};
}
function buildAddressRecipePlan(recipe, filters) {
const maxLimit = maxLimitForIntent(recipe.intent);
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
const boostedLimit = (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, 200)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
? Math.max(baseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
typeof filters.account === "string" &&
filters.account.trim().length > 0
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
? [String(filters.account)]
: Array.isArray(recipe.account_scope)
@@ -0,0 +1,125 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.contractCandidatesFromRows = contractCandidatesFromRows;
exports.composeFactualReply = composeFactualReply;
exports.inferReplyType = inferReplyType;
function uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
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 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, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
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")
};
}
function inferReplyType(responseType) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}
@@ -0,0 +1,181 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
const addressIntentResolver_1 = require("../addressIntentResolver");
const addressFilterExtractor_1 = require("../addressFilterExtractor");
function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text) {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(String(text ?? ""));
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasAllTimeHint(normalized)) {
return true;
}
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
return true;
}
return normalized.split(/\s+/).filter(Boolean).length <= 8;
}
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current };
const reasons = [];
if (!followupContext) {
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const allTimeRequested = hasAllTimeHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (!toNonEmptyString(merged.account)) {
const inheritedAccount = previousAccount ??
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
if (inheritedAccount) {
merged.account = inheritedAccount;
reasons.push("account_from_followup_context");
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract = previousContract ??
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
}
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
delete merged.period_from;
delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup");
}
return { filters: merged, reasons };
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (toNonEmptyString(previous.period_from)) {
merged.period_from = previous.period_from;
}
if (toNonEmptyString(previous.period_to)) {
merged.period_to = previous.period_to;
}
reasons.push("period_from_followup_context");
}
return { filters: merged, reasons };
}
function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"]
};
const required = requiredByIntent[intent] ?? [];
return required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
}
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
}
function runAddressDecomposeStage(userMessage, followupContext) {
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
const filters = {
extracted_filters: followupMerged.filters,
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
};
const followupContextApplied = Boolean(followupContext) &&
(mode.reasons.includes("address_mode_from_followup_context") ||
intent.reasons.includes("intent_from_followup_context") ||
followupMerged.reasons.length > 0);
const baseReasons = [
...mode.reasons,
...shape.reasons,
...intent.reasons,
...followupMerged.reasons,
...(followupContextApplied ? ["address_followup_context_applied"] : [])
];
return {
mode,
shape,
intent,
filters,
baseReasons
};
}
@@ -0,0 +1,179 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolvePrimaryAnchor = resolvePrimaryAnchor;
exports.refineAnchorFromRows = refineAnchorFromRows;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
function transliterateCyrillicToLatin(value) {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya"
};
let out = "";
for (const char of String(value ?? "").toLowerCase()) {
out += map[char] ?? char;
}
return out;
}
function normalizeSearchText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value) {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
if (!direct) {
return false;
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
}
function uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
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 refineAnchorFromRows(anchor, rows) {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
+299 -73
View File
@@ -1731,8 +1731,9 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
@@ -1790,12 +1791,204 @@ function buildAddressDebugPayload(addressDebug) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
llm_provider_used: llmMeta?.provider ?? null,
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
normalizer_output: llmMeta?.traceId
? {
trace_id: llmMeta.traceId,
prompt_version: "normalizer_v2_0_2",
applied: Boolean(llmMeta?.applied),
effective_message: llmMeta?.effectiveMessage ?? null
}
: null
};
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function readAddressFilterString(addressDebug, key) {
const filters = addressDebug?.extracted_filters;
if (!filters || typeof filters !== "object") {
return null;
}
return toNonEmptyString(filters[key]);
}
function findLastAddressAssistantDebug(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
return debug;
}
}
return null;
}
function hasAddressFollowupContextSignal(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
return true;
}
if (hasReferentialPointer(text)) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
return false;
}
function resolveAddressFollowupCarryoverContext(userMessage, items) {
if (!hasAddressFollowupContextSignal(userMessage)) {
return null;
}
const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
const previousFiltersRaw = previousAddressDebug.extracted_filters;
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw }
: {};
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
return {
followupContext: {
previous_intent: previousIntent ?? undefined,
previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor
};
}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
}
function extractAddressQuestionFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
return null;
}
const source = normalized;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
if (domainRelevance === "out_of_scope") {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
if (readiness === "no_route") {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
}
}
return null;
}
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const baseMeta = {
attempted: false,
applied: false,
provider,
traceId: null,
effectiveMessage: userMessage,
reason: "not_attempted"
};
if (Boolean(payload?.useMock)) {
return {
...baseMeta,
reason: "skipped_in_mock"
};
}
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
return {
...baseMeta,
reason: "not_address_like"
};
}
const normalizePayload = {
llmProvider: payload?.llmProvider,
apiKey: payload?.apiKey,
model: payload?.model,
baseUrl: payload?.baseUrl,
temperature: 0,
maxOutputTokens: payload?.maxOutputTokens,
promptVersion: "normalizer_v2_0_2",
userQuestion: userMessage,
context: payload?.context,
useMock: Boolean(payload?.useMock),
retryPolicy: "single-pass-strict"
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
if (!normalized?.ok || !candidate) {
return {
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
};
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;
return {
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
};
}
catch (error) {
return {
...baseMeta,
attempted: true,
reason: `error:${error instanceof Error ? error.message : String(error)}`
};
}
}
class AssistantService {
normalizerService;
sessions;
@@ -1827,80 +2020,112 @@ 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,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
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,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
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,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_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,
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
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,
effective_address_user_message: effectiveAddressUserMessage,
address_followup_context_applied: Boolean(carryoverMeta),
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
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,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
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,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
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,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_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,
conversation_item: assistantItem,
debug,
conversation
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
};
};
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
: {
attempted: false,
applied: false,
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
traceId: null,
effectiveMessage: userMessage,
reason: "disabled_by_feature_flag"
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
if (primaryAddressLane?.handled) {
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
}
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
if (carryover?.followupContext) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext
});
if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
}
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
@@ -1917,12 +2142,13 @@ class AssistantService {
usage: null
};
const normalizePayload = {
llmProvider: payload.llmProvider,
apiKey: payload.apiKey,
model: payload.model,
baseUrl: payload.baseUrl,
temperature: payload.temperature,
maxOutputTokens: payload.maxOutputTokens,
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
systemPrompt: payload.systemPrompt,
developerPrompt: payload.developerPrompt,
domainPrompt: payload.domainPrompt,
@@ -871,6 +871,7 @@ class NormalizerService {
async normalize(payload) {
const traceId = (0, nanoid_1.nanoid)(14);
const startedAt = Date.now();
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
const model = payload.model ?? config_1.DEFAULT_MODEL;
const baseUrl = payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL;
const temperature = payload.temperature ?? config_1.DEFAULT_TEMPERATURE;
@@ -903,6 +904,7 @@ class NormalizerService {
else {
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
const firstTry = await this.openaiClient.normalize({
llmProvider,
apiKey: String(apiKey ?? ""),
model,
baseUrl,
@@ -946,6 +948,7 @@ class NormalizerService {
if (!payload.useMock && !validation.passed && canRetry) {
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
const retry = await this.openaiClient.normalize({
llmProvider,
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,
+230 -44
View File
@@ -8,6 +8,20 @@ const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const http_1 = require("../utils/http");
function resolveProvider(config) {
return config.llmProvider === "local" ? "local" : "openai";
}
function resolveApiKey(config) {
const candidate = String(config.apiKey ?? "").trim();
if (candidate.length > 0) {
return candidate;
}
if (resolveProvider(config) === "local") {
// Local OpenAI-compatible servers often accept any token.
return "local-dev-token";
}
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
}
function extractUsage(raw) {
const usage = (raw.usage ?? {});
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
@@ -19,7 +33,7 @@ function extractUsage(raw) {
total_tokens: Number.isFinite(total) ? total : 0
};
}
function extractOutputText(raw) {
function extractOutputTextFromResponses(raw) {
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
return raw.output_text;
}
@@ -51,7 +65,55 @@ function extractOutputText(raw) {
return nested.output_text;
}
}
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
}
function extractOutputTextFromChatCompletions(raw) {
const choices = raw.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
}
const first = choices[0];
if (!first || typeof first !== "object") {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
}
const message = first.message;
if (!message || typeof message !== "object") {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
}
const content = message.content;
if (typeof content === "string" && content.trim().length > 0) {
return content;
}
if (Array.isArray(content)) {
const textParts = content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const block = item;
return typeof block.text === "string" ? block.text : "";
})
.filter((item) => item.trim().length > 0);
if (textParts.length > 0) {
return textParts.join("\n");
}
}
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
}
function shouldFallbackToChatCompletions(error) {
if (!(error instanceof http_1.ApiError)) {
return false;
}
if (error.code !== "OPENAI_REQUEST_FAILED") {
return false;
}
const details = (error.details ?? {});
const status = Number(details.status ?? 0);
if ([404, 405, 501].includes(status)) {
return true;
}
const message = String(error.message ?? "").toLowerCase();
return message.includes("/responses") || message.includes("responses");
}
function loadSchemaForTransport(schemaVersion) {
const schemaFile = schemaVersion === "v1"
@@ -64,19 +126,54 @@ function loadSchemaForTransport(schemaVersion) {
const schemaPath = path_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile);
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
}
function buildBaseUrlCandidates(config) {
const base = (config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const provider = resolveProvider(config);
if (provider !== "local") {
return [base];
}
const hasVersionSuffix = /\/v\d+$/i.test(base);
if (hasVersionSuffix) {
return [base];
}
return Array.from(new Set([base, `${base}/v1`]));
}
class OpenAIResponsesClient {
async listModels(config) {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
const ids = data
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
return String(item.id ?? "").trim();
})
.filter((item) => item.length > 0);
return Array.from(new Set(ids));
}
async testConnection(config) {
const payload = {
const provider = resolveProvider(config);
if (provider === "local") {
try {
await this.getModels(config);
}
catch {
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
await this.postChatCompletions(config, {
model: config.model,
messages: [{ role: "user", content: "ping" }],
max_tokens: 4,
temperature: 0
});
}
return { ok: true, model: config.model };
}
await this.postResponses(config, {
model: config.model,
input: [
{
role: "user",
content: [{ type: "input_text", text: "ping" }]
}
],
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
max_output_tokens: 16
};
await this.post(config, payload);
});
return { ok: true, model: config.model };
}
async normalize(config, prompt) {
@@ -91,7 +188,7 @@ class OpenAIResponsesClient {
const developerPrompt = prompt.controlledRetryInstruction
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
: prompt.developerPrompt;
const payload = {
const responsesPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_output_tokens: config.maxOutputTokens ?? 700,
@@ -109,7 +206,7 @@ class OpenAIResponsesClient {
content: [
{
type: "input_text",
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
}
]
}
@@ -123,44 +220,133 @@ class OpenAIResponsesClient {
}
}
};
const raw = await this.post(config, payload);
const outputText = extractOutputText(raw);
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
// local provider: prefer /responses if available, fallback to /chat/completions
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_tokens: config.maxOutputTokens ?? 700,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
},
{
role: "user",
content: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
`Return only JSON that matches schema: ${schemaName}.`
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
async post(config, payload) {
if (!config.apiKey || config.apiKey.trim().length < 10) {
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
async getModels(config) {
return this.requestJson(config, "/models", "GET");
}
async postResponses(config, payload) {
return this.requestJson(config, "/responses", "POST", payload);
}
async postChatCompletions(config, payload) {
return this.requestJson(config, "/chat/completions", "POST", payload);
}
async requestJson(config, routePath, method, payload) {
const apiKey = resolveApiKey(config);
const baseCandidates = buildBaseUrlCandidates(config);
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
let lastNetworkError = null;
const headers = {
Authorization: `Bearer ${apiKey}`
};
if (method === "POST") {
headers["Content-Type"] = "application/json";
}
const url = `${(config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
for (let index = 0; index < baseCandidates.length; index += 1) {
const base = baseCandidates[index];
const isLastCandidate = index === baseCandidates.length - 1;
const url = `${base}${routePath}`;
let response;
try {
response = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
});
}
catch (error) {
lastNetworkError = error;
if (!isLastCandidate) {
continue;
}
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
url,
reason: error instanceof Error ? error.message : String(error)
});
}
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
const text = await response.text();
let data = {};
if (text.trim().length > 0) {
try {
data = JSON.parse(text);
}
catch {
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
route: routePath,
url,
status: response.status,
body: text.slice(0, 500)
});
}
}
if (!response.ok) {
const errorObj = (data.error ?? {});
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `Model endpoint failed: ${response.status}`), response.status, {
route: routePath,
url,
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
});
}
return data;
}
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
}
catch {
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
}
if (!response.ok) {
const errorObj = (data.error ?? {});
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `OpenAI request failed with status ${response.status}`), response.status, {
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
});
}
return data;
}
}
exports.OpenAIResponsesClient = OpenAIResponsesClient;
+33 -4
View File
@@ -12,6 +12,7 @@
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"iconv-lite": "^0.7.0",
"llm-normalizer-workspace": "file:..",
"nanoid": "^5.1.5"
},
@@ -1224,6 +1225,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1923,15 +1936,19 @@
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
@@ -2280,6 +2297,18 @@
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+1
View File
@@ -15,6 +15,7 @@
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"iconv-lite": "^0.7.0",
"llm-normalizer-workspace": "file:..",
"nanoid": "^5.1.5"
},
+4
View File
@@ -95,6 +95,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
true
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1,
true
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true
@@ -1,4 +1,4 @@
import { Router } from "express";
import { NextFunction, Request, Response, Router } from "express";
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
import { ok } from "../utils/http";
@@ -6,23 +6,76 @@ import { ok } from "../utils/http";
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
const router = Router();
router.post("/api/openai/test-connection", async (req, res, next) => {
const handler = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
const model = String(body.model ?? DEFAULT_MODEL);
const baseUrl = String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL);
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
const result = await client.testConnection({
llmProvider,
apiKey,
model,
baseUrl
});
let modelFound: boolean | null = null;
let modelsCount: number | null = null;
if (llmProvider === "local") {
try {
const models = await client.listModels({
llmProvider,
apiKey,
model,
baseUrl
});
modelsCount = models.length;
modelFound = models.includes(model);
} catch {
modelFound = null;
modelsCount = null;
}
}
ok(res, {
ok: true,
provider: llmProvider,
model: result.model,
model_found: modelFound,
models_count: modelsCount,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
};
const listModelsHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const models = await client.listModels({
llmProvider: body.llmProvider === "local" ? "local" : "openai",
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL)
});
ok(res, {
ok: true,
model: result.model,
models,
count: models.length,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
});
};
router.post("/api/llm/test-connection", handler);
router.post("/api/llm/models", listModelsHandler);
// Backward-compatible route for old frontend builds.
router.post("/api/openai/test-connection", handler);
router.post("/api/openai/models", listModelsHandler);
return router;
}
@@ -2,13 +2,24 @@
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 COUNTERPARTY_PATTERN =
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
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;
const YEAR_RANGE_PATTERN =
/(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
const YEAR_PERIOD_PATTERN =
/(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_ANY_PATTERN =
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
function toIsoDate(year: number, month: number, day: number): string | null {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
@@ -68,6 +79,57 @@ function parseDateToken(token: string): string | undefined {
return undefined;
}
function resolveMonthByName(rawMonthName: string): number | undefined {
const token = String(rawMonthName ?? "").trim().toLowerCase();
if (!token) {
return undefined;
}
if (/^янв|^january|^jan/.test(token)) return 1;
if (/^фев|^february|^feb/.test(token)) return 2;
if (/^мар|^march|^mar/.test(token)) return 3;
if (/^апр|^april|^apr/.test(token)) return 4;
if (/^ма[йя]|^may/.test(token)) return 5;
if (/^июн|^june|^jun/.test(token)) return 6;
if (/^июл|^july|^jul/.test(token)) return 7;
if (/^авг|^august|^aug/.test(token)) return 8;
if (/^сен|^сент|^september|^sep/.test(token)) return 9;
if (/^окт|^october|^oct/.test(token)) return 10;
if (/^ноя|^november|^nov/.test(token)) return 11;
if (/^дек|^december|^dec/.test(token)) return 12;
return undefined;
}
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
if (numericMatch) {
const month = Number(numericMatch[1]);
const year = Number(numericMatch[2]);
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
if (byNameMatch) {
const month = resolveMonthByName(String(byNameMatch[1]));
const year = Number(byNameMatch[2]);
if (month && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
return {};
}
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) {
@@ -81,6 +143,70 @@ function extractPeriodRange(text: string): { period_from?: string; period_to?: s
};
}
function extractYearPeriod(text: string): { period_from?: string; period_to?: string } {
const match = text.match(YEAR_PERIOD_PATTERN);
if (match) {
const year = Number(match[1]);
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
return {};
}
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
if (relaxedYearMatch) {
const year = Number(relaxedYearMatch[1]);
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
}
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
if (!shortYearMatch) {
return {};
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return {};
}
const year = 2000 + shortYear;
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
function extractYearRangePeriod(text: string): { period_from?: string; period_to?: string } {
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
if (!match) {
return {};
}
const leftYear = Number(match[1]);
const rightYear = Number(match[2]);
if (
!Number.isFinite(leftYear) ||
!Number.isFinite(rightYear) ||
leftYear < 2000 ||
leftYear > 2099 ||
rightYear < 2000 ||
rightYear > 2099
) {
return {};
}
const fromYear = Math.min(leftYear, rightYear);
const toYear = Math.max(leftYear, rightYear);
return {
period_from: `${fromYear}-01-01`,
period_to: `${toYear}-12-31`
};
}
function cleanupAnchorValue(value: string): string {
const normalized = String(value ?? "").trim();
if (!normalized) {
@@ -95,11 +221,13 @@ function cleanupAnchorValue(value: string): string {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPattern =
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPatternEn =
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
@@ -112,7 +240,203 @@ function cleanupAnchorValue(value: string): string {
function hasAllTimeHint(text: string): boolean {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text: string): string | undefined {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
const token = String(match[1] ?? "").trim();
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
if (stopWords.has(lowered)) {
return undefined;
}
return token;
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"покеж",
"покажи",
"выведи"
]);
return !stopWords.has(lowered);
}
function hasDocsOrBankSignal(text: string): boolean {
const lowered = String(text ?? "").toLowerCase();
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
lowered
);
}
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function extractImplicitCounterpartyValue(text: string): string | undefined {
const input = String(text ?? "");
const beforeDocsMatch = input.match(
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
const afterDocsMatch = input.match(
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
return undefined;
}
function shiftDaysIso(baseIso: string, deltaDays: number): string {
@@ -156,6 +480,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const fallbackCounterparty = extractLooseByAnchorValue(text);
if (fallbackCounterparty) {
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const implicitCounterparty = extractImplicitCounterpartyValue(text);
if (implicitCounterparty) {
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
@@ -170,6 +515,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
filters.period_to = periodRange.period_to;
}
if (!filters.period_from && !filters.period_to) {
const monthPeriod = extractMonthPeriod(text);
if (monthPeriod.period_from && monthPeriod.period_to) {
filters.period_from = monthPeriod.period_from;
filters.period_to = monthPeriod.period_to;
warnings.push("period_derived_from_month_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearRangePeriod = extractYearRangePeriod(text);
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
filters.period_from = yearRangePeriod.period_from;
filters.period_to = yearRangePeriod.period_to;
warnings.push("period_derived_from_year_range_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearPeriod = extractYearPeriod(text);
if (yearPeriod.period_from && yearPeriod.period_to) {
filters.period_from = yearPeriod.period_from;
filters.period_to = yearPeriod.period_to;
warnings.push("period_derived_from_year_phrase");
}
}
// 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);
@@ -67,9 +67,20 @@ const OPEN_ITEMS_HINTS = [
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"documents by company",
"documents by supplier",
"documents by customer",
"documents by client",
"documents by partner",
"show documents by counterparty",
"list documents by counterparty",
"документы по",
"доступные документы",
"список документов",
"документ",
"доки",
"доки по",
"док по",
"по контрагент"
];
@@ -77,16 +88,202 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"bank operations by counterparty",
"bank payments by counterparty",
"payment orders by counterparty",
"bank operations by company",
"bank operations by supplier",
"bank operations by customer",
"show bank operations by counterparty",
"bank ops",
"transactions by counterparty",
"банков",
"выписк",
"платеж"
"платеж",
"платёж",
"оплат",
"списан",
"поступлен",
"движени"
];
function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"доки?",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй"
]);
return !stopWords.has(token);
}
function hasPartyAnchorMention(text: string): boolean {
return (
text.includes("контраг") ||
text.includes("контра") ||
text.includes("counterparty") ||
text.includes("компан") ||
text.includes("company") ||
text.includes("организац") ||
text.includes("supplier") ||
text.includes("vendor") ||
text.includes("customer") ||
text.includes("client") ||
text.includes("partner") ||
text.includes("поставщик") ||
text.includes("клиент") ||
text.includes("покупател") ||
text.includes("партнер")
);
}
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам"
]);
return !stopWords.has(token);
}
function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
const beforeDocsMatch = text.match(
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
);
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
return true;
}
const afterDocsMatch = text.match(
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
);
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
return true;
}
return false;
}
function hasDocsOrBankSignal(text: string): boolean {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
text
);
}
function hasHeuristicCounterpartyAnchor(text: string): boolean {
if (!hasDocsOrBankSignal(text)) {
return false;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return true;
}
return false;
}
function hasGenericAddressLookupSignal(text: string): boolean {
return (
/\bесть\b/iu.test(text) ||
/\bпокажи\b/iu.test(text) ||
/\bвыведи\b/iu.test(text) ||
/\bкакие\b/iu.test(text) ||
/\bчто(?:-|\s)?то\b/iu.test(text) ||
/за\s+любой\s+период/iu.test(text) ||
/за\s+вс[её]\s+время/iu.test(text) ||
/for\s+all\s+time/iu.test(text) ||
/all\s+time/iu.test(text)
);
}
function hasAccountNumberAnchor(text: string): boolean {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
@@ -128,7 +325,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))
) {
return {
intent: "bank_operations_by_counterparty",
@@ -139,7 +336,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
(hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text))
) {
return {
intent: "list_documents_by_counterparty",
@@ -148,6 +348,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
return {
intent: "list_documents_by_counterparty",
confidence: "low",
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
@@ -3,6 +3,7 @@
ASSISTANT_MCP_PROXY_URL,
ASSISTANT_MCP_TIMEOUT_MS
} from "../config";
import iconv from "iconv-lite";
interface McpExecuteQueryResponse {
success?: unknown;
@@ -36,8 +37,81 @@ function parseFiniteNumber(value: unknown): number | null {
return null;
}
function textMojibakeScore(value: string): number {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
}
function looksLikeMojibake(value: string): boolean {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
}
function decodeUtf8FromWin1251Mojibake(value: string): string {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const bytes = iconv.encode(value, "win1251");
const decoded = bytes.toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function decodeUtf8FromLatin1Mojibake(value: string): string {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const decoded = Buffer.from(value, "latin1").toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function normalizeMojibakeString(value: string): string {
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
return decodeUtf8FromLatin1Mojibake(fromWin1251);
}
function normalizeMojibakeValue(value: unknown): unknown {
if (typeof value === "string") {
return normalizeMojibakeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMojibakeValue(item));
}
if (value && typeof value === "object") {
const source = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(source)) {
const repairedKey = normalizeMojibakeString(key);
normalized[repairedKey] = normalizeMojibakeValue(raw);
}
return normalized;
}
return value;
}
function normalizeMojibakeRows(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
return rows.map((row) => normalizeMojibakeValue(row) as Record<string, unknown>);
}
function parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
@@ -111,7 +185,7 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
rows.push(row);
}
return rows;
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
@@ -133,9 +207,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
}
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);
const rows = normalizeMojibakeRows(
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,
@@ -152,9 +228,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
}
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);
const rows = normalizeMojibakeRows(
((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,
@@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"company",
"organization",
"supplier",
"vendor",
"customer",
"client",
"partner",
"contract",
"contracts",
"account",
@@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
"owes",
"owed",
"контрагент",
"контра",
"компан",
"организац",
"поставщик",
"клиент",
"покупател",
"партнер",
"банк",
"выписк",
"операц",
"договор",
"счет",
"счёт",
"документ",
"доки",
"док",
"остаток",
"дебитор",
"кредитор",
@@ -73,6 +92,56 @@ const DEEP_REASONING_TOKENS = [
"ошибк"
];
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
return !stopWords.has(token);
}
function hasAddressFollowupSignal(text: string): boolean {
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
return true;
}
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
return true;
}
return false;
}
function hasAnyToken(text: string, tokens: string[]): boolean {
return tokens.some((token) => text.includes(token));
}
@@ -90,6 +159,8 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
const hasLooseByAnchor = hasLooseByAnchorMention(text);
const hasFollowupSignal = hasAddressFollowupSignal(text);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
@@ -99,6 +170,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
};
}
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
@@ -13,12 +13,11 @@ import type {
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";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, contractCandidatesFromRows, inferReplyType } from "./address_runtime/composeStage";
interface NormalizedAddressRow {
period: string | null;
@@ -29,6 +28,10 @@ interface NormalizedAddressRow {
analytics: string[];
}
interface AddressTryHandleOptions {
followupContext?: AddressFollowupContext | null;
}
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const PARTY_ANCHOR_STOPWORDS = new Set([
@@ -388,21 +391,65 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
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 hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
return (
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
);
}
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return "partial_coverage";
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
}
function toIsoDatePrefix(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) {
return match[1];
}
return null;
}
function deriveObservedPeriodWindow(rows: NormalizedAddressRow[]): { period_from: string | null; period_to: string | null } {
const dates = rows
.map((row) => toIsoDatePrefix(row.period))
.filter((item): item is string => Boolean(item))
.sort();
if (dates.length === 0) {
return {
period_from: null,
period_to: null
};
}
return {
period_from: dates[0],
period_to: dates[dates.length - 1]
};
}
function composeAutoBroadenedPeriodPrefix(
requested: AddressFilterSet,
observed: { period_from: string | null; period_to: string | null }
): string {
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
}
if (requestedFrom && requestedTo) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
}
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
@@ -418,14 +465,6 @@ function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCatego
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:
@@ -580,99 +619,6 @@ function toLegacyMcpStatus(
return status;
}
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
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 refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(
rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
);
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
const heading =
category === "empty_match"
@@ -777,137 +723,20 @@ function buildLimitedExecutionResult(input: {
};
}
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> {
public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise<AddressExecutionResult | null> {
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = detectAddressQuestionMode(userMessage);
if (mode.mode !== "address_query") {
const followupContext = options.followupContext ?? null;
const decompose = runAddressDecomposeStage(userMessage, followupContext);
if (!decompose) {
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 { mode, shape, intent, filters, baseReasons } = decompose;
let 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({
@@ -1130,6 +959,112 @@ export class AddressQueryService {
});
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
const broadenedMcp = await executeAddressMcpQuery({
query: broadenedPlan.query,
limit: broadenedPlan.limit
});
if (!broadenedMcp.error) {
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
const broadenedAccountScopeFallbackApplied =
broadenedPlan.account_scope_mode === "preferred" &&
broadenedPlan.account_scope.length > 0 &&
broadenedRawRows.length > 0 &&
broadenedScopedRows.length === 0;
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
let broadenedAnchor = resolvePrimaryAnchor(intent.intent, autoBroadenedFilters);
broadenedAnchor = refineAnchorFromRows(broadenedAnchor, broadenedNormalizedRows);
const broadenedFiltersForMatching: AddressFilterSet =
broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
: autoBroadenedFilters;
const broadenedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: broadenedFiltersForMatching,
accountScope: broadenedPlan.account_scope,
rowsBeforeScope: broadenedRawRows.length,
rowsAfterScope: broadenedNormalizedRows.length
});
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
broadenedMcp.raw_rows,
broadenedNormalizedRows.length,
broadenedNormalizedRows.length
);
const broadenedStageStatus = deriveMcpStageStatus({
rawRowsReceived: broadenedMcp.raw_rows.length,
rowsMaterialized: broadenedNormalizedRows.length,
rowsAnchorMatched: broadenedRowsByAnchor.length,
rowsMatched: broadenedFilteredRows.length
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
handled: true,
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
reply_type: inferReplyType(broadenedFactual.responseType),
response_type: broadenedFactual.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: broadenedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
account_scope_mode: broadenedPlan.account_scope_mode,
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
anchor_type: broadenedAnchor.anchor_type,
anchor_value_raw: broadenedAnchor.anchor_value_raw,
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
resolver_confidence: broadenedAnchor.resolver_confidence,
ambiguity_count: broadenedAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: broadenedStageStatus,
rows_fetched: broadenedMcp.fetched_rows,
raw_rows_received: broadenedMcp.raw_rows.length,
rows_after_account_scope: broadenedNormalizedRows.length,
rows_after_recipe_filter: broadenedRowsByAnchor.length,
rows_materialized: broadenedNormalizedRows.length,
rows_matched: broadenedFilteredRows.length,
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
limitations: broadenedLimitations,
reasons: broadenedReasons
}
};
}
}
}
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
@@ -128,6 +128,9 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
}
];
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
export interface AddressRecipeExecutionPlan {
recipe: AddressRecipeDefinition;
query: string;
@@ -196,6 +199,13 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
return !hasPeriod;
}
function maxLimitForIntent(intent: AddressIntent): number {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
return ADDRESS_MAX_LIMIT_DEFAULT;
}
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@@ -222,16 +232,21 @@ export function buildAddressRecipePlan(
recipe: AddressRecipeDefinition,
filters: AddressFilterSet
): AddressRecipeExecutionPlan {
const maxLimit = maxLimitForIntent(recipe.intent);
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
const boostedLimit =
(recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, 200)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
? Math.max(baseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
typeof filters.account === "string" &&
filters.account.trim().length > 0
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
const accountScope =
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
@@ -0,0 +1,150 @@
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
export interface ComposeStageRow {
period: string | null;
registrator: string;
account_dt: string | null;
account_kt: string | null;
amount: number | null;
analytics: string[];
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
values
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
);
}
function formatTopRows(rows: ComposeStageRow[], 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}`;
});
}
export function contractCandidatesFromRows(rows: ComposeStageRow[]): 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);
}
export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[]
): { 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, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
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 function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}
@@ -0,0 +1,244 @@
import type {
AddressFilterSet,
AddressIntent,
AddressIntentResolution,
AddressModeDetection,
AddressQueryShapeDetection
} from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
export interface AddressFollowupContext {
previous_intent?: AddressIntent;
previous_filters?: AddressFilterSet;
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
previous_anchor_value?: string | null;
}
export interface AddressDecomposeStageResult {
mode: AddressModeDetection;
shape: AddressQueryShapeDetection;
intent: AddressIntentResolution;
filters: {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
warnings: string[];
};
baseReasons: string[];
}
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
return (
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
);
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text: string): boolean {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(
String(text ?? "")
);
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasAllTimeHint(normalized)) {
return true;
}
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
return true;
}
return normalized.split(/\s+/).filter(Boolean).length <= 8;
}
function mergeFollowupFilters(
current: AddressFilterSet,
intent: AddressIntent,
userMessage: string,
followupContext: AddressFollowupContext | null
): { filters: AddressFilterSet; reasons: string[] } {
const merged: AddressFilterSet = { ...current };
const reasons: string[] = [];
if (!followupContext) {
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const allTimeRequested = hasAllTimeHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty =
previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (!toNonEmptyString(merged.account)) {
const inheritedAccount =
previousAccount ??
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
if (inheritedAccount) {
merged.account = inheritedAccount;
reasons.push("account_from_followup_context");
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract =
previousContract ??
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
}
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty =
previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
delete merged.period_from;
delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup");
}
return { filters: merged, reasons };
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (toNonEmptyString(previous.period_from)) {
merged.period_from = previous.period_from;
}
if (toNonEmptyString(previous.period_to)) {
merged.period_to = previous.period_to;
}
reasons.push("period_from_followup_context");
}
return { filters: merged, reasons };
}
function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFilterSet): string[] {
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"]
};
const required = requiredByIntent[intent] ?? [];
return required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
}
function deriveIntentWithFollowupContext(
detectedIntent: AddressIntentResolution,
userMessage: string,
followupContext: AddressFollowupContext | null
): AddressIntentResolution {
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
}
export function runAddressDecomposeStage(
userMessage: string,
followupContext: AddressFollowupContext | null
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const mode =
detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query" as const,
confidence: "medium" as const,
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = resolveAddressIntent(userMessage);
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
const filters = {
extracted_filters: followupMerged.filters,
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
};
const followupContextApplied =
Boolean(followupContext) &&
(mode.reasons.includes("address_mode_from_followup_context") ||
intent.reasons.includes("intent_from_followup_context") ||
followupMerged.reasons.length > 0);
const baseReasons = [
...mode.reasons,
...shape.reasons,
...intent.reasons,
...followupMerged.reasons,
...(followupContextApplied ? ["address_followup_context_applied"] : [])
];
return {
mode,
shape,
intent,
filters,
baseReasons
};
}
@@ -0,0 +1,211 @@
import type { AddressFilterSet, AddressIntent } from "../../types/addressQuery";
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
export 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;
}
export interface ResolveStageRow {
registrator: string;
account_dt: string | null;
account_kt: string | null;
analytics: string[];
}
function transliterateCyrillicToLatin(value: string): string {
const map: Record<string, string> = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya"
};
let out = "";
for (const char of String(value ?? "").toLowerCase()) {
out += map[char] ?? char;
}
return out;
}
function normalizeSearchText(value: string): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value: string): string[] {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
if (!direct) {
return false;
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
values
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
);
}
export 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
};
}
export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: ResolveStageRow[]): AnchorResolutionDebug {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(
rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
);
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
@@ -1693,8 +1693,9 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
@@ -1752,12 +1753,204 @@ function buildAddressDebugPayload(addressDebug) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
llm_provider_used: llmMeta?.provider ?? null,
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
normalizer_output: llmMeta?.traceId
? {
trace_id: llmMeta.traceId,
prompt_version: "normalizer_v2_0_2",
applied: Boolean(llmMeta?.applied),
effective_message: llmMeta?.effectiveMessage ?? null
}
: null
};
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function readAddressFilterString(addressDebug, key) {
const filters = addressDebug?.extracted_filters;
if (!filters || typeof filters !== "object") {
return null;
}
return toNonEmptyString(filters[key]);
}
function findLastAddressAssistantDebug(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
return debug;
}
}
return null;
}
function hasAddressFollowupContextSignal(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
return true;
}
if (hasReferentialPointer(text)) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
return false;
}
function resolveAddressFollowupCarryoverContext(userMessage, items) {
if (!hasAddressFollowupContextSignal(userMessage)) {
return null;
}
const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
const previousFiltersRaw = previousAddressDebug.extracted_filters;
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw }
: {};
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
return {
followupContext: {
previous_intent: previousIntent ?? undefined,
previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor
};
}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
}
function extractAddressQuestionFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
return null;
}
const source = normalized;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
if (domainRelevance === "out_of_scope") {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
if (readiness === "no_route") {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
}
}
return null;
}
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const baseMeta = {
attempted: false,
applied: false,
provider,
traceId: null,
effectiveMessage: userMessage,
reason: "not_attempted"
};
if (Boolean(payload?.useMock)) {
return {
...baseMeta,
reason: "skipped_in_mock"
};
}
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
return {
...baseMeta,
reason: "not_address_like"
};
}
const normalizePayload = {
llmProvider: payload?.llmProvider,
apiKey: payload?.apiKey,
model: payload?.model,
baseUrl: payload?.baseUrl,
temperature: 0,
maxOutputTokens: payload?.maxOutputTokens,
promptVersion: "normalizer_v2_0_2",
userQuestion: userMessage,
context: payload?.context,
useMock: Boolean(payload?.useMock),
retryPolicy: "single-pass-strict"
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
if (!normalized?.ok || !candidate) {
return {
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
};
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;
return {
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
};
}
catch (error) {
return {
...baseMeta,
attempted: true,
reason: `error:${error instanceof Error ? error.message : String(error)}`
};
}
}
export class AssistantService {
normalizerService;
sessions;
@@ -1789,80 +1982,112 @@ 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,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
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,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
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,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_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,
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
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,
effective_address_user_message: effectiveAddressUserMessage,
address_followup_context_applied: Boolean(carryoverMeta),
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
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,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
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,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
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,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_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,
conversation_item: assistantItem,
debug,
conversation
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
};
};
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
: {
attempted: false,
applied: false,
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
traceId: null,
effectiveMessage: userMessage,
reason: "disabled_by_feature_flag"
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
if (primaryAddressLane?.handled) {
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
}
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
if (carryover?.followupContext) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext
});
if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
}
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
@@ -1879,12 +2104,13 @@ export class AssistantService {
usage: null
};
const normalizePayload = {
llmProvider: payload.llmProvider,
apiKey: payload.apiKey,
model: payload.model,
baseUrl: payload.baseUrl,
temperature: payload.temperature,
maxOutputTokens: payload.maxOutputTokens,
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
systemPrompt: payload.systemPrompt,
developerPrompt: payload.developerPrompt,
domainPrompt: payload.domainPrompt,
@@ -1036,6 +1036,7 @@ export class NormalizerService {
public async normalize(payload: NormalizeRequestPayload): Promise<NormalizeResponsePayload> {
const traceId = nanoid(14);
const startedAt = Date.now();
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
const model = payload.model ?? DEFAULT_MODEL;
const baseUrl = payload.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
const temperature = payload.temperature ?? DEFAULT_TEMPERATURE;
@@ -1072,6 +1073,7 @@ export class NormalizerService {
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
const firstTry = await this.openaiClient.normalize(
{
llmProvider,
apiKey: String(apiKey ?? ""),
model,
baseUrl,
@@ -1118,6 +1120,7 @@ export class NormalizerService {
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
const retry = await this.openaiClient.normalize(
{
llmProvider,
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,
@@ -1,9 +1,11 @@
import fs from "fs";
import path from "path";
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
import type { LlmProvider } from "../types/normalizer";
import { ApiError } from "../utils/http";
export interface OpenAIRequestConfig {
llmProvider?: LlmProvider;
apiKey: string;
model: string;
baseUrl?: string;
@@ -21,6 +23,22 @@ export interface OpenAIResponseEnvelope {
};
}
function resolveProvider(config: OpenAIRequestConfig): LlmProvider {
return config.llmProvider === "local" ? "local" : "openai";
}
function resolveApiKey(config: OpenAIRequestConfig): string {
const candidate = String(config.apiKey ?? "").trim();
if (candidate.length > 0) {
return candidate;
}
if (resolveProvider(config) === "local") {
// Local OpenAI-compatible servers often accept any token.
return "local-dev-token";
}
throw new ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
}
function extractUsage(raw: Record<string, unknown>): {
input_tokens: number;
output_tokens: number;
@@ -37,7 +55,7 @@ function extractUsage(raw: Record<string, unknown>): {
};
}
function extractOutputText(raw: Record<string, unknown>): string {
function extractOutputTextFromResponses(raw: Record<string, unknown>): string {
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
return raw.output_text;
}
@@ -72,7 +90,58 @@ function extractOutputText(raw: Record<string, unknown>): string {
}
}
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
}
function extractOutputTextFromChatCompletions(raw: Record<string, unknown>): string {
const choices = raw.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
}
const first = choices[0];
if (!first || typeof first !== "object") {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
}
const message = (first as Record<string, unknown>).message;
if (!message || typeof message !== "object") {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
}
const content = (message as Record<string, unknown>).content;
if (typeof content === "string" && content.trim().length > 0) {
return content;
}
if (Array.isArray(content)) {
const textParts = content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const block = item as Record<string, unknown>;
return typeof block.text === "string" ? block.text : "";
})
.filter((item) => item.trim().length > 0);
if (textParts.length > 0) {
return textParts.join("\n");
}
}
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
}
function shouldFallbackToChatCompletions(error: unknown): boolean {
if (!(error instanceof ApiError)) {
return false;
}
if (error.code !== "OPENAI_REQUEST_FAILED") {
return false;
}
const details = (error.details ?? {}) as Record<string, unknown>;
const status = Number(details.status ?? 0);
if ([404, 405, 501].includes(status)) {
return true;
}
const message = String(error.message ?? "").toLowerCase();
return message.includes("/responses") || message.includes("responses");
}
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
@@ -83,24 +152,62 @@ function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"
? "normalized_query_v2_0_1.json"
: schemaVersion === "v2_0_2"
? "normalized_query_v2_0_2.json"
: "normalized_query_v2.json";
: "normalized_query_v2.json";
const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile);
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
}
function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
const base = (config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const provider = resolveProvider(config);
if (provider !== "local") {
return [base];
}
const hasVersionSuffix = /\/v\d+$/i.test(base);
if (hasVersionSuffix) {
return [base];
}
return Array.from(new Set([base, `${base}/v1`]));
}
export class OpenAIResponsesClient {
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
const payload = {
model: config.model,
input: [
{
role: "user",
content: [{ type: "input_text", text: "ping" }]
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
const ids = data
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
],
return String((item as Record<string, unknown>).id ?? "").trim();
})
.filter((item) => item.length > 0);
return Array.from(new Set(ids));
}
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
const provider = resolveProvider(config);
if (provider === "local") {
try {
await this.getModels(config);
} catch {
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
await this.postChatCompletions(config, {
model: config.model,
messages: [{ role: "user", content: "ping" }],
max_tokens: 4,
temperature: 0
});
}
return { ok: true, model: config.model };
}
await this.postResponses(config, {
model: config.model,
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
max_output_tokens: 16
};
await this.post(config, payload);
});
return { ok: true, model: config.model };
}
@@ -123,13 +230,13 @@ export class OpenAIResponsesClient {
? "normalized_query_v2_0_1"
: prompt.schemaVersion === "v2_0_2"
? "normalized_query_v2_0_2"
: "normalized_query_v2";
: "normalized_query_v2";
const developerPrompt = prompt.controlledRetryInstruction
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
: prompt.developerPrompt;
const payload = {
const responsesPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_output_tokens: config.maxOutputTokens ?? 700,
@@ -147,7 +254,7 @@ export class OpenAIResponsesClient {
content: [
{
type: "input_text",
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
}
]
}
@@ -162,52 +269,157 @@ export class OpenAIResponsesClient {
}
};
const raw = await this.post(config, payload);
const outputText = extractOutputText(raw);
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
// local provider: prefer /responses if available, fallback to /chat/completions
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
} catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_tokens: config.maxOutputTokens ?? 700,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
},
{
role: "user",
content:
`${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
`Return only JSON that matches schema: ${schemaName}.`
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
if (!config.apiKey || config.apiKey.trim().length < 10) {
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
private async getModels(config: OpenAIRequestConfig): Promise<Record<string, unknown>> {
return this.requestJson(config, "/models", "GET");
}
private async postResponses(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
return this.requestJson(config, "/responses", "POST", payload);
}
private async postChatCompletions(
config: OpenAIRequestConfig,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return this.requestJson(config, "/chat/completions", "POST", payload);
}
private async requestJson(
config: OpenAIRequestConfig,
routePath: string,
method: "GET" | "POST",
payload?: Record<string, unknown>
): Promise<Record<string, unknown>> {
const apiKey = resolveApiKey(config);
const baseCandidates = buildBaseUrlCandidates(config);
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
let lastNetworkError: unknown = null;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`
};
if (method === "POST") {
headers["Content-Type"] = "application/json";
}
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const text = await response.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
}
if (!response.ok) {
const errorObj = (data.error ?? {}) as Record<string, unknown>;
throw new ApiError(
"OPENAI_REQUEST_FAILED",
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
response.status,
{
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
for (let index = 0; index < baseCandidates.length; index += 1) {
const base = baseCandidates[index];
const isLastCandidate = index === baseCandidates.length - 1;
const url = `${base}${routePath}`;
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
});
} catch (error) {
lastNetworkError = error;
if (!isLastCandidate) {
continue;
}
);
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
url,
reason: error instanceof Error ? error.message : String(error)
});
}
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
const text = await response.text();
let data: Record<string, unknown> = {};
if (text.trim().length > 0) {
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
route: routePath,
url,
status: response.status,
body: text.slice(0, 500)
});
}
}
if (!response.ok) {
const errorObj = (data.error ?? {}) as Record<string, unknown>;
throw new ApiError(
"OPENAI_REQUEST_FAILED",
String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
response.status,
{
route: routePath,
url,
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
}
);
}
return data;
}
return data;
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
});
}
}
@@ -255,6 +255,7 @@ export interface AssistantMessageRequestPayload {
user_message?: string;
message?: string;
mode?: "assistant" | string;
llmProvider?: NormalizeRequestPayload["llmProvider"];
apiKey?: string;
model?: string;
baseUrl?: string;
@@ -370,6 +371,13 @@ export interface AssistantDebugPayload {
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";
execution_lane?: "address_query" | "deep_analysis";
llm_decomposition_applied?: boolean;
llm_decomposition_attempted?: boolean;
llm_provider_used?: "openai" | "local" | null;
llm_decomposition_trace_id?: string | null;
llm_decomposition_effective_message?: string | null;
llm_decomposition_reason?: string | null;
business_scope_raw?: string[];
business_scope_resolved?: string[];
company_grounding_applied?: boolean;
@@ -30,6 +30,7 @@ export type PromptVersion =
| "normalizer_v2_0_2";
export type EvalRunMode = "standard" | "single-pass-strict";
export type LlmProvider = "openai" | "local";
export interface NormalizedQueryV1 {
schema_version: "normalized_query_v1";
@@ -235,6 +236,7 @@ export type RouteHintSummary = RouteHintSummaryV1 | RouteHintSummaryV2;
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
export interface NormalizeRequestPayload {
llmProvider?: LlmProvider;
apiKey?: string;
model?: string;
baseUrl?: string;
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
});
describe("address MCP encoding repair", () => {
it("repairs UTF-8/CP1251 mojibake in object rows", async () => {
const payload = {
success: true,
data: [
{
"Период": "2020-07-30T12:00:00Z",
"Регистратор": "Поступление на расчетный счет 0001",
"СчетДт": "51",
"СчетКт": "62.01",
"РЎСѓРјРјР°": "20000",
"Контрагент": "Группа СВК"
}
]
};
globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json"
}
})
) as typeof fetch;
const result = await executeAddressMcpQuery({
query: "SELECT 1",
limit: 20,
account_scope: []
});
expect(result.error).toBeNull();
expect(result.fetched_rows).toBe(1);
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
});
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
@@ -21,6 +22,16 @@ describe("address query shape classifier", () => {
const result = classifyAddressQueryShape("who owes us and who we owe today?");
expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY");
});
it("keeps company lookup phrasing in address lane", () => {
const result = detectAddressQuestionMode("какие компании есть в базе");
expect(result.mode).toBe("address_query");
});
it("keeps loose by-anchor follow-up phrasing in address lane", () => {
const result = detectAddressQuestionMode("за любой период есть что-то по свк?");
expect(result.mode).toBe("address_query");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
@@ -38,6 +49,41 @@ describe("address intent resolver expansion (M2.3a)", () => {
const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents by company phrase as counterparty intent", () => {
const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves bank operations by supplier phrase", () => {
const result = resolveAddressIntent("Покажи платежи по поставщику Альфа за июль 2020");
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves documents by client phrase", () => {
const result = resolveAddressIntent("Выведи документы по клиенту Бета за 2020-07");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves short slang docs phrase with loose by-anchor", () => {
const result = resolveAddressIntent("какие доки есть по свк за 2021");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves typo slang docs phrase with implicit anchor", () => {
const result = resolveAddressIntent("свк доки за 20год покеж");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves noisy docs phrase with slang tail", () => {
const result = resolveAddressIntent("свк 20 год - покажи доки плс");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => {
const result = resolveAddressIntent("за любой период есть что-то по свк?");
expect(result.intent).toBe("list_documents_by_counterparty");
});
});
describe("address filter extraction for balance drilldown", () => {
@@ -68,6 +114,123 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts counterparty from company phrase and derives year period", () => {
const result = extractAddressFilters(
"Какие документы доступны по компании СВК за 2021 год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2021-01-01");
expect(result.extracted_filters.period_to).toBe("2021-12-31");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts counterparty from supplier phrase and derives month period", () => {
const result = extractAddressFilters(
"Покажи документы по поставщику Альфа за июль 2020",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("Альфа");
expect(result.extracted_filters.period_from).toBe("2020-07-01");
expect(result.extracted_filters.period_to).toBe("2020-07-31");
expect(result.warnings).toContain("period_derived_from_month_phrase");
});
it("treats 'за весь период' as all-time hint and does not force 90-day default", () => {
const result = extractAddressFilters(
"Покажи банковские операции по клиенту Бета за весь период",
"bank_operations_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("Бета");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts loose by-anchor and year period for short slang docs phrase", () => {
const result = extractAddressFilters(
"какие доки есть по свк за 2021",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2021-01-01");
expect(result.extracted_filters.period_to).toBe("2021-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts implicit counterparty and short-year period for typo slang docs phrase", () => {
const result = extractAddressFilters(
"свк доки за 20год покеж",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_implicit_phrase");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => {
const result = extractAddressFilters(
"свк 20 год - покажи доки плс",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic");
expect(result.warnings).toContain("period_derived_from_year_phrase");
expect(result.extracted_filters.counterparty).not.toBe("плс");
});
it("extracts explicit year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025 год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2000-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
});
it("extracts multiline year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025\n год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2000-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
expect(result.warnings).not.toContain("period_derived_from_year_phrase");
});
it("extracts russian year range period from 'с ... по ...' phrase", () => {
const result = extractAddressFilters(
"какие есть доки по свк с 2020 по 2025 год",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("treats 'за любой период' as all-time hint and keeps loose by-anchor", () => {
const result = extractAddressFilters(
"за любой период есть что-то по свк?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
});
describe("address query limited taxonomy and stage diagnostics", () => {
@@ -93,7 +256,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
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(["LIMITED_WITH_REASON", "FACTUAL_LIST"]).toContain(result?.response_type);
expect(result?.debug.anchor_type).toBe("account");
expect(result?.debug.rows_fetched).toBeTypeOf("number");
@@ -108,6 +271,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.match_failure_stage).toBeDefined();
expect([
"error",
"no_raw_rows",
"raw_rows_received_but_not_materialized",
"materialized_but_not_anchor_matched",
@@ -122,6 +286,50 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1");
expect(result?.debug.account_scope_drop_reason).toBeDefined();
});
it("keeps short slang docs request in address lane (no deep fallback)", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какие доки есть по свк за 2021");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.period_from).toBe("2021-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2021-12-31");
});
it("keeps typo slang docs request in address lane and extracts implicit anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("свк доки за 20год покеж");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
});
it("keeps noisy docs request in address lane and ignores slang tail token", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("свк 20 год - покажи доки плс");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.counterparty).not.toBe("плс");
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
});
it("auto-broadens out-of-window period and returns available factual rows", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие документы по СВК за 2000 год?");
expect(result?.handled).toBe(true);
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON"]).toContain(result?.response_type);
if (result?.response_type === "FACTUAL_LIST") {
expect(result?.debug.limited_reason_category).toBeNull();
}
});
});
describe("address recipe catalog counterparty filtering", () => {
@@ -134,7 +342,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("boosts limit for english all-time counterparty queries", () => {
@@ -146,7 +354,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("cuts english all-time tail from counterparty anchor", () => {
@@ -159,4 +367,16 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("boosts limit for account snapshot queries with explicit account", () => {
const filters = extractAddressFilters(
"Какой остаток по счету 60 на дату 2020-07-31",
"account_balance_snapshot"
).extracted_filters;
const selected = selectAddressRecipe("account_balance_snapshot", filters);
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
});
});
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
return {
handled: true,
reply_text: "Собран список документов по контрагенту.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "medium",
detected_intent: "list_documents_by_counterparty",
detected_intent_confidence: "medium",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
missing_required_filters: [],
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "counterparty",
anchor_value_raw: "свк",
anchor_value_resolved: "Группа СВК",
resolver_confidence: "medium",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 20,
raw_rows_received: 20,
rows_after_account_scope: 5,
rows_after_recipe_filter: 3,
rows_materialized: 5,
rows_matched: 3,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
account_token_raw: null,
account_token_normalized: null,
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
account_scope_drop_reason: "not_applicable",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
limitations: [],
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
describe("assistant address follow-up carryover", () => {
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "какие есть доки по свк с 2020 по 2025 год") {
return buildAddressLaneResult();
}
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
if (message === "а за все время?" && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту за все время.",
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "какие есть доки по свк с 2020 по 2025 год",
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: "а за все время?",
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(3);
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext).toBeUndefined();
expect(calls[2].message).toBe("а за все время?");
expect(calls[2].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
expect(calls[2].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});