JavaScript 56.7%
TypeScript 42.6%
1/**2 * Étape 2 — Plan : à partir des extractions, Claude Opus 5 propose par espace une arborescence3 * logique + un nom explicite pour chaque fichier. Les photos sont regroupées en albums.4 * Sortie : data/plan.json (validé : chaque fichier exactement une fois, noms uniques, extensions conservées).5 */6import Anthropic from "@anthropic-ai/sdk";7import fs from "fs";8import path from "path";9import { prisma } from "../../lib/prisma";10import {11 EXTRACT_PATH, MODEL, PLAN_PATH, cleanName, extOf, filesInScope, pad, parseJson, readJsonl, sharedSpacesInScope, slugTitle,12 type Extraction, DATA_DIR,13} from "./common";1415interface DupesFile { groups: { master: string; duplicates: string[] }[]; duplicateIds: string[] }16function loadDupes(): DupesFile {17 const p = path.join(DATA_DIR, "dupes.json");18 return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : { groups: [], duplicateIds: [] };19}2021/** Les copies strictement identiques (même SHA-256) rejoignent « 99 Doublons identiques » avec le nom du fichier maître. */22function applyDupes(files: PlanFile[], dupes: DupesFile) {23 const byId = new Map(files.map((f) => [f.id, f]));24 for (const g of dupes.groups) {25 const master = byId.get(g.master);26 if (!master) continue;27 g.duplicates.forEach((id, i) => {28 const d = byId.get(id);29 if (!d) return;30 const ext = extOf(master.name);31 const stem = ext ? master.name.slice(0, -(ext.length + 1)) : master.name;32 d.folder = "99 Doublons identiques";33 d.name = `${stem}_doublon${g.duplicates.length > 1 ? "-" + (i + 1) : ""}${ext ? "." + ext : ""}`;34 d.summary = `Copie identique (même contenu) de « ${master.folder}/${master.name} ».`;35 });36 }37}3839const client = new Anthropic();4041export interface PlanFile { id: string; oldName: string; oldFolder: string; folder: string; name: string; summary?: string }42export interface PlanSpace { spaceId: string; oldName: string; newName: string; files: PlanFile[]; notes?: string }43export interface Plan { generatedAt: string; spaces: PlanSpace[] }4445const CONTEXT = `Contexte : cloud familial privé (Québec) partagé entre Richard Boucher et son fils Simon-Pierre Boucher.46Sylvie Defoy (conjointe de Richard, mère de Simon-Pierre) est décédée en février 2026. Les documents concernent sa succession,47ses placements, les impôts (Sylvie, Simon-Pierre, Richard), des assurances, un condo à Hallandale Beach (Floride), Hydro-Québec, etc.`;4849const DOC_RULES = `Tu es un archiviste méticuleux. Propose une organisation ULTRA STRUCTURÉE, logique et lisible pour un humain, en français avec accents.5051RÈGLES DES DOSSIERS52- Arborescence de 1 à 2 niveaux maximum (« Niveau 1/Niveau 2 »), séparateur « / ».53- Dossiers de niveau 1 numérotés « 01 », « 02 »… dans un ordre logique (officiel → financier → fiscal → autres). Niveau 2 non numéroté.54- Regrouper par NATURE puis par INSTITUTION ou par ANNÉE (ex. « 02 Comptes bancaires/RBC », « 01 Année d'imposition 2025/Feuillets T5 et Relevés 3 »).55- Fusionner les doublons de dossiers actuels (ex. « Banque Royale » ×2, « RBC Placement Direct » / « RBC placement Direct », « Caisse Desjardins » / « Caisse Desjardins »).56- Pas de dossier pour 1 seul fichier si un dossier frère peut l'accueillir logiquement ; pas de dossier vide.57- Noms de dossiers : mots entiers, accents, espaces (pas de _), ≤ 60 caractères.5859RÈGLES DES NOMS DE FICHIERS60- Format : AAAA-MM-JJ_Émetteur_Type-de-document_Détail.ext (si la date exacte est inconnue : AAAA-MM_ ou AAAA_ ; si aucune date : omettre).61- Composants séparés par « _ », mots d'un même composant séparés par « - ». Accents permis. ≤ 100 caractères. Conserver l'extension d'origine en minuscules.62- Le nom doit permettre de comprendre le contenu SANS ouvrir le fichier (institution, type exact, compte/police abrégé, période, personne si ambigu).63- Deux fichiers identiques (doublons évidents : même document, même compte, même période) : garder les deux mais suffixer « _copie-2 » le second.64- Ne jamais inventer de données : n'utilise que les métadonnées fournies.6566FORMAT DE RÉPONSE : UNIQUEMENT un objet JSON :67{68 "space_name": "nom propre et lisible pour l'espace partagé (ex. « Succession — Sylvie Defoy », « Impôts — Simon-Pierre Boucher »), accents, ≤ 50 caractères",69 "files": [ { "id": "identifiant fourni", "folder": "01 Dossier/Sous-dossier", "name": "AAAA-MM-JJ_Émetteur_Type_Détail.ext" } ],70 "notes": "2-3 phrases : logique retenue, doublons repérés, points d'attention"71}72Chaque identifiant fourni doit apparaître EXACTEMENT une fois dans "files".`;7374const PHOTO_RULES = `Tu es un archiviste de photos de famille. On te fournit, dans l'ORDRE DE NUMÉRISATION (les tirages d'un même album/événement ont été scannés à la suite), la description de chaque photo : index #n, titre, scène, hypothèse d'événement, indice de lieu, époque estimée, personnes, saison.7576Regroupe les photos en ALBUMS cohérents (événement, voyage, période, thème). Règles :77- Utilise fortement l'ordre de numérisation : des indices consécutifs appartiennent presque toujours au même album ; découpe aux vrais changements de contexte.78- 6 à 25 albums au total ; un album a en général ≥ 8 photos (fusionne les petits fragments dans l'album voisin le plus logique ; un album « Divers » est acceptable pour les orphelins).79- Nom d'album : « AAAA? Événement ou thème — Lieu » quand l'époque est raisonnablement estimable (ex. « Années 1990 — Vacances à Virginia Beach », « Gala et soupers professionnels — Château Frontenac », « Portraits de famille », « Hiver et fêtes de Noël »). Français, accents, ≤ 60 caractères, sans « / ».80- Ordonne les albums chronologiquement (approximatif), numérotés « 01 », « 02 »…81FORMAT DE RÉPONSE : UNIQUEMENT un objet JSON :82{ "albums": [ { "folder": "01 Années 1990 — Vacances à Virginia Beach", "indices": ["1-59", "61", "63-70"] } ], "notes": "logique retenue" }83Les "indices" sont des index #n ou des plages "a-b" (inclusives). Chaque index de 1 à N doit apparaître exactement une fois.`;8485function v(x: unknown): string { return x === null || x === undefined || x === "" ? "—" : String(x); }8687async function ask(system: string, user: string, maxTokens: number): Promise<Record<string, unknown>> {88 for (let attempt = 0; attempt < 3; attempt++) {89 const stream = client.messages.stream({90 model: MODEL,91 max_tokens: maxTokens,92 system,93 thinking: { type: "adaptive" },94 output_config: { effort: "high" },95 messages: [{ role: "user", content: user }],96 });97 const res = await stream.finalMessage();98 if (res.stop_reason === "refusal") throw new Error("refusal: " + ((res as unknown as { stop_details?: { explanation?: string } }).stop_details?.explanation ?? ""));99 const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n");100 const json = parseJson(text);101 if (json) return json;102 console.warn("Réponse sans JSON exploitable, nouvel essai…", text.slice(0, 200));103 }104 throw new Error("Impossible d'obtenir un JSON valide");105}106107function uniqueNames(files: PlanFile[]) {108 const seen = new Map<string, number>();109 for (const f of files) {110 const key = `${f.folder}::${f.name.toLowerCase()}`;111 const n = seen.get(key) ?? 0;112 seen.set(key, n + 1);113 if (n > 0) {114 const ext = extOf(f.name);115 const stem = ext ? f.name.slice(0, -(ext.length + 1)) : f.name;116 f.name = `${stem}_copie-${n + 1}${ext ? "." + ext : ""}`;117 }118 }119}120121function normalizeFolder(p: string): string {122 return p.split("/").map((s) => cleanName(s, 60)).filter(Boolean).slice(0, 2).join("/") || "01 Documents";123}124125function normalizeFileName(proposed: string, oldName: string): string {126 const oldExt = extOf(oldName);127 let stem = proposed.replace(/\.[A-Za-z0-9]{1,6}$/, "");128 stem = stem.normalize("NFC").replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "_").replace(/_{2,}/g, "_").replace(/^[_\-.]+|[_\-.]+$/g, "");129 if (stem.length > 100) stem = stem.slice(0, 100).replace(/[_\-.]+$/g, "");130 if (!stem) stem = oldName.replace(/\.[A-Za-z0-9]{1,6}$/, "");131 return oldExt ? `${stem}.${oldExt}` : stem;132}133134function photoSeq(name: string): number {135 // FastFoto_2026_02_0998_a.jpeg → 0998 ; 01_Baignade… → 1 (série déjà nommée : placée avant les FastFoto)136 const m = name.match(/^FastFoto_(\d{4})_(\d{2})_(\d{2,4})(?:_(\d{2,4}))?/);137 if (m) {138 const n = m[4] ? Number(m[3]) * 10000 + Number(m[4]) : Number(m[3]);139 return 1_000_000 + n;140 }141 const k = name.match(/^(\d{1,4})[_\- ]/);142 return k ? Number(k[1]) : 5_000_000;143}144145function expandIndices(spec: unknown[], n: number): number[] {146 const out: number[] = [];147 for (const s of spec) {148 const str = String(s).trim();149 const m = str.match(/^#?(\d+)\s*[-–]\s*#?(\d+)$/);150 if (m) { const a = Number(m[1]), b = Number(m[2]); for (let i = Math.min(a, b); i <= Math.max(a, b); i++) if (i >= 1 && i <= n) out.push(i); }151 else { const i = Number(str.replace("#", "")); if (i >= 1 && i <= n) out.push(i); }152 }153 return out;154}155156async function planDocSpace(space: { id: string; name: string }, exts: Extraction[]): Promise<PlanSpace> {157 const lines = exts.map((e) => {158 const d = (e.data ?? {}) as Record<string, unknown>;159 return `ID=${e.id} | actuel="${e.name}" | dossier="${e.folderName}" | type=${v(d.doc_type)} | émetteur=${v(d.issuer)} | personne=${v(d.person)} | date=${v(d.date)} | période=${v(d.period)} | année-fiscale=${v(d.tax_year)} | compte=${v(d.account)} | sujet=${v(d.topic)} | résumé=${v(d.summary)} | proposition=${v(d.filename_stem)}${e.ok ? "" : " | ⚠ analyse impossible : " + e.error}`;160 });161 const user = `Espace partagé actuel : « ${space.name} » (${exts.length} fichiers).\n\nFICHIERS :\n${lines.join("\n")}`;162 const json = await ask(`${CONTEXT}\n\n${DOC_RULES}`, user, 32000);163 const byId = new Map(exts.map((e) => [e.id, e]));164 const proposed = Array.isArray(json.files) ? (json.files as Array<Record<string, unknown>>) : [];165 const files: PlanFile[] = [];166 const seen = new Set<string>();167 for (const p of proposed) {168 const id = String(p.id ?? "");169 const e = byId.get(id);170 if (!e || seen.has(id)) continue;171 seen.add(id);172 files.push({ id, oldName: e.name, oldFolder: e.folderName, folder: normalizeFolder(String(p.folder ?? "")), name: normalizeFileName(String(p.name ?? e.name), e.name), summary: (e.data as Record<string, unknown> | undefined)?.summary as string | undefined });173 }174 for (const e of exts) if (!seen.has(e.id)) {175 const d = (e.data ?? {}) as Record<string, unknown>;176 files.push({ id: e.id, oldName: e.name, oldFolder: e.folderName, folder: "99 À classer", name: normalizeFileName(String(d.filename_stem ?? e.name), e.name), summary: d.summary as string | undefined });177 }178 applyDupes(files, loadDupes());179 uniqueNames(files);180 return { spaceId: space.id, oldName: space.name, newName: cleanName(String(json.space_name ?? space.name), 50), files, notes: typeof json.notes === "string" ? json.notes : undefined };181}182183async function planPhotoSpace(space: { id: string; name: string }, exts: Extraction[]): Promise<PlanSpace> {184 const ordered = [...exts].sort((a, b) => photoSeq(a.name) - photoSeq(b.name) || a.name.localeCompare(b.name));185 const lines = ordered.map((e, i) => {186 const d = (e.data ?? {}) as Record<string, unknown>;187 return `#${i + 1} | ${v(d.title)} | scène=${v(d.scene)} | événement=${v(d.event)} | lieu=${v(d.location_hint)} | époque=${v(d.era)} | personnes=${v(d.people)} | saison=${v(d.season)} | ${d.indoor ? "intérieur" : "extérieur"}`;188 });189 const json = await ask(`${CONTEXT}\n\n${PHOTO_RULES}`, `${ordered.length} photos dans l'ordre de numérisation :\n${lines.join("\n")}`, 24000);190 const albums = Array.isArray(json.albums) ? (json.albums as Array<Record<string, unknown>>) : [];191 const assigned = new Map<number, string>();192 const albumOrder: string[] = [];193 for (const a of albums) {194 const folder = cleanName(String(a.folder ?? "Divers"), 60);195 albumOrder.push(folder);196 for (const i of expandIndices(Array.isArray(a.indices) ? a.indices : [], ordered.length)) if (!assigned.has(i)) assigned.set(i, folder);197 }198 // Orphelins → album voisin précédent, sinon « Divers »199 let last = albumOrder[0] ?? "99 Divers";200 for (let i = 1; i <= ordered.length; i++) {201 if (assigned.has(i)) last = assigned.get(i)!;202 else assigned.set(i, last);203 }204 // Deux scans portant le même nom d'origine (FastFoto : original + version retouchée) partagent numéro et titre : « _version-2 ».205 const counters = new Map<string, number>();206 const firstByOldName = new Map<string, { n: number; titlePart: string; folder: string; versions: number }>();207 const files: PlanFile[] = ordered.map((e, idx) => {208 let folder = assigned.get(idx + 1)!;209 const d = (e.data ?? {}) as Record<string, unknown>;210 const title = typeof d.title === "string" && d.title.trim() ? d.title.trim() : "Photo";211 let titlePart = title.normalize("NFC").replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60) || slugTitle(title);212 const ext = extOf(e.name) || "jpeg";213 const prev = firstByOldName.get(e.name);214 let n: number; let suffix = "";215 if (prev) {216 prev.versions++; n = prev.n; titlePart = prev.titlePart; folder = prev.folder; suffix = `_version-${prev.versions}`;217 } else {218 n = (counters.get(folder) ?? 0) + 1; counters.set(folder, n);219 firstByOldName.set(e.name, { n, titlePart, folder, versions: 1 });220 }221 return { id: e.id, oldName: e.name, oldFolder: e.folderName, folder, name: `${pad(n)}_${titlePart}${suffix}.${ext}`, summary: typeof d.description === "string" ? d.description : undefined };222 });223 applyDupes(files, loadDupes());224 uniqueNames(files);225 return { spaceId: space.id, oldName: space.name, newName: "Photos de famille", files, notes: typeof json.notes === "string" ? json.notes : undefined };226}227228async function main() {229 const exts = readJsonl<Extraction>(EXTRACT_PATH);230 const latest = new Map<string, Extraction>();231 for (const e of exts) latest.set(e.id, e); // dernière extraction par fichier232 const scope = await filesInScope();233 const spaces = await sharedSpacesInScope();234 const only = process.argv.find((a) => a.startsWith("--space="))?.split("=")[1];235 const missing = scope.filter((f) => !latest.has(f.id));236 if (missing.length) console.warn(`⚠ ${missing.length} fichiers sans extraction — ils seront classés avec leurs métadonnées de base.`);237 for (const f of missing) latest.set(f.id, { id: f.id, name: f.name, spaceId: f.spaceId, spaceName: f.spaceName, folderName: f.folderName, mimeType: f.mimeType, kind: "other", ok: false, error: "non extrait", at: "" });238239 const existing: Plan = fs.existsSync(PLAN_PATH) ? JSON.parse(fs.readFileSync(PLAN_PATH, "utf8")) : { generatedAt: "", spaces: [] };240 const plan: Plan = { generatedAt: new Date().toISOString(), spaces: [] };241 for (const s of spaces) {242 const inSpace = scope.filter((f) => f.spaceId === s.id).map((f) => latest.get(f.id)!);243 if (inSpace.length === 0) continue;244 if (only && s.name !== only && s.id !== only) {245 const prev = existing.spaces.find((p) => p.spaceId === s.id);246 if (prev) plan.spaces.push(prev);247 continue;248 }249 console.log(`→ ${s.name} : ${inSpace.length} fichiers…`);250 const isPhoto = inSpace.filter((e) => e.kind === "photo").length > inSpace.length * 0.8;251 const ps = isPhoto ? await planPhotoSpace(s, inSpace) : await planDocSpace(s, inSpace);252 plan.spaces.push(ps);253 const folders = new Set(ps.files.map((f) => f.folder));254 console.log(` « ${ps.newName} » · ${folders.size} dossiers · ${ps.files.length} fichiers\n ${ps.notes ?? ""}`);255 fs.writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));256 }257 fs.writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));258 console.log(`Plan écrit : ${PLAN_PATH}`);259 await prisma.$disconnect();260}261262main().catch((e) => { console.error(e); process.exit(1); });263