АРЧ - Усилить root-frame возврат, memory recap и colloquial provenance follow-up

This commit is contained in:
2026-04-15 20:34:21 +03:00
parent f911f9893b
commit 8056bdfaf2
16 changed files with 2032 additions and 21 deletions
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
describe("inventory root frame regressions", () => {
it("restores inventory root frame for restatement on the same period after foreign domain drift", () => {
const result = runAddressDecomposeStage(
"ладно ок. покажи мне еще раз позиции на складе на тот же период рассмотрения",
{
previous_intent: "customer_revenue_and_payments",
previous_filters: {
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "organization",
previous_anchor_value: "ООО \\Альтернатива Плюс\\",
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
organization: "ООО \\Альтернатива Плюс\\",
period_from: "2022-02-01",
period_to: "2022-02-28",
as_of_date: "2022-02-28"
},
root_anchor_type: "organization",
root_anchor_value: "ООО \\Альтернатива Плюс\\",
current_frame_kind: "generic"
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame");
expect(result?.filters.extracted_filters.organization).toBe("ООО \\Альтернатива Плюс\\");
expect(result?.filters.extracted_filters.period_from).toBe("2022-02-01");
expect(result?.filters.extracted_filters.period_to).toBe("2022-02-28");
expect(result?.filters.extracted_filters.as_of_date).toBe("2022-02-28");
expect(result?.filters.warnings).toContain("period_from_from_followup_context");
expect(result?.filters.warnings).toContain("period_to_from_followup_context");
});
it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?',
{
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
organization: "ООО \\Альтернатива Плюс\\",
warehouse: "Основной склад",
period_from: "2022-02-01",
period_to: "2022-02-28",
as_of_date: "2022-02-28"
},
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.item).toBe("Зеркало для инвалидов поворотное травмобезопасное");
expect(result?.filters.extracted_filters.as_of_date).toBe("2022-02-28");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
).toBe(true);
});
});
@@ -23,6 +23,19 @@ describe("inventory warehouse anchor extraction", () => {
expect(filters.as_of_date).toBe("2019-03-31");
expect(filters.warehouse).toBeUndefined();
});
it("does not materialize 'за май' as warehouse in inventory balance phrasing from the run", () => {
const filters = extractAddressFilters(
"проверить остатки по складу за май 2020 года",
"inventory_on_hand_as_of_date"
).extracted_filters;
expect(filters.period_from).toBe("2020-05-01");
expect(filters.period_to).toBe("2020-05-31");
expect(filters.as_of_date).toBe("2020-05-31");
expect(filters.warehouse).toBeUndefined();
});
it("treats 'у нас' as implicit self-scope instead of literal warehouse anchor", () => {
const result = extractAddressFilters("что на складе у нас", "inventory_on_hand_as_of_date");
@@ -2058,5 +2058,323 @@ describe("assistant address follow-up carryover", () => {
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("sanitizes selected-item carryover when inventory drilldown pivots into VAT follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "\u0430 \u043d\u0434\u0441?";
const itemLabel =
"\u041a\u0440\u043e\u043c\u043a\u0430 \u0441 \u043a\u043b\u0435\u0435\u043c 33 \u0434\u0443\u0431 \u043d\u0438\u0430\u0433\u0430\u0440\u0430 137 \u043c";
const organization = "\u041e\u041e\u041e \\\u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441\\";
const warehouse = "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0441\u043a\u043b\u0430\u0434";
const vatResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_payable_confirmed_as_of_date",
extracted_filters: {
sort: "period_desc",
period_from: "2021-03-01",
period_to: "2021-03-31",
as_of_date: "2021-03-31",
organization
},
selected_recipe: "address_vat_payable_confirmed_as_of_date_v1",
response_type: "FACTUAL_SUMMARY",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: [
"address_action_detected",
"address_entity_detected",
"address_followup_context_applied"
]
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
return vatResult;
})
} 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-inventory-vat-pivot-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: "inventory root seed",
reply_type: "factual",
created_at: "2026-04-15T14:01:00.000Z",
trace_id: "address-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31",
organization,
warehouse
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-sale-seed",
session_id: sessionId,
role: "assistant",
text: "inventory sale trace seed",
reply_type: "factual",
created_at: "2026-04-15T14:02:00.000Z",
trace_id: "address-sale-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_sale_trace_for_item",
extracted_filters: {
item: itemLabel,
organization,
as_of_date: "2021-03-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
anchor_type: "item",
anchor_value_raw: itemLabel,
anchor_value_resolved: itemLabel
}
} as any);
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("vat_payable_confirmed_as_of_date");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_anchor_type).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_anchor_value).toBeNull();
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe(warehouse);
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.previous_filters?.period_from).toBe("2021-03-01");
expect(calls[0].options?.followupContext?.previous_filters?.period_to).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01");
expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short supplier follow-up after sale trace as continuation of the active selected object", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а купили у кого";
const saleTraceResult = {
handled: true,
reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден покупатель: ООО \\Ромашка\\.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_sale_trace_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
anchor_type: "item",
anchor_value_raw: "Столешница 600*3050*26 дуб ниагара",
anchor_value_resolved: "Столешница 600*3050*26 дуб ниагара",
reasons: ["address_action_detected", "address_entity_detected"]
}
} as any;
const provenanceResult = {
handled: true,
reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден поставщик: Торговый дом \\Союз\\.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
} as any;
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return provenanceResult;
}
return saleTraceResult;
})
} 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-sale-to-supplier-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-sale-trace-seed",
session_id: sessionId,
role: "assistant",
text: saleTraceResult.reply_text,
reply_type: saleTraceResult.reply_type,
created_at: "2026-04-15T18:00:00.000Z",
trace_id: "address-sale-seed",
debug: saleTraceResult.debug
} as any);
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(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_sale_trace_for_item");
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not carry VAT previous_intent into a fresh inventory root query", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "прогноз ндс на март 2020";
const secondMessage = "остаток на складе за май 2020";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return {
handled: true,
reply_text: "Прогноз НДС на март 2020 собран.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "vat_payable_forecast",
detected_intent_confidence: "high",
extracted_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
selected_recipe: "address_vat_payable_forecast_v1"
}
};
}
return {
handled: true,
reply_text: "Нужно уточнить организацию.",
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
period_from: "2020-05-01",
period_to: "2020-05-31",
as_of_date: "2020-05-31"
},
selected_recipe: null,
limited_reason_category: "missing_anchor",
reasons: ["organization_clarification_required", "multiple_known_organizations_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-vat-inventory-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
const second = await service.handleMessage({
session_id: sessionId,
user_message: secondMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(calls.length).toBeGreaterThanOrEqual(2);
const inventoryCalls = calls.slice(1);
expect(inventoryCalls.every((call) => call.message === secondMessage)).toBe(true);
expect(inventoryCalls.every((call) => call.options?.followupContext === undefined)).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});
@@ -133,4 +133,35 @@ describe("assistant living chat runtime adapter", () => {
expect(output.debug?.living_chat_response_source).toBe("llm_chat_grounding_guard");
expect(executeLlmChat).toHaveBeenCalledTimes(1);
});
it("builds deterministic memory recap for prior selected-object address context", async () => {
const executeLlmChat = vi.fn(async () => "raw-llm");
const input = buildRuntimeInput({
userMessage: "а ты помнишь мы зеркало обсуждали?",
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Зеркало для инвалидов поворотное травмобезопасное",
organization: "ООО Альтернатива Плюс",
as_of_date: "2022-02-28"
}
}
}
],
executeLlmChat
});
const output = await runAssistantLivingChatRuntime(input);
expect(output.handled).toBe(true);
expect(output.chatText).toContain("Зеркало для инвалидов поворотное травмобезопасное");
expect(output.chatText).toContain("кто поставил");
expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract");
expect(executeLlmChat).not.toHaveBeenCalled();
});
});
@@ -730,6 +730,98 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("meta_followup_over_grounded_answer");
});
it("routes evaluative VAT follow-up 'это много или мало' to contextual chat instead of replaying address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "это много или мало?",
effectiveAddressUserMessage: "это много или мало?",
followupContext: {
previous_intent: "vat_payable_forecast",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: 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
}
} as any,
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "vat_payable_forecast"
}
}
],
useMock: false
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("meta_followup_over_grounded_answer");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("meta_followup_over_grounded_answer");
});
it("routes memory recap follow-up over prior address context to deterministic chat instead of generic non-domain chat", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "а ты помнишь мы зеркало обсуждали?",
effectiveAddressUserMessage: "а ты помнишь мы зеркало обсуждали?",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: false,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
} as any,
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Зеркало для инвалидов поворотное травмобезопасное",
as_of_date: "2022-02-28"
}
}
}
],
useMock: false
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("memory_recap_followup_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("memory_recap_followup_detected");
});
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
const question =
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";