SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
7.6 KB · 137 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Lecture assistée des grilles APCHQ : Claude Haiku 4.5 lit le PDF (document4 * base64) et TRANSCRIT les lignes (métier, classification, montants). L'IA ne5 * fixe aucun prix : chaque ligne est ensuite vérifiée doublement —6 *   1. cohérence interne : base + vacances + avantages + cotisations ≈ total ;7 *   2. les couples (taux horaire, total) doivent exister dans l'extraction8 *      déterministe (regex) du même document.9 * Les lignes qui échouent sont rejetées. Sert à rétablir les libellés que la10 * conversion PDF → markdown décale ou fusionne.11 */12import Anthropic from "@anthropic-ai/sdk";13import type Database from "better-sqlite3";14import { getCostDb } from "../db";15import type { ApchqRow } from "./apchq";1617const MODEL = "claude-haiku-4-5";1819interface AiRow {20  trade: string;21  classification: string;22  base_wage: number;23  vacation: number | null;24  benefits: number | null;25  total: number;26  contributions_sum: number | null;27}2829const TOOL: Anthropic.Tool = {30  name: "record_rows",31  description: "Transcrit toutes les lignes de la grille de coût horaire (une ligne par métier/occupation et par classification).",32  strict: true,33  input_schema: {34    type: "object",35    additionalProperties: false,36    properties: {37      effective_date: { type: ["string", "null"], description: "Date d'entrée en vigueur (YYYY-MM-DD) lue dans l'en-tête" },38      sector: { type: ["string", "null"], description: "Secteur lu dans l'en-tête (résidentiel léger, lourd, institutionnel-commercial…)" },39      rows: {40        type: "array",41        items: {42          type: "object",43          additionalProperties: false,44          properties: {45            trade: { type: "string", description: "Libellé exact du métier ou de l'occupation (ligne compagnon), tel qu'imprimé" },46            classification: { type: "string", enum: ["compagnon", "apprenti-1", "apprenti-2", "apprenti-3", "apprenti-4", "apprenti-5"] },47            base_wage: { type: "number", description: "Taux horaire ($)" },48            vacation: { type: ["number", "null"], description: "Indemnité de vacances ($)" },49            benefits: { type: ["number", "null"], description: "Avantages sociaux ($)" },50            contributions_sum: { type: ["number", "null"], description: "Somme de toutes les autres colonnes entre avantages sociaux et Total ($)" },51            total: { type: "number", description: "Colonne Total ($)" },52          },53          required: ["trade", "classification", "base_wage", "vacation", "benefits", "contributions_sum", "total"],54        },55      },56    },57    required: ["effective_date", "sector", "rows"],58  },59};6061const 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).62Rè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.`;6364export interface AiExtractResult {65  rows: ApchqRow[];66  checked: number;67  dropped: number;68  usage: { input: number; output: number; cacheRead: number; latencyMs: number };69}7071export async function downloadPdf(url: string, timeoutMs = 60_000): Promise<Buffer> {72  const ctrl = new AbortController();73  const t = setTimeout(() => ctrl.abort(), timeoutMs);74  try {75    const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Vrai-Prix cost connector (contact@spboucher.ai)" } });76    if (!res.ok) throw new Error(`PDF HTTP ${res.status}`);77    return Buffer.from(await res.arrayBuffer());78  } finally {79    clearTimeout(t);80  }81}8283/** Croise la transcription IA avec les couples (base, total) trouvés par le parseur déterministe. */84export function reconcile(ai: AiRow[], regexRows: ApchqRow[], fixedOther: number): { rows: ApchqRow[]; dropped: number } {85  const pairs = new Set(regexRows.map((r) => `${r.baseWage.toFixed(2)}|${r.total.toFixed(2)}`));86  const bases = new Set(regexRows.map((r) => r.baseWage.toFixed(2)));87  const totals = new Set(regexRows.map((r) => r.total.toFixed(2)));88  const rows: ApchqRow[] = [];89  let dropped = 0;90  const seen = new Set<string>();91  for (const a of ai) {92    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; }93    const key = `${a.base_wage.toFixed(2)}|${a.total.toFixed(2)}`;94    const inRegex = pairs.has(key) || (bases.has(a.base_wage.toFixed(2)) && totals.has(a.total.toFixed(2)));95    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;96    const ben = a.benefits != null && a.benefits >= 3 && a.benefits <= 14 ? a.benefits : null;97    const sumOk = a.contributions_sum != null && ben != null ? Math.abs(a.base_wage + vac + ben + a.contributions_sum - a.total) <= 0.08 : false;98    if (!inRegex && !sumOk) { dropped++; continue; }99    const k = `${a.trade.toLowerCase()}|${a.classification}`;100    if (seen.has(k)) continue;101    seen.add(k);102    const benefits = ben ?? 0;103    const employer = Math.max(0, Math.round((a.total - a.base_wage - vac - benefits - fixedOther) * 100) / 100);104    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 });105  }106  return { rows, dropped };107}108109export async function extractApchqWithClaude(pdf: Buffer, regexRows: ApchqRow[], meta: { url: string; sector: string; effectiveDate: string }, d: Database.Database = getCostDb()): Promise<AiExtractResult> {110  const client = new Anthropic();111  const t0 = Date.now();112  const res = await client.messages.create({113    model: MODEL,114    max_tokens: 16000,115    system: SYSTEM,116    tools: [TOOL],117    tool_choice: { type: "tool", name: "record_rows" },118    messages: [{ role: "user", content: [119      { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } },120      { type: "text", text: `Grille « ${meta.sector} » en vigueur au ${meta.effectiveDate} (${meta.url}). Transcris toutes les lignes.` },121    ] }],122  });123  const latencyMs = Date.now() - t0;124  const usage = { input: res.usage.input_tokens, output: res.usage.output_tokens, cacheRead: res.usage.cache_read_input_tokens ?? 0, latencyMs };125  try {126    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,?,?,?,?,?)")127      .run(MODEL, usage.input, usage.output, usage.cacheRead, (usage.input * 1 + usage.output * 5) / 1e6, latencyMs);128  } catch { /* table absente en test */ }129  const tu = res.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");130  if (!tu) return { rows: [], checked: 0, dropped: 0, usage };131  const input = tu.input as { rows?: AiRow[] };132  const ai = Array.isArray(input.rows) ? input.rows.filter((r) => r && typeof r.trade === "string" && typeof r.base_wage === "number" && typeof r.total === "number") : [];133  const fixedOther = regexRows[0]?.otherFixed ?? 1.203;134  const { rows, dropped } = reconcile(ai, regexRows, fixedOther);135  return { rows, checked: ai.length, dropped, usage };136}137