319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import request from "supertest";
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
|
||
const FLAG_KEYS = [
|
||
"FEATURE_ASSISTANT_INVESTIGATION_STATE_V1",
|
||
"FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1",
|
||
"FEATURE_ASSISTANT_CONTRACTS_V11",
|
||
"FEATURE_ASSISTANT_PROBLEM_UNITS_V1",
|
||
"FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1",
|
||
"FEATURE_ASSISTANT_ANSWER_POLICY_V11",
|
||
"FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1"
|
||
] as const;
|
||
|
||
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
|
||
FLAG_KEYS.map((key) => [key, process.env[key]])
|
||
);
|
||
|
||
function restoreFlags(): void {
|
||
for (const key of FLAG_KEYS) {
|
||
const original = ORIGINAL_FLAGS[key];
|
||
if (original === undefined) {
|
||
delete process.env[key];
|
||
} else {
|
||
process.env[key] = original;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function createAppWithFlags(flags: {
|
||
state: "0" | "1";
|
||
binding: "0" | "1";
|
||
contracts?: "0" | "1";
|
||
problemUnits?: "0" | "1";
|
||
continuity?: "0" | "1";
|
||
answerPolicy?: "0" | "1";
|
||
problemCentric?: "0" | "1";
|
||
}) {
|
||
process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = flags.state;
|
||
process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = flags.binding;
|
||
process.env.FEATURE_ASSISTANT_CONTRACTS_V11 = flags.contracts ?? "1";
|
||
process.env.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = flags.problemUnits ?? "0";
|
||
process.env.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = flags.continuity ?? "0";
|
||
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = flags.answerPolicy ?? "0";
|
||
process.env.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = flags.problemCentric ?? "0";
|
||
vi.resetModules();
|
||
const { createApp } = await import("../src/server");
|
||
return createApp();
|
||
}
|
||
|
||
describe.sequential("assistant follow-up state binding", () => {
|
||
afterEach(() => {
|
||
restoreFlags();
|
||
vi.resetModules();
|
||
});
|
||
|
||
it("applies investigation_state binding in follow-up flow when flags are ON", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1"
|
||
});
|
||
const sessionId = `asst-wave2-on-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Разложи цепочку документов по контрагентам."
|
||
});
|
||
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.debug?.followup_state_usage).toBeUndefined();
|
||
expect(first.body.debug?.investigation_state_snapshot?.turn_index).toBe(1);
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "И по периоду 2020-06."
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
|
||
expect(second.body.debug?.followup_state_usage?.context_patch?.business_context_from_state).toBe(true);
|
||
expect(second.body.debug?.followup_state_usage?.state_turn_index).toBe(1);
|
||
expect(
|
||
(second.body.debug?.routes ?? []).some((item: { route?: string }) => item.route && item.route !== "no_route")
|
||
).toBe(true);
|
||
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
|
||
});
|
||
|
||
it("does not apply follow-up binding when binding flag is OFF", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "0"
|
||
});
|
||
const sessionId = `asst-wave2-off-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Разложи цепочку документов по контрагентам."
|
||
});
|
||
expect(first.status).toBe(200);
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "И по периоду 2020-06."
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.debug?.followup_state_usage).toBeUndefined();
|
||
expect((second.body.debug?.routes ?? []).every((item: { route?: string }) => item.route === "no_route")).toBe(true);
|
||
});
|
||
|
||
it("keeps legacy-like behavior when investigation state flag is OFF", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "0",
|
||
binding: "1"
|
||
});
|
||
|
||
const response = await request(app).post("/api/assistant/message").send({
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "И по периоду 2020-06."
|
||
});
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(response.body.debug?.investigation_state_snapshot).toBeNull();
|
||
expect(response.body.debug?.followup_state_usage).toBeUndefined();
|
||
});
|
||
|
||
it("applies problem continuity hints only when continuity flag is ON and follow-up has no strong new anchors", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1",
|
||
problemUnits: "1",
|
||
continuity: "1"
|
||
});
|
||
const sessionId = `asst-wave4-problem-continuity-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Разложи цепочку документов и оплат по контрагентам за 2020-06, где разрыв механизма закрытия."
|
||
});
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.debug?.investigation_state_snapshot?.problem_unit_state).toBeTruthy();
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "И по тому же разрыву добавь уточнение."
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
|
||
expect(second.body.debug?.followup_state_usage?.context_patch?.problem_continuity_available).toBe(true);
|
||
expect(second.body.debug?.followup_state_usage?.context_patch?.problem_continuity_applied).toBe(true);
|
||
expect(second.body.debug?.followup_state_usage?.context_patch?.strong_new_anchor_detected).toBe(false);
|
||
});
|
||
|
||
it("does not apply follow-up continuity when user gives strong new anchors", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1",
|
||
problemUnits: "1",
|
||
continuity: "1"
|
||
});
|
||
const sessionId = `asst-wave4-strong-anchor-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Разбери хвосты по счету 60 за 2020-06 и покажи проблемные цепочки."
|
||
});
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.debug?.investigation_state_snapshot?.turn_index).toBe(1);
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "И отдельно по счету 97 за 2020-07."
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.debug?.followup_state_usage).toBeUndefined();
|
||
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
|
||
});
|
||
|
||
it("isolates scope for independent cross-domain turn and does not carry stale period/domain", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1",
|
||
problemUnits: "1",
|
||
continuity: "1",
|
||
answerPolicy: "1",
|
||
problemCentric: "1"
|
||
});
|
||
const sessionId = `asst-wave17-scope-isolation-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Проверь хвосты по счету 60.01 за 2020-06 и почему не закрылся долг."
|
||
});
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.debug?.investigation_state_snapshot?.question_scope_id).toContain("d:settlements_60_62");
|
||
expect(first.body.debug?.investigation_state_snapshot?.scope_origin).toBe("explicit_from_message");
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Проверь НДС по счету-фактуре: где разрыв в цепочке документ -> регистр -> книга?"
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.debug?.followup_state_usage).toBeUndefined();
|
||
expect(second.body.debug?.investigation_state_snapshot?.scope_origin).toBe("explicit_from_message");
|
||
expect(String(second.body.debug?.investigation_state_snapshot?.question_scope_id ?? "")).toContain(
|
||
"d:vat_document_register_book"
|
||
);
|
||
expect(second.body.debug?.investigation_state_snapshot?.focus?.period).not.toBe("2020-06");
|
||
expect(String(second.body.debug?.investigation_state_snapshot?.focus?.domain ?? "")).not.toContain("settlements_60_62");
|
||
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.question_scope_id).toBeTruthy();
|
||
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.scope_origin).toBe("explicit_from_message");
|
||
});
|
||
|
||
it("rebinds follow-up domain away from settlements on fixed-asset amortization query", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1",
|
||
problemUnits: "1",
|
||
continuity: "1",
|
||
answerPolicy: "1",
|
||
problemCentric: "1"
|
||
});
|
||
const sessionId = `asst-wave16-fa-domain-${Date.now()}`;
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Почему деньги ушли, а долг по 60.01/62.02 остался?"
|
||
});
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.debug?.investigation_state_snapshot?.followup_context?.active_domain).toBe("settlements_60_62");
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: sessionId,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message:
|
||
"Полно ли начислена амортизация по объектам ОС за июль? Проверь по 01/02, нет ли пропущенных объектов."
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
const activeDomain = String(second.body.debug?.investigation_state_snapshot?.followup_context?.active_domain ?? "");
|
||
expect(activeDomain).not.toBe("settlements_60_62");
|
||
expect(activeDomain).toMatch(/fixed_asset_amortization|month_close_costs_20_44|no_route|hybrid_store_plus_live|fixed_asset/i);
|
||
|
||
const settlementActions = second.body.debug?.investigation_state_snapshot?.followup_context?.settlement_next_actions;
|
||
expect(Array.isArray(settlementActions) ? settlementActions.length : 0).toBe(0);
|
||
});
|
||
|
||
it("keeps UTF-8 follow-up period refinement in-scope with soft continuity hints", async () => {
|
||
const app = await createAppWithFlags({
|
||
state: "1",
|
||
binding: "1",
|
||
problemUnits: "1",
|
||
continuity: "1",
|
||
answerPolicy: "1",
|
||
problemCentric: "1"
|
||
});
|
||
|
||
const first = await request(app).post("/api/assistant/message").send({
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Посмотри, пожалуйста, где по поставщикам сейчас хвосты уже похожи именно на проблему, а не просто на шум."
|
||
});
|
||
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.reply_type).not.toBe("out_of_scope");
|
||
|
||
const second = await request(app).post("/api/assistant/message").send({
|
||
session_id: first.body.session_id,
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "А если только за июнь 2020 смотреть, по кому это сильнее всего видно и что из этого реально может мешать закрытию?"
|
||
});
|
||
|
||
expect(second.status).toBe(200);
|
||
expect(second.body.reply_type).not.toBe("out_of_scope");
|
||
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
|
||
expect(typeof second.body.debug?.followup_state_usage?.context_patch?.problem_continuity_applied).toBe("boolean");
|
||
expect(second.body.debug?.followup_state_usage?.context_patch?.strong_new_anchor_detected).toBe(false);
|
||
expect(
|
||
(second.body.debug?.routes ?? []).some((item: { route?: string }) => item.route && item.route !== "no_route")
|
||
).toBe(true);
|
||
|
||
const third = await request(app).post("/api/assistant/message").send({
|
||
useMock: true,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
user_message: "Проверь, пожалуйста, по 60-му счёту за июнь 2020, где есть самый явный проблемный участок по расчётам с поставщиками."
|
||
});
|
||
|
||
expect(third.status).toBe(200);
|
||
expect(third.body.reply_type).not.toBe("out_of_scope");
|
||
});
|
||
});
|