SPB Git

spb/uqo-eval

Public
23.4 KB · 215 lines typescript
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
Files · main
Menu

Theme