ОРРКЕСТРАЦИЯ - Оркестрация домена: ввести step-validator и починить исторический as_of_date в item-trace

This commit is contained in:
2026-04-14 23:01:23 +03:00
parent c020ef08e1
commit 82a020e302
16 changed files with 1414 additions and 75 deletions
@@ -114,6 +114,42 @@ describe("inventory selected-object follow-up", () => {
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
});
it("uses analysis date hint for canonical item provenance wording without explicit date", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2019-02-12T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 3724.17,
SubcontoDt1: "Столешница 600*3050*26 дуб ниагара",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз",
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("От какого поставщика куплен товар Столешница 600*3050*26 дуб ниагара", {
analysisDateHint: "2019-03-31"
});
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз");
});
it("handles selected-object supplier slang 'кто это поставил нам' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
@@ -160,6 +196,153 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
});
it("handles selected-object colloquial supplier wording 'у кого купили' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2016-07-20T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000011 от 20.07.2016 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 695360,
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoDt3: "Основной склад",
SubcontoKt1: "ООО \\Производство мебели\\",
SubcontoKt2: "Договор поставки № 7 от 15.07.2016",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-07-31",
period_from: "2016-07-01",
period_to: "2016-07-31",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
}
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-07-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
it("handles selected-object wording 'где мы купили это' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2016-05-20T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 695360,
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoDt3: "Основной склад",
SubcontoKt1: "ООО \\Производство мебели\\",
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-05-31",
period_from: "2016-05-01",
period_to: "2016-05-31",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
}
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
it("handles selected-object wording 'где куплено!!' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2016-05-20T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 695360,
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoDt3: "Основной склад",
SubcontoKt1: "ООО \\Производство мебели\\",
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-05-31",
period_from: "2016-05-01",
period_to: "2016-05-31",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
}
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
@@ -203,6 +203,39 @@ describe("address query shape classifier", () => {
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object colloquial supplier wording 'у кого купили' in inventory provenance intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили'
);
const result = resolveAddressIntent(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили'
);
expect(mode.mode).toBe("address_query");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object colloquial supplier wording 'где мы купили это' in inventory provenance intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
);
const result = resolveAddressIntent(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
);
expect(mode.mode).toBe("address_query");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object terse supplier wording 'где куплено!!' in inventory provenance intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!'
);
const result = resolveAddressIntent(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!'
);
expect(mode.mode).toBe("address_query");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили'
@@ -2845,6 +2878,14 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("as_of_date_derived_from_period_to");
});
it("derives month period for inventory snapshot from prepositional month wording 'в мае 2016'", () => {
const result = extractAddressFilters("Что лежит на складе в мае 2016 года?", "inventory_on_hand_as_of_date");
expect(result.extracted_filters.period_from).toBe("2016-05-01");
expect(result.extracted_filters.period_to).toBe("2016-05-31");
expect(result.extracted_filters.as_of_date).toBe("2016-05-31");
expect(result.warnings).toContain("period_derived_from_month_phrase");
});
it("extracts dotted account by heuristic for docs-forming phrasing without 'счет' keyword", () => {
const result = extractAddressFilters(
"раскрой остаток 60.01 по документам на конец июля 2020",
@@ -3139,6 +3180,16 @@ describe("address filter extraction for balance drilldown", () => {
});
describe("address query limited taxonomy and stage diagnostics", { timeout: 15000 }, () => {
it("does not default standalone item provenance questions to today without explicit temporal cue", () => {
const result = extractAddressFilters(
"От какого поставщика куплен товар Столешница 600*3050*26 дуб ниагара",
"inventory_purchase_provenance_for_item"
);
expect(result.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(result.extracted_filters.as_of_date).toBeUndefined();
expect(result.warnings).not.toContain("as_of_date_defaulted_today");
});
it("injects as_of_date from analysis context when user message has no explicit period", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи контрагентов с незакрытыми хвостами", {
@@ -3924,6 +3975,78 @@ describe("address decompose stage follow-up carryover", () => {
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
).toBe(true);
});
it("promotes selected-object wording 'у кого купили' into inventory provenance with inherited date context", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили',
{
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-07-31",
period_from: "2016-07-01",
period_to: "2016-07-31",
warehouse: "Основной склад"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.filters.extracted_filters.as_of_date).toBe("2016-07-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
).toBe(true);
});
it("promotes selected-object wording 'где мы купили это' into inventory provenance with inherited date context", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
{
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-05-31",
period_from: "2016-05-01",
period_to: "2016-05-31",
warehouse: "Основной склад"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.filters.extracted_filters.as_of_date).toBe("2016-05-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
).toBe(true);
});
it("promotes selected-object wording 'где куплено!!' into inventory provenance with inherited date context", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!',
{
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-05-31",
period_from: "2016-05-01",
period_to: "2016-05-31",
warehouse: "Основной склад"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.filters.extracted_filters.as_of_date).toBe("2016-05-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
@@ -350,6 +350,105 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
});
it("rejects selected-object rewrite that drops object context and injects generic documents intent", 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-selected-object-context-loss",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
normalized_fragment_text: "Покупка рабочей станции универсального специалиста (индивидуальное изготовление). Кто поставщик и какие документы подтверждают?",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "missing",
value: null,
confidence: "low"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: true,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "medium",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
},
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-selected-object-${Date.now()}`,
user_message: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
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(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
);
expect([
"normalized_fragment_rejected_intent_conflict",
"normalized_fragment_rejected_selected_object_context_loss"
]).toContain(response.debug?.llm_decomposition_reason);
});
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {