Ctrl K
ComunidadGuías…

scripts/relabel-categories.mts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// One-off, idempotent: move every non-manual transaction whose category is not
// in the app taxonomy (raw Plaid OTHER / TRANSFER_OUT / LOAN_PAYMENTS…, and the
// legacy Food / Bills / Transport / Health labels) onto a CATEGORIES value, and
// relabel rules that still hold a legacy label. Hand-set rows
// (category_source='manual') are never touched.
//
// Dry run by default (runs inside a transaction, prints, rolls back):
//   FINANCE_DB_PATH=/path/to/copy.db npx tsx scripts/relabel-categories.mts
// Write it:
//   FINANCE_DB_PATH=data/finance.db npx tsx scripts/relabel-categories.mts --apply
const apply = process.argv.includes("--apply");
if (!process.env.FINANCE_DB_PATH) throw new Error("set FINANCE_DB_PATH explicitly");

const { db } = await import("../lib/db.ts");
const { CATEGORIES, mapPfc } = await import("../lib/taxonomy.ts");
const { applyRules } = await import("../lib/categorize.ts");

const CATS = new Set<string>(CATEGORIES);
// Unambiguous legacy labels. "Bills" is not: it covered a mortgage, a lending
// platform and an ISBN purchase, so it resolves from Plaid's detailed category.
const LEGACY: Record<string, string> = { Food: "Dining", Transport: "Transportation", Health: "Healthcare" };

const d = db();
const counts = () => new Map(
  (d.prepare(`SELECT COALESCE(category,'(null)') AS c, COUNT(*) AS n FROM transactions GROUP BY 1`).all() as
    { c: string; n: number }[]).map((r) => [r.c, r.n]),
);
const before = counts();

d.exec("BEGIN");
try {
  // --- Rules first, so rule-sourced rows re-derive from the relabelled rules.
  const rules = d.prepare(`SELECT id, pattern, field, category FROM rules WHERE category IS NOT NULL`).all() as
    { id: number; pattern: string; field: string; category: string }[];
  const setRuleCat = d.prepare(`UPDATE rules SET category=? WHERE id=?`);
  for (const r of rules) {
    if (CATS.has(r.category)) continue;
    let next: string | null = LEGACY[r.category] ?? null;
    if (!next) {
      // Most common decisive mapping among the rows this rule labelled.
      const rows = d.prepare(
        `SELECT plaid_category AS p, plaid_detailed AS dt FROM transactions WHERE category=? AND category_source='rule'
           AND instr(lower(${r.field === "name" ? "name" : "COALESCE(NULLIF(merchant,''),name)"}), lower(?)) > 0`,
      ).all(r.category, r.pattern) as { p: string | null; dt: string | null }[];
      const tally = new Map<string, number>();
      for (const x of rows) {
        const m = mapPfc(x.p, x.dt);
        if (m && m !== "Other") tally.set(m, (tally.get(m) ?? 0) + 1);
      }
      next = [...tally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
    }
    // No decisive mapping: drop the category, keep the rule's rename/entity job.
    setRuleCat.run(next, r.id);
    console.log(`rule ${r.id} "${r.pattern}": ${r.category} -> ${next ?? "(no category)"}`);
  }

  // --- Transactions.
  const rows = d.prepare(
    `SELECT id, name, json_extract(raw,'$.merchant_name') AS rawMerchant, category, category_source AS src,
            plaid_category AS p, plaid_detailed AS dt
     FROM transactions
     WHERE category IS NOT NULL AND COALESCE(category_source,'') != 'manual'`,
  ).all() as { id: string; name: string | null; rawMerchant: string | null; category: string; src: string | null; p: string | null; dt: string | null }[];
  const setTxn = d.prepare(`UPDATE transactions SET category=?, category_source=?, confidence=? WHERE id=?`);
  let moved = 0;
  for (const t of rows) {
    if (CATS.has(t.category)) continue;
    // Same inputs sync passes to applyRules.
    const ruled = t.src === "rule" ? applyRules(t.rawMerchant ?? t.name, t.name ?? "").category : undefined;
    if (ruled && CATS.has(ruled)) { setTxn.run(ruled, "rule", 1.0, t.id); moved++; continue; }
    const next = LEGACY[t.category] ?? mapPfc(t.p, t.dt) ?? "Other";
    setTxn.run(next, "plaid", 0.5, t.id);
    moved++;
  }

  const after = counts();
  const keys = [...new Set([...before.keys(), ...after.keys()])]
    .filter((k) => (before.get(k) ?? 0) !== (after.get(k) ?? 0))
    .sort((a, b) => (after.get(b) ?? 0) - (before.get(b) ?? 0) - ((after.get(a) ?? 0) - (before.get(a) ?? 0)));
  console.log(`\n${apply ? "APPLIED" : "DRY RUN"}: ${moved} transactions relabelled\n`);
  console.log("category".padEnd(22), "before".padStart(7), "after".padStart(7));
  for (const k of keys) console.log(k.padEnd(22), String(before.get(k) ?? 0).padStart(7), String(after.get(k) ?? 0).padStart(7));
  const leftover = [...after.keys()].filter((k) => k !== "(null)" && !CATS.has(k));
  console.log(`\nnon-taxonomy categories left: ${leftover.length ? leftover.map((k) => `${k}=${after.get(k)}`).join(", ") + " (manual rows)" : "none"}`);

  d.exec(apply ? "COMMIT" : "ROLLBACK");
} catch (e) {
  d.exec("ROLLBACK");
  throw e;
}