АДРЕСНЫЙ РЕЖИМ -ADDRESS: стабилизация контекстных цепочек и live-якорей (договор 19/15)

This commit is contained in:
2026-04-02 15:03:53 +03:00
parent cc7fcabf05
commit 4cb9fc7021
90 changed files with 15954 additions and 1561 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(proc
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 1200);
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
exports.DATA_DIR = process.env.DATA_DIR ?? path_1.default.resolve(exports.MODULE_ROOT, "data");
exports.TRACES_DIR = path_1.default.resolve(exports.DATA_DIR, "traces");
@@ -378,7 +378,20 @@ function extractLooseByAnchorValue(text) {
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
"взаиморасчётам",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому"
]);
if (stopWords.has(lowered)) {
return undefined;
@@ -466,6 +479,19 @@ function isLikelyCounterpartyToken(rawToken) {
"show",
"list",
"please",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому",
"vse",
"all",
"kakie",
@@ -352,7 +352,7 @@ function hasContractAnchorSignal(text) {
if (hasContractAnchorMention(text)) {
return true;
}
// Allow short forms like "15/24" for follow-up prompts if document/bank signal exists.
// Allow short forms like "19/15" for follow-up prompts if document/bank signal exists.
return hasContractNumberLikeToken(text) && hasDocsOrBankSignal(text);
}
function hasLooseByAnchorMention(text) {
@@ -24,6 +24,78 @@ function hasAllTimeHint(text) {
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
}
function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
function hasDocumentSignal(text) {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?)/iu.test(String(text ?? ""));
}
function hasBankSignal(text) {
return /(?:банк|банков|операц|опер|выписк|платеж|платёж|оплат|списан|поступлен|движени|bank|payment|payments|transaction|transactions|statement|wire)/iu.test(String(text ?? ""));
}
function hasAccountSignal(text) {
const normalized = String(text ?? "");
return (/(?:сч[её]т|account)\D{0,12}\d{2}(?:[.,]\d{1,2})?/iu.test(normalized) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(normalized) ||
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(normalized));
}
function hasExplicitCounterpartyMention(text) {
return /(?:контраг|counterparty|компан|организац|supplier|vendor|customer|client|partner|поставщик|клиент|покупател|партнер)/iu.test(String(text ?? ""));
}
function hasExplicitContractMention(text) {
return /(?:договор|контракт|contract|\bдог\.?\b)/iu.test(String(text ?? ""));
}
function hasContractNumberLikeToken(text) {
if (/(?:^|[\s([{])(?:№|#|n)\s*[a-zа-яё0-9][a-zа-яё0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(String(text ?? ""))) {
return true;
}
return /\b\d{1,6}[./_-]\d{1,6}(?:[./_-]\d{1,6})?\b/iu.test(String(text ?? ""));
}
function hasExplicitLooseByAnchorToken(text) {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").trim().toLowerCase();
if (!token) {
return false;
}
const pronounTokens = new Set([
"нему",
"ней",
"ним",
"немуже",
"нейже",
"этому",
"этомуже",
"этому-то",
"тому",
"томуже",
"этому",
"этой",
"той"
]);
const genericTokens = new Set([
"контрагенту",
"контрагента",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду"
]);
return !pronounTokens.has(token) && !genericTokens.has(token);
}
function mapCounterpartyIntentToContractIntent(intent) {
if (intent === "list_documents_by_counterparty") {
return "list_documents_by_contract";
}
if (intent === "bank_operations_by_counterparty") {
return "bank_operations_by_contract";
}
return null;
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
@@ -153,14 +225,76 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
const normalizedMessage = String(userMessage ?? "");
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
if (!hasFollowupSignal) {
return detectedIntent;
}
const previousIntent = followupContext.previous_intent;
const previousFilters = followupContext.previous_filters ?? {};
const previousContract = toNonEmptyString(previousFilters.contract);
const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
const previousContractFromAnchor = followupContext.previous_anchor_type === "contract" ? toNonEmptyString(followupContext.previous_anchor_value) : null;
const previousCounterpartyFromAnchor = followupContext.previous_anchor_type === "counterparty" ? toNonEmptyString(followupContext.previous_anchor_value) : null;
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "open_items_from_followup_context"]
};
}
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance";
if (previousIsBalanceFamily &&
hasAccountSignal(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_counterparty")) {
const preferDocumentsForming = hasDocumentSignal(normalizedMessage) &&
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
return {
intent: preferDocumentsForming ? "documents_forming_balance" : "account_balance_snapshot",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
};
}
if (hasPreviousContract) {
if (detectedIntent.intent === "unknown") {
if (hasBankSignal(normalizedMessage)) {
return {
intent: "bank_operations_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
if (hasDocumentSignal(normalizedMessage)) {
return {
intent: "list_documents_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
}
const mappedIntent = mapCounterpartyIntentToContractIntent(detectedIntent.intent);
if (mappedIntent) {
const explicitCounterpartyTarget = hasExplicitCounterpartyMention(normalizedMessage) || hasExplicitLooseByAnchorToken(normalizedMessage);
const explicitContractTarget = hasExplicitContractMention(normalizedMessage) || hasContractNumberLikeToken(normalizedMessage);
if (!explicitCounterpartyTarget || explicitContractTarget) {
return {
intent: mappedIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
}
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
intent: previousIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
+1 -1
View File
@@ -108,7 +108,7 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
""
);
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 1200);
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
@@ -415,7 +415,20 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
"взаиморасчётам",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому"
]);
if (stopWords.has(lowered)) {
return undefined;
@@ -506,6 +519,19 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
"show",
"list",
"please",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому",
"vse",
"all",
"kakie",
@@ -385,7 +385,7 @@ 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.
// Allow short forms like "19/15" for follow-up prompts if document/bank signal exists.
return hasContractNumberLikeToken(text) && hasDocsOrBankSignal(text);
}
@@ -630,3 +630,4 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
reasons: ["intent_not_supported_in_v1"]
};
}
@@ -57,6 +57,93 @@ function hasSameDateHint(text: string): boolean {
);
}
function hasOpenItemsHint(text: string): boolean {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
function hasDocumentSignal(text: string): boolean {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?)/iu.test(String(text ?? ""));
}
function hasBankSignal(text: string): boolean {
return /(?:банк|банков|операц|опер|выписк|платеж|платёж|оплат|списан|поступлен|движени|bank|payment|payments|transaction|transactions|statement|wire)/iu.test(
String(text ?? "")
);
}
function hasAccountSignal(text: string): boolean {
const normalized = String(text ?? "");
return (
/(?:сч[её]т|account)\D{0,12}\d{2}(?:[.,]\d{1,2})?/iu.test(normalized) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(normalized) ||
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(normalized)
);
}
function hasExplicitCounterpartyMention(text: string): boolean {
return /(?:контраг|counterparty|компан|организац|supplier|vendor|customer|client|partner|поставщик|клиент|покупател|партнер)/iu.test(
String(text ?? "")
);
}
function hasExplicitContractMention(text: string): boolean {
return /(?:договор|контракт|contract|\bдог\.?\b)/iu.test(String(text ?? ""));
}
function hasContractNumberLikeToken(text: string): boolean {
if (/(?:^|[\s([{])(?:№|#|n)\s*[a-zа-яё0-9][a-zа-яё0-9./_-]{1,}(?=$|[\s,.;:!?)\]}])/iu.test(String(text ?? ""))) {
return true;
}
return /\b\d{1,6}[./_-]\d{1,6}(?:[./_-]\d{1,6})?\b/iu.test(String(text ?? ""));
}
function hasExplicitLooseByAnchorToken(text: string): boolean {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").trim().toLowerCase();
if (!token) {
return false;
}
const pronounTokens = new Set([
"нему",
"ней",
"ним",
"немуже",
"нейже",
"этому",
"этомуже",
"этому-то",
"тому",
"томуже",
"этому",
"этой",
"той"
]);
const genericTokens = new Set([
"контрагенту",
"контрагента",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду"
]);
return !pronounTokens.has(token) && !genericTokens.has(token);
}
function mapCounterpartyIntentToContractIntent(intent: AddressIntent): AddressIntent | null {
if (intent === "list_documents_by_counterparty") {
return "list_documents_by_contract";
}
if (intent === "bank_operations_by_counterparty") {
return "bank_operations_by_contract";
}
return null;
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
@@ -213,14 +300,88 @@ function deriveIntentWithFollowupContext(
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
const normalizedMessage = String(userMessage ?? "");
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
if (!hasFollowupSignal) {
return detectedIntent;
}
const previousIntent = followupContext.previous_intent;
const previousFilters = followupContext.previous_filters ?? {};
const previousContract = toNonEmptyString(previousFilters.contract);
const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
const previousContractFromAnchor =
followupContext.previous_anchor_type === "contract" ? toNonEmptyString(followupContext.previous_anchor_value) : null;
const previousCounterpartyFromAnchor =
followupContext.previous_anchor_type === "counterparty" ? toNonEmptyString(followupContext.previous_anchor_value) : null;
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "open_items_from_followup_context"]
};
}
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance";
if (
previousIsBalanceFamily &&
hasAccountSignal(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_counterparty")
) {
const preferDocumentsForming =
hasDocumentSignal(normalizedMessage) &&
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
return {
intent: preferDocumentsForming ? "documents_forming_balance" : "account_balance_snapshot",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_balance_followup_context"]
};
}
if (hasPreviousContract) {
if (detectedIntent.intent === "unknown") {
if (hasBankSignal(normalizedMessage)) {
return {
intent: "bank_operations_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
if (hasDocumentSignal(normalizedMessage)) {
return {
intent: "list_documents_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
}
const mappedIntent = mapCounterpartyIntentToContractIntent(detectedIntent.intent);
if (mappedIntent) {
const explicitCounterpartyTarget = hasExplicitCounterpartyMention(normalizedMessage) || hasExplicitLooseByAnchorToken(normalizedMessage);
const explicitContractTarget = hasExplicitContractMention(normalizedMessage) || hasContractNumberLikeToken(normalizedMessage);
if (!explicitCounterpartyTarget || explicitContractTarget) {
return {
intent: mappedIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
}
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
intent: previousIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
@@ -119,22 +119,22 @@ describe("address intent resolver expansion (M2.3a)", () => {
});
it("resolves documents by contract intent", () => {
const result = resolveAddressIntent("Покажи документы по договору 15/24 за 2020");
const result = resolveAddressIntent("Покажи документы по договору 19/15 за 2020");
expect(result.intent).toBe("list_documents_by_contract");
});
it("resolves bank operations by contract intent", () => {
const result = resolveAddressIntent("Покажи банковские операции по договору 15/24");
const result = resolveAddressIntent("Покажи банковские операции по договору 19/15");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves shorthand bank-by-contract slang intent", () => {
const result = resolveAddressIntent("покажи банк опер по дог 15/24 пж");
const result = resolveAddressIntent("покажи банк опер по дог 19/15 пж");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves debt-by-contract query to open items intent", () => {
const result = resolveAddressIntent("Есть ли долг по договору 15/24 на 2020-07-31");
const result = resolveAddressIntent("Есть ли долг по договору 19/15 на 2020-07-31");
expect(result.intent).toBe("open_items_by_counterparty_or_contract");
});
@@ -145,7 +145,7 @@ describe("address intent resolver expansion (M2.3a)", () => {
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
const result = resolveAddressIntent(
"Показать банковские операции (счета 51, 60, 62) связанные с договором 15/24."
"Показать банковские операции (счета 51, 60, 62) связанные с договором 19/15."
);
expect(result.intent).toBe("bank_operations_by_contract");
});
@@ -332,6 +332,17 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("account_anchor_derived_from_heuristic_token");
});
it("does not derive counterparty from follow-up filler token in bank phrase", () => {
const result = extractAddressFilters("а теперь банковские операции", "bank_operations_by_counterparty");
expect(result.extracted_filters.counterparty).toBeUndefined();
});
it("keeps compact account for docs-forming follow-up and avoids fake counterparty anchor", () => {
const result = extractAddressFilters("раскрой 62.01 документами на ту же дату", "documents_forming_balance");
expect(result.extracted_filters.account).toBe("62.01");
expect(result.extracted_filters.counterparty).toBeUndefined();
});
it("drops accidental account for non-account intent without explicit account cue", () => {
const result = extractAddressFilters("покажи банк операции по свк за 2020", "bank_operations_by_counterparty");
expect(result.extracted_filters.account).toBeUndefined();
@@ -457,10 +468,10 @@ describe("address filter extraction for balance drilldown", () => {
it("extracts contract and year period for contract document list", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24 за 2020 год",
"Покажи документы по договору 19/15 за 2020 год",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.contract).toBe("19/15");
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");
@@ -478,10 +489,10 @@ describe("address filter extraction for balance drilldown", () => {
it("does not force 90-day default window for by-contract query without explicit period", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24",
"Покажи документы по договору 19/15",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.contract).toBe("19/15");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
@@ -489,10 +500,10 @@ describe("address filter extraction for balance drilldown", () => {
it("extracts heuristic contract token for noisy contract phrase", () => {
const result = extractAddressFilters(
"доки 15/24 за 2020",
"доки 19/15 за 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.contract).toBe("19/15");
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");
@@ -500,20 +511,20 @@ describe("address filter extraction for balance drilldown", () => {
it("trims english year tail from contract anchor", () => {
const result = extractAddressFilters(
"docs by contract 15/24 year 2020",
"docs by contract 19/15 year 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.contract).toBe("19/15");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("trims trailing separated year from contract anchor", () => {
const result = extractAddressFilters(
"docs by contract 15/24 2020",
"docs by contract 19/15 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.contract).toBe("19/15");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
@@ -567,7 +578,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
it("does not return fallback factual rows for unmatched open-items contract anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи открытые позиции по договору 15/24");
const result = await service.tryHandle("Покажи открытые позиции по договору 9999/NOPE");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.reply_type).toBe("partial_coverage");
@@ -579,7 +590,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
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");
const result = await service.tryHandle("show documents by contract 19/15");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_documents_by_contract");
expect(result?.debug.selected_recipe).toBe("address_documents_by_contract_v1");
@@ -589,7 +600,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
it("routes bank operations by contract intent into address recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи банковские операции по договору 15/24");
const result = await service.tryHandle("Покажи банковские операции по договору 19/15");
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");
@@ -716,6 +727,59 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("keeps contract scope when follow-up asks for bank operations without explicit anchor", () => {
const result = runAddressDecomposeStage("а теперь банковские операции", {
previous_intent: "list_documents_by_contract",
previous_filters: {
contract: "19/15",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "contract",
previous_anchor_value: "19/15"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("bank_operations_by_contract");
expect(result?.filters.extracted_filters.contract).toBe("19/15");
expect(result?.baseReasons).toContain("intent_adjusted_to_contract_followup_context");
});
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
previous_intent: "bank_operations_by_contract",
previous_filters: {
contract: "19/15",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_anchor_type: "contract",
previous_anchor_value: "19/15"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("open_items_by_counterparty_or_contract");
expect(result?.filters.extracted_filters.contract).toBe("19/15");
expect(result?.baseReasons).toContain("open_items_from_followup_context");
});
it("keeps balance family in follow-up when user gives compact account token", () => {
const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", {
previous_intent: "documents_forming_balance",
previous_filters: {
account: "62",
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
},
previous_anchor_type: "account",
previous_anchor_value: "62"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("account_balance_snapshot");
expect(result?.filters.extracted_filters.account).toBe("60");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-12-31");
expect(result?.baseReasons).toContain("intent_adjusted_to_balance_followup_context");
});
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");
@@ -785,12 +849,12 @@ describe("address recipe catalog counterparty filtering", () => {
it("allows extended limit for open-items by contract intent", () => {
const selected = selectAddressRecipe("open_items_by_counterparty_or_contract", {
contract: "15/24",
contract: "19/15",
limit: 1000
});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
contract: "15/24",
contract: "19/15",
limit: 1000
});
@@ -837,3 +901,4 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПОДОБНО \"60.1%\"");
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
@@ -231,7 +231,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи документы по договору 15/24 за 2020",
user_message_raw: "Покажи документы по договору 19/15 за 2020",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
@@ -258,7 +258,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-docs-${Date.now()}`,
user_message: "Покажи документы по договору 15/24 за 2020",
user_message: "Покажи документы по договору 19/15 за 2020",
llmProvider: "local",
useMock: false
} as any);
@@ -266,7 +266,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по договору 15/24 за 2020 год");
expect(calls[0].message).toBe("документы по договору 19/15 за 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");
@@ -287,7 +287,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи банковские операции по договору 15/24",
user_message_raw: "Покажи банковские операции по договору 19/15",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
@@ -314,7 +314,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-bank-${Date.now()}`,
user_message: "Покажи банковские операции по договору 15/24",
user_message: "Покажи банковские операции по договору 19/15",
llmProvider: "local",
useMock: false
} as any);
@@ -322,7 +322,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по договору 15/24");
expect(calls[0].message).toBe("банковские операции по договору 19/15");
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");
@@ -509,7 +509,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "покажи банк опер по дог 15/24 пж",
user_message_raw: "покажи банк опер по дог 19/15 пж",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
@@ -536,7 +536,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
const response = await service.handleMessage({
session_id: `asst-predecompose-bank-contract-slang-${Date.now()}`,
user_message: "покажи банк опер по дог 15/24 пж",
user_message: "покажи банк опер по дог 19/15 пж",
llmProvider: "local",
useMock: false
} as any);
@@ -544,7 +544,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по договору 15/24");
expect(calls[0].message).toBe("банковские операции по договору 19/15");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite");
});
@@ -715,3 +715,4 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(calls[0].message.toLowerCase()).toContain("договору 1-пм/2020");
});
});