/** * Étape 1 — Extraction : décrit chaque fichier du périmètre avec Claude Opus 5 * (vision pour les photos, document PDF natif, texte pour le reste). * Résultats mis en cache dans data/extract.jsonl (idempotent : relancer reprend où ça s'est arrêté). */ import Anthropic from "@anthropic-ai/sdk"; import fs from "fs"; import os from "os"; import path from "path"; import { execFile } from "child_process"; import { promisify } from "util"; import sharp from "sharp"; import { PDFDocument } from "pdf-lib"; import mammoth from "mammoth"; import * as XLSX from "xlsx"; import { loadFileData } from "../../lib/storage"; import { prisma } from "../../lib/prisma"; import { EXTRACT_PATH, MODEL, appendJsonl, filesInScope, parseJson, pool, readJsonl, sleep, type Extraction, type ScopeFile, } from "./common"; const client = new Anthropic(); const execFileP = promisify(execFile); const CONCURRENCY = Number(process.env.ORG_CONCURRENCY || 6); const MAX_PDF_PAGES = 24; const CONTEXT = `Contexte : cloud familial privé (Québec, Canada) partagé entre Richard Boucher et son fils Simon-Pierre Boucher. Sylvie Defoy (conjointe de Richard, mère de Simon-Pierre) est décédée en février 2026 ; les documents concernent surtout sa succession, ses placements, les impôts (Sylvie, Simon-Pierre, Richard), des assurances, un condo à Hallandale Beach (Floride), des factures Hydro-Québec, et des photos de famille numérisées (FastFoto). Réponds en français, avec accents.`; const DOC_PROMPT = `${CONTEXT} Analyse ce document et réponds UNIQUEMENT avec un objet JSON (aucun texte autour) : { "doc_type": "type précis (ex. Relevé de compte, Feuillet T5, Relevé 3, T4, Relevé 1, Facture, Certificat d'assurance, Acte de décès, Contrat, Formulaire fiscal, Rapport de performance, Sommaire des transactions, Lettre, Reçu, Avis de décès…)", "issuer": "émetteur (institution / entreprise / organisme, forme courte et courante ex. RBC, Banque Nationale, Desjardins, TD, Scotia, Hydro-Québec, Revenu Québec, ARC, Wealthsimple…) ou null", "person": "personne principale concernée : Sylvie Defoy | Simon-Pierre Boucher | Richard Boucher | Succession Sylvie Defoy | autre nom | null", "date": "date du document AAAA-MM-JJ (date d'émission/fin de période) ou AAAA-MM ou AAAA ou null", "period": "période couverte si relevé (ex. 2025-12, 2026-01 à 2026-02, année 2025) ou null", "tax_year": "année d'imposition si feuillet/formulaire fiscal (ex. 2025) ou null", "account": "identifiant de compte/police/contrat abrégé (4-8 derniers caractères, ex. 66NRVKE, …1210, CELI) ou null", "amount": "montant principal si pertinent (ex. 1 234,56 $) ou null", "language": "fr | en", "summary": "une phrase (≤ 30 mots) décrivant précisément le contenu", "filename_stem": "nom de fichier proposé SANS extension, format AAAA-MM-JJ_Émetteur_Type-de-document_Détail (accents permis, espaces remplacés par _ ou -), ≤ 90 caractères" }`; const PHOTO_PROMPT = `${CONTEXT} Ceci est une photo de famille numérisée (tirage papier scanné). Réponds UNIQUEMENT avec un objet JSON : { "title": "titre court en français (3 à 7 mots, sans article initial, ex. Baignade dans les vagues, Souper de gala au Château Frontenac)", "description": "1 phrase descriptive (≤ 25 mots)", "scene": "plage | piscine | ville | nature | maison | fête | mariage | gala | restaurant | école | sport | voyage | portrait | hiver | autre", "event": "hypothèse d'événement/série (ex. Vacances à Virginia Beach, Gala professionnel, Noël en famille, Portrait studio, Baptême, Voyage…) ", "location_hint": "lieu identifiable (enseigne, panneau, texte visible, architecture) ou null", "people_count": 0, "people": "description brève des personnes (ex. mère et jeune garçon, groupe d'hommes en costume) ou null", "child_present": true, "indoor": true, "era": "décennie estimée d'après vêtements/qualité (ex. 1990s, 2000s, 1980s) ou null", "season": "été | hiver | automne | printemps | null", "text_visible": "texte lisible dans l'image ou null", "orientation_issue": "upside_down | rotated_left | rotated_right | ok", "quality": "bonne | moyenne | faible" }`; const TEXT_PROMPT = `${CONTEXT} Voici le contenu (possiblement tronqué) d'un fichier texte/données. Réponds UNIQUEMENT avec un objet JSON : { "doc_type": "type (ex. Fiche de compétence IA (skill), Export CSV de transactions, Analyse immobilière, Script Python, Configuration JSON, Note Markdown…)", "issuer": "source/outil/institution ou null", "person": "personne concernée ou null", "date": "AAAA-MM-JJ ou AAAA-MM ou AAAA ou null", "topic": "sujet principal en 3-6 mots", "summary": "une phrase (≤ 30 mots)", "filename_stem": "nom de fichier proposé SANS extension, clair et descriptif (≤ 80 caractères, espaces → _ ou -)" }`; function kindOf(f: ScopeFile): Extraction["kind"] { const m = f.mimeType; if (m.startsWith("image/")) return "photo"; if (m === "application/pdf") return "pdf"; if (m.startsWith("text/") || m.includes("json") || m.includes("python")) return "text"; if (m.includes("word") || m.includes("sheet") || m.includes("excel") || m.includes("presentation")) return "office"; return "other"; } async function callClaude(content: Anthropic.MessageParam["content"]): Promise> { let lastErr: unknown; for (let attempt = 0; attempt < 5; attempt++) { try { const res = await client.messages.create({ model: MODEL, max_tokens: 1200, output_config: { effort: "low" }, messages: [{ role: "user", content }], }); if (res.stop_reason === "refusal") throw new Error(`refusal: ${(res as unknown as { stop_details?: { explanation?: string } }).stop_details?.explanation ?? ""}`); const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n"); const json = parseJson(text); if (!json) throw new Error("JSON introuvable dans la réponse : " + text.slice(0, 200)); return json; } catch (e) { lastErr = e; if (e instanceof Anthropic.RateLimitError || (e instanceof Anthropic.APIError && (e.status ?? 0) >= 500) || e instanceof Anthropic.APIConnectionError) { await sleep(2000 * (attempt + 1) + Math.random() * 1000); continue; } if (e instanceof Error && e.message.startsWith("JSON introuvable") && attempt < 2) continue; throw e; } } throw lastErr; } async function pdfForClaude(buf: Buffer): Promise<{ data: Buffer; pages: number; truncated: boolean }> { // pdf-lib sert uniquement à compter/tronquer ; s'il échoue (PDF atypique), on envoie le fichier brut. try { const doc = await PDFDocument.load(buf, { ignoreEncryption: true, updateMetadata: false }); const pages = doc.getPageCount(); if (pages <= MAX_PDF_PAGES && buf.length < 20 * 1024 * 1024) return { data: buf, pages, truncated: false }; const out = await PDFDocument.create(); const idx = Array.from({ length: Math.min(MAX_PDF_PAGES, pages) }, (_, i) => i); const copied = await out.copyPages(doc, idx); copied.forEach((p) => out.addPage(p)); return { data: Buffer.from(await out.save()), pages, truncated: true }; } catch { return { data: buf, pages: 0, truncated: false }; } } /** Secours : rend les premières pages en images via pdftoppm (poppler) ou sips (macOS, 1re page) quand l'API refuse le PDF. */ async function pdfPagesAsImages(buf: Buffer, maxPages = 6): Promise { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "spb-pdf-")); const src = path.join(dir, "in.pdf"); fs.writeFileSync(src, buf); const out: Buffer[] = []; try { try { await execFileP("pdftoppm", ["-r", "110", "-l", String(maxPages), "-jpeg", src, path.join(dir, "p")]); for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("p") && x.endsWith(".jpg")).sort()) out.push(fs.readFileSync(path.join(dir, f))); } catch { const jpg = path.join(dir, "p1.jpg"); await execFileP("sips", ["-s", "format", "jpeg", "-Z", "1600", src, "--out", jpg]); out.push(fs.readFileSync(jpg)); } } catch { /* aucun convertisseur disponible */ } fs.rmSync(dir, { recursive: true, force: true }); return out; } async function extractOne(f: ScopeFile): Promise { const base: Extraction = { id: f.id, name: f.name, spaceId: f.spaceId, spaceName: f.spaceName, folderName: f.folderName, mimeType: f.mimeType, kind: kindOf(f), ok: false, at: new Date().toISOString() }; const hint = `Nom actuel : ${f.name}\nDossier actuel : ${f.folderName}\nEspace : ${f.spaceName}`; try { const buf = await loadFileData(f.storagePath, f.isEncrypted); let data: Record; if (base.kind === "photo") { if (!/jpeg|png|webp|gif/.test(f.mimeType)) throw new Error("format image non pris en charge : " + f.mimeType); const img = await sharp(buf).rotate().resize({ width: 1280, height: 1280, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 82 }).toBuffer(); const meta = await sharp(buf).metadata(); data = await callClaude([ { type: "image", source: { type: "base64", media_type: "image/jpeg", data: img.toString("base64") } }, { type: "text", text: `${PHOTO_PROMPT}\n\n${hint}` }, ]); data._width = meta.width; data._height = meta.height; } else if (base.kind === "pdf") { const { data: pdf, pages, truncated } = await pdfForClaude(buf); try { data = await callClaude([ { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } }, { type: "text", text: `${DOC_PROMPT}\n\n${hint}${truncated ? `\n(Seules les ${MAX_PDF_PAGES} premières pages sur ${pages} sont fournies.)` : ""}` }, ]); } catch (e) { if (!(e instanceof Anthropic.BadRequestError)) throw e; const imgs = await pdfPagesAsImages(buf); if (!imgs.length) throw e; data = await callClaude([ ...imgs.map((b) => ({ type: "image" as const, source: { type: "base64" as const, media_type: "image/jpeg" as const, data: b.toString("base64") } })), { type: "text", text: `${DOC_PROMPT}\n\n${hint}\n(PDF fourni sous forme d'images des ${imgs.length} premières pages.)` }, ]); data._rendered = true; } data._pages = pages; } else if (base.kind === "office") { let text = ""; if (f.mimeType.includes("word")) text = (await mammoth.extractRawText({ buffer: buf })).value; else { const wb = XLSX.read(buf, { type: "buffer" }); text = wb.SheetNames.slice(0, 3).map((n) => `## ${n}\n` + XLSX.utils.sheet_to_csv(wb.Sheets[n]).split("\n").slice(0, 150).join("\n")).join("\n\n"); } data = await callClaude([{ type: "text", text: `${DOC_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text.slice(0, 12000)}` }]); } else if (base.kind === "text") { const text = buf.toString("utf8").slice(0, 8000); data = await callClaude([{ type: "text", text: `${TEXT_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text}` }]); } else { throw new Error("type non analysé : " + f.mimeType); } return { ...base, ok: true, data }; } catch (e) { return { ...base, ok: false, error: e instanceof Error ? e.message : String(e) }; } } async function main() { const files = await filesInScope(); const done = new Map(readJsonl(EXTRACT_PATH).map((e) => [e.id, e])); const retryErrors = process.argv.includes("--retry-errors"); const limitArg = process.argv.find((a) => a.startsWith("--limit=")); let todo = files.filter((f) => !done.has(f.id) || (retryErrors && !done.get(f.id)!.ok)); if (limitArg) { // Échantillon varié : quelques fichiers de chaque type const n = Number(limitArg.split("=")[1]); const byKind = new Map(); for (const f of todo) { const k = kindOf(f); if (!byKind.has(k)) byKind.set(k, []); byKind.get(k)!.push(f); } todo = Array.from(byKind.values()).flatMap((arr) => arr.slice(0, Math.max(1, Math.ceil(n / byKind.size)))); } console.log(`Périmètre : ${files.length} fichiers · déjà extraits : ${done.size} · à traiter : ${todo.length} · modèle ${MODEL} · concurrence ${CONCURRENCY}`); let n = 0, ok = 0, ko = 0; const t0 = Date.now(); await pool(todo, CONCURRENCY, async (f) => { const ext = await extractOne(f); appendJsonl(EXTRACT_PATH, ext); n++; if (ext.ok) ok++; else ko++; if (n % 25 === 0 || !ext.ok) { const rate = n / ((Date.now() - t0) / 60000); console.log(`[${n}/${todo.length}] ok=${ok} ko=${ko} · ${rate.toFixed(1)}/min · ${ext.ok ? "✓" : "✗ " + ext.error} ${f.spaceName} / ${f.name}`); } }); console.log(`Terminé : ${ok} ok, ${ko} erreurs, ${((Date.now() - t0) / 60000).toFixed(1)} min`); await prisma.$disconnect(); } main().catch((e) => { console.error(e); process.exit(1); });