// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Lecture assistée des grilles APCHQ : Claude Haiku 4.5 lit le PDF (document * base64) et TRANSCRIT les lignes (métier, classification, montants). L'IA ne * fixe aucun prix : chaque ligne est ensuite vérifiée doublement — * 1. cohérence interne : base + vacances + avantages + cotisations ≈ total ; * 2. les couples (taux horaire, total) doivent exister dans l'extraction * déterministe (regex) du même document. * Les lignes qui échouent sont rejetées. Sert à rétablir les libellés que la * conversion PDF → markdown décale ou fusionne. */ import Anthropic from "@anthropic-ai/sdk"; import type Database from "better-sqlite3"; import { getCostDb } from "../db"; import type { ApchqRow } from "./apchq"; const MODEL = "claude-haiku-4-5"; interface AiRow { trade: string; classification: string; base_wage: number; vacation: number | null; benefits: number | null; total: number; contributions_sum: number | null; } const TOOL: Anthropic.Tool = { name: "record_rows", description: "Transcrit toutes les lignes de la grille de coût horaire (une ligne par métier/occupation et par classification).", strict: true, input_schema: { type: "object", additionalProperties: false, properties: { effective_date: { type: ["string", "null"], description: "Date d'entrée en vigueur (YYYY-MM-DD) lue dans l'en-tête" }, sector: { type: ["string", "null"], description: "Secteur lu dans l'en-tête (résidentiel léger, lourd, institutionnel-commercial…)" }, rows: { type: "array", items: { type: "object", additionalProperties: false, properties: { trade: { type: "string", description: "Libellé exact du métier ou de l'occupation (ligne compagnon), tel qu'imprimé" }, classification: { type: "string", enum: ["compagnon", "apprenti-1", "apprenti-2", "apprenti-3", "apprenti-4", "apprenti-5"] }, base_wage: { type: "number", description: "Taux horaire ($)" }, vacation: { type: ["number", "null"], description: "Indemnité de vacances ($)" }, benefits: { type: ["number", "null"], description: "Avantages sociaux ($)" }, contributions_sum: { type: ["number", "null"], description: "Somme de toutes les autres colonnes entre avantages sociaux et Total ($)" }, total: { type: "number", description: "Colonne Total ($)" }, }, required: ["trade", "classification", "base_wage", "vacation", "benefits", "contributions_sum", "total"], }, }, }, required: ["effective_date", "sector", "rows"], }, }; const SYSTEM = `Tu es un moteur de transcription de tableaux. On te fournit un PDF de l'APCHQ « Coût horaire de la main-d'œuvre » (grille par métier : taux horaire, indemnité de vacances, avantages sociaux, cotisations et prélèvements, Total). Règles strictes : transcris UNIQUEMENT les nombres imprimés, sans arrondir ni calculer ; une ligne par métier/occupation et par classification (la ligne du métier = compagnon, puis « Apprenti-période n » = apprenti-n) ; si une valeur est illisible, mets null ; ne saute aucune ligne, y compris la section « Occupations » ; n'invente jamais une ligne. Réponds seulement par l'appel de l'outil record_rows.`; export interface AiExtractResult { rows: ApchqRow[]; checked: number; dropped: number; usage: { input: number; output: number; cacheRead: number; latencyMs: number }; } export async function downloadPdf(url: string, timeoutMs = 60_000): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Vrai-Prix cost connector (contact@spboucher.ai)" } }); if (!res.ok) throw new Error(`PDF HTTP ${res.status}`); return Buffer.from(await res.arrayBuffer()); } finally { clearTimeout(t); } } /** Croise la transcription IA avec les couples (base, total) trouvés par le parseur déterministe. */ export function reconcile(ai: AiRow[], regexRows: ApchqRow[], fixedOther: number): { rows: ApchqRow[]; dropped: number } { const pairs = new Set(regexRows.map((r) => `${r.baseWage.toFixed(2)}|${r.total.toFixed(2)}`)); const bases = new Set(regexRows.map((r) => r.baseWage.toFixed(2))); const totals = new Set(regexRows.map((r) => r.total.toFixed(2))); const rows: ApchqRow[] = []; let dropped = 0; const seen = new Set(); for (const a of ai) { if (!(a.base_wage > 10 && a.base_wage < 200) || !(a.total > a.base_wage * 1.25 && a.total < a.base_wage * 2.1)) { dropped++; continue; } const key = `${a.base_wage.toFixed(2)}|${a.total.toFixed(2)}`; const inRegex = pairs.has(key) || (bases.has(a.base_wage.toFixed(2)) && totals.has(a.total.toFixed(2))); const vac = a.vacation != null && a.vacation >= a.base_wage * 0.1 && a.vacation <= a.base_wage * 0.16 ? a.vacation : Math.round(a.base_wage * 0.13 * 100) / 100; const ben = a.benefits != null && a.benefits >= 3 && a.benefits <= 14 ? a.benefits : null; const sumOk = a.contributions_sum != null && ben != null ? Math.abs(a.base_wage + vac + ben + a.contributions_sum - a.total) <= 0.08 : false; if (!inRegex && !sumOk) { dropped++; continue; } const k = `${a.trade.toLowerCase()}|${a.classification}`; if (seen.has(k)) continue; seen.add(k); const benefits = ben ?? 0; const employer = Math.max(0, Math.round((a.total - a.base_wage - vac - benefits - fixedOther) * 100) / 100); rows.push({ trade: a.trade.trim(), classification: a.classification, baseWage: a.base_wage, vacation: vac, benefits, otherFixed: fixedOther, employer, total: a.total, exactBreakdown: inRegex && sumOk }); } return { rows, dropped }; } export async function extractApchqWithClaude(pdf: Buffer, regexRows: ApchqRow[], meta: { url: string; sector: string; effectiveDate: string }, d: Database.Database = getCostDb()): Promise { const client = new Anthropic(); const t0 = Date.now(); const res = await client.messages.create({ model: MODEL, max_tokens: 16000, system: SYSTEM, tools: [TOOL], tool_choice: { type: "tool", name: "record_rows" }, messages: [{ role: "user", content: [ { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } }, { type: "text", text: `Grille « ${meta.sector} » en vigueur au ${meta.effectiveDate} (${meta.url}). Transcris toutes les lignes.` }, ] }], }); const latencyMs = Date.now() - t0; const usage = { input: res.usage.input_tokens, output: res.usage.output_tokens, cacheRead: res.usage.cache_read_input_tokens ?? 0, latencyMs }; try { d.prepare("INSERT INTO ai_usage(analysis_id,purpose,model,input_images,input_tokens,output_tokens,cache_read_tokens,estimated_cost_usd,latency_ms) VALUES(NULL,'apchq_extract',?,0,?,?,?,?,?)") .run(MODEL, usage.input, usage.output, usage.cacheRead, (usage.input * 1 + usage.output * 5) / 1e6, latencyMs); } catch { /* table absente en test */ } const tu = res.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use"); if (!tu) return { rows: [], checked: 0, dropped: 0, usage }; const input = tu.input as { rows?: AiRow[] }; const ai = Array.isArray(input.rows) ? input.rows.filter((r) => r && typeof r.trade === "string" && typeof r.base_wage === "number" && typeof r.total === "number") : []; const fixedOther = regexRows[0]?.otherFixed ?? 1.203; const { rows, dropped } = reconcile(ai, regexRows, fixedOther); return { rows, checked: ai.length, dropped, usage }; }