1// UQO Éval — Métré : assistance IA avec garde-fous (Phase G). SERVEUR seulement.2/**3 * Capacités : (1) `assembly_mapping` — proposer des codes d'assemblage du catalogue pour des mesures/lignes4 * sans assemblage ; (2) `missing_scope` — signaler des postes vraisemblablement manquants ; (3) `explain` —5 * expliquer l'estimation en langage clair ; (4) `anomalies` — commenter les écarts de coûts unitaires6 * détectés DÉTERMINISTIQUEMENT (écart ≥ 2 écarts-types robustes par rapport au catalogue) ; (5) `classify` —7 * rattacher des observations de prix importées à une division MasterFormat.8 *9 * Règles : une suggestion est TOUJOURS enregistrée en `pending`, étiquetée (modèle, version de prompt,10 * empreinte d'entrée, preuves), revue puis acceptée/refusée explicitement (journal d'audit). L'IA n'écrit11 * jamais de prix ; `assembly_mapping` ne peut proposer que des codes existants (filtrés côté serveur).12 * Clé API : variable d'environnement du serveur (ANTHROPIC_API_KEY) — jamais exposée au navigateur.13 * Le fournisseur est injectable (tests sans réseau).14 */15import { createHash, randomUUID } from "crypto";16import type Database from "better-sqlite3";17import { audit, nowIso } from "./db";18import { HttpError, type Ctx } from "./auth";19import { computeEstimateRow, getEstimate, listCustomAssemblies } from "./estimates";20import { listMeasurements } from "./plans";21import { getProject } from "./projects";22import { getCostDb } from "../cost/db";23import { loadAssemblies } from "../cost/catalog";24import { MASTERFORMAT } from "../cost/taxonomy";25import { searchObservations } from "./sources/ingest";2627export const AI_PROMPT_VERSION = "metre-ai-1.0";28export const DEFAULT_AI_MODEL = process.env.METRE_AI_MODEL ?? "claude-haiku-5-5";29/** Tarif approximatif (USD / M jetons) pour le suivi de coût — Haiku 5.5 ; ajuster via METRE_AI_PRICE_IN/OUT. */30const PRICE_IN = Number(process.env.METRE_AI_PRICE_IN ?? 1), PRICE_OUT = Number(process.env.METRE_AI_PRICE_OUT ?? 5);31const DAILY_BUDGET_USD = Number(process.env.METRE_AI_DAILY_BUDGET_USD ?? 5);3233export type SuggestionKind = "assembly_mapping" | "missing_scope" | "explain" | "anomalies" | "classify";3435export interface AiProvider {36 readonly model: string;37 /** Appel structuré : renvoie un objet JSON conforme au schéma demandé. */38 complete(input: { system: string; user: string; schema: Record<string, unknown>; toolName: string; maxTokens: number }): Promise<{ output: unknown; inputTokens: number; outputTokens: number; latencyMs: number }>;39}4041/** Fournisseur Claude (Anthropic SDK, sortie forcée par outil). */42export class ClaudeProvider implements AiProvider {43 readonly model: string;44 constructor(model = DEFAULT_AI_MODEL) { this.model = model; }45 async complete(input: { system: string; user: string; schema: Record<string, unknown>; toolName: string; maxTokens: number }) {46 if (!process.env.ANTHROPIC_API_KEY) throw new HttpError(503, "ANTHROPIC_API_KEY non configurée sur le serveur.");47 const { default: Anthropic } = await import("@anthropic-ai/sdk");48 const client = new Anthropic();49 const t0 = Date.now();50 const msg = await client.messages.create({51 model: this.model, max_tokens: input.maxTokens, system: input.system,52 messages: [{ role: "user", content: input.user }],53 tools: [{ name: input.toolName, description: "Réponse structurée", input_schema: input.schema as { type: "object"; [k: string]: unknown } }],54 tool_choice: { type: "tool", name: input.toolName },55 });56 const block = msg.content.find((b) => b.type === "tool_use") as { type: "tool_use"; input: unknown } | undefined;57 if (!block) throw new HttpError(502, "Réponse du modèle sans sortie structurée.");58 return { output: block.input, inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, latencyMs: Date.now() - t0 };59 }60}6162const SYSTEM = `Tu es l'assistante pédagogique du module « Métré estimatif » d'UQO Éval (cours IMM1033, Université du Québec en Outaouais).63Rè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.`;6465interface Catalogue { codes: string[]; text: string }66function catalogueFor(d: Database.Database, projectId: string | null): Catalogue {67 const cat = [...loadAssemblies(getCostDb()).values()].map((a) => ({ code: a.code, name: a.nameFr, unit: a.unit, category: a.category }));68 const custom = listCustomAssemblies(d, projectId).map((a) => ({ code: a.code, name: `${a.nameFr} (recette)`, unit: a.unit, category: a.category }));69 const all = [...custom, ...cat];70 return { codes: all.map((a) => a.code), text: all.map((a) => `${a.code} | ${a.name} | ${a.unit} | ${a.category}`).join("\n") };71}7273const hash = (o: unknown) => createHash("sha256").update(JSON.stringify(o)).digest("hex");7475function checkBudget(d: Database.Database): void {76 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 };77 if (r.c >= DAILY_BUDGET_USD) throw new HttpError(429, `Budget IA quotidien atteint (${DAILY_BUDGET_USD} $ US).`);78}7980export 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 }8182function mapRow(r: Record<string, unknown>): SuggestionRow {83 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) };84}8586export function listSuggestions(d: Database.Database, projectId: string | null, limit = 50): SuggestionRow[] {87 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<string, unknown>[]).map(mapRow);88}8990export function getSuggestion(d: Database.Database, id: string): SuggestionRow | null {91 const r = d.prepare("SELECT * FROM metre_ai_suggestions WHERE id=?").get(id) as Record<string, unknown> | undefined;92 return r ? mapRow(r) : null;93}9495export function reviewSuggestion(ctx: Ctx, id: string, status: "accepted" | "rejected", note: string | null): SuggestionRow {96 const s = getSuggestion(ctx.db, id);97 if (!s) throw new HttpError(404, "Suggestion introuvable.");98 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);99 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 });100 return getSuggestion(ctx.db, id)!;101}102103export function aiUsage(d: Database.Database) {104 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 };105}106107/* ------------------------------------------------------------------ cœur */108109async function run(ctx: Ctx, provider: AiProvider, p: { projectId: string | null; kind: SuggestionKind; targetType?: string; targetId?: string; user: string; schema: Record<string, unknown>; toolName: string; inputSummary: string; evidence: unknown; postProcess?: (o: unknown) => unknown; maxTokens?: number }): Promise<SuggestionRow> {110 checkBudget(ctx.db);111 const inputHash = hash({ v: AI_PROMPT_VERSION, m: provider.model, u: p.user });112 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;113 if (cached) return getSuggestion(ctx.db, cached.id)!; // même entrée → même suggestion en attente (pas de nouvel appel)114 const r = await provider.complete({ system: SYSTEM, user: p.user, schema: p.schema, toolName: p.toolName, maxTokens: p.maxTokens ?? 2000 });115 const output = p.postProcess ? p.postProcess(r.output) : r.output;116 const cost = (r.inputTokens * PRICE_IN + r.outputTokens * PRICE_OUT) / 1_000_000;117 const id = randomUUID();118 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")119 .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);120 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 });121 return getSuggestion(ctx.db, id)!;122}123124/** (1) Proposer des assemblages pour les mesures et lignes sans assemblage. */125export async function suggestAssemblyMapping(ctx: Ctx, provider: AiProvider, projectId: string, estimateId: string | null): Promise<SuggestionRow> {126 const d = ctx.db;127 const cat = catalogueFor(d, projectId);128 const measures = listMeasurements(d, projectId).map((m) => ({ id: m.id, name: m.name, value: m.value, unit: m.unit, formula: m.formula }));129 const est = estimateId ? getEstimate(d, estimateId) : null;130 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 })) : [];131 if (!measures.length && !lines.length) throw new HttpError(400, "Rien à rattacher : aucune mesure ni ligne sans assemblage.");132 const project = getProject(d, projectId);133 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).`;134 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"] };135 const valid = new Set(cat.codes); const ids = new Set([...measures.map((m) => m.id), ...lines.map((l) => l.id)]);136 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 },137 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 }; } });138}139140/** (2) Postes vraisemblablement manquants dans l'estimation. */141export async function suggestMissingScope(ctx: Ctx, provider: AiProvider, estimateId: string): Promise<SuggestionRow> {142 const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable.");143 const project = getProject(ctx.db, e.projectId)!;144 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}`);145 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.`;146 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"] };147 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 } });148}149150/** (3) Explication de l'estimation en langage clair (pédagogique). */151export async function explainEstimate(ctx: Ctx, provider: AiProvider, estimateId: string, lang: "fr" | "en"): Promise<SuggestionRow> {152 const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable.");153 const { result } = computeEstimateRow(ctx.db, e);154 const T = result.totals;155 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.`;156 const schema = { type: "object", properties: { explanation: { type: "string" }, checks: { type: "array", items: { type: "string" } } }, required: ["explanation", "checks"] };157 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 } });158}159160/** (4) Anomalies : détection DÉTERMINISTE puis commentaire. */161export function detectAnomalies(ctx: Ctx, estimateId: string): { lineId: string; description: string; unitDirect: number; catalogueMedian: number | null; zScore: number | null; reason: string }[] {162 const e = getEstimate(ctx.db, estimateId); if (!e) throw new HttpError(404, "Estimation introuvable.");163 const { result } = computeEstimateRow(ctx.db, e);164 // référence : coûts unitaires directs des assemblages du catalogue, par unité165 const cat = [...loadAssemblies(getCostDb()).values()];166 const out: ReturnType<typeof detectAnomalies> = [];167 const byUnit = new Map<string, number[]>();168 for (const l of result.lines) if (!l.excluded && l.unitDirect > 0) byUnit.set(l.unit, [...(byUnit.get(l.unit) ?? []), l.unitDirect]);169 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; };170 for (const l of result.lines) {171 if (l.excluded) continue;172 if (l.unitDirect <= 0) { out.push({ lineId: l.id, description: l.description, unitDirect: 0, catalogueMedian: null, zScore: null, reason: "ligne non chiffrée" }); continue; }173 const peers = (byUnit.get(l.unit) ?? []).filter((x) => x > 0);174 if (peers.length >= 4) {175 const m = med(peers); const mad = med(peers.map((x) => Math.abs(x - m))) || 1e-9; const z = (l.unitDirect - m) / (1.4826 * mad);176 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)} $)` });177 }178 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" });179 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}` });180 }181 void cat;182 return out;183}184185export async function commentAnomalies(ctx: Ctx, provider: AiProvider, estimateId: string): Promise<SuggestionRow> {186 const e = getEstimate(ctx.db, estimateId)!;187 const found = detectAnomalies(ctx, estimateId);188 if (!found.length) throw new HttpError(400, "Aucune anomalie détectée par les règles déterministes.");189 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.`;190 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"] };191 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 } });192}193194/** (5) Classer des observations importées (barèmes) dans les divisions MasterFormat. */195export async function classifyObservations(ctx: Ctx, provider: AiProvider, sourceKey: string, limit = 40): Promise<SuggestionRow> {196 const obs = (searchObservations(ctx.db, { sourceKey, limit }) as { id: string; description: string; unit: string | null; label: string | null }[]).filter((o) => o.description);197 if (!obs.length) throw new HttpError(400, "Aucune observation pour cette source.");198 const divisions = Object.entries(MASTERFORMAT).map(([k, v]) => `${k} ${v.fr}`).join(" ; ");199 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.`;200 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"] };201 const valid = new Set(Object.keys(MASTERFORMAT));202 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") })) }) });203}204205/** Application d'une suggestion acceptée de classification : écrit le schéma/code sur les observations (traçable). */206export function applyClassification(ctx: Ctx, suggestion: SuggestionRow): number {207 if (suggestion.kind !== "classify" || suggestion.status !== "accepted") throw new HttpError(409, "Suggestion non applicable.");208 const items = ((suggestion.output as { items?: { id: string; division: string }[] }).items ?? []);209 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=?");210 let n = 0;211 for (const it of items) n += up.run(it.division, MASTERFORMAT[it.division]?.fr ?? it.division, it.id).changes;212 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 });213 return n;214}215