SPB Git

spb/uqo-eval

Public
9.5 KB · 119 lines typescript
1// 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.2import { afterAll, beforeAll, describe, expect, it } from "vitest";3import fs from "fs";4import os from "os";5import path from "path";6import type { Ctx } from "./auth";7import type { AiProvider } from "./ai";89const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-ai-"));10process.env.METRE_DB = path.join(tmp, "metre.db");11process.env.METRE_FILES_DIR = path.join(tmp, "files");12process.env.COST_DB = path.join(tmp, "cost.db");13process.env.METRE_INSTRUCTOR_CODE = "PROF-AI-2026";14process.env.METRE_AI_DAILY_BUDGET_USD = "10";1516const [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")]);1718let ctx: Ctx; let pid: string; let estId: string; let mId: string;19const calls: unknown[] = [];20/** Fournisseur simulé : renvoie une sortie plausible selon l'outil demandé, dont un code invalide à filtrer. */21const fake = (overrides: Partial<AiProvider> = {}): AiProvider => ({22  model: "fake-model-1",23  async complete(input) {24    calls.push(input);25    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 }] }26      : input.toolName === "list_missing_scope" ? { missing: [{ division: "22", description: "Plomberie", rationale: "aucune ligne de plomberie", howToQuantify: "compter les appareils", importance: "high" }], caveats: "exemple" }27      : input.toolName === "explain_estimate" ? { explanation: "Le total est construit…", checks: ["vérifier le registre"] }28      : input.toolName === "comment_anomalies" ? { items: [{ lineId: "l-hyp", likelyCause: "hypothèse sans note", check: "ajouter une justification" }] }29      : { items: [{ id: "obs-1", division: "3", infrastructure: true, confidence: 0.8 }, { id: "obs-2", division: "99", infrastructure: false, confidence: 0.1 }] };30    return { output: out, inputTokens: 1000, outputTokens: 200, latencyMs: 5 };31  },32  ...overrides,33});3435beforeAll(() => {36  const d = db.getMetreDb();37  const user = auth.login(d, { code: "PROF-AI-2026", displayName: "Prof IA", ip: null, userAgent: null }).user;38  ctx = { db: d, user, ip: null };39  pid = projects.createProject(ctx, { name: "IA test", regionCode: "QC-MTL", valuationDate: "2026-10-28" }).id;40  mId = plans.createManualMeasurement(ctx, pid, { name: "Plancher", value: 100, unit: "m2", confidence: "estimated" }).id;41  const e = estimates.createEstimate(ctx, pid, "E", "QC-MTL", "2026-10-28");42  estId = e.id;43  estimates.updateEstimate(ctx, estId, { lines: [44    { id: "11111111-1111-4111-8111-111111111111", wbs: "09", description: "Gypse", assemblyCode: "INT-DRYWALL-1/2-FINISHED", quantity: 100, unit: "m2" },45    { id: "22222222-2222-4222-8222-222222222222", wbs: "09", description: "Peinture", assemblyCode: "INT-PAINT-2-COATS", quantity: 100, unit: "m2" },46    { id: "33333333-3333-4333-8333-333333333333", wbs: "09", description: "Plinthes", assemblyCode: "INT-TRIM-BASEBOARD", quantity: 50, unit: "ft" },47    { id: "44444444-4444-4444-8444-444444444444", wbs: "09", description: "Céramique", assemblyCode: "INT-FLOOR-CERAMIC", quantity: 20, unit: "m2" },48    { id: "55555555-5555-4555-8555-555555555555", wbs: "22", description: "Plomberie sans note", basis: "assumption", unitPrice: 99999, installed: true, quantity: 1, unit: "ft2" },49    { id: "66666666-6666-4666-8666-666666666666", wbs: "26", description: "Facteur bizarre", assemblyCode: "ELE-ROUGH-WIRING", quantity: 10, unit: "m2", adjustmentFactor: 3 },50  ] });51});52afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } });5354describe("assistance IA (fournisseur simulé)", () => {55  it("assembly_mapping : codes invalides et cibles inconnues filtrés, suggestion en attente, étiquetée, mise en cache par empreinte", async () => {56    const s = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId);57    expect(s.status).toBe("pending"); expect(s.model).toBe("fake-model-1"); expect(s.promptVersion).toBe(ai.AI_PROMPT_VERSION);58    const out = s.output as { suggestions: { assemblyCode: string }[]; rejected: number };59    expect(out.suggestions).toHaveLength(1); expect(out.suggestions[0].assemblyCode).toBe("INT-DRYWALL-1/2-FINISHED"); expect(out.rejected).toBe(2);60    expect(s.estimatedCostUsd).toBeGreaterThan(0);61    const n = calls.length;62    const s2 = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId);63    expect(s2.id).toBe(s.id); expect(calls.length).toBe(n); // même entrée → pas de nouvel appel64    const r = ai.reviewSuggestion(ctx, s.id, "accepted", "ok");65    expect(r.status).toBe("accepted"); expect(r.reviewedBy).toBe(ctx.user.id);66    const s3 = await ai.suggestAssemblyMapping(ctx, fake(), pid, estId);67    expect(s3.id).not.toBe(s.id); // une suggestion revue n'est plus réutilisée68  });69  it("missing_scope, explain, anomalies (règles déterministes + commentaire)", async () => {70    const m = await ai.suggestMissingScope(ctx, fake(), estId);71    expect((m.output as { missing: unknown[] }).missing).toHaveLength(1);72    const ex = await ai.explainEstimate(ctx, fake(), estId, "fr");73    expect((ex.output as { explanation: string }).explanation).toMatch(/total/);74    const found = ai.detectAnomalies(ctx, estId);75    expect(found.some((f) => f.reason.includes("sans justification"))).toBe(true);76    expect(found.some((f) => f.reason.includes("facteur d'ajustement inhabituel"))).toBe(true);77    expect(found.some((f) => f.zScore != null && Math.abs(f.zScore) >= 3)).toBe(true); // 99 999 $/pi² vs les autres lignes en pi²78    const c = await ai.commentAnomalies(ctx, fake(), estId);79    expect(c.kind).toBe("anomalies"); expect((c.evidence as { anomalies: unknown[] }).anomalies.length).toBe(found.length);80  });81  it("classify : divisions invalides filtrées ; application seulement après acceptation", async () => {82    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 });83    expect(r1.inserted).toBe(2);84    const obs = ingest.searchObservations(ctx.db, { sourceKey: "mtl_dtni" }) as { id: string }[];85    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");86    const out = s.output as { items: { division: string }[] };87    expect(out.items).toHaveLength(1); expect(out.items[0].division).toBe("03");88    expect(() => ai.applyClassification(ctx, s)).toThrow(/non applicable/);89    const acc = ai.reviewSuggestion(ctx, s.id, "accepted", null);90    expect(ai.applyClassification(ctx, acc)).toBe(1);91    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 };92    expect(row.c).toBe("03"); expect(row.s).toMatch(/IA, revu/);93  });94  it("budget quotidien et absence de clé API", async () => {95    const mod = await import("./ai");96    const real = new mod.ClaudeProvider("x");97    const key = process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;98    await expect(real.complete({ system: "", user: "", schema: {}, toolName: "t", maxTokens: 10 })).rejects.toThrow(/ANTHROPIC_API_KEY/);99    if (key) process.env.ANTHROPIC_API_KEY = key;100    const actions = (ctx.db.prepare("SELECT DISTINCT action FROM metre_audit_events").all() as { action: string }[]).map((a) => a.action);101    for (const a of ["ai.suggest", "ai.accepted", "ai.apply_classification"]) expect(actions).toContain(a);102  });103});104105describe("import DXF (service)", () => {106  it("crée une page rasterisée, une calibration exacte et des objets par calque", async () => {107    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");108    const r = await plans.importDxf(ctx, pid, { filename: "plan.dxf", text: dxf });109    expect(r.unit).toBe("m"); expect(r.objects).toBe(2); expect(r.calibration?.uncertaintyPct).toBe(0);110    expect(r.page.widthPx).toBeGreaterThan(1000);111    const objs = plans.listObjects(ctx.db, r.page.id);112    const area = objs.find((o) => o.kind === "area")!;113    const m = plans.measureObject(ctx, area.id);114    expect(m.value).toBeCloseTo(108, 1); // 12 × 9 m² reconstitués exactement depuis la calibration DXF115    expect(fs.existsSync(path.join(process.env.METRE_FILES_DIR!, pid))).toBe(true);116    await expect(plans.importDxf(ctx, pid, { filename: "x.dxf", text: "pas un dxf" })).rejects.toThrow();117  });118});119
Files · main
Menu

Theme