АДРЕСНЫЙ РЕЖИМ - M2.3e: стабилизация address runtime парсинг периодов, LLM pre-decompose fallback и приоритет intent для банковских операций

This commit is contained in:
2026-04-01 19:35:25 +03:00
parent 4d59672576
commit 18e0f1364d
16 changed files with 1467 additions and 562 deletions
@@ -14,8 +14,10 @@ const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|д
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;
const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
function toIsoDate(year, month, day) {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
@@ -100,10 +102,22 @@ function resolveMonthByName(rawMonthName) {
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]);
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
if (numericMonthYearMatch) {
const month = Number(numericMonthYearMatch[1]);
const year = Number(numericMonthYearMatch[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 numericYearMonthMatch = text.match(MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN);
if (numericYearMonthMatch) {
const year = Number(numericYearMonthMatch[1]);
const month = Number(numericYearMonthMatch[2]);
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
@@ -124,6 +138,18 @@ function extractMonthPeriod(text) {
};
}
}
const byNameYearFirstMatch = text.match(MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN);
if (byNameYearFirstMatch) {
const year = Number(byNameYearFirstMatch[1]);
const month = resolveMonthByName(String(byNameYearFirstMatch[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) {
@@ -100,6 +100,19 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
function hasAny(text, patterns) {
return patterns.some((item) => text.includes(item));
}
function hasDocumentsFormingBalanceSignal(text) {
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS)) {
return true;
}
const hasDocLexeme = text.includes("документ") || text.includes("доки");
const hasFormingLexeme = text.includes("формир");
const hasBalanceLexeme = text.includes("остат");
const hasAccountLexeme = text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text);
if (hasDocLexeme && hasFormingLexeme && hasBalanceLexeme && hasAccountLexeme) {
return true;
}
return hasBalanceLexeme && hasAccountLexeme && text.includes("из чего состоит");
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
@@ -276,20 +289,13 @@ function resolveAddressIntent(userMessage) {
reasons: ["payables_signal_detected"]
};
}
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS) && (hasAccountNumberAnchor(text) || text.includes("счет"))) {
if (hasDocumentsFormingBalanceSignal(text) && (hasAccountNumberAnchor(text) || text.includes("счет"))) {
return {
intent: "documents_forming_balance",
confidence: "high",
reasons: ["documents_forming_balance_signal_detected"]
};
}
if (hasAny(text, ACCOUNT_BALANCE_HINTS) || hasAccountNumberAnchor(text)) {
return {
intent: "account_balance_snapshot",
confidence: "high",
reasons: ["account_balance_signal_detected"]
};
}
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))) {
return {
@@ -309,6 +315,13 @@ function resolveAddressIntent(userMessage) {
reasons: ["documents_by_counterparty_signal_detected"]
};
}
if (hasAny(text, ACCOUNT_BALANCE_HINTS) || hasAccountNumberAnchor(text)) {
return {
intent: "account_balance_snapshot",
confidence: "high",
reasons: ["account_balance_signal_detected"]
};
}
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
return {
intent: "list_documents_by_counterparty",
@@ -140,7 +140,7 @@ function toDateTimeExpr(isoDate, endOfDay) {
const second = endOfDay ? 59 : 0;
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters, fieldPath) {
function buildWhereClause(filters, fieldPath, extraConditions = []) {
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
: null;
@@ -150,20 +150,71 @@ function buildWhereClause(filters, fieldPath) {
const asOfExpr = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null;
const conditions = [];
if (periodFromExpr && periodToExpr) {
return `ГДЕ\n ${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
conditions.push(`${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`);
}
if (periodFromExpr) {
return `ГДЕ\n ${fieldPath} >= ${periodFromExpr}`;
else if (periodFromExpr) {
conditions.push(`${fieldPath} >= ${periodFromExpr}`);
}
if (periodToExpr) {
return `ГДЕ\n ${fieldPath} <= ${periodToExpr}`;
else if (periodToExpr) {
conditions.push(`${fieldPath} <= ${periodToExpr}`);
}
if (asOfExpr) {
return `ГДЕ\n ${fieldPath} <= ${asOfExpr}`;
else if (asOfExpr) {
conditions.push(`${fieldPath} <= ${asOfExpr}`);
}
for (const condition of extraConditions) {
const value = String(condition ?? "").trim();
if (value) {
conditions.push(value);
}
}
if (conditions.length > 0) {
return `ГДЕ\n ${conditions.join("\n И ")}`;
}
return "";
}
function normalizeAccountTokenForQuery(value) {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
if (!match) {
return source;
}
const base = match[1];
if (!match[2]) {
return base;
}
return `${base}.${match[2]}`;
}
function buildMovementAccountCondition(filters) {
const raw = typeof filters.account === "string" ? filters.account.trim() : "";
if (!raw) {
return null;
}
const normalized = normalizeAccountTokenForQuery(raw);
const match = normalized.match(/^(\d{2})(?:\.(\d{1,2}))?/);
if (!match) {
return null;
}
const base = match[1];
const subRaw = match[2] ?? null;
const patterns = new Set();
if (!subRaw) {
patterns.add(base);
}
else {
patterns.add(`${base}.${subRaw}`);
patterns.add(`${base}.${String(Number(subRaw))}`);
}
const clauses = Array.from(patterns)
.map((pattern) => pattern.trim())
.filter((pattern) => pattern.length > 0)
.map((pattern) => `(Движения.СчетДт.Код ПОДОБНО "${pattern}%" ИЛИ Движения.СчетКт.Код ПОДОБНО "${pattern}%")`);
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function shouldBoostLimitForAllTimeCounterparty(filters) {
const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0;
if (!hasCounterparty) {
@@ -224,7 +275,14 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период"));
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})());
return {
recipe,
query,
+130 -3
View File
@@ -1923,6 +1923,119 @@ function extractAddressQuestionFromNormalized(normalized) {
}
return null;
}
function stripMarkdownJsonFence(text) {
return String(text ?? "")
.trim()
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/```$/i, "")
.trim();
}
function safeParseLooseJson(text) {
const fenced = stripMarkdownJsonFence(text);
if (!fenced) {
return null;
}
try {
return JSON.parse(fenced);
}
catch (_error) {
// Local OpenAI-compatible models often wrap JSON with extra text.
// Try extracting the first top-level JSON object defensively.
const start = fenced.indexOf("{");
const end = fenced.lastIndexOf("}");
if (start < 0 || end < 0 || end <= start) {
return null;
}
const candidate = fenced.slice(start, end + 1).trim();
try {
return JSON.parse(candidate);
}
catch (_nestedError) {
return null;
}
}
}
function extractOutputTextFromRawNormalizerOutput(raw) {
if (!raw || typeof raw !== "object") {
return null;
}
const source = raw;
if (typeof source.output_text === "string" && source.output_text.trim().length > 0) {
return source.output_text;
}
if (Array.isArray(source.output)) {
for (const item of source.output) {
if (!item || typeof item !== "object") {
continue;
}
const content = item.content;
if (!Array.isArray(content)) {
continue;
}
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
if (typeof block.text === "string" && block.text.trim().length > 0) {
return block.text;
}
}
}
}
if (source.response && typeof source.response === "object") {
const nested = source.response;
if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) {
return nested.output_text;
}
}
if (Array.isArray(source.choices) && source.choices.length > 0) {
const first = source.choices[0];
if (first && typeof first === "object" && first.message && typeof first.message === "object") {
const message = first.message;
if (typeof message.content === "string" && message.content.trim().length > 0) {
return message.content;
}
}
}
return null;
}
function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
const outputText = extractOutputTextFromRawNormalizerOutput(rawModelOutput);
if (!outputText) {
return null;
}
const parsed = safeParseLooseJson(outputText);
if (!parsed || typeof parsed !== "object") {
return null;
}
const source = parsed;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = fragment.domain_relevance;
if (typeof domainRelevance === "string" && domainRelevance.trim().toLowerCase() === "out_of_scope") {
continue;
}
if (domainRelevance === false) {
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 = {
@@ -1960,8 +2073,10 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
if (!normalized?.ok || !candidate) {
const candidateFromNormalized = extractAddressQuestionFromNormalized(normalized?.normalized);
const candidateFromRaw = candidateFromNormalized ? null : extractAddressQuestionFromRawNormalizerOutput(normalized?.raw_model_output);
const candidate = candidateFromNormalized ?? candidateFromRaw;
if (!candidate) {
return {
...baseMeta,
attempted: true,
@@ -1972,13 +2087,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;
const candidateSource = candidateFromNormalized ? "normalized" : "raw";
const reason = candidateSource === "normalized"
? applied
? "normalized_fragment_applied"
: "normalized_fragment_same"
: normalized?.ok
? applied
? "raw_fragment_applied"
: "raw_fragment_same"
: applied
? "raw_fragment_applied_after_normalize_failed"
: "raw_fragment_same_after_normalize_failed";
return {
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
reason
};
}
catch (error) {