/** * Tests Vitest pour server/aiClassifier.ts * * Ces tests vérifient le comportement du module de classification IA : * - Fallback sur les règles en cas d'erreur LLM * - Résultat "hors périmètre" quand pertinent = false * - Classification correcte quand les deux étapes réussissent */ import { describe, it, expect, vi, beforeEach } from "vitest"; // ─── Mock du module LLM ─────────────────────────────────────────────────────── vi.mock("./_core/llm", () => ({ invokeLLM: vi.fn(), })); import { invokeLLM } from "./_core/llm"; import { classifyArticle } from "./aiClassifier"; const mockInvokeLLM = vi.mocked(invokeLLM); // ─── Helpers ────────────────────────────────────────────────────────────────── function makeLLMResponse(content: string) { return { id: "test", created: Date.now(), model: "test", choices: [ { index: 0, message: { role: "assistant" as const, content }, finish_reason: "stop", }, ], }; } const fallbackFn = () => ({ categorie: "Autre" as const, typeVeille: "generale" as const, }); // ─── Tests ──────────────────────────────────────────────────────────────────── describe("classifyArticle", () => { beforeEach(() => { vi.clearAllMocks(); }); it("retourne le fallback (rules) si le LLM échoue à l'étape 1 (pertinence)", async () => { mockInvokeLLM.mockRejectedValueOnce(new Error("LLM timeout")); const result = await classifyArticle("Titre test", "Résumé test", fallbackFn); expect(result.classifiedBy).toBe("rules"); expect(result.categorie).toBe("Autre"); expect(result.relevant).toBe(true); // on suppose pertinent par défaut }); it("retourne le fallback (rules) si le LLM retourne un JSON malformé à l'étape 1", async () => { mockInvokeLLM.mockResolvedValueOnce(makeLLMResponse("pas du JSON valide ici")); const result = await classifyArticle("Titre test", "Résumé test", fallbackFn); expect(result.classifiedBy).toBe("rules"); expect(result.categorie).toBe("Autre"); }); it("retourne relevant=false et categorie=Autre si l'article est hors périmètre", async () => { mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse(JSON.stringify({ pertinent: false, raison: "Actualité politique générale sans lien médico-social" })) ); const result = await classifyArticle( "Résultats des élections législatives", "Les partis politiques ont obtenu les résultats suivants...", fallbackFn ); expect(result.relevant).toBe(false); expect(result.categorie).toBe("Autre"); expect(result.classifiedBy).toBe("ia"); expect(result.reason).toContain("politique"); }); it("classifie correctement un article pertinent en deux étapes", async () => { // Étape 1 : pertinent = true mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les EHPAD et la perte d'autonomie" })) ); // Étape 2 : classification mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse( JSON.stringify({ categorie: "PA", typeVeille: "reglementaire", raison: "Décret relatif au financement des EHPAD", }) ) ); const result = await classifyArticle( "Nouveau décret sur le financement des EHPAD", "Le gouvernement publie un décret encadrant les tarifs des établissements pour personnes âgées dépendantes.", fallbackFn ); expect(result.relevant).toBe(true); expect(result.categorie).toBe("PA"); expect(result.typeVeille).toBe("reglementaire"); expect(result.classifiedBy).toBe("ia"); }); it("retourne le fallback (rules) si l'étape 2 (classification) échoue", async () => { // Étape 1 : pertinent = true mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Article pertinent" })) ); // Étape 2 : erreur LLM mockInvokeLLM.mockRejectedValueOnce(new Error("LLM quota exceeded")); const result = await classifyArticle("Titre pertinent", "Résumé pertinent", fallbackFn); expect(result.classifiedBy).toBe("rules"); expect(result.relevant).toBe(true); expect(result.reason).toContain("Pertinent"); }); it("accepte les réponses LLM avec balises markdown ```json```", async () => { // Étape 1 avec balises markdown mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse("```json\n" + JSON.stringify({ pertinent: false, raison: "Hors périmètre" }) + "\n```") ); const result = await classifyArticle("Titre sport", "Résultats du championnat de football", fallbackFn); expect(result.relevant).toBe(false); expect(result.classifiedBy).toBe("ia"); }); it("classe un article handicap correctement", async () => { mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les IME et l'inclusion scolaire" })) ); mockInvokeLLM.mockResolvedValueOnce( makeLLMResponse( JSON.stringify({ categorie: "Handicap", typeVeille: "reglementaire", raison: "Circulaire sur l'inclusion scolaire des enfants en situation de handicap", }) ) ); const result = await classifyArticle( "Circulaire sur l'inclusion scolaire des enfants handicapés", "Le ministère publie une circulaire relative à l'accompagnement des élèves en situation de handicap dans les IME.", fallbackFn ); expect(result.categorie).toBe("Handicap"); expect(result.typeVeille).toBe("reglementaire"); expect(result.classifiedBy).toBe("ia"); }); });