ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала

This commit is contained in:
2026-04-12 13:46:14 +03:00
parent ca2feab893
commit 1b2ee93176
19 changed files with 2453 additions and 252 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
@@ -1830,6 +1830,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_payables_counterparties");
});
it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => {
const result = resolveAddressIntent("каму мы должны заплатить за май 2020");
expect(result.intent).toBe("list_payables_counterparties");
expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected");
});
it("keeps out-of-scope supplier control wording as unknown intent", () => {
const result = resolveAddressIntent(
"Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?"
@@ -2488,6 +2494,33 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with explicit fallback contract", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("каму мы должны заплатить за май 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_payables_counterparties");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.as_of_date_basis).toBe("period_range");
expect(Array.isArray(result?.debug.reasons)).toBe(true);
const reply = String(result?.reply_text ?? "");
if (result?.debug.result_mode === "confirmed_balance") {
expect(result?.debug.selected_recipe).toBe("address_movements_payables_v1");
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("подтвержденный срез обязательств к оплате");
expect(reply).toContain("Кому нужно заплатить в первую очередь");
} else {
expect(result?.debug.result_mode).toBe("heuristic_candidates");
expect(result?.debug.balance_confirmed).toBe(false);
expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(reply).toContain("эвристический скоринг");
expect(reply).toContain("Контрагентов-кандидатов:");
expect(reply).toContain("Блок 1. Статус результата");
expect(reply).toContain("\n\nБлок 2. Как читать результат");
expect(reply).toContain("\n\nБлок 3. Сводка выборки");
expect(reply).not.toContain("Кому нужно заплатить в первую очередь");
}
});
it("routes shipment-to-payment lag wording into receivables lane without missing-anchor fallback", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@@ -2685,6 +2718,16 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes 'кто больше всего принес денег в 2020' into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("кто больше всего принес денег в 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes typo highest-check wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("с каких кликентов самый высокий чек");
@@ -708,6 +708,312 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves short declined counterparty mention from displayed top list into contracts follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по гамме";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "гамме",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-gamma-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_from).toBe("2020-01-01");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_to).toBe("2020-12-31");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("list_contracts_by_counterparty");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered item from displayed top list into counterparty drill-down", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по пункту 4";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "пункт 4",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-counterparty-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered contract item from previous contract list into documents-by-contract follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи договоры по гамме";
const followupMessage = "покажи документы по пункту 2";
const contractsReply = [
"Собран список договоров по контрагенту Гамма-мебель, ООО.",
"1. Договор № 1-ГМ/2020 | операций: 4 | последняя активность: 2020-12-14T12:00:00Z",
"2. Договор № 2-ГМ/2020 | операций: 3 | последняя активность: 2020-12-30T12:00:00Z",
"3. Договор № 3-ГМ/2020 | операций: 1 | последняя активность: 2020-08-11T13:15:30Z"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.contract !== "Договор № 2-ГМ/2020") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список документов по договору № 2-ГМ/2020.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_contract",
selected_recipe: "address_documents_by_contract_v1",
extracted_filters: {
contract: "Договор № 2-ГМ/2020"
},
anchor_type: "contract",
anchor_value_raw: "пункт 2",
anchor_value_resolved: "Договор № 2-ГМ/2020",
reasons: ["address_action_detected", "documents_by_contract_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: contractsReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
counterparty: "Гамма-мебель, ООО",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "гамма",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-contract-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_documents_by_contract");
expect(second.debug?.extracted_filters?.contract).toBe("Договор № 2-ГМ/2020");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.contract === "Договор № 2-ГМ/2020"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("contract");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Договор № 2-ГМ/2020");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not carry address follow-up context into capability question", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
@@ -219,6 +219,39 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "кто больше всего принес денег в 2020",
effectiveAddressUserMessage: "кто больше всего принес денег в 2020",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
},
semanticExtractionContract: {
valid: false,
apply_canonical_recommended: false,
reason_codes: ["unsupported_low_confidence_contract"]
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected"]).toContain(
String(decision.toolGateReason)
);
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("routes unsupported turnover-by-organization query to deep analysis", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434",