SPB Git forge

spb/spb-cloud

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