АДРЕСНЫЙ РЕЖИМ - стабилизация LLM fallback якорей и периода

This commit is contained in:
2026-04-02 00:30:38 +03:00
parent 2d95ce6332
commit 7dd6607ded
19 changed files with 2727 additions and 49 deletions
@@ -312,6 +312,18 @@ function cleanupAnchorValue(value: string): string {
return "";
}
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
const asOfTailPattern =
/\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
if (asOfTailPattern.test(normalized)) {
return normalized.replace(asOfTailPattern, "").trim();
}
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
if (asOfTruncatedTailPattern.test(normalized)) {
return normalized.replace(asOfTruncatedTailPattern, "").trim();
}
// 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 =
@@ -386,6 +398,29 @@ function extractLooseByAnchorValue(text: string): string | undefined {
return token;
}
function extractContractTokenHeuristic(text: string): string | undefined {
const source = String(text ?? "");
const explicit = source.match(/(?:|#|n)\s*([a-zа-яё0-9][a-zа-яё0-9./_-]{1,})/iu);
if (explicit) {
const token = String(explicit[1] ?? "").trim();
if (token.length >= 2) {
return token;
}
}
const slashLike = source.match(/\b(\d{1,6}[./_-]\d{1,6}(?:[./_-]\d{1,6})?)\b/iu);
if (!slashLike) {
return undefined;
}
const token = String(slashLike[1] ?? "").trim();
if (!token) {
return undefined;
}
if (/^(?:19|20)\d{2}[./-](?:0?[1-9]|1[0-2])$/.test(token)) {
return undefined;
}
return token;
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
@@ -577,6 +612,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ["counterparty"];
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return ["contract"];
}
return [];
}
@@ -632,6 +670,13 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
}
if (!filters.contract && (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract")) {
const heuristicContract = extractContractTokenHeuristic(text);
if (heuristicContract) {
filters.contract = cleanupAnchorValue(heuristicContract);
warnings.push("contract_anchor_derived_from_heuristic_token");
}
}
const periodRange = extractPeriodRange(text);
if (periodRange.period_from) {
@@ -678,7 +723,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
// 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") &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty") &&
!filters.period_from &&
!filters.period_to &&
!hasAllTimeHint(text)
@@ -103,6 +103,46 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"поступлен",
"движени"
];
const DOCUMENTS_BY_CONTRACT_HINTS = [
"documents by contract",
"docs by contract",
"show documents by contract",
"list documents by contract",
"документы по договору",
"доки по договору",
"док по договору",
"документы договор",
"договор"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
"bank payments by contract",
"payment orders by contract",
"transactions by contract",
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
"выписка по договору"
];
const BANK_OPERATION_CORE_HINTS = [
"банков",
"выписк",
"платеж",
"платёж",
"оплат",
"списан",
"поступлен",
"движени",
"транзак",
"bank",
"payment",
"payments",
"transaction",
"transactions",
"statement",
"wire"
];
function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
@@ -198,6 +238,72 @@ function hasPartyAnchorMention(text: string): boolean {
);
}
function hasContractAnchorMention(text: string): boolean {
return (
text.includes("договор") ||
text.includes("дог.") ||
text.includes("contract") ||
text.includes("dogovor")
);
}
function hasContractNumberLikeToken(text: string): boolean {
if (/(?:^|[\s([{])(?:|#|n)\s*[a-zа-яё0-9][a-zа-яё0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(text)) {
return true;
}
const rawTokens = text
.split(/[\s,;:!?()[\]{}"«»]+/u)
.map((token) => token.replace(/^[^\p{L}\p{N}#]+|[^\p{L}\p{N}./_-]+$/gu, "").trim())
.filter((token) => token.length > 0);
for (const rawToken of rawTokens) {
const token = String(rawToken ?? "").trim();
if (!/^\d{1,6}[./_-]\d{1,6}(?:[./_-]\d{1,6})?$/u.test(token)) {
continue;
}
if (!token) {
continue;
}
const parts = token.split(/[./_-]+/u).map((part) => Number(part));
if (!parts.every((part) => Number.isFinite(part))) {
return true;
}
if (parts.length === 2) {
const [a, b] = parts;
const yearFirst = a >= 1900 && a <= 2099 && b >= 1 && b <= 12;
const yearSecond = b >= 1900 && b <= 2099 && a >= 1 && a <= 12;
if (yearFirst || yearSecond) {
continue;
}
return true;
}
if (parts.length === 3) {
const [a, b, c] = parts;
const ymd = a >= 1900 && a <= 2099 && b >= 1 && b <= 12 && c >= 1 && c <= 31;
const dmy = c >= 1900 && c <= 2099 && a >= 1 && a <= 31 && b >= 1 && b <= 12;
if (ymd || dmy) {
continue;
}
return true;
}
return true;
}
return false;
}
function hasContractAnchorSignal(text: string): boolean {
if (hasContractAnchorMention(text)) {
return true;
}
// Allow short forms like "15/24" for follow-up prompts if document/bank signal exists.
return hasContractNumberLikeToken(text) && hasDocsOrBankSignal(text);
}
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
@@ -260,6 +366,20 @@ function hasDocsOrBankSignal(text: string): boolean {
);
}
function hasBankOperationSignal(text: string): boolean {
return hasAny(text, BANK_OPERATION_CORE_HINTS) || hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) || hasAny(text, BANK_OPERATIONS_BY_CONTRACT_HINTS);
}
function hasDocumentSignal(text: string): boolean {
return (
text.includes("док") ||
text.includes("доки") ||
text.includes("документ") ||
text.includes("docs") ||
text.includes("documents")
);
}
function hasHeuristicCounterpartyAnchor(text: string): boolean {
if (!hasDocsOrBankSignal(text)) {
return false;
@@ -329,6 +449,28 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (
hasContractAnchorSignal(text) &&
hasBankOperationSignal(text)
) {
return {
intent: "bank_operations_by_contract",
confidence: "medium",
reasons: ["bank_ops_by_contract_signal_detected"]
};
}
if (
hasContractAnchorSignal(text) &&
(hasAny(text, DOCUMENTS_BY_CONTRACT_HINTS) || hasDocumentSignal(text))
) {
return {
intent: "list_documents_by_contract",
confidence: "medium",
reasons: ["documents_by_contract_signal_detected"]
};
}
if (
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))
@@ -34,6 +34,7 @@ interface AddressTryHandleOptions {
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 ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@@ -48,6 +49,35 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
"по",
"by"
]);
const LOW_QUALITY_PARTY_ANCHOR_TOKENS = new Set([
"что",
"чо",
"были",
"был",
"была",
"было",
"ли",
"какие",
"какой",
"покажи",
"показать",
"выведи",
"списания",
"списание",
"поступления",
"поступление",
"доки",
"документ",
"документы",
"документов",
"банковские",
"операции",
"платежи",
"платеж",
"платежи",
"плс",
"please"
]);
const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
@@ -152,6 +182,30 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
});
}
function isLikelyLowQualityPartyAnchor(value: string | null | undefined): boolean {
const normalized = normalizeSearchText(String(value ?? ""));
if (!normalized) {
return true;
}
const tokens = normalized.split(" ").filter(Boolean);
if (tokens.length === 0) {
return true;
}
const meaningfulTokens = tokens.filter((token) => {
if (token.length < 2) {
return false;
}
if (PARTY_ANCHOR_STOPWORDS.has(token) || LOW_QUALITY_PARTY_ANCHOR_TOKENS.has(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return true;
});
return meaningfulTokens.length === 0;
}
function normalizeAccountToken(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/(\d{2})(?:\.(\d{1,2}))?/);
@@ -370,13 +424,13 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
}
function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddressRow[]): NormalizedAddressRow[] {
if (intent === "bank_operations_by_counterparty") {
if (intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_contract") {
const bankDocPattern =
/(?:списаниесрасчетногосчета|поступлениенарасчетныйсчет|списание с расчетного счета|поступление на расчетный счет|bank|payment|wire|statement)/i;
return rows.filter((row) => bankDocPattern.test(row.registrator.toLowerCase()));
}
if (intent === "list_documents_by_counterparty") {
if (intent === "list_documents_by_counterparty" || intent === "list_documents_by_contract") {
const documentPattern =
/(?:документ|реализац|поступлен|счет[-\s]?фактур|акт|накладн|payment|invoice|document|sale|purchase|bank)/i;
return rows.filter((row) => documentPattern.test(row.registrator.toLowerCase()) || row.analytics.length > 0);
@@ -402,7 +456,21 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
);
}
function isDocumentOrBankIntent(intent: AddressIntent): boolean {
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
);
}
function toIsoDatePrefix(value: string | null): string | null {
@@ -959,6 +1027,124 @@ export class AddressQueryService {
});
}
if (
filteredRows.length === 0 &&
isDocumentOrBankIntent(intent.intent) &&
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
) {
const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
: plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters: AddressFilterSet = {
...filters.extracted_filters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
};
const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters);
if (expandedSelection.selected_recipe && expandedSelection.missing_required_filters.length === 0) {
const expandedPlan = buildAddressRecipePlan(expandedSelection.selected_recipe, expandedLimitFilters);
if (expandedPlan.limit > currentLimit) {
const expandedMcp = await executeAddressMcpQuery({
query: expandedPlan.query,
limit: expandedPlan.limit
});
if (!expandedMcp.error) {
const expandedRawRows = toNormalizedRows(expandedMcp.raw_rows);
const expandedScopedRows = applyAccountScopeFilter(expandedRawRows, expandedPlan.account_scope);
const expandedAccountScopeFallbackApplied =
expandedPlan.account_scope_mode === "preferred" &&
expandedPlan.account_scope.length > 0 &&
expandedRawRows.length > 0 &&
expandedScopedRows.length === 0;
const expandedNormalizedRows = expandedAccountScopeFallbackApplied ? expandedRawRows : expandedScopedRows;
let expandedAnchor = resolvePrimaryAnchor(intent.intent, expandedLimitFilters);
expandedAnchor = refineAnchorFromRows(expandedAnchor, expandedNormalizedRows);
const expandedFiltersForMatching: AddressFilterSet =
expandedAnchor.anchor_type === "counterparty" && expandedAnchor.anchor_value_resolved
? { ...expandedLimitFilters, counterparty: expandedAnchor.anchor_value_resolved }
: expandedAnchor.anchor_type === "contract" && expandedAnchor.anchor_value_resolved
? { ...expandedLimitFilters, contract: expandedAnchor.anchor_value_resolved }
: expandedLimitFilters;
const expandedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: expandedFiltersForMatching,
accountScope: expandedPlan.account_scope,
rowsBeforeScope: expandedRawRows.length,
rowsAfterScope: expandedNormalizedRows.length
});
const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching);
const expandedRowsByAnchor = expandedAnchorFilter.rows;
const expandedFilteredRows = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor);
if (expandedFilteredRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(
expandedMcp.raw_rows,
expandedNormalizedRows.length,
expandedNormalizedRows.length
);
const expandedStageStatus = deriveMcpStageStatus({
rawRowsReceived: expandedMcp.raw_rows.length,
rowsMaterialized: expandedNormalizedRows.length,
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows);
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
return {
handled: true,
reply_text: `${expandedPrefix}\n${expandedFactual.text}`,
reply_type: inferReplyType(expandedFactual.responseType),
response_type: expandedFactual.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: expandedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(expandedStageStatus),
account_scope_mode: expandedPlan.account_scope_mode,
account_scope_fallback_applied: expandedAccountScopeFallbackApplied,
anchor_type: expandedAnchor.anchor_type,
anchor_value_raw: expandedAnchor.anchor_value_raw,
anchor_value_resolved: expandedAnchor.anchor_value_resolved,
resolver_confidence: expandedAnchor.resolver_confidence,
ambiguity_count: expandedAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: expandedStageStatus,
rows_fetched: expandedMcp.fetched_rows,
raw_rows_received: expandedMcp.raw_rows.length,
rows_after_account_scope: expandedNormalizedRows.length,
rows_after_recipe_filter: expandedRowsByAnchor.length,
rows_materialized: expandedNormalizedRows.length,
rows_matched: expandedFilteredRows.length,
raw_row_keys_sample: expandedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: expandedRowDiagnostics.materializationDropReason,
account_token_raw: expandedAccountScopeAudit.accountTokenRaw,
account_token_normalized: expandedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: expandedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: expandedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: expandedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
limitations: expandedLimitations,
reasons: expandedReasons
}
};
}
}
}
}
}
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
@@ -1071,32 +1257,52 @@ export class AddressQueryService {
const isVisibilityGapCandidate =
hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
(intent.intent === "list_documents_by_counterparty" ||
intent.intent === "bank_operations_by_counterparty" ||
intent.intent === "list_documents_by_contract" ||
intent.intent === "bank_operations_by_contract");
const isAnchorMismatch = stageStatus === "materialized_but_not_anchor_matched";
const isRecipeFilteredOut = stageStatus === "materialized_but_filtered_out_by_recipe";
const isFollowupAnchorCarryover =
Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
const isLowQualityPartyAnchor =
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
const anchorMismatchCategory: AddressLimitedReasonCategory =
isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? "missing_anchor"
? anchorMismatchCategory
: isRecipeFilteredOut
? "recipe_visibility_gap"
: isVisibilityGapCandidate
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? "якорь контрагента/договора не найден в материализованных live-строках"
? anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в материализованных live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? "уточните контрагента точным именем или добавьте ИНН/договор"
? anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? ["anchor_not_matched_after_materialization"]
? [
anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
: [
@@ -108,6 +108,26 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_documents_by_contract_v1",
intent: "list_documents_by_contract",
purpose: "Collect contract-related document lines from movements",
required_filters: ["contract"],
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "counterparty", "limit", "sort"],
default_limit: 100,
account_scope: ["60", "62", "76", "51", "52"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_bank_operations_by_contract_v1",
intent: "bank_operations_by_contract",
purpose: "Collect bank operation candidates by contract from movement lines",
required_filters: ["contract"],
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "counterparty", "limit", "sort"],
default_limit: 100,
account_scope: ["51", "52"],
account_scope_mode: "preferred"
},
{
recipe_id: "address_documents_forming_balance_v1",
intent: "documents_forming_balance",
@@ -241,8 +261,10 @@ function buildMovementAccountCondition(filters: AddressFilterSet): string | null
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasCounterparty = typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0;
if (!hasCounterparty) {
const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
if (!hasAnchor) {
return false;
}
const hasPeriod = Boolean(
@@ -254,7 +276,12 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
}
function maxLimitForIntent(intent: AddressIntent): number {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
) {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
return ADDRESS_MAX_LIMIT_DEFAULT;
@@ -292,7 +319,10 @@ export function buildAddressRecipePlan(
? 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") &&
(recipe.intent === "list_documents_by_counterparty" ||
recipe.intent === "bank_operations_by_counterparty" ||
recipe.intent === "list_documents_by_contract" ||
recipe.intent === "bank_operations_by_contract") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
@@ -116,6 +116,18 @@ export function composeFactualReply(
};
}
if (intent === "list_documents_by_contract") {
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).",
@@ -128,6 +140,18 @@ export function composeFactualReply(
};
}
if (intent === "bank_operations_by_contract") {
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."
@@ -119,6 +119,16 @@ function mergeFollowupFilters(
}
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
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 (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (!toNonEmptyString(merged.account)) {
const inheritedAccount = previousAccount ?? (followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
@@ -184,7 +194,9 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"]
bank_operations_by_counterparty: ["counterparty"],
list_documents_by_contract: ["contract"],
bank_operations_by_contract: ["contract"]
};
const required = requiredByIntent[intent] ?? [];
return required.filter((key) => {
@@ -134,6 +134,18 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
}
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
@@ -208,4 +220,3 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
ambiguity_count: candidates.length - 1
};
}
@@ -1828,9 +1828,24 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"show",
"list",
"выведи",
"что",
"чо",
"которые",
"какие",
"какой",
"были",
"был",
"была",
"было",
"ли",
"списания",
"списание",
"поступления",
"поступление",
"расчетного",
"расчётного",
"счета",
"счёта",
"есть",
"est",
"kakie",
@@ -1910,6 +1925,8 @@ const ADDRESS_MONTH_ALIAS_MAP = {
dec: "12"
};
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц)/i;
const ADDRESS_BANK_SIGNAL_PATTERN = /(?:bank|банк|банков|выписк|плат[её]ж|оплат|поступлен|списан|операц|расчетн)/i;
const ADDRESS_CONTRACT_SIGNAL_PATTERN = /(?:договор(?:а|у|ом|е)?|\bcontract\b)/iu;
const ADDRESS_BALANCE_SIGNAL_PATTERN = /(?:остат|сальдо|баланс|взаиморасч|долг|saldo|balance)/i;
const ADDRESS_ALL_TIME_PATTERN = /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|for\s+all\s+time|all\s+time|entire\s+period|full\s+history)/iu;
function normalizeAddressMonthAliasToken(token) {
@@ -1975,14 +1992,21 @@ function extractAddressFallbackYear(text) {
return fullYearMatch[1];
}
const shortYearMatch = source.match(/(?:^|[^0-9])(\d{2})\s*(?:г(?:од|ода)?|г|год|year|god)(?=$|[^a-zа-яё0-9])/iu);
if (!shortYearMatch) {
if (shortYearMatch) {
const shortYear = Number(shortYearMatch[1]);
if (Number.isFinite(shortYear) && shortYear >= 0 && shortYear <= 99) {
return String(2000 + shortYear);
}
}
const shortOrdinalYearMatch = source.match(/(?:^|[^0-9])(\d{2})\s*(?:[-\s]?(?:й|ый|ой|th))(?=$|[^a-zа-яё0-9])/iu);
if (!shortOrdinalYearMatch) {
return null;
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
const shortOrdinalYear = Number(shortOrdinalYearMatch[1]);
if (!Number.isFinite(shortOrdinalYear) || shortOrdinalYear < 0 || shortOrdinalYear > 99) {
return null;
}
return String(2000 + shortYear);
return String(2000 + shortOrdinalYear);
}
function extractAddressFallbackMonthYear(text) {
const source = String(text ?? "");
@@ -1998,14 +2022,14 @@ function extractAddressFallbackMonthYear(text) {
const year = numericMonthYear[2];
return `${year}-${month}`;
}
const namedMonthYear = source.match(/\b([a-zа-яё]+)\s+(20\d{2})\b/iu);
const namedMonthYear = source.match(/(?:^|[^a-zа-яё0-9])([a-zа-яё]+)\s+(20\d{2})(?=$|[^a-zа-яё0-9])/iu);
if (namedMonthYear) {
const month = normalizeAddressMonthAliasToken(namedMonthYear[1]);
if (month) {
return `${namedMonthYear[2]}-${month}`;
}
}
const yearNamedMonth = source.match(/\b(20\d{2})\s+([a-zа-яё]+)\b/iu);
const yearNamedMonth = source.match(/(?:^|[^a-zа-яё0-9])(20\d{2})\s+([a-zа-яё]+)(?=$|[^a-zа-яё0-9])/iu);
if (yearNamedMonth) {
const month = normalizeAddressMonthAliasToken(yearNamedMonth[2]);
if (month) {
@@ -2014,7 +2038,42 @@ function extractAddressFallbackMonthYear(text) {
}
return null;
}
function extractAddressFallbackAccountToken(text) {
const source = String(text ?? "");
const explicitMatch = source.match(/(?:сч[её]т(?:а|у|ом|е)?|account)\D{0,12}(\d{2}(?:[.,]\d{1,2})?)/iu);
if (explicitMatch && explicitMatch[1]) {
return String(explicitMatch[1]).replace(",", ".");
}
const tokenPattern = /\b(\d{2}(?:[.,]\d{1,2})?)\b/giu;
let match = tokenPattern.exec(source);
while (match) {
const raw = String(match[1] ?? "");
const start = match.index;
const end = start + raw.length;
const prev = start > 0 ? source[start - 1] : " ";
const next = end < source.length ? source[end] : " ";
if (!/[./-]/.test(prev) && !/[./-]/.test(next) && !/\d/.test(prev) && !/\d/.test(next)) {
return raw.replace(",", ".");
}
match = tokenPattern.exec(source);
}
return null;
}
function pickAddressFallbackCounterpartyToken(text) {
const source = String(text ?? "");
const byAnchor = source.match(/(?:^|[\s,.;:!?()\-])(?:по|от)\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=$|[\s,.;:!?()\-])/iu);
if (byAnchor && byAnchor[1]) {
const byToken = String(byAnchor[1]).trim();
const normalizedByToken = byToken.toLowerCase();
if (byToken &&
!ADDRESS_PREDECOMPOSE_NOISE_TOKENS.has(normalizedByToken) &&
!/^\d{2}(?:\.\d{1,2})?$/.test(normalizedByToken) &&
!/^(?:19|20)\d{2}$/.test(normalizedByToken) &&
!/^(?:янв|фев|мар|апр|май|июн|июл|авг|сен|сент|окт|ноя|дек|january|february|march|april|may|june|july|august|september|october|november|december)/i.test(normalizedByToken) &&
!/^(?:договор|договора|договору|договором|договоре|contract)$/.test(normalizedByToken)) {
return byToken;
}
}
const candidates = extractAddressAnchorTokens(text);
for (const token of candidates) {
const normalized = String(token ?? "").toLowerCase();
@@ -2027,10 +2086,44 @@ function pickAddressFallbackCounterpartyToken(text) {
if (/^(?:янв|фев|мар|апр|май|июн|июл|авг|сен|сент|окт|ноя|дек|january|february|march|april|may|june|july|august|september|october|november|december)/i.test(normalized)) {
continue;
}
if (/^(?:договор|договора|договору|договором|договоре|contract)$/.test(normalized)) {
continue;
}
return token;
}
return null;
}
function extractAddressFallbackContractToken(text) {
const source = String(text ?? "");
const patterns = [
/(?:договор(?:а|у|ом|е)?|\bcontract\b)\s*(?:|#|n|no\.?)?\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})/iu,
/(?:|#|n|no\.?)\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})\s*(?:договор(?:а|у|ом|е)?|\bcontract\b)/iu
];
for (const pattern of patterns) {
const match = pattern.exec(source);
if (!match || !match[1]) {
continue;
}
const candidate = String(match[1]).replace(/^[^a-zа-я0-9]+|[^a-zа-я0-9/_-]+$/giu, "");
if (!candidate || candidate.length < 2) {
continue;
}
if (/^(?:19|20)\d{2}$/.test(candidate)) {
continue;
}
if (/^(?:19|20)\d{2}[./-](?:0?[1-9]|1[0-2])(?:[./-](?:0?[1-9]|[12]\d|3[01]))?$/.test(candidate)) {
continue;
}
return candidate;
}
if (ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source)) {
const generic = source.match(/\b([a-zа-я0-9]{1,10}[/-][a-zа-я0-9]{1,10}(?:[/-][a-zа-я0-9]{1,10})?)\b/iu);
if (generic && generic[1]) {
return generic[1];
}
}
return null;
}
function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage) {
const sourceRaw = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")));
const source = compactWhitespace(String(sanitizedUserMessage ?? sourceRaw).toLowerCase());
@@ -2040,9 +2133,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const monthYear = extractAddressFallbackMonthYear(source);
const year = extractAddressFallbackYear(source);
const allTime = ADDRESS_ALL_TIME_PATTERN.test(source);
const accountMatch = source.match(/\b(\d{2}(?:[.,]\d{1,2})?)\b/);
const account = accountMatch ? String(accountMatch[1]).replace(",", ".") : null;
const account = extractAddressFallbackAccountToken(source);
const docsSignal = ADDRESS_DOCS_SIGNAL_PATTERN.test(source);
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
if (balanceSignal && account) {
let periodClause = "";
@@ -2064,28 +2158,59 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
}
}
if (docsSignal) {
const counterparty = pickAddressFallbackCounterpartyToken(source);
if (counterparty) {
let periodClause = "";
let rule = "documents_counterparty_rewrite";
if (allTime) {
periodClause = " за все время";
rule = "documents_counterparty_all_time_rewrite";
if (contractSignal) {
const contract = extractAddressFallbackContractToken(sourceRaw || source);
if (contract) {
let periodClause = "";
let rule = bankSignal ? "bank_operations_contract_rewrite" : "documents_contract_rewrite";
if (allTime) {
periodClause = " за все время";
rule = bankSignal ? "bank_operations_contract_all_time_rewrite" : "documents_contract_all_time_rewrite";
}
else if (monthYear) {
periodClause = ` за ${monthYear}`;
rule = bankSignal ? "bank_operations_contract_month_rewrite" : "documents_contract_month_rewrite";
}
else if (year) {
periodClause = ` за ${year} год`;
rule = bankSignal ? "bank_operations_contract_year_rewrite" : "documents_contract_year_rewrite";
}
const subject = bankSignal ? "банковские операции" : "документы";
const candidate = compactWhitespace(`${subject} по договору ${contract}${periodClause}`);
if (candidate && candidate !== sourceRaw.toLowerCase()) {
return {
candidate,
rule
};
}
}
else if (monthYear) {
periodClause = ` за ${monthYear}`;
rule = "documents_counterparty_month_rewrite";
}
else if (year) {
periodClause = ` за ${year} год`;
rule = "documents_counterparty_year_rewrite";
}
const candidate = compactWhitespace(`документы по контрагенту ${counterparty}${periodClause}`);
if (candidate && candidate !== sourceRaw.toLowerCase()) {
return {
candidate,
rule
};
}
else {
const counterparty = pickAddressFallbackCounterpartyToken(source);
if (counterparty) {
let periodClause = "";
const subject = bankSignal ? "банковские операции" : "документы";
const rulePrefix = bankSignal ? "bank_operations_counterparty" : "documents_counterparty";
let rule = `${rulePrefix}_rewrite`;
if (allTime) {
periodClause = " за все время";
rule = `${rulePrefix}_all_time_rewrite`;
}
else if (monthYear) {
periodClause = ` за ${monthYear}`;
rule = `${rulePrefix}_month_rewrite`;
}
else if (year) {
periodClause = ` за ${year} год`;
rule = `${rulePrefix}_year_rewrite`;
}
const candidate = compactWhitespace(`${subject} по контрагенту ${counterparty}${periodClause}`);
if (candidate && candidate !== sourceRaw.toLowerCase()) {
return {
candidate,
rule
};
}
}
}
}
@@ -2620,12 +2745,13 @@ export class AssistantService {
};
this.sessions.appendItem(sessionId, userItem);
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const safeAddressReply = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(addressLane.reply_text) ?? "").trim() || String(addressLane.reply_text ?? "");
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,
text: safeAddressReply,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
@@ -8,6 +8,8 @@ export type AddressIntent =
| "open_items_by_counterparty_or_contract"
| "list_documents_by_counterparty"
| "bank_operations_by_counterparty"
| "list_documents_by_contract"
| "bank_operations_by_contract"
| "documents_forming_balance"
| "unknown";
@@ -6,6 +6,7 @@ import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => {
@@ -35,6 +36,36 @@ describe("address query shape classifier", () => {
});
});
describe("address compose stage utf8 headers", () => {
it("renders readable russian header for contract document list", () => {
const reply = composeFactualReply("list_documents_by_contract", [
{
period: "2020-10-15T13:34:53Z",
registrator: "Списание с расчетного счета 00000000246",
account_dt: "66.02",
account_kt: "51",
amount: 30819.47,
analytics: []
}
]);
expect(reply.text).toContain("Собран список документов по договору (live address lane).");
});
it("renders readable russian header for contract bank operations", () => {
const reply = composeFactualReply("bank_operations_by_contract", [
{
period: "2020-10-15T13:34:53Z",
registrator: "Списание с расчетного счета 00000000246",
account_dt: "66.02",
account_kt: "51",
amount: 30819.47,
analytics: []
}
]);
expect(reply.text).toContain("Собран список банковских операций по договору (live address lane).");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
it("resolves documents by counterparty intent", () => {
const result = resolveAddressIntent("show documents by counterparty Alfa from 2020-07-01 to 2020-07-31");
@@ -66,6 +97,23 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves documents by contract intent", () => {
const result = resolveAddressIntent("Покажи документы по договору 15/24 за 2020");
expect(result.intent).toBe("list_documents_by_contract");
});
it("resolves bank operations by contract intent", () => {
const result = resolveAddressIntent("Покажи банковские операции по договору 15/24");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
const result = resolveAddressIntent(
"Показать банковские операции (счета 51, 60, 62) связанные с договором 15/24."
);
expect(result.intent).toBe("bank_operations_by_contract");
});
it("keeps bank_operations_by_counterparty even when account hints are present", () => {
const result = resolveAddressIntent("Показать банковские операции (счета 51, 62) для контрагента СВК за 2020 год");
expect(result.intent).toBe("bank_operations_by_counterparty");
@@ -287,6 +335,49 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
});
it("extracts contract and year period for contract document list", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24 за 2020 год",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("cuts trailing as-of date from contract anchor", () => {
const result = extractAddressFilters(
"Покажи документы по договору 1-ПМ/2020 на дату 31.07.2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("1-ПМ/2020");
expect(result.extracted_filters.period_from).toBe("2020-07-01");
expect(result.extracted_filters.period_to).toBe("2020-07-31");
});
it("does not force 90-day default window for by-contract query without explicit period", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
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 heuristic contract token for noisy contract phrase", () => {
const result = extractAddressFilters(
"доки 15/24 за 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.warnings).toContain("contract_anchor_derived_from_heuristic_token");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("extracts multiline year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025\n год?",
@@ -334,13 +425,24 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.mcp_call_status).toBe("skipped");
});
it("returns unsupported for not-implemented contract document list intent", async () => {
it("routes contract document list intent into address recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show documents by contract 15/24");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.limited_reason_category).toBe("unsupported");
expect(result?.debug.mcp_call_status).toBe("skipped");
expect(result?.debug.detected_intent).toBe("list_documents_by_contract");
expect(result?.debug.selected_recipe).toBe("address_documents_by_contract_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("routes bank operations by contract intent into address recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи банковские операции по договору 15/24");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("bank_operations_by_contract");
expect(result?.debug.selected_recipe).toBe("address_bank_operations_by_contract_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("includes resolver and row-stage diagnostics", async () => {
@@ -461,6 +563,24 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("does not downgrade inherited follow-up anchor to missing_anchor when period has no rows", async () => {
const service = new AddressQueryService();
const seed = await service.tryHandle("покажи документы по свк за 2020");
expect(seed?.handled).toBe(true);
const followup = await service.tryHandle("а теперь только за май 2020", {
followupContext: {
previous_intent: (seed?.debug.detected_intent as any) ?? "list_documents_by_counterparty",
previous_filters: seed?.debug.extracted_filters,
previous_anchor_type: (seed?.debug.anchor_type as any) ?? "counterparty",
previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null
}
});
expect(followup?.handled).toBe(true);
if (followup?.reply_type === "partial_coverage") {
expect(followup?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
});
describe("address recipe catalog counterparty filtering", () => {
@@ -215,4 +215,391 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.sanitized_user_message).toContain("свк");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("keeps contract anchor in deterministic fallback when llm output is unusable", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-contract-docs",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи документы по договору 15/24 за 2020",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-docs-${Date.now()}`,
user_message: "Покажи документы по договору 15/24 за 2020",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по договору 15/24 за 2020 год");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_contract_year_rewrite");
});
it("keeps bank-by-contract intent in deterministic fallback when llm output is unusable", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-contract-bank",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи банковские операции по договору 15/24",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-bank-${Date.now()}`,
user_message: "Покажи банковские операции по договору 15/24",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по договору 15/24");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite");
});
it("keeps month scope for balance fallback in 'year month' phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-balance-year-month",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Какой остаток по счету 60 на 2020 май",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-balance-year-month-${Date.now()}`,
user_message: "Какой остаток по счету 60 на 2020 май",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("остаток по счету 60 на 2020-05");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("balance_month_period_rewrite");
});
it("does not pick service words as counterparty anchor in noisy docs query", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-noisy-counterparty",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "что по свк за 2020 год выведи все доки плиз что есть",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-noisy-counterparty-${Date.now()}`,
user_message: "что по свк за 2020 год выведи все доки плиз что есть",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
});
it("rewrites payment-style counterparty phrasing to bank operations", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-bank-counterparty",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "какие платежи были по свк в 2020",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-bank-counterparty-${Date.now()}`,
user_message: "какие платежи были по свк в 2020",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_counterparty_year_rewrite");
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-short-ordinal-year",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "бля епт покажи доки по свк за 20й",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-short-ordinal-year-${Date.now()}`,
user_message: "бля епт покажи доки по свк за 20й",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
});
it("does not treat date fragments as account in balance fallback", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-date-not-account",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "есть ли долг по договору 1-ПМ/2020 на 2020-07-31",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-date-not-account-${Date.now()}`,
user_message: "есть ли долг по договору 1-ПМ/2020 на 2020-07-31",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).not.toContain("остаток по счету 07");
expect(calls[0].message.toLowerCase()).toContain("договору 1-пм/2020");
});
});