289 lines
14 KiB
TypeScript
289 lines
14 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import request from "supertest";
|
|
import { describe, expect, it } from "vitest";
|
|
import { ASSISTANT_SESSIONS_DIR } from "../src/config";
|
|
import { createApp } from "../src/server";
|
|
|
|
describe("assistant mode API", () => {
|
|
it("processes message and returns assistant response with debug payload", async () => {
|
|
const app = createApp();
|
|
const response = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Prover schet 97 i podsveti riskovye zony."
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.ok).toBe(true);
|
|
expect(typeof response.body.session_id).toBe("string");
|
|
expect(typeof response.body.assistant_reply).toBe("string");
|
|
expect(typeof response.body.reply_type).toBe("string");
|
|
expect(response.body.conversation_item?.role).toBe("assistant");
|
|
expect(response.body.conversation_item?.reply_type).toBe(response.body.reply_type);
|
|
expect(response.body.debug?.trace_id).toBeTypeOf("string");
|
|
expect(Array.isArray(response.body.debug?.routes)).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.requirements_extracted)).toBe(true);
|
|
expect(typeof response.body.debug?.coverage_report?.requirements_total).toBe("number");
|
|
expect(typeof response.body.debug?.answer_grounding_check?.status).toBe("string");
|
|
expect(Array.isArray(response.body.debug?.retrieval_status)).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true);
|
|
expect(typeof response.body.debug?.temporal_guard_applied).toBe("boolean");
|
|
expect(typeof response.body.debug?.temporal_guard_outcome).toBe("string");
|
|
expect(typeof response.body.debug?.temporal_alignment_status).toBe("string");
|
|
expect(typeof response.body.debug?.temporal_guard_basis).toBe("string");
|
|
expect(response.body.debug).toHaveProperty("effective_primary_period");
|
|
expect(response.body.debug).toHaveProperty("temporal_guard_input");
|
|
expect(response.body.debug?.domain_polarity_guard).toBeTruthy();
|
|
expect(Array.isArray(response.body.debug?.raw_numeric_tokens)).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.classified_numeric_tokens)).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.rejected_as_non_accounts)).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.resolved_account_anchors)).toBe(true);
|
|
expect(response.body.debug?.evidence_admissibility_gate).toBeTruthy();
|
|
expect(response.body.debug?.grounded_answer_eligibility_guard).toBeTruthy();
|
|
expect(typeof response.body.debug?.eligibility_time_basis).toBe("string");
|
|
expect(typeof response.body.debug?.grounded_answer_eligibility_guard?.eligibility_time_basis).toBe("string");
|
|
expect(typeof response.body.debug?.grounded_answer_eligibility_guard?.business_scope_passed).toBe("boolean");
|
|
expect(Array.isArray(response.body.debug?.company_scope_resolution_reason ?? [])).toBe(true);
|
|
expect(Array.isArray(response.body.conversation)).toBe(true);
|
|
expect(response.body.conversation.length).toBe(2);
|
|
});
|
|
|
|
it("keeps session-scoped history and returns it via session endpoint", async () => {
|
|
const app = createApp();
|
|
const first = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Sdelai proverku po postavshchikam."
|
|
});
|
|
expect(first.status).toBe(200);
|
|
const sessionId = String(first.body.session_id);
|
|
|
|
const second = await request(app).post("/api/assistant/message").send({
|
|
session_id: sessionId,
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Dobav proverku po periodu 2020-06."
|
|
});
|
|
expect(second.status).toBe(200);
|
|
expect(second.body.session_id).toBe(sessionId);
|
|
|
|
const session = await request(app).get(`/api/assistant/session/${sessionId}`);
|
|
expect(session.status).toBe(200);
|
|
expect(session.body.ok).toBe(true);
|
|
expect(session.body.session?.session_id).toBe(sessionId);
|
|
expect(Array.isArray(session.body.session?.items)).toBe(true);
|
|
expect(session.body.session.items.length).toBe(4);
|
|
});
|
|
|
|
it("executes factual retrieval for routed fragments", async () => {
|
|
const app = createApp();
|
|
|
|
const riskResponse = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Проверь НДС и рискованные записи по документам."
|
|
});
|
|
|
|
expect(riskResponse.status).toBe(200);
|
|
expect(Array.isArray(riskResponse.body.debug?.retrieval_results)).toBe(true);
|
|
expect(riskResponse.body.debug.retrieval_results.length).toBeGreaterThan(0);
|
|
expect(
|
|
riskResponse.body.debug.retrieval_results.some((item: { route?: string }) =>
|
|
["store_feature_risk", "hybrid_store_plus_live"].includes(String(item.route ?? ""))
|
|
)
|
|
).toBe(true);
|
|
expect(riskResponse.body.debug.retrieval_results.some((item: { status?: string }) => item.status === "ok")).toBe(true);
|
|
expect(typeof riskResponse.body.reply_type).toBe("string");
|
|
expect(["factual_with_explanation", "partial_coverage"]).toContain(riskResponse.body.reply_type);
|
|
expect(String(riskResponse.body.assistant_reply)).toMatch(/Коротко|Почему|проблем|сигнал/i);
|
|
expect(String(riskResponse.body.assistant_reply)).not.toMatch(/graph traversal mode|domain\/document\/relation|account_scope|relation_patterns/i);
|
|
|
|
const chainResponse = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Разложи цепочку документов и оплат по контрагентам."
|
|
});
|
|
|
|
expect(chainResponse.status).toBe(200);
|
|
expect(Array.isArray(chainResponse.body.debug?.retrieval_results)).toBe(true);
|
|
expect(chainResponse.body.debug.retrieval_results.some((item: { route?: string }) => item.route === "hybrid_store_plus_live")).toBe(true);
|
|
const answerStructure = chainResponse.body.debug?.answer_structure_v11;
|
|
const evidenceBlock = answerStructure?.evidence_block;
|
|
if (Array.isArray(evidenceBlock?.evidence_ids) && evidenceBlock.evidence_ids.length > 0) {
|
|
expect(Array.isArray(evidenceBlock.claim_evidence_links)).toBe(true);
|
|
expect(typeof evidenceBlock.claim_evidence_links[0]?.claim_ref).toBe("string");
|
|
expect(Array.isArray(evidenceBlock.claim_evidence_links[0]?.evidence_ids)).toBe(true);
|
|
}
|
|
expect(String(chainResponse.body.assistant_reply)).toMatch(/Коротко|разрыв|связан|переход/i);
|
|
expect(String(chainResponse.body.assistant_reply)).not.toMatch(/graph traversal mode|domain\/document\/relation|account_scope|relation_patterns|closure_risk/i);
|
|
});
|
|
|
|
it("keeps in-domain translit queries in scope and routed", async () => {
|
|
const app = createApp();
|
|
|
|
const response = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Prover schet 60 za 2020-06, gde taili postavshikov i kakie dokumenty ne zakryvayut oplaty."
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.reply_type).not.toBe("out_of_scope");
|
|
expect(response.body.debug?.route_summary?.message_in_scope).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.routes)).toBe(true);
|
|
expect(response.body.debug?.routes.some((item: { route?: string }) => item.route !== "no_route")).toBe(true);
|
|
expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true);
|
|
expect(response.body.debug?.retrieval_results.some((item: { status?: string }) => item.status === "ok")).toBe(true);
|
|
});
|
|
|
|
it("avoids false route mismatch when supported evidence exists for bounded answer", async () => {
|
|
const app = createApp();
|
|
|
|
const response = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message:
|
|
"Покажи хвосты поставщиков по счету 60 за 2020-06 и выдели, где проблема уже похожа на разрыв цепочки документов."
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.reply_type).not.toBe("route_mismatch_blocked");
|
|
expect(response.body.debug?.answer_grounding_check?.status).not.toBe("route_mismatch_blocked");
|
|
expect(["partial", "grounded", "no_grounded_answer"]).toContain(String(response.body.debug?.answer_grounding_check?.status));
|
|
expect(["partial_coverage", "factual_with_explanation"]).toContain(String(response.body.reply_type));
|
|
});
|
|
|
|
it("returns bounded answer when critical domain token has weak grounding", async () => {
|
|
const app = createApp();
|
|
|
|
const response = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Проверь основные средства и рискованные записи."
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(["partial_coverage", "clarification_required", "route_mismatch_blocked", "factual_with_explanation"]).toContain(
|
|
String(response.body.reply_type)
|
|
);
|
|
expect(["partial", "grounded", "route_mismatch_blocked", "no_grounded_answer"]).toContain(
|
|
String(response.body.debug?.answer_grounding_check?.status)
|
|
);
|
|
expect(typeof response.body.debug?.answer_grounding_check?.route_subject_match).toBe("boolean");
|
|
expect(Array.isArray(response.body.debug?.answer_grounding_check?.reasons)).toBe(true);
|
|
expect(String(response.body.assistant_reply).length).toBeGreaterThan(20);
|
|
});
|
|
|
|
it("applies semantic narrowing profile for hybrid retrieval without GUID", async () => {
|
|
const app = createApp();
|
|
|
|
const first = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Разложи цепочку по 51 и 60 счетам: где закрытие не тем документом."
|
|
});
|
|
expect(first.status).toBe(200);
|
|
|
|
const second = await request(app).post("/api/assistant/message").send({
|
|
useMock: true,
|
|
promptVersion: "normalizer_v2_0_2",
|
|
user_message: "Разложи цепочку по банку: где выписка, документ и проводка живут отдельно и повторяется паттерн."
|
|
});
|
|
expect(second.status).toBe(200);
|
|
|
|
const firstHybrid = (first.body.debug?.retrieval_results ?? []).find((item: { route?: string }) => item.route === "hybrid_store_plus_live");
|
|
const secondHybrid = (second.body.debug?.retrieval_results ?? []).find((item: { route?: string }) => item.route === "hybrid_store_plus_live");
|
|
|
|
expect(firstHybrid).toBeTruthy();
|
|
expect(secondHybrid).toBeTruthy();
|
|
|
|
const firstSummary = (firstHybrid as { summary?: Record<string, unknown> }).summary ?? {};
|
|
const secondSummary = (secondHybrid as { summary?: Record<string, unknown> }).summary ?? {};
|
|
|
|
expect(firstSummary.semantic_narrowing_applied).toBe(true);
|
|
expect(typeof firstSummary.source_records).toBe("number");
|
|
expect(typeof firstSummary.filtered_records_after_narrowing).toBe("number");
|
|
expect(Number(firstSummary.filtered_records_after_narrowing)).toBeLessThan(Number(firstSummary.source_records));
|
|
|
|
const firstProfile = firstSummary.semantic_profile as Record<string, unknown>;
|
|
const secondProfile = secondSummary.semantic_profile as Record<string, unknown>;
|
|
expect(firstProfile).toBeTruthy();
|
|
expect(secondProfile).toBeTruthy();
|
|
|
|
expect(Array.isArray(firstProfile.account_scope)).toBe(true);
|
|
expect((firstProfile.account_scope as string[]).includes("51")).toBe(true);
|
|
expect((firstProfile.account_scope as string[]).includes("60")).toBe(true);
|
|
|
|
expect(Array.isArray(firstProfile.anomaly_patterns)).toBe(true);
|
|
expect(Array.isArray(secondProfile.anomaly_patterns)).toBe(true);
|
|
expect((firstProfile.anomaly_patterns as string[]).includes("wrong_document_type")).toBe(true);
|
|
expect((secondProfile.anomaly_patterns as string[]).includes("repeated_anomaly")).toBe(true);
|
|
});
|
|
|
|
it("writes one persistent JSON log file per session", async () => {
|
|
const app = createApp();
|
|
const sessionId = `asst-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
|
|
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",
|
|
message: "Разложи цепочку документов по контрагентам."
|
|
});
|
|
expect(second.status).toBe(200);
|
|
|
|
const logPath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
|
|
expect(fs.existsSync(logPath)).toBe(true);
|
|
|
|
const logPayload = JSON.parse(fs.readFileSync(logPath, "utf-8")) as {
|
|
schema_version: string;
|
|
session_id: string;
|
|
counters: {
|
|
total_messages: number;
|
|
user_messages: number;
|
|
assistant_messages: number;
|
|
};
|
|
turns: Array<{
|
|
human_block: string;
|
|
human_readable: {
|
|
question_raw: string;
|
|
question_understood: string;
|
|
decomposition: string[];
|
|
answer: string;
|
|
};
|
|
}>;
|
|
conversation: unknown[];
|
|
};
|
|
|
|
expect(logPayload.schema_version).toBe("assistant_session_log_v1");
|
|
expect(logPayload.session_id).toBe(sessionId);
|
|
expect(logPayload.counters.total_messages).toBe(4);
|
|
expect(logPayload.counters.user_messages).toBe(2);
|
|
expect(logPayload.counters.assistant_messages).toBe(2);
|
|
expect(Array.isArray(logPayload.turns)).toBe(true);
|
|
expect(logPayload.turns.length).toBe(2);
|
|
expect(logPayload.turns[0].human_block).toContain("Вопрос:");
|
|
expect(logPayload.turns[0].human_block).toContain("Понято как:");
|
|
expect(logPayload.turns[0].human_block).toContain("Декомпозиция:");
|
|
expect(logPayload.turns[0].human_block).toContain("Ответ:");
|
|
expect(Array.isArray(logPayload.turns[0].human_readable.decomposition)).toBe(true);
|
|
expect(Array.isArray(logPayload.conversation)).toBe(true);
|
|
expect(logPayload.conversation.length).toBe(4);
|
|
|
|
const sameSessionFiles = fs.readdirSync(ASSISTANT_SESSIONS_DIR).filter((item) => item === `${sessionId}.json`);
|
|
expect(sameSessionFiles.length).toBe(1);
|
|
|
|
fs.unlinkSync(logPath);
|
|
});
|
|
});
|
|
|