// UQO Éval — Métré : tests de l'assistance IA avec un fournisseur simulé (aucun réseau) — étiquetage, filtrage des codes, revue, budget, anomalies déterministes, import DXF. import { afterAll, beforeAll, describe, expect, it } from "vitest"; import fs from "fs"; import os from "os"; import path from "path"; import type { Ctx } from "./auth"; import type { AiProvider } from "./ai"; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-ai-")); process.env.METRE_DB = path.join(tmp, "metre.db"); process.env.METRE_FILES_DIR = path.join(tmp, "files"); process.env.COST_DB = path.join(tmp, "cost.db"); process.env.METRE_INSTRUCTOR_CODE = "PROF-AI-2026"; process.env.METRE_AI_DAILY_BUDGET_USD = "10"; const [db, auth, projects, plans, estimates, ai, ingest] = await Promise.all([import("./db"), import("./auth"), import("./projects"), import("./plans"), import("./estimates"), import("./ai"), import("./sources/ingest")]); let ctx: Ctx; let pid: string; let estId: string; let mId: string; const calls: unknown[] = []; /** Fournisseur simulé : renvoie une sortie plausible selon l'outil demandé, dont un code invalide à filtrer. */ const fake = (overrides: Partial = {}): AiProvider => ({ model: "fake-model-1", async complete(input) { calls.push(input); const out = input.toolName === "propose_assembly_mapping" ? { suggestions: [{ targetType: "measurement", targetId: mId, assemblyCode: "INT-DRYWALL-1/2-FINISHED", rationale: "surface de plancher → cloisons", confidence: 0.7 }, { targetType: "measurement", targetId: mId, assemblyCode: "CODE-INVENTE", rationale: "x", confidence: 0.9 }, { targetType: "line", targetId: "inconnu", assemblyCode: "INT-PAINT-2-COATS", rationale: "x", confidence: 0.5 }] } : input.toolName === "list_missing_scope" ? { missing: [{ division: "22", description: "Plomberie", rationale: "aucune ligne de plomberie", howToQuantify: "compter les appareils", importance: "high" }], caveats: "exemple" } : input.toolName === "explain_estimate" ? { explanation: "Le total est construit…", checks: ["vérifier le registre"] } : input.toolName === "comment_anomalies" ? { items: [{ lineId: "l-hyp", likelyCause: "hypothèse sans note", check: "ajouter une justification" }] } : { items: [{ id: "obs-1", division: "3", infrastructure: true, confidence: 0.8 }, { id: "obs-2", division: "99", infrastructure: false, confidence: 0.1 }] }; return { output: out, inputTokens: 1000, outputTokens: 200, latencyMs: 5 }; }, ...overrides, }); beforeAll(() => { const d = db.getMetreDb(); const user = auth.login(d, { code: "PROF-AI-2026", displayName: "Prof IA", ip: null, userAgent: null }).user; ctx = { db: d, user, ip: null }; pid = projects.createProject(ctx, { name: "IA test", regionCode: "QC-MTL", valuationDate: "2026-10-28" }).id; mId = plans.createManualMeasurement(ctx, pid, { name: "Plancher", value: 100, unit: "m2", confidence: "estimated" }).id; const e = estimates.createEstimate(ctx, pid, "E", "QC-MTL", "2026-10-28"); estId = e.id; estimates.updateEstimate(ctx, estId, { lines: [ { id: "11111111-1111-4111-8111-111111111111", wbs: "09", description: "Gypse", assemblyCode: "INT-DRYWALL-1/2-FINISHED", quantity: 100, unit: "m2" }, { id: "22222222-2222-4222-8222-222222222222", wbs: "09", description: "Peinture", assemblyCode: "INT-PAINT-2-COATS", quantity: 100, unit: "m2" }, { id: "33333333-3333-4333-8333-333333333333", wbs: "09", description: "Plinthes", assemblyCode: "INT-TRIM-BASEBOARD", quantity: 50, unit: "ft" }, { id: "44444444-4444-4444-8444-444444444444", wbs: "09", description: "Céramique", assemblyCode: "INT-FLOOR-CERAMIC", quantity: 20, unit: "m2" }, { id: "55555555-5555-4555-8555-555555555555", wbs: "22", description: "Plomberie sans note", basis: "assumption", unitPrice: 99999, installed: true, quantity: 1, unit: "ft2" }, { id: "66666666-6666-4666-8666-666666666666", wbs: "26", description: "Facteur bizarre", assemblyCode: "ELE-ROUGH-WIRING", quantity: 10, unit: "m2", adjustmentFactor: 3 }, ] }); }); afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } }); describe("assistance IA (fournisseur simulé)", () => { it("assembly_mapping : codes invalides et cibles inconnues filtrés, suggestion en attente, étiquetée, mise en cache par empreinte", async () => { const s = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId); expect(s.status).toBe("pending"); expect(s.model).toBe("fake-model-1"); expect(s.promptVersion).toBe(ai.AI_PROMPT_VERSION); const out = s.output as { suggestions: { assemblyCode: string }[]; rejected: number }; expect(out.suggestions).toHaveLength(1); expect(out.suggestions[0].assemblyCode).toBe("INT-DRYWALL-1/2-FINISHED"); expect(out.rejected).toBe(2); expect(s.estimatedCostUsd).toBeGreaterThan(0); const n = calls.length; const s2 = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId); expect(s2.id).toBe(s.id); expect(calls.length).toBe(n); // même entrée → pas de nouvel appel const r = ai.reviewSuggestion(ctx, s.id, "accepted", "ok"); expect(r.status).toBe("accepted"); expect(r.reviewedBy).toBe(ctx.user.id); const s3 = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId); expect(s3.id).not.toBe(s.id); // une suggestion revue n'est plus réutilisée }); it("missing_scope, explain, anomalies (règles déterministes + commentaire)", async () => { const m = await ai.suggestMissingScope(ctx, fake(), estId); expect((m.output as { missing: unknown[] }).missing).toHaveLength(1); const ex = await ai.explainEstimate(ctx, fake(), estId, "fr"); expect((ex.output as { explanation: string }).explanation).toMatch(/total/); const found = ai.detectAnomalies(ctx, estId); expect(found.some((f) => f.reason.includes("sans justification"))).toBe(true); expect(found.some((f) => f.reason.includes("facteur d'ajustement inhabituel"))).toBe(true); expect(found.some((f) => f.zScore != null && Math.abs(f.zScore) >= 3)).toBe(true); // 99 999 $/pi² vs les autres lignes en pi² const c = await ai.commentAnomalies(ctx, fake(), estId); expect(c.kind).toBe("anomalies"); expect((c.evidence as { anomalies: unknown[] }).anomalies.length).toBe(found.length); }); it("classify : divisions invalides filtrées ; application seulement après acceptation", async () => { const r1 = ingest.importScheduleCsv(ctx, { sourceKey: "mtl_dtni", csv: "code,description,unite,prix\nX1,Béton 30 MPa,m3,285\nX2,Autre,m,3\n", title: "t", url: null, publicationDate: null, effectiveDate: null, license: "test", evidenceClass: "published_schedule", geography: null, regionCode: null }); expect(r1.inserted).toBe(2); const obs = ingest.searchObservations(ctx.db, { sourceKey: "mtl_dtni" }) as { id: string }[]; const s = await ai.classifyObservations(ctx, fake({ async complete(input) { calls.push(input); return { output: { items: [{ id: obs[0].id, division: "3", infrastructure: true, confidence: 0.8 }, { id: obs[1].id, division: "99", infrastructure: false, confidence: 0.1 }] }, inputTokens: 10, outputTokens: 10, latencyMs: 1 }; } }), "mtl_dtni"); const out = s.output as { items: { division: string }[] }; expect(out.items).toHaveLength(1); expect(out.items[0].division).toBe("03"); expect(() => ai.applyClassification(ctx, s)).toThrow(/non applicable/); const acc = ai.reviewSuggestion(ctx, s.id, "accepted", null); expect(ai.applyClassification(ctx, acc)).toBe(1); const row = ctx.db.prepare("SELECT classification_code c, classification_scheme s FROM metre_price_observations WHERE id=?").get(obs[0].id) as { c: string; s: string }; expect(row.c).toBe("03"); expect(row.s).toMatch(/IA, revu/); }); it("budget quotidien et absence de clé API", async () => { const mod = await import("./ai"); const real = new mod.ClaudeProvider("x"); const key = process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY; await expect(real.complete({ system: "", user: "", schema: {}, toolName: "t", maxTokens: 10 })).rejects.toThrow(/ANTHROPIC_API_KEY/); if (key) process.env.ANTHROPIC_API_KEY = key; const actions = (ctx.db.prepare("SELECT DISTINCT action FROM metre_audit_events").all() as { action: string }[]).map((a) => a.action); for (const a of ["ai.suggest", "ai.accepted", "ai.apply_classification"]) expect(actions).toContain(a); }); }); describe("import DXF (service)", () => { it("crée une page rasterisée, une calibration exacte et des objets par calque", async () => { const dxf = ["0", "SECTION", "2", "HEADER", "9", "$INSUNITS", "70", "6", "0", "ENDSEC", "0", "SECTION", "2", "ENTITIES", "0", "LWPOLYLINE", "8", "PIECES", "90", "4", "70", "1", "10", "0", "20", "0", "10", "12", "20", "0", "10", "12", "20", "9", "10", "0", "20", "9", "0", "LINE", "8", "MURS", "10", "0", "20", "0", "11", "12", "21", "0", "0", "ENDSEC", "0", "EOF"].join("\n"); const r = await plans.importDxf(ctx, pid, { filename: "plan.dxf", text: dxf }); expect(r.unit).toBe("m"); expect(r.objects).toBe(2); expect(r.calibration?.uncertaintyPct).toBe(0); expect(r.page.widthPx).toBeGreaterThan(1000); const objs = plans.listObjects(ctx.db, r.page.id); const area = objs.find((o) => o.kind === "area")!; const m = plans.measureObject(ctx, area.id); expect(m.value).toBeCloseTo(108, 1); // 12 × 9 m² reconstitués exactement depuis la calibration DXF expect(fs.existsSync(path.join(process.env.METRE_FILES_DIR!, pid))).toBe(true); await expect(plans.importDxf(ctx, pid, { filename: "x.dxf", text: "pas un dxf" })).rejects.toThrow(); }); });