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(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 }) => item.route === "store_feature_risk")).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(/risk_score|Counterparty|Почему|попало|why/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(/Counterparty|closure_risk|relation_patterns/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"]).toContain(String(response.body.debug?.answer_grounding_check?.status)); expect(response.body.reply_type).toBe("partial_coverage"); }); it("blocks answer when critical domain token is not grounded", 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(response.body.reply_type).toBe("route_mismatch_blocked"); expect(response.body.debug?.answer_grounding_check?.status).toBe("route_mismatch_blocked"); expect(response.body.debug?.answer_grounding_check?.route_subject_match).toBe(false); 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 }).summary ?? {}; const secondSummary = (secondHybrid as { summary?: Record }).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; const secondProfile = secondSummary.semantic_profile as Record; 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); }); });