SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
12.8 KB · 239 lines typescript
Raw Blame History
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