Checkpoint: Classification IA en deux étapes (filtre pertinence + classification catégorie) intégrée dans le moteur RSS. Inclut : module aiClassifier.ts avec les deux prompts, intégration dans rssEngine.ts avec fallback sur règles, procédures tRPC reclassifyWithAI pour veille et AAP, badge IA violet dans VeilleDashboard, bouton "Classer avec l'IA" (admin), section "Analyse par l'IA" dans la boîte de dialogue détail, 7 nouveaux tests Vitest (20 tests au total). Migration BDD appliquée (4 nouveaux champs IA dans veille_items et aap_items).
This commit is contained in:
166
server/aiClassifier.test.ts
Normal file
166
server/aiClassifier.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user