import { describe, expect, it, vi } from "vitest"; import { NormalizerService } from "../src/services/normalizerService"; import type { OpenAIResponsesClient } from "../src/services/openaiResponsesClient"; import type { NormalizedQueryV2_0_2 } from "../src/types/normalizer"; function makeService(outputText: string) { const normalizeMock = vi.fn().mockResolvedValue({ raw: { mode: "stubbed" }, outputText, usage: { input_tokens: 11, output_tokens: 22, total_tokens: 33 } }); const openaiClient = { normalize: normalizeMock } as unknown as OpenAIResponsesClient; return { service: new NormalizerService(openaiClient), normalizeMock }; } describe("NormalizerService coercion layer", () => { it("coerces malformed v2.0.2 local payload into a usable routed fragment", async () => { const malformed = { schema_version: "normalized_query_v2_0_2", global_notes: { needs_clarification: true, clarification_reason: "insufficient_specificity" }, fragments: [ { fragment_id: 1, raw_fragment_text: "свк доки за 20год покеж", normalized_fragment_text: "показать документы за 20 год", domain_relevance: true, business_scope: "document_review", entity_hints: [], account_hints: [], document_hints: ["документы"], register_hints: [], time_scope: { period_type: "year", year: 2020, month: null }, flags: {}, candidate_labels: ["show_documents"], confidence: 0.85, execution_readiness: "needs_clarification", clarification_reason: "insufficient_specificity", soft_assumption_used: [], route_status: "no_route", no_route_reason: "insufficient_specificity" } ] }; const { service, normalizeMock } = makeService(JSON.stringify(malformed)); const response = await service.normalize({ llmProvider: "local", model: "qwen2.5-14b-instruct-1m", baseUrl: "http://127.0.0.1:1234/v1", promptVersion: "normalizer_v2_0_2", userQuestion: "свк доки за 20год покеж", retryPolicy: "single-pass-strict" }); expect(normalizeMock).toHaveBeenCalledTimes(1); expect(response.ok).toBe(true); expect(response.normalized?.schema_version).toBe("normalized_query_v2_0_2"); const normalized = response.normalized as NormalizedQueryV2_0_2; expect(normalized.fragments).toHaveLength(1); expect(normalized.fragments[0]).toMatchObject({ fragment_id: "F1", domain_relevance: "in_scope", business_scope: "company_specific_accounting", confidence: "high", route_status: "routed", no_route_reason: null }); expect(normalized.fragments[0].candidate_labels).toContain("simple_factual"); expect(normalized.fragments[0].time_scope).toMatchObject({ type: "explicit", value: "2020" }); }); it("coerces period_type month payload into explicit YYYY-MM time scope", async () => { const malformed = { schema_version: "normalized_query_v2_0_2", fragments: [ { fragment_id: "frag_main", raw_fragment_text: "Какой остаток по счету 60 на 2020 май", normalized_fragment_text: "остаток по счету 60 на май 2020", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: ["счет"], account_hints: ["60"], document_hints: [], register_hints: ["остаток"], time_scope: { period_type: "month", year: 2020, month: 5 }, flags: { asks_for_period_summary: false }, candidate_labels: ["simple_factual"], confidence: 0.67 } ] }; const { service } = makeService(JSON.stringify(malformed)); const response = await service.normalize({ llmProvider: "local", model: "qwen2.5-14b-instruct-1m", baseUrl: "http://127.0.0.1:1234/v1", promptVersion: "normalizer_v2_0_2", userQuestion: "Какой остаток по счету 60 на 2020 май", retryPolicy: "single-pass-strict" }); expect(response.ok).toBe(true); const normalized = response.normalized as NormalizedQueryV2_0_2; expect(normalized.fragments).toHaveLength(1); expect(normalized.fragments[0].time_scope).toMatchObject({ type: "explicit", value: "2020-05" }); expect(normalized.fragments[0].route_status).toBe("routed"); }); });