АДРЕСНЫЙ РЕЖИМ - M2.3e: стабилизация address runtime парсинг периодов, LLM pre-decompose fallback и приоритет intent для банковских операций
This commit is contained in:
@@ -18,8 +18,14 @@ const YEAR_PERIOD_PATTERN =
|
||||
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: number, month: number, day: number): string | null {
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
@@ -101,10 +107,23 @@ function resolveMonthByName(rawMonthName: string): number | 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]);
|
||||
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 {
|
||||
@@ -127,6 +146,19 @@ function extractMonthPeriod(text: string): { period_from?: string; period_to?: s
|
||||
}
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,20 @@ function hasAny(text: string, patterns: string[]): boolean {
|
||||
return patterns.some((item) => text.includes(item));
|
||||
}
|
||||
|
||||
function hasDocumentsFormingBalanceSignal(text: string): boolean {
|
||||
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: string): boolean {
|
||||
const token = String(rawToken ?? "").trim().toLowerCase();
|
||||
if (!token || token.length < 2) {
|
||||
@@ -307,7 +321,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -315,14 +329,6 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -348,6 +354,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -156,7 +156,7 @@ function toDateTimeExpr(isoDate: string, endOfDay: boolean): string | null {
|
||||
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
|
||||
}
|
||||
|
||||
function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string {
|
||||
function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string {
|
||||
const periodFromExpr =
|
||||
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_from, false)
|
||||
@@ -170,22 +170,76 @@ function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string
|
||||
? toDateTimeExpr(filters.as_of_date, true)
|
||||
: null;
|
||||
|
||||
const conditions: string[] = [];
|
||||
if (periodFromExpr && periodToExpr) {
|
||||
return `ГДЕ\n ${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
|
||||
conditions.push(`${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`);
|
||||
} else if (periodFromExpr) {
|
||||
conditions.push(`${fieldPath} >= ${periodFromExpr}`);
|
||||
} else if (periodToExpr) {
|
||||
conditions.push(`${fieldPath} <= ${periodToExpr}`);
|
||||
} else if (asOfExpr) {
|
||||
conditions.push(`${fieldPath} <= ${asOfExpr}`);
|
||||
}
|
||||
if (periodFromExpr) {
|
||||
return `ГДЕ\n ${fieldPath} >= ${periodFromExpr}`;
|
||||
}
|
||||
if (periodToExpr) {
|
||||
return `ГДЕ\n ${fieldPath} <= ${periodToExpr}`;
|
||||
}
|
||||
if (asOfExpr) {
|
||||
return `ГДЕ\n ${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: string): string {
|
||||
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: AddressFilterSet): string | null {
|
||||
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<string>();
|
||||
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: AddressFilterSet): boolean {
|
||||
const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0;
|
||||
if (!hasCounterparty) {
|
||||
@@ -262,10 +316,14 @@ export function buildAddressRecipePlan(
|
||||
.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: string[] = [];
|
||||
const accountCondition = buildMovementAccountCondition(filters);
|
||||
if (accountCondition) {
|
||||
extraConditions.push(accountCondition);
|
||||
}
|
||||
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
||||
})());
|
||||
|
||||
return {
|
||||
recipe,
|
||||
|
||||
@@ -1885,6 +1885,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 = {
|
||||
@@ -1922,8 +2035,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,
|
||||
@@ -1934,13 +2049,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) {
|
||||
|
||||
Reference in New Issue
Block a user