АДРЕСНЫЙ РЕЖИМ - M2.3c микро набор вопросов отрабатывается коректно

This commit is contained in:
2026-03-29 22:41:28 +03:00
parent a2886faed6
commit 4060a5e575
6 changed files with 369 additions and 8 deletions
@@ -78,11 +78,29 @@ function cleanupAnchorValue(value) {
if (!normalized) {
return "";
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
const periodTailPattern = /\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\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;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
return normalized
.replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "")
.replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "")
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
}
function hasAllTimeHint(text) {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
}
function shiftDaysIso(baseIso, deltaDays) {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
@@ -140,7 +158,8 @@ function extractAddressFilters(userMessage, intent) {
// For document/bank lists we default to a short recent window if no explicit period was provided.
if ((intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") &&
!filters.period_from &&
!filters.period_to) {
!filters.period_to &&
!hasAllTimeHint(text)) {
const today = new Date().toISOString().slice(0, 10);
filters.period_to = today;
filters.period_from = shiftDaysIso(today, -90);
@@ -162,6 +162,16 @@ function buildWhereClause(filters, fieldPath) {
}
return "";
}
function shouldBoostLimitForAllTimeCounterparty(filters) {
const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0;
if (!hasCounterparty) {
return false;
}
const hasPeriod = Boolean((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0) ||
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0));
return !hasPeriod;
}
function selectAddressRecipe(intent, filters) {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@@ -182,9 +192,14 @@ function selectAddressRecipe(intent, filters) {
};
}
function buildAddressRecipePlan(recipe, filters) {
const resolvedLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, 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));
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
? [String(filters.account)]
: Array.isArray(recipe.account_scope)
@@ -86,12 +86,35 @@ function cleanupAnchorValue(value: string): string {
if (!normalized) {
return "";
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
const periodTailPattern =
/\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\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;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
return normalized
.replace(/\s+(?:from|to|between|and)\b[\s\S]*$/i, "")
.replace(/\s+(?:с|по|за)\b[\s\S]*$/i, "")
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
}
function hasAllTimeHint(text: string): boolean {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
}
function shiftDaysIso(baseIso: string, deltaDays: number): string {
const date = new Date(`${baseIso}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + deltaDays);
@@ -159,7 +182,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (
(intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") &&
!filters.period_from &&
!filters.period_to
!filters.period_to &&
!hasAllTimeHint(text)
) {
const today = new Date().toISOString().slice(0, 10);
filters.period_to = today;
@@ -183,6 +183,19 @@ function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string
return "";
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0;
if (!hasCounterparty) {
return false;
}
const hasPeriod = Boolean(
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0) ||
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0)
);
return !hasPeriod;
}
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@@ -209,10 +222,16 @@ export function buildAddressRecipePlan(
recipe: AddressRecipeDefinition,
filters: AddressFilterSet
): AddressRecipeExecutionPlan {
const resolvedLimit =
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, 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));
const accountScope =
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
@@ -3,6 +3,7 @@ import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => {
@@ -46,6 +47,27 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.as_of_date).toBeDefined();
expect(result.missing_required_filters).toEqual([]);
});
it("cuts period tail from counterparty anchor", () => {
const result = extractAddressFilters(
"Покажи документы по контрагенту test_cp с 2020-07-01 по 2020-07-31",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("test_cp");
expect(result.extracted_filters.period_from).toBe("2020-07-01");
expect(result.extracted_filters.period_to).toBe("2020-07-31");
});
it("cuts all-time tail from counterparty anchor and skips 90-day default window", () => {
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).not.toContain("period_defaulted_last_90_days");
});
});
describe("address query limited taxonomy and stage diagnostics", () => {
@@ -101,3 +123,40 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.account_scope_drop_reason).toBeDefined();
});
});
describe("address recipe catalog counterparty filtering", () => {
it("boosts limit for all-time counterparty queries", () => {
const filters = extractAddressFilters(
"Покажи документы по контрагенту тестовый за все время",
"list_documents_by_counterparty"
).extracted_filters;
const selected = selectAddressRecipe("list_documents_by_counterparty", filters);
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
});
it("boosts limit for english all-time counterparty queries", () => {
const filters = extractAddressFilters(
"show documents by counterparty test_cp for all time",
"list_documents_by_counterparty"
).extracted_filters;
const selected = selectAddressRecipe("list_documents_by_counterparty", filters);
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
});
it("cuts english all-time tail from counterparty anchor", () => {
const result = extractAddressFilters(
"show documents by counterparty test_cp for all time",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("test_cp");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
});