NODEDC_1C/llm_normalizer/backend/tests/assistantEndpoint.test.ts

286 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?.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.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);
});
});