АРЧ АП11 - Архитектура после ге :
This commit is contained in:
@@ -54,6 +54,13 @@ describe("addressCounterpartyIntentSignals", () => {
|
||||
expect(result?.reasons).toContain("counterparty_item_flow_signal_detected");
|
||||
});
|
||||
|
||||
it("classifies passive shipment wording with instrumental counterparty phrasing", () => {
|
||||
const result = resolveAddressIntent("какие товары или услуги были отгружены нашей компании контрагентом чапурновым?");
|
||||
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result.reasons).toContain("counterparty_item_flow_signal_detected");
|
||||
});
|
||||
|
||||
it("keeps the main resolver behavior stable through counterparty-owner delegation", () => {
|
||||
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveCounterpartyAddressIntent } from "../src/services/addressCounterpartyIntentSignals";
|
||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||
|
||||
const utf8Deps = {
|
||||
hasAny: (text: string, hints: readonly string[]) => hints.some((hint) => text.includes(hint)),
|
||||
openItemsHints: ["хвост", "долг", "open items"],
|
||||
openContractsHints: ["договор", "контракт", "open contracts"],
|
||||
documentsByCounterpartyHints: ["документы по", "documents by counterparty"],
|
||||
bankOperationsByCounterpartyHints: ["банк", "выписка", "bank operations by counterparty"],
|
||||
documentsByContractHints: ["документы по договору", "documents by contract"],
|
||||
hasCounterpartyDebtLongevitySignal: () => false,
|
||||
hasInventoryAgingSignal: () => false,
|
||||
hasInventoryProvenanceSignalV2: () => false,
|
||||
hasInventoryPurchaseDocumentsSignalV2: () => false,
|
||||
hasInventorySaleTraceSignalV2: () => false,
|
||||
hasAccountNumberAnchor: (text: string) => /(?:60|62|76)/.test(text),
|
||||
hasCompactAccountCodeToken: () => false,
|
||||
hasPeriodCoverageProfileSignal: () => false,
|
||||
hasPartyAnchorMention: () => false,
|
||||
hasContractAnchorSignal: (text: string) => text.includes("договор"),
|
||||
hasAccountBalanceSignal: () => false,
|
||||
hasDocumentTypeAndAccountSectionProfileSignal: () => false,
|
||||
hasCounterpartyPopulationAndRolesSignal: () => false,
|
||||
hasCounterpartyActivityLifecycleSignal: () => false,
|
||||
hasContractUsageOverviewSignal: () => false,
|
||||
hasOpenContractsListSignal: () => false,
|
||||
hasCustomerRevenueAndPaymentsSignal: () => false,
|
||||
hasSupplierPayoutsProfileSignal: () => false,
|
||||
hasContractUsageAndValueSignal: () => false,
|
||||
hasContractListByCounterpartySignal: () => false,
|
||||
hasBankOperationSignal: (text: string) => text.includes("банк"),
|
||||
hasDocumentSignal: (text: string) => text.includes("док"),
|
||||
hasLooseByAnchorMention: () => false,
|
||||
hasHeuristicCounterpartyAnchor: () => false,
|
||||
hasCounterpartyShipmentItemFlowSignal: (text: string) => text.includes("отгружал"),
|
||||
hasImplicitCounterpartyAnchorAroundDocs: () => false,
|
||||
hasGenericAddressLookupSignal: (text: string) => text.includes("покажи")
|
||||
};
|
||||
|
||||
describe("address counterparty utf8 regression", () => {
|
||||
it("classifies direct documents-by-counterparty wording with a loose human anchor", () => {
|
||||
const result = resolveCounterpartyAddressIntent("покажи все документы по чепурнову", utf8Deps);
|
||||
|
||||
expect(result?.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.reasons).toContain("documents_by_counterparty_signal_detected");
|
||||
});
|
||||
|
||||
it("keeps the main resolver in the supported contour for direct documents-by-counterparty wording", () => {
|
||||
const result = resolveAddressIntent("покажи все документы по чепурнову");
|
||||
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,13 @@ import { resolveInventoryAddressIntent } from "../src/services/addressInventoryI
|
||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||
|
||||
describe("addressInventoryIntentSignals", () => {
|
||||
it("classifies plain Russian stock wording from the agent replay as inventory on hand", () => {
|
||||
const result = resolveAddressIntent("какие остатки на складе на март 2021");
|
||||
|
||||
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result.reasons).toContain("inventory_on_hand_signal_detected");
|
||||
});
|
||||
|
||||
it("classifies warehouse snapshot wording through the extracted inventory owner", () => {
|
||||
const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15");
|
||||
|
||||
|
||||
@@ -12,6 +12,48 @@ vi.mock("../src/services/addressMcpClient", async () => {
|
||||
...actual,
|
||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
||||
};
|
||||
it("keeps plain March stock wording in the inventory contour and asks to choose the company", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2021-03-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 6490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Пуф арий",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
},
|
||||
{
|
||||
Period: "2021-03-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 34490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Диван трехместный",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Лайсвуд"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("какие остатки на складе на март 2021");
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
|
||||
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
|
||||
expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]);
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
@@ -143,4 +185,52 @@ describe("inventory organization scope grounding", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defers company clarification for item-focused inventory queries and grounds the company from observed rows", async () => {
|
||||
const item = "Столешница 600*3050*26 дуб ниагара";
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2019-02-11T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2019 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 3724.17,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: item,
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз МСК\\",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи документы по этой позиции", {
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"],
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item,
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||||
expect(result?.debug.reasons).toContain("organization_grounded_from_observed_rows");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -549,6 +549,61 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077");
|
||||
});
|
||||
|
||||
it("does not let carried counterparty scope steal selected-item document follow-up into open items", 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: 3690,
|
||||
SubcontoDt1: "Столешница 600*3050*26 альмандин",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи документы по этой позиции", {
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item: "Столешница 600*3050*26 альмандин",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
counterparty: "Альтернатива Плюс, ООО",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ООО \\Альтернатива Плюс\\",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
root_filters: {
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1");
|
||||
expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_documents_for_item");
|
||||
expect(result?.debug.reasons).not.toContain("open_items_from_followup_context");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003");
|
||||
});
|
||||
|
||||
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: "Подтвержден адресный ответ.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_SUMMARY",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
reasons: ["address_action_detected", "address_entity_detected"],
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
},
|
||||
...(overrides ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
function buildAddressLimitedLaneResult(overrides?: Record<string, unknown>): any {
|
||||
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",
|
||||
extracted_filters: {
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: null,
|
||||
limited_reason_category: "missing_anchor",
|
||||
organization_candidates: ['ООО "Альтернатива Плюс"', 'ООО "Лайсвуд"'],
|
||||
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
|
||||
},
|
||||
...(overrides ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent semantic inventory regressions", () => {
|
||||
it("continues the stock query after exact clarification phrase 'давай по Альтернативе Плюс'", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "какие остатки на складе на март 2021";
|
||||
const secondMessage = "давай по Альтернативе Плюс";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === firstMessage) {
|
||||
return buildAddressLimitedLaneResult();
|
||||
}
|
||||
if (
|
||||
message === secondMessage &&
|
||||
options?.activeOrganization === organization &&
|
||||
options?.followupContext?.previous_intent === "inventory_on_hand_as_of_date"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} 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 = `agent-semantic-org-${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("partial_coverage");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: secondMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].options?.activeOrganization).toBe(organization);
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores root stock slice after selected-object drilldown and does not rerun address lane for memory recap", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const restatementMessage = "покажи еще раз остатки на эту же дату";
|
||||
const recapMessage = "а что мы уже выяснили по этой позиции?";
|
||||
const item = "Столешница 600*3050*26 альмандин";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (
|
||||
message === restatementMessage &&
|
||||
options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" &&
|
||||
options?.followupContext?.root_filters?.as_of_date === "2021-03-31" &&
|
||||
options?.followupContext?.root_filters?.period_from === "2021-03-01" &&
|
||||
options?.followupContext?.root_filters?.period_to === "2021-03-31"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской остаток.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} 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 = `agent-semantic-inventory-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:37:39.000Z",
|
||||
trace_id: "address-root-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-provenance",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:37:52.000Z",
|
||||
trace_id: "address-provenance-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-docs",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} найден документ закупки.`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T16:38:16.000Z",
|
||||
trace_id: "address-docs-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_documents_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const restatement = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: restatementMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(restatement.ok).toBe(true);
|
||||
expect(restatement.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
|
||||
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?.root_filters?.as_of_date).toBe("2021-03-31");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
|
||||
|
||||
const recap = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: recapMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(recap.ok).toBe(true);
|
||||
expect(recap.reply_type).toBe("factual_with_explanation");
|
||||
expect(recap.debug?.tool_gate_reason).toBe("memory_recap_followup_detected");
|
||||
expect(recap.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract");
|
||||
expect(String(recap.assistant_reply ?? "")).toContain(item);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores inventory root for bare 'остатки по складу на эту же дату' after receivables drift", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const message = "остатки по складу на эту же дату";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (
|
||||
requestMessage === message &&
|
||||
options?.followupContext?.root_intent === "inventory_on_hand_as_of_date" &&
|
||||
options?.followupContext?.root_filters?.as_of_date === "2020-03-31"
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
as_of_date: "2020-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_restored_to_inventory_root_frame"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} 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 = `agent-semantic-same-date-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root-stock",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2020 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:20:00.000Z",
|
||||
trace_id: "address-root-stock-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
as_of_date: "2020-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-receivables",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Итого подтвержденная дебиторская задолженность на 31.03.2020: 15 404 897,08 ₽.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:25:00.000Z",
|
||||
trace_id: "address-receivables-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "receivables_confirmed_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
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_context_only).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps selected-object document follow-up in inventory contour after an intermediate capability chat turn", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const item = "Столешница 600*3050*26 альмандин";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
const message = `По выбранному объекту "${item}": покажи документы по этой позиции`;
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (
|
||||
requestMessage === message &&
|
||||
options?.followupContext?.previous_intent === "inventory_purchase_provenance_for_item" &&
|
||||
options?.followupContext?.previous_filters?.item === item &&
|
||||
options?.followupContext?.previous_filters?.organization === organization
|
||||
) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: `По позиции ${item} найден подтвержденный документ закупки.`,
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "intent_adjusted_to_inventory_followup_context"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} 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 = `agent-semantic-meta-docs-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-root-inventory",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:29:00.000Z",
|
||||
trace_id: "address-root-seed-2",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-item-provenance",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: `По позиции ${item} подтвержден поставщик: Торговый дом "Союз".`,
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T17:30:00.000Z",
|
||||
trace_id: "address-provenance-seed-2",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item,
|
||||
organization,
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: item,
|
||||
anchor_value_resolved: item,
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-capability-chat",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Могу работать с остатками, документами и взаиморасчетами.",
|
||||
reply_type: "factual_with_explanation",
|
||||
created_at: "2026-04-17T17:31:00.000Z",
|
||||
trace_id: "chat-capability-seed",
|
||||
debug: {
|
||||
execution_lane: "living_chat",
|
||||
living_chat_response_source: "deterministic_capability_contract"
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(item);
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses grounded organization from the last factual address answer instead of re-asking for company", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
const message = "какие остатки на складе на март 2021";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (requestMessage: string, options?: any) => {
|
||||
calls.push({ message: requestMessage, options });
|
||||
if (requestMessage === message && options?.activeOrganization === organization) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.03.2021 по ООО \"Альтернатива Плюс\" подтвержден складской срез.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
organization,
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
reasons: ["organization_scope_restored_from_grounded_address_history"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLimitedLaneResult();
|
||||
})
|
||||
} 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 = `agent-semantic-grounded-org-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-counterparty-seed",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "По Чапурнову подтверждены документы по ООО \"Альтернатива Плюс\".",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-17T18:05:00.000Z",
|
||||
trace_id: "address-counterparty-seed",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
counterparty: "Чапурнов",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
selected_recipe: "address_list_documents_by_counterparty_v1",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: message,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.activeOrganization).toBe(organization);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,10 @@ describe("assistantBoundaryPolicy", () => {
|
||||
organizations: ["ООО Альтернатива Плюс"]
|
||||
});
|
||||
|
||||
expect(reply).toContain("MCP-канале `finance`");
|
||||
expect(reply).toContain("Сейчас доступна организация");
|
||||
expect(reply).toContain("ООО Альтернатива Плюс");
|
||||
expect(reply.toLowerCase()).toContain("read-only");
|
||||
expect(reply).not.toContain("MCP");
|
||||
expect(reply.toLowerCase()).not.toContain("read-only");
|
||||
});
|
||||
|
||||
it("strips unexpected CJK fragments from live chat reply", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("assistant living chat mode", () => {
|
||||
const items = [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
||||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||
debug: {
|
||||
trace_id: "chat-org-scope",
|
||||
living_chat_data_scope_probe_status: "resolved",
|
||||
@@ -91,7 +91,7 @@ describe("assistant living chat mode", () => {
|
||||
message_id: "msg-seed-org-scope",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: "Сейчас в активном MCP-канале `default` доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ.",
|
||||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||||
reply_type: "factual_with_explanation",
|
||||
created_at: new Date().toISOString(),
|
||||
trace_id: "chat-seed-org-scope",
|
||||
@@ -780,7 +780,8 @@ describe("assistant living chat mode", () => {
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("организаций");
|
||||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("mcp");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
@@ -827,7 +828,8 @@ describe("assistant living chat mode", () => {
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("read-only");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("по какой смотреть данные");
|
||||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("read-only");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -42,6 +42,12 @@ describe("assistantLivingModePolicy", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects bare recap wording without 'помнишь' as memory signal", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true);
|
||||
});
|
||||
|
||||
it("routes casual small-talk to chat mode", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
|
||||
@@ -1169,6 +1169,55 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps exact open-items lookup in address lane even when semantic guard overflags deep investigation", () => {
|
||||
const rawUserMessage = "хвосты покажи по счету 60 на август 2022";
|
||||
const effectiveAddressUserMessage = "хвосты по счету 60 на август 2022";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
semanticHints: {
|
||||
exact_data_request_detected: true,
|
||||
account_scope_kind: "explicit",
|
||||
account_scope_text: "60",
|
||||
date_scope_kind: "explicit_period",
|
||||
date_scope_text: "август 2022"
|
||||
}
|
||||
});
|
||||
const semanticExtractionContract = buildAddressSemanticExtractionContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
predecomposeContract
|
||||
});
|
||||
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract,
|
||||
semanticExtractionContract: {
|
||||
...semanticExtractionContract,
|
||||
guard_hints: {
|
||||
...(semanticExtractionContract.guard_hints ?? {}),
|
||||
deep_investigation_signal_detected: true
|
||||
}
|
||||
}
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
|
||||
expect(
|
||||
decision.orchestrationContract?.semantic_route_arbitration?.exact_address_intent_protected_from_semantic_deep_hint
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
|
||||
|
||||
@@ -48,7 +48,19 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
strongDataSignal: false,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true
|
||||
hasPriorAddressDebug: true,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "list_documents_by_counterparty"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||
@@ -67,14 +79,60 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
strongDataSignal: true,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true
|
||||
hasPriorAddressDebug: true,
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("builds deterministic recap from prior selected object context", () => {
|
||||
it("does not trigger recap from ungrounded address history", () => {
|
||||
const signals = policy.resolveRouteMemorySignals({
|
||||
rawUserMessage: "а ты помнишь что мы обсуждали?",
|
||||
repairedRawUserMessage: "",
|
||||
effectiveAddressUserMessage: "",
|
||||
repairedEffectiveAddressUserMessage: "",
|
||||
dataScopeMetaQuery: false,
|
||||
capabilityMetaQuery: false,
|
||||
dataRetrievalSignal: false,
|
||||
strongDataSignal: false,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
lastGroundedAddressDebug: null,
|
||||
hasPriorAddressDebug: true,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(false);
|
||||
});
|
||||
|
||||
it("builds deterministic recap summary from recent selected-object facts", () => {
|
||||
const context = resolveAssistantLivingChatMemoryContext({
|
||||
modeDecisionReason: "memory_recap_followup_detected",
|
||||
sessionItems: [
|
||||
@@ -82,6 +140,9 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_resolved: "Рабочая станция",
|
||||
extracted_filters: {
|
||||
@@ -89,6 +150,34 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -96,6 +185,50 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
const reply = buildAddressMemoryRecapReply({
|
||||
organization: null,
|
||||
addressDebug: context.lastMemoryAddressDebug,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "inventory_purchase_documents_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
as_of_date: "2022-02-28"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
toNonEmptyString: (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.length > 0 ? text : null;
|
||||
@@ -104,6 +237,8 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
|
||||
expect(context.contextualMemoryRecapFollowup).toBe(true);
|
||||
expect(reply).toContain("Рабочая станция");
|
||||
expect(reply).toContain("28.02.2022");
|
||||
expect(reply).toContain("мы уже выяснили");
|
||||
expect(reply).toContain("разобрали, кто поставлял");
|
||||
expect(reply).toContain("подняли документы закупки");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,4 +294,27 @@ describe("assistantRoutePolicy", () => {
|
||||
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
||||
expect(decision.orchestrationContract?.provider_execution?.llm_runtime_unavailable_detected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify colloquial VAT root query as non-domain when L0 address gate is positive", () => {
|
||||
const policy = buildPolicy({
|
||||
hasStrongDataIntentSignal: () => true,
|
||||
resolveAddressToolGateDecision: () => ({
|
||||
runAddressLane: true,
|
||||
decision: "run_address_lane",
|
||||
reason: "address_signal_detected"
|
||||
})
|
||||
});
|
||||
|
||||
const decision = policy.resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
|
||||
effectiveAddressUserMessage: "скок ндс надо заплатить в налоговую на февраль 2017",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: true
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateReason).toBe("address_signal_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveDomainPolarityGuard } from "../src/services/assistantRuntimeGuards";
|
||||
|
||||
describe("assistant runtime guards utf8 regression", () => {
|
||||
it("resolves supplier polarity for declined russian account wording", () => {
|
||||
const guard = resolveDomainPolarityGuard({
|
||||
userMessage: "хвосты покажи по счёту 60 на август 2022",
|
||||
focusDomainHint: "settlements_60_62"
|
||||
});
|
||||
|
||||
expect(guard.outcome).toBe("passed");
|
||||
expect(guard.polarity).toBe("supplier_payable");
|
||||
expect(guard.account_scope).toContain("60");
|
||||
expect(guard.resolved_account_anchors).toContain("60");
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,29 @@ describe("assistantTransitionPolicy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes same-date inventory restatement after drilldown into root-scoped carryover", () => {
|
||||
const policy = buildPolicy({
|
||||
hasInventoryRootTemporalFollowupSignal: () => false
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"покажи еще раз остатки на эту же дату",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_root_context");
|
||||
expect(carryover?.followupContext?.root_context_only).toBe(true);
|
||||
expect(carryover?.followupContext?.previous_intent).toBeUndefined();
|
||||
expect(carryover?.followupContext?.previous_filters).toEqual({
|
||||
as_of_date: "2020-03-31",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
});
|
||||
|
||||
it("builds continuation contract from extracted root carryover", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
@@ -146,4 +169,143 @@ describe("assistantTransitionPolicy", () => {
|
||||
expect(contract.anchor_type).toBe("item");
|
||||
expect(contract.anchor_value).toBe("Рабочая станция");
|
||||
});
|
||||
it("prefers carryover target intent over llm contract drift in continuation contract", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
const contract = policy.buildAddressDialogContinuationContractV2(
|
||||
"покажи договор по гамме",
|
||||
"покажи договор по гамме",
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "customer_revenue_and_payments",
|
||||
target_intent: "list_contracts_by_counterparty",
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "Гамма-мебель, ООО"
|
||||
},
|
||||
previousSourceIntent: "customer_revenue_and_payments",
|
||||
previousAddressIntent: "customer_revenue_and_payments",
|
||||
followupSelectionMode: "carry_referenced_entity",
|
||||
hasImplicitContinuationSignal: false
|
||||
},
|
||||
{
|
||||
predecomposeContract: {
|
||||
intent: "unknown"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(contract.target_intent).toBe("list_contracts_by_counterparty");
|
||||
expect(contract.decision).toBe("continue_previous");
|
||||
});
|
||||
|
||||
it("drops stale carryover for a fresh standalone topic from another intent family", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Прогноз НДС на март 2020 собран.",
|
||||
debug: {
|
||||
detected_intent: "vat_payable_forecast",
|
||||
extracted_filters: {
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
}
|
||||
}
|
||||
}),
|
||||
hasAddressFollowupContextSignal: () => true,
|
||||
hasStandaloneAddressTopicSignal: () => true,
|
||||
resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date" }),
|
||||
resolveAddressIntentFamily: (intent: unknown) => {
|
||||
if (String(intent ?? "").startsWith("vat_")) return "vat";
|
||||
if (String(intent ?? "").startsWith("inventory_")) return "inventory";
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"остаток на складе за май 2020",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps document intent for short counterparty retarget wording with action verb", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Собран список документов по контрагенту Чапурнов.",
|
||||
debug: {
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
counterparty: "Чапурнов"
|
||||
},
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_resolved: "Чапурнов"
|
||||
}
|
||||
}),
|
||||
buildAddressFollowupOffer: () => ({
|
||||
enabled: true,
|
||||
source_intent: "list_documents_by_counterparty",
|
||||
suggested_intents: ["bank_operations_by_counterparty"]
|
||||
}),
|
||||
isImplicitAddressContinuationByLlm: () => true
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext("покажи по свк", [], null, null, null);
|
||||
|
||||
expect(carryover?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_previous_intent");
|
||||
});
|
||||
it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
text: "Собран sale trace по позиции.",
|
||||
debug: {
|
||||
detected_intent: "inventory_sale_trace_for_item",
|
||||
extracted_filters: {
|
||||
item: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2021-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_resolved: "Кромка с клеем 33 дуб ниагара 137 м"
|
||||
}
|
||||
}),
|
||||
hasAddressFollowupContextSignal: () => true,
|
||||
resolveAddressIntent: () => ({ intent: "vat_payable_confirmed_as_of_date" }),
|
||||
resolveAddressIntentFamily: (intent: unknown) => {
|
||||
if (String(intent ?? "").startsWith("vat_")) return "vat";
|
||||
if (String(intent ?? "").startsWith("inventory_")) return "inventory";
|
||||
return null;
|
||||
},
|
||||
hasForeignAccountingPivotOverInventoryMessage: () => true,
|
||||
findRecentInventoryRootFrame: () => ({
|
||||
intent: "inventory_on_hand_as_of_date",
|
||||
filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
},
|
||||
anchorType: "organization",
|
||||
anchorValue: 'ООО "Альтернатива Плюс"'
|
||||
})
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext("а ндс?", [], null, null, null);
|
||||
|
||||
expect(carryover?.followupSelectionMode).toBe("carry_root_context");
|
||||
expect(carryover?.followupContext?.root_context_only).toBe(true);
|
||||
expect(carryover?.followupContext?.previous_intent).toBeUndefined();
|
||||
expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(carryover?.followupContext?.previous_filters).toEqual({
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
warehouse: "Основной склад",
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
|
||||
|
||||
describe("counterparty analytics reply builders", () => {
|
||||
it("keeps counterparty role split answers business-first", () => {
|
||||
const reply = composeFactualReply(
|
||||
"counterparty_population_and_roles",
|
||||
[
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_TOTAL",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 30,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 12,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_SUPPLIER_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 9,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CP_MIXED_ACTIVE",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 4,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{ userMessage: "покажи роли контрагентов" }
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Распределение ролей по активности:");
|
||||
expect(reply.text).not.toContain("supplier-роль");
|
||||
expect(reply.text).not.toContain("customer-роль");
|
||||
});
|
||||
|
||||
it("formats value rankings without technical 'max single' label", () => {
|
||||
const reply = composeFactualReply(
|
||||
"customer_revenue_and_payments",
|
||||
[
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "Поступление 1",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 500,
|
||||
analytics: ["Клиент А", "Договор А-1"]
|
||||
},
|
||||
{
|
||||
period: "2020-03-02T00:00:00Z",
|
||||
registrator: "Поступление 2",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 1200,
|
||||
analytics: ["Клиент Б", "Договор Б-1"]
|
||||
}
|
||||
],
|
||||
{ userMessage: "с каких клиенктов самый высокий чек" }
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_LIST");
|
||||
expect(reply.text).toContain("максимальной сумме одной входящей операции");
|
||||
expect(reply.text).toContain("максимальная разовая сумма");
|
||||
expect(reply.text).not.toContain("max single");
|
||||
});
|
||||
|
||||
it("explains organization activity age as 1C activity rather than legal age", () => {
|
||||
const reply = composeFactualReply(
|
||||
"counterparty_activity_lifecycle",
|
||||
[
|
||||
{
|
||||
period: "2020-01-15T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVITY",
|
||||
account_dt: "62.01",
|
||||
account_kt: "90.01",
|
||||
amount: 12,
|
||||
analytics: ['ООО "Ромашка"']
|
||||
},
|
||||
{
|
||||
period: "2024-03-10T00:00:00Z",
|
||||
registrator: "CP_CUSTOMER_ACTIVITY",
|
||||
account_dt: "62.01",
|
||||
account_kt: "90.01",
|
||||
amount: 4,
|
||||
analytics: ['ООО "Ландыш"']
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "сколько лет активности в базе 1с у нашей компании",
|
||||
organizationHint: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain('По активности организации ООО "Альтернатива Плюс" в базе 1С');
|
||||
expect(reply.text).toContain("Это возраст активности организации в 1С");
|
||||
});
|
||||
|
||||
it("renders contract usage overview with explicit confirmed wording", () => {
|
||||
const reply = composeFactualReply("contract_usage_overview", [
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CT_TOTAL",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 520,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2000-01-01T00:00:00Z",
|
||||
registrator: "CT_USED",
|
||||
account_dt: "",
|
||||
account_kt: "",
|
||||
amount: 148,
|
||||
analytics: []
|
||||
}
|
||||
]);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Профиль договорной базы собран по справочнику и подтвержденным операциям.");
|
||||
expect(reply.text).toContain("Использованных договоров с подтвержденной связью с операциями: 148.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user