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("recovers company-specific scope for settlement follow-up without explicit month anchor", async () => { const app = createApp(); const sessionId = `asst-scope-${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 остался хвост. Проверь закрытие по договору и объекту расчетов." }); 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: "Это по тому же договору: закрытие подтверждено или хвост все еще живет?" }); expect(second.status).toBe(200); const debug = second.body.debug ?? {}; expect(Array.isArray(debug.business_scope_resolved)).toBe(true); expect(debug.business_scope_resolved).toContain("company_specific_accounting"); expect(Array.isArray(debug.scope_resolution_reason)).toBe(true); expect(debug.scope_resolution_reason).toContain("settlement_claim_company_scope_recovery"); expect(["resolved_supplier", "resolved_customer", "mixed", "unknown", "not_applicable"]).toContain( String(debug.polarity_resolution_status ?? "") ); }); 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.length).toBeGreaterThan(0); expect( response.body.debug?.routes.every((item: { route?: string }) => item.route === "hybrid_store_plus_live") ).toBe(true); expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true); expect( response.body.debug?.retrieval_results.some( (item: { status?: string; route?: string }) => item.status === "ok" && item.route === "hybrid_store_plus_live" ) ).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 }).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); }); });