// UQO Éval — Métré : assistance IA avec garde-fous (Phase G). SERVEUR seulement. /** * Capacités : (1) `assembly_mapping` — proposer des codes d'assemblage du catalogue pour des mesures/lignes * sans assemblage ; (2) `missing_scope` — signaler des postes vraisemblablement manquants ; (3) `explain` — * expliquer l'estimation en langage clair ; (4) `anomalies` — commenter les écarts de coûts unitaires * détectés DÉTERMINISTIQUEMENT (écart ≥ 2 écarts-types robustes par rapport au catalogue) ; (5) `classify` — * rattacher des observations de prix importées à une division MasterFormat. * * Règles : une suggestion est TOUJOURS enregistrée en `pending`, étiquetée (modèle, version de prompt, * empreinte d'entrée, preuves), revue puis acceptée/refusée explicitement (journal d'audit). L'IA n'écrit * jamais de prix ; `assembly_mapping` ne peut proposer que des codes existants (filtrés côté serveur). * Clé API : variable d'environnement du serveur (ANTHROPIC_API_KEY) — jamais exposée au navigateur. * Le fournisseur est injectable (tests sans réseau). */ import { createHash, randomUUID } from "crypto"; import type Database from "better-sqlite3"; import { audit, nowIso } from "./db"; import { HttpError, type Ctx } from "./auth"; import { computeEstimateRow, getEstimate, listCustomAssemblies } from "./estimates"; import { listMeasurements } from "./plans"; import { getProject } from "./projects"; import { getCostDb } from "../cost/db"; import { loadAssemblies } from "../cost/catalog"; import { MASTERFORMAT } from "../cost/taxonomy"; import { searchObservations } from "./sources/ingest"; export const AI_PROMPT_VERSION = "metre-ai-1.0"; export const DEFAULT_AI_MODEL = process.env.METRE_AI_MODEL ?? "claude-haiku-5-5"; /** Tarif approximatif (USD / M jetons) pour le suivi de coût — Haiku 5.5 ; ajuster via METRE_AI_PRICE_IN/OUT. */ const PRICE_IN = Number(process.env.METRE_AI_PRICE_IN ?? 1), PRICE_OUT = Number(process.env.METRE_AI_PRICE_OUT ?? 5); const DAILY_BUDGET_USD = Number(process.env.METRE_AI_DAILY_BUDGET_USD ?? 5); export type SuggestionKind = "assembly_mapping" | "missing_scope" | "explain" | "anomalies" | "classify"; export interface AiProvider { readonly model: string; /** Appel structuré : renvoie un objet JSON conforme au schéma demandé. */ complete(input: { system: string; user: string; schema: Record; toolName: string; maxTokens: number }): Promise<{ output: unknown; inputTokens: number; outputTokens: number; latencyMs: number }>; } /** Fournisseur Claude (Anthropic SDK, sortie forcée par outil). */ export class ClaudeProvider implements AiProvider { readonly model: string; constructor(model = DEFAULT_AI_MODEL) { this.model = model; } async complete(input: { system: string; user: string; schema: Record; toolName: string; maxTokens: number }) { if (!process.env.ANTHROPIC_API_KEY) throw new HttpError(503, "ANTHROPIC_API_KEY non configurée sur le serveur."); const { default: Anthropic } = await import("@anthropic-ai/sdk"); const client = new Anthropic(); const t0 = Date.now(); const msg = await client.messages.create({ model: this.model, max_tokens: input.maxTokens, system: input.system, messages: [{ role: "user", content: input.user }], tools: [{ name: input.toolName, description: "Réponse structurée", input_schema: input.schema as { type: "object"; [k: string]: unknown } }], tool_choice: { type: "tool", name: input.toolName }, }); const block = msg.content.find((b) => b.type === "tool_use") as { type: "tool_use"; input: unknown } | undefined; if (!block) throw new HttpError(502, "Réponse du modèle sans sortie structurée."); return { output: block.input, inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, latencyMs: Date.now() - t0 }; } } const SYSTEM = `Tu es l'assistante pédagogique du module « Métré estimatif » d'UQO Éval (cours IMM1033, Université du Québec en Outaouais). Règles absolues : (1) tu ne fournis JAMAIS de prix, de taux ni de montant ; (2) tu ne proposes que des codes d'assemblage présents dans la liste fournie ; (3) tes suggestions sont des hypothèses à faire vérifier par un humain — formule-les prudemment, en français, avec une justification courte ; (4) cite les éléments d'entrée (noms de mesures, descriptions de lignes) qui motivent chaque suggestion.`; interface Catalogue { codes: string[]; text: string } function catalogueFor(d: Database.Database, projectId: string | null): Catalogue { const cat = [...loadAssemblies(getCostDb()).values()].map((a) => ({ code: a.code, name: a.nameFr, unit: a.unit, category: a.category })); const custom = listCustomAssemblies(d, projectId).map((a) => ({ code: a.code, name: `${a.nameFr} (recette)`, unit: a.unit, category: a.category })); const all = [...custom, ...cat]; return { codes: all.map((a) => a.code), text: all.map((a) => `${a.code} | ${a.name} | ${a.unit} | ${a.category}`).join("\n") }; } const hash = (o: unknown) => createHash("sha256").update(JSON.stringify(o)).digest("hex"); function checkBudget(d: Database.Database): void { const r = d.prepare("SELECT COALESCE(SUM(estimated_cost_usd),0) c, COUNT(*) n FROM metre_ai_suggestions WHERE created_at >= date('now')").get() as { c: number; n: number }; if (r.c >= DAILY_BUDGET_USD) throw new HttpError(429, `Budget IA quotidien atteint (${DAILY_BUDGET_USD} $ US).`); } export interface SuggestionRow { id: string; projectId: string | null; kind: SuggestionKind; targetType: string | null; targetId: string | null; model: string; promptVersion: string; inputHash: string; inputSummary: string | null; output: unknown; evidence: unknown; status: "pending" | "accepted" | "rejected"; reviewNote: string | null; createdBy: string; reviewedBy: string | null; reviewedAt: string | null; inputTokens: number; outputTokens: number; estimatedCostUsd: number | null; latencyMs: number | null; createdAt: string } function mapRow(r: Record): SuggestionRow { return { id: String(r.id), projectId: r.project_id as string | null, kind: r.kind as SuggestionKind, targetType: r.target_type as string | null, targetId: r.target_id as string | null, model: String(r.model), promptVersion: String(r.prompt_version), inputHash: String(r.input_hash), inputSummary: r.input_summary as string | null, output: JSON.parse(String(r.output_json)), evidence: r.evidence_json ? JSON.parse(String(r.evidence_json)) : null, status: r.status as SuggestionRow["status"], reviewNote: r.review_note as string | null, createdBy: String(r.created_by), reviewedBy: r.reviewed_by as string | null, reviewedAt: r.reviewed_at as string | null, inputTokens: Number(r.input_tokens), outputTokens: Number(r.output_tokens), estimatedCostUsd: r.estimated_cost_usd as number | null, latencyMs: r.latency_ms as number | null, createdAt: String(r.created_at) }; } export function listSuggestions(d: Database.Database, projectId: string | null, limit = 50): SuggestionRow[] { return (d.prepare(`SELECT * FROM metre_ai_suggestions ${projectId ? "WHERE project_id=?" : ""} ORDER BY created_at DESC LIMIT ?`).all(...(projectId ? [projectId, limit] : [limit])) as Record[]).map(mapRow); } export function getSuggestion(d: Database.Database, id: string): SuggestionRow | null { const r = d.prepare("SELECT * FROM metre_ai_suggestions WHERE id=?").get(id) as Record | undefined; return r ? mapRow(r) : null; } export function reviewSuggestion(ctx: Ctx, id: string, status: "accepted" | "rejected", note: string | null): SuggestionRow { const s = getSuggestion(ctx.db, id); if (!s) throw new HttpError(404, "Suggestion introuvable."); ctx.db.prepare("UPDATE metre_ai_suggestions SET status=?, review_note=?, reviewed_by=?, reviewed_at=? WHERE id=?").run(status, note, ctx.user.id, nowIso(), id); audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: `ai.${status}`, entityType: "ai_suggestion", entityId: id, projectId: s.projectId, details: { kind: s.kind, note }, ip: ctx.ip }); return getSuggestion(ctx.db, id)!; } export function aiUsage(d: Database.Database) { return { today: d.prepare("SELECT COUNT(*) n, COALESCE(SUM(estimated_cost_usd),0) usd, COALESCE(SUM(input_tokens),0) input, COALESCE(SUM(output_tokens),0) output FROM metre_ai_suggestions WHERE created_at >= date('now')").get(), byKind: d.prepare("SELECT kind, status, COUNT(*) n, COALESCE(SUM(estimated_cost_usd),0) usd FROM metre_ai_suggestions GROUP BY kind, status").all(), dailyBudgetUsd: DAILY_BUDGET_USD, model: DEFAULT_AI_MODEL }; } /* ------------------------------------------------------------------ cœur */ async function run(ctx: Ctx, provider: AiProvider, p: { projectId: string | null; kind: SuggestionKind; targetType?: string; targetId?: string; user: string; schema: Record; toolName: string; inputSummary: string; evidence: unknown; postProcess?: (o: unknown) => unknown; maxTokens?: number }): Promise { checkBudget(ctx.db); const inputHash = hash({ v: AI_PROMPT_VERSION, m: provider.model, u: p.user }); const cached = ctx.db.prepare("SELECT id FROM metre_ai_suggestions WHERE input_hash=? AND kind=? AND status='pending' ORDER BY created_at DESC LIMIT 1").get(inputHash, p.kind) as { id: string } | undefined; if (cached) return getSuggestion(ctx.db, cached.id)!; // même entrée → même suggestion en attente (pas de nouvel appel) const r = await provider.complete({ system: SYSTEM, user: p.user, schema: p.schema, toolName: p.toolName, maxTokens: p.maxTokens ?? 2000 }); const output = p.postProcess ? p.postProcess(r.output) : r.output; const cost = (r.inputTokens * PRICE_IN + r.outputTokens * PRICE_OUT) / 1_000_000; const id = randomUUID(); ctx.db.prepare("INSERT INTO metre_ai_suggestions(id,project_id,kind,target_type,target_id,model,prompt_version,input_hash,input_summary,output_json,evidence_json,created_by,input_tokens,output_tokens,estimated_cost_usd,latency_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") .run(id, p.projectId, p.kind, p.targetType ?? null, p.targetId ?? null, provider.model, AI_PROMPT_VERSION, inputHash, p.inputSummary.slice(0, 500), JSON.stringify(output), JSON.stringify(p.evidence).slice(0, 20000), ctx.user.id, r.inputTokens, r.outputTokens, Math.round(cost * 1e6) / 1e6, r.latencyMs); audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "ai.suggest", entityType: "ai_suggestion", entityId: id, projectId: p.projectId, details: { kind: p.kind, model: provider.model, tokens: [r.inputTokens, r.outputTokens] }, ip: ctx.ip }); return getSuggestion(ctx.db, id)!; } /** (1) Proposer des assemblages pour les mesures et lignes sans assemblage. */ export async function suggestAssemblyMapping(ctx: Ctx, provider: AiProvider, projectId: string, estimateId: string | null): Promise { const d = ctx.db; const cat = catalogueFor(d, projectId); const measures = listMeasurements(d, projectId).map((m) => ({ id: m.id, name: m.name, value: m.value, unit: m.unit, formula: m.formula })); const est = estimateId ? getEstimate(d, estimateId) : null; const lines = est ? est.lines.filter((l) => !l.assemblyCode && l.basis !== "assumption").map((l) => ({ id: l.id, description: l.description, unit: l.unit, measurementId: l.measurementId })) : []; if (!measures.length && !lines.length) throw new HttpError(400, "Rien à rattacher : aucune mesure ni ligne sans assemblage."); const project = getProject(d, projectId); const user = `Bâtiment : ${project?.buildingKind ?? "?"}, ${project?.stories ?? "?"} étage(s), ${project?.municipality ?? "?"}.\n\nMesures du registre (id | nom | valeur | unité | formule) :\n${measures.map((m) => `${m.id} | ${m.name} | ${m.value} | ${m.unit} | ${m.formula}`).join("\n")}\n\nLignes d'estimation sans assemblage (id | description | unité) :\n${lines.map((l) => `${l.id} | ${l.description} | ${l.unit}`).join("\n") || "—"}\n\nCatalogue (code | nom | unité | catégorie) :\n${cat.text}\n\nPour chaque mesure ou ligne, propose au plus 2 codes d'assemblage compatibles (unité de même dimension), avec une justification d'une phrase et un niveau de confiance (0-1).`; const schema = { type: "object", properties: { suggestions: { type: "array", items: { type: "object", properties: { targetType: { type: "string", enum: ["measurement", "line"] }, targetId: { type: "string" }, assemblyCode: { type: "string" }, rationale: { type: "string" }, confidence: { type: "number" } }, required: ["targetType", "targetId", "assemblyCode", "rationale", "confidence"] } } }, required: ["suggestions"] }; const valid = new Set(cat.codes); const ids = new Set([...measures.map((m) => m.id), ...lines.map((l) => l.id)]); return run(ctx, provider, { projectId, kind: "assembly_mapping", targetType: estimateId ? "estimate" : "project", targetId: estimateId ?? projectId, user, schema, toolName: "propose_assembly_mapping", inputSummary: `${measures.length} mesures, ${lines.length} lignes`, evidence: { measures, lines }, postProcess: (o) => { const s = ((o as { suggestions?: unknown[] }).suggestions ?? []) as { targetType: string; targetId: string; assemblyCode: string; rationale: string; confidence: number }[]; return { suggestions: s.filter((x) => valid.has(String(x.assemblyCode).toUpperCase()) && ids.has(x.targetId)).map((x) => ({ ...x, assemblyCode: String(x.assemblyCode).toUpperCase(), confidence: Math.max(0, Math.min(1, Number(x.confidence) || 0)) })), rejected: s.length - s.filter((x) => valid.has(String(x.assemblyCode).toUpperCase()) && ids.has(x.targetId)).length }; } }); } /** (2) Postes vraisemblablement manquants dans l'estimation. */ export async function suggestMissingScope(ctx: Ctx, provider: AiProvider, estimateId: string): Promise { const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable."); const project = getProject(ctx.db, e.projectId)!; const lines = e.lines.filter((l) => !l.excluded).map((l) => `${l.wbs} | ${l.description} | ${l.assemblyCode ?? (l.basis === "assumption" ? "hypothèse" : "—")} | ${l.quantity} ${l.unit}`); const user = `Bâtiment : ${project.buildingKind}, ${project.stories} étage(s), ${project.municipality ?? "?"}, Québec.\nLignes actuelles (division | description | assemblage | quantité) :\n${lines.join("\n") || "—"}\n\nEn te fondant sur la pratique du résidentiel québécois (MasterFormat), liste les postes vraisemblablement MANQUANTS (max 12), chacun avec la division, une description, pourquoi il est attendu, et la mesure du registre ou l'élément de plan qui permettrait de le quantifier. N'indique aucun montant.`; const schema = { type: "object", properties: { missing: { type: "array", items: { type: "object", properties: { division: { type: "string" }, description: { type: "string" }, rationale: { type: "string" }, howToQuantify: { type: "string" }, importance: { type: "string", enum: ["high", "medium", "low"] } }, required: ["division", "description", "rationale", "howToQuantify", "importance"] } }, caveats: { type: "string" } }, required: ["missing"] }; return run(ctx, provider, { projectId: e.projectId, kind: "missing_scope", targetType: "estimate", targetId: estimateId, user, schema, toolName: "list_missing_scope", inputSummary: `${lines.length} lignes`, evidence: { lines } }); } /** (3) Explication de l'estimation en langage clair (pédagogique). */ export async function explainEstimate(ctx: Ctx, provider: AiProvider, estimateId: string, lang: "fr" | "en"): Promise { const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable."); const { result } = computeEstimateRow(ctx.db, e); const T = result.totals; const user = `${lang === "en" ? "Answer in English." : "Réponds en français."}\nRésultat (chiffres du moteur déterministe — ne les modifie pas) : direct ${T.direct} $, localisé ${T.located} $, CG ${T.generalConditions} $, FG ${T.overhead} $, profit ${T.profit} $, contingence ${T.contingency} $, actualisation ${T.escalation} $, avant taxes ${T.subtotalBeforeTax} $, total ${T.total} $. Répartition : matériaux ${T.material}, MO ${T.labour}, équipement ${T.equipment}, autres ${T.other}. Couverture observée ${Math.round(result.coverage.observedShare * 100)} %, hypothèses ${Math.round(result.coverage.assumptionShare * 100)} %.\nOrdre d'application : ${result.applicationOrder.join(" ; ")}\nDivisions : ${result.byWbs.map((w) => `${w.wbs} ${w.located} $ (${w.sharePct} %)`).join(" ; ")}\nAvertissements : ${result.warnings.join(" ; ") || "aucun"}.\n\nExplique à un·e étudiant·e, en 150-250 mots, comment ce total est construit, ce que signifient la part observée et la part d'hypothèses, et quelles vérifications faire avant de s'y fier. Aucun nouveau chiffre.`; const schema = { type: "object", properties: { explanation: { type: "string" }, checks: { type: "array", items: { type: "string" } } }, required: ["explanation", "checks"] }; return run(ctx, provider, { projectId: e.projectId, kind: "explain", targetType: "estimate", targetId: estimateId, user, schema, toolName: "explain_estimate", inputSummary: `total ${T.total}`, evidence: { totals: T, coverage: result.coverage } }); } /** (4) Anomalies : détection DÉTERMINISTE puis commentaire. */ export function detectAnomalies(ctx: Ctx, estimateId: string): { lineId: string; description: string; unitDirect: number; catalogueMedian: number | null; zScore: number | null; reason: string }[] { const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable."); const { result } = computeEstimateRow(ctx.db, e); // référence : coûts unitaires directs des assemblages du catalogue, par unité const cat = [...loadAssemblies(getCostDb()).values()]; const out: ReturnType = []; const byUnit = new Map(); for (const l of result.lines) if (!l.excluded && l.unitDirect > 0) byUnit.set(l.unit, [...(byUnit.get(l.unit) ?? []), l.unitDirect]); const med = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : 0; }; for (const l of result.lines) { if (l.excluded) continue; if (l.unitDirect <= 0) { out.push({ lineId: l.id, description: l.description, unitDirect: 0, catalogueMedian: null, zScore: null, reason: "ligne non chiffrée" }); continue; } const peers = (byUnit.get(l.unit) ?? []).filter((x) => x > 0); if (peers.length >= 4) { const m = med(peers); const mad = med(peers.map((x) => Math.abs(x - m))) || 1e-9; const z = (l.unitDirect - m) / (1.4826 * mad); if (Math.abs(z) >= 3) out.push({ lineId: l.id, description: l.description, unitDirect: l.unitDirect, catalogueMedian: m, zScore: Math.round(z * 10) / 10, reason: `coût unitaire ${z > 0 ? "très supérieur" : "très inférieur"} aux autres lignes en ${l.unit} (médiane ${m.toFixed(2)} $)` }); } if (l.basis === "assumption" && !l.provenance.note) out.push({ lineId: l.id, description: l.description, unitDirect: l.unitDirect, catalogueMedian: null, zScore: null, reason: "hypothèse sans justification notée" }); if (l.quantity > 0 && l.adjustmentFactor !== 1 && (l.adjustmentFactor > 1.5 || l.adjustmentFactor < 0.5)) out.push({ lineId: l.id, description: l.description, unitDirect: l.unitDirect, catalogueMedian: null, zScore: null, reason: `facteur d'ajustement inhabituel × ${l.adjustmentFactor}` }); } void cat; return out; } export async function commentAnomalies(ctx: Ctx, provider: AiProvider, estimateId: string): Promise { const e = getEstimate(ctx.db, estimateId)!; const found = detectAnomalies(ctx, estimateId); if (!found.length) throw new HttpError(400, "Aucune anomalie détectée par les règles déterministes."); const user = `Anomalies détectées par des règles déterministes (ne les conteste pas, explique-les et propose des vérifications) :\n${found.map((f) => `- ${f.description} : ${f.reason}${f.zScore != null ? ` (z = ${f.zScore})` : ""}`).join("\n")}\n\nPour chacune, donne une cause plausible et la vérification à faire (plan, registre, catalogue, soumission). Aucun montant.`; const schema = { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { lineId: { type: "string" }, likelyCause: { type: "string" }, check: { type: "string" } }, required: ["lineId", "likelyCause", "check"] } } }, required: ["items"] }; return run(ctx, provider, { projectId: e.projectId, kind: "anomalies", targetType: "estimate", targetId: estimateId, user: user + `\nIdentifiants de lignes : ${found.map((f) => `${f.lineId} = ${f.description}`).join(" ; ")}`, schema, toolName: "comment_anomalies", inputSummary: `${found.length} anomalies`, evidence: { anomalies: found } }); } /** (5) Classer des observations importées (barèmes) dans les divisions MasterFormat. */ export async function classifyObservations(ctx: Ctx, provider: AiProvider, sourceKey: string, limit = 40): Promise { const obs = (searchObservations(ctx.db, { sourceKey, limit }) as { id: string; description: string; unit: string | null; label: string | null }[]).filter((o) => o.description); if (!obs.length) throw new HttpError(400, "Aucune observation pour cette source."); const divisions = Object.entries(MASTERFORMAT).map(([k, v]) => `${k} ${v.fr}`).join(" ; "); const user = `Divisions MasterFormat admissibles : ${divisions}.\nObservations (id | description | unité | catégorie d'origine) :\n${obs.map((o) => `${o.id} | ${o.description} | ${o.unit ?? ""} | ${o.label ?? ""}`).join("\n")}\n\nPour chaque observation, indique la division (code à deux chiffres) la plus probable et si l'élément relève d'infrastructure/génie civil plutôt que de bâtiment résidentiel.`; const schema = { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { id: { type: "string" }, division: { type: "string" }, infrastructure: { type: "boolean" }, confidence: { type: "number" } }, required: ["id", "division", "infrastructure", "confidence"] } } }, required: ["items"] }; const valid = new Set(Object.keys(MASTERFORMAT)); return run(ctx, provider, { projectId: null, kind: "classify", targetType: "source", targetId: sourceKey, user, schema, toolName: "classify_observations", inputSummary: `${obs.length} observations ${sourceKey}`, evidence: { ids: obs.map((o) => o.id) }, postProcess: (o) => ({ items: (((o as { items?: unknown[] }).items ?? []) as { id: string; division: string; infrastructure: boolean; confidence: number }[]).filter((x) => valid.has(String(x.division).padStart(2, "0"))).map((x) => ({ ...x, division: String(x.division).padStart(2, "0") })) }) }); } /** Application d'une suggestion acceptée de classification : écrit le schéma/code sur les observations (traçable). */ export function applyClassification(ctx: Ctx, suggestion: SuggestionRow): number { if (suggestion.kind !== "classify" || suggestion.status !== "accepted") throw new HttpError(409, "Suggestion non applicable."); const items = ((suggestion.output as { items?: { id: string; division: string }[] }).items ?? []); const up = ctx.db.prepare("UPDATE metre_price_observations SET classification_scheme=COALESCE(classification_scheme,'MasterFormat (IA, revu)'), classification_code=COALESCE(classification_code, ?), classification_label=COALESCE(classification_label, ?) WHERE id=?"); let n = 0; for (const it of items) n += up.run(it.division, MASTERFORMAT[it.division]?.fr ?? it.division, it.id).changes; audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "ai.apply_classification", entityType: "ai_suggestion", entityId: suggestion.id, details: { updated: n }, ip: ctx.ip }); return n; }