JavaScript 56.7%
TypeScript 42.6%
1/**2 * Étape 1 — Extraction : décrit chaque fichier du périmètre avec Claude Opus 53 * (vision pour les photos, document PDF natif, texte pour le reste).4 * Résultats mis en cache dans data/extract.jsonl (idempotent : relancer reprend où ça s'est arrêté).5 */6import Anthropic from "@anthropic-ai/sdk";7import fs from "fs";8import os from "os";9import path from "path";10import { execFile } from "child_process";11import { promisify } from "util";12import sharp from "sharp";13import { PDFDocument } from "pdf-lib";14import mammoth from "mammoth";15import * as XLSX from "xlsx";16import { loadFileData } from "../../lib/storage";17import { prisma } from "../../lib/prisma";18import {19 EXTRACT_PATH, MODEL, appendJsonl, filesInScope, parseJson, pool, readJsonl, sleep,20 type Extraction, type ScopeFile,21} from "./common";2223const client = new Anthropic();24const execFileP = promisify(execFile);25const CONCURRENCY = Number(process.env.ORG_CONCURRENCY || 6);26const MAX_PDF_PAGES = 24;2728const CONTEXT = `Contexte : cloud familial privé (Québec, Canada) partagé entre Richard Boucher et son fils Simon-Pierre Boucher.29Sylvie Defoy (conjointe de Richard, mère de Simon-Pierre) est décédée en février 2026 ; les documents concernent surtout30sa succession, ses placements, les impôts (Sylvie, Simon-Pierre, Richard), des assurances, un condo à Hallandale Beach (Floride),31des factures Hydro-Québec, et des photos de famille numérisées (FastFoto). Réponds en français, avec accents.`;3233const DOC_PROMPT = `${CONTEXT}3435Analyse ce document et réponds UNIQUEMENT avec un objet JSON (aucun texte autour) :36{37 "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…)",38 "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",39 "person": "personne principale concernée : Sylvie Defoy | Simon-Pierre Boucher | Richard Boucher | Succession Sylvie Defoy | autre nom | null",40 "date": "date du document AAAA-MM-JJ (date d'émission/fin de période) ou AAAA-MM ou AAAA ou null",41 "period": "période couverte si relevé (ex. 2025-12, 2026-01 à 2026-02, année 2025) ou null",42 "tax_year": "année d'imposition si feuillet/formulaire fiscal (ex. 2025) ou null",43 "account": "identifiant de compte/police/contrat abrégé (4-8 derniers caractères, ex. 66NRVKE, …1210, CELI) ou null",44 "amount": "montant principal si pertinent (ex. 1 234,56 $) ou null",45 "language": "fr | en",46 "summary": "une phrase (≤ 30 mots) décrivant précisément le contenu",47 "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"48}`;4950const PHOTO_PROMPT = `${CONTEXT}5152Ceci est une photo de famille numérisée (tirage papier scanné). Réponds UNIQUEMENT avec un objet JSON :53{54 "title": "titre court en français (3 à 7 mots, sans article initial, ex. Baignade dans les vagues, Souper de gala au Château Frontenac)",55 "description": "1 phrase descriptive (≤ 25 mots)",56 "scene": "plage | piscine | ville | nature | maison | fête | mariage | gala | restaurant | école | sport | voyage | portrait | hiver | autre",57 "event": "hypothèse d'événement/série (ex. Vacances à Virginia Beach, Gala professionnel, Noël en famille, Portrait studio, Baptême, Voyage…) ",58 "location_hint": "lieu identifiable (enseigne, panneau, texte visible, architecture) ou null",59 "people_count": 0,60 "people": "description brève des personnes (ex. mère et jeune garçon, groupe d'hommes en costume) ou null",61 "child_present": true,62 "indoor": true,63 "era": "décennie estimée d'après vêtements/qualité (ex. 1990s, 2000s, 1980s) ou null",64 "season": "été | hiver | automne | printemps | null",65 "text_visible": "texte lisible dans l'image ou null",66 "orientation_issue": "upside_down | rotated_left | rotated_right | ok",67 "quality": "bonne | moyenne | faible"68}`;6970const TEXT_PROMPT = `${CONTEXT}7172Voici le contenu (possiblement tronqué) d'un fichier texte/données. Réponds UNIQUEMENT avec un objet JSON :73{74 "doc_type": "type (ex. Fiche de compétence IA (skill), Export CSV de transactions, Analyse immobilière, Script Python, Configuration JSON, Note Markdown…)",75 "issuer": "source/outil/institution ou null",76 "person": "personne concernée ou null",77 "date": "AAAA-MM-JJ ou AAAA-MM ou AAAA ou null",78 "topic": "sujet principal en 3-6 mots",79 "summary": "une phrase (≤ 30 mots)",80 "filename_stem": "nom de fichier proposé SANS extension, clair et descriptif (≤ 80 caractères, espaces → _ ou -)"81}`;8283function kindOf(f: ScopeFile): Extraction["kind"] {84 const m = f.mimeType;85 if (m.startsWith("image/")) return "photo";86 if (m === "application/pdf") return "pdf";87 if (m.startsWith("text/") || m.includes("json") || m.includes("python")) return "text";88 if (m.includes("word") || m.includes("sheet") || m.includes("excel") || m.includes("presentation")) return "office";89 return "other";90}9192async function callClaude(content: Anthropic.MessageParam["content"]): Promise<Record<string, unknown>> {93 let lastErr: unknown;94 for (let attempt = 0; attempt < 5; attempt++) {95 try {96 const res = await client.messages.create({97 model: MODEL,98 max_tokens: 1200,99 output_config: { effort: "low" },100 messages: [{ role: "user", content }],101 });102 if (res.stop_reason === "refusal") throw new Error(`refusal: ${(res as unknown as { stop_details?: { explanation?: string } }).stop_details?.explanation ?? ""}`);103 const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n");104 const json = parseJson(text);105 if (!json) throw new Error("JSON introuvable dans la réponse : " + text.slice(0, 200));106 return json;107 } catch (e) {108 lastErr = e;109 if (e instanceof Anthropic.RateLimitError || (e instanceof Anthropic.APIError && (e.status ?? 0) >= 500) || e instanceof Anthropic.APIConnectionError) {110 await sleep(2000 * (attempt + 1) + Math.random() * 1000);111 continue;112 }113 if (e instanceof Error && e.message.startsWith("JSON introuvable") && attempt < 2) continue;114 throw e;115 }116 }117 throw lastErr;118}119120async function pdfForClaude(buf: Buffer): Promise<{ data: Buffer; pages: number; truncated: boolean }> {121 // pdf-lib sert uniquement à compter/tronquer ; s'il échoue (PDF atypique), on envoie le fichier brut.122 try {123 const doc = await PDFDocument.load(buf, { ignoreEncryption: true, updateMetadata: false });124 const pages = doc.getPageCount();125 if (pages <= MAX_PDF_PAGES && buf.length < 20 * 1024 * 1024) return { data: buf, pages, truncated: false };126 const out = await PDFDocument.create();127 const idx = Array.from({ length: Math.min(MAX_PDF_PAGES, pages) }, (_, i) => i);128 const copied = await out.copyPages(doc, idx);129 copied.forEach((p) => out.addPage(p));130 return { data: Buffer.from(await out.save()), pages, truncated: true };131 } catch {132 return { data: buf, pages: 0, truncated: false };133 }134}135136/** Secours : rend les premières pages en images via pdftoppm (poppler) ou sips (macOS, 1re page) quand l'API refuse le PDF. */137async function pdfPagesAsImages(buf: Buffer, maxPages = 6): Promise<Buffer[]> {138 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "spb-pdf-"));139 const src = path.join(dir, "in.pdf");140 fs.writeFileSync(src, buf);141 const out: Buffer[] = [];142 try {143 try {144 await execFileP("pdftoppm", ["-r", "110", "-l", String(maxPages), "-jpeg", src, path.join(dir, "p")]);145 for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("p") && x.endsWith(".jpg")).sort()) out.push(fs.readFileSync(path.join(dir, f)));146 } catch {147 const jpg = path.join(dir, "p1.jpg");148 await execFileP("sips", ["-s", "format", "jpeg", "-Z", "1600", src, "--out", jpg]);149 out.push(fs.readFileSync(jpg));150 }151 } catch { /* aucun convertisseur disponible */ }152 fs.rmSync(dir, { recursive: true, force: true });153 return out;154}155156async function extractOne(f: ScopeFile): Promise<Extraction> {157 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() };158 const hint = `Nom actuel : ${f.name}\nDossier actuel : ${f.folderName}\nEspace : ${f.spaceName}`;159 try {160 const buf = await loadFileData(f.storagePath, f.isEncrypted);161 let data: Record<string, unknown>;162 if (base.kind === "photo") {163 if (!/jpeg|png|webp|gif/.test(f.mimeType)) throw new Error("format image non pris en charge : " + f.mimeType);164 const img = await sharp(buf).rotate().resize({ width: 1280, height: 1280, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 82 }).toBuffer();165 const meta = await sharp(buf).metadata();166 data = await callClaude([167 { type: "image", source: { type: "base64", media_type: "image/jpeg", data: img.toString("base64") } },168 { type: "text", text: `${PHOTO_PROMPT}\n\n${hint}` },169 ]);170 data._width = meta.width; data._height = meta.height;171 } else if (base.kind === "pdf") {172 const { data: pdf, pages, truncated } = await pdfForClaude(buf);173 try {174 data = await callClaude([175 { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } },176 { type: "text", text: `${DOC_PROMPT}\n\n${hint}${truncated ? `\n(Seules les ${MAX_PDF_PAGES} premières pages sur ${pages} sont fournies.)` : ""}` },177 ]);178 } catch (e) {179 if (!(e instanceof Anthropic.BadRequestError)) throw e;180 const imgs = await pdfPagesAsImages(buf);181 if (!imgs.length) throw e;182 data = await callClaude([183 ...imgs.map((b) => ({ type: "image" as const, source: { type: "base64" as const, media_type: "image/jpeg" as const, data: b.toString("base64") } })),184 { type: "text", text: `${DOC_PROMPT}\n\n${hint}\n(PDF fourni sous forme d'images des ${imgs.length} premières pages.)` },185 ]);186 data._rendered = true;187 }188 data._pages = pages;189 } else if (base.kind === "office") {190 let text = "";191 if (f.mimeType.includes("word")) text = (await mammoth.extractRawText({ buffer: buf })).value;192 else {193 const wb = XLSX.read(buf, { type: "buffer" });194 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");195 }196 data = await callClaude([{ type: "text", text: `${DOC_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text.slice(0, 12000)}` }]);197 } else if (base.kind === "text") {198 const text = buf.toString("utf8").slice(0, 8000);199 data = await callClaude([{ type: "text", text: `${TEXT_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text}` }]);200 } else {201 throw new Error("type non analysé : " + f.mimeType);202 }203 return { ...base, ok: true, data };204 } catch (e) {205 return { ...base, ok: false, error: e instanceof Error ? e.message : String(e) };206 }207}208209async function main() {210 const files = await filesInScope();211 const done = new Map(readJsonl<Extraction>(EXTRACT_PATH).map((e) => [e.id, e]));212 const retryErrors = process.argv.includes("--retry-errors");213 const limitArg = process.argv.find((a) => a.startsWith("--limit="));214 let todo = files.filter((f) => !done.has(f.id) || (retryErrors && !done.get(f.id)!.ok));215 if (limitArg) {216 // Échantillon varié : quelques fichiers de chaque type217 const n = Number(limitArg.split("=")[1]);218 const byKind = new Map<string, ScopeFile[]>();219 for (const f of todo) { const k = kindOf(f); if (!byKind.has(k)) byKind.set(k, []); byKind.get(k)!.push(f); }220 todo = Array.from(byKind.values()).flatMap((arr) => arr.slice(0, Math.max(1, Math.ceil(n / byKind.size))));221 }222 console.log(`Périmètre : ${files.length} fichiers · déjà extraits : ${done.size} · à traiter : ${todo.length} · modèle ${MODEL} · concurrence ${CONCURRENCY}`);223 let n = 0, ok = 0, ko = 0;224 const t0 = Date.now();225 await pool(todo, CONCURRENCY, async (f) => {226 const ext = await extractOne(f);227 appendJsonl(EXTRACT_PATH, ext);228 n++; if (ext.ok) ok++; else ko++;229 if (n % 25 === 0 || !ext.ok) {230 const rate = n / ((Date.now() - t0) / 60000);231 console.log(`[${n}/${todo.length}] ok=${ok} ko=${ko} · ${rate.toFixed(1)}/min · ${ext.ok ? "✓" : "✗ " + ext.error} ${f.spaceName} / ${f.name}`);232 }233 });234 console.log(`Terminé : ${ok} ok, ${ko} erreurs, ${((Date.now() - t0) / 60000).toFixed(1)} min`);235 await prisma.$disconnect();236}237238main().catch((e) => { console.error(e); process.exit(1); });239