ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user