bhcto.com

content_supervisor.js

// Overnight supervisor: runs the full 3-round content pipeline end to end.
//   Round 1 (draft, web-search) -> await -> Round 2 (SEO rewrite using seo/ intel)
//   -> await -> Round 3 (editorial polish) -> await -> final/ + index.md
//
// Each round submits ONE batch, polls until it ends, writes its outputs, then the
// next round begins. Safe to leave running unattended.
//
// Usage:
//   node content_supervisor.js                 # all 15, all 3 rounds
//   node content_supervisor.js --resume        # skip items whose output already exists
//   node content_supervisor.js --limit 2       # first N briefs (cheap smoke test)
//   node content_supervisor.js --slug <slug>   # a single brief through all 3 rounds
//
// Notes:
//  - Model is probed once (Fable 5 -> Opus 4.8 fallback) and reused for all rounds.
//  - A per-item failure in a round just drops that item from later rounds; the rest
//    flow through. A round only starts for items that have the prior round's output.
//  - Resumable: with --resume, a crashed/killed run picks up where it left off.

const fs = require("fs");
const path = require("path");
const { parseBriefs } = require("./lib/briefs");
const { probeModel, runRound, WEB_SEARCH } = require("./lib/batch");
const { cleanArticle } = require("./lib/clean");
const P = require("./lib/prompts");

const DIRS = {
  round1: path.join(__dirname, "round1"),
  round2: path.join(__dirname, "round2"),
  round3: path.join(__dirname, "final"),
  seo: path.join(__dirname, "seo"),
};
const LOG_FILE = path.join(__dirname, "supervisor.log");
const STATUS_FILE = path.join(__dirname, "supervisor_status.json");

function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + "\n");
}
function arg(name) {
  const i = process.argv.indexOf(`--${name}`);
  return i >= 0 ? process.argv[i + 1] : null;
}
const hasFlag = (name) => process.argv.includes(`--${name}`);
function readMaybe(p) {
  try {
    return fs.readFileSync(p, "utf8");
  } catch {
    return null;
  }
}
function readJson(p) {
  const t = readMaybe(p);
  return t ? JSON.parse(t) : null;
}

const status = { started_at: new Date().toISOString(), model: null, rounds: {} };
function saveStatus() {
  fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2));
}

// Run one round over `items`; write each cleaned result via writeFn(slug, text).
async function doRound(roundName, model, items, buildFn, writeFn) {
  log(`--- ${roundName}: ${items.length} item(s) ---`);
  if (!items.length) {
    status.rounds[roundName] = { ok: 0, failed: 0, skipped: 0, items: [] };
    saveStatus();
    return [];
  }
  const results = await runRound(model, items, buildFn);
  const ok = [];
  const rec = [];
  for (const b of items) {
    const r = results.get(b.slug);
    if (r && r.ok) {
      writeFn(b.slug, cleanArticle(r.text));
      log(`  ✓ ${b.slug} (${r.model})`);
      ok.push(b);
      rec.push({ slug: b.slug, ok: true, model: r.model });
    } else {
      log(`  ✗ ${b.slug} — ${r ? r.error || r.stop_reason : "no result"}`);
      rec.push({ slug: b.slug, ok: false, reason: r ? r.error || r.stop_reason : "no result" });
    }
  }
  status.rounds[roundName] = { ok: ok.length, failed: items.length - ok.length, items: rec };
  saveStatus();
  return ok; // items that succeeded flow to the next round
}

async function main() {
  for (const d of Object.values(DIRS)) fs.mkdirSync(d, { recursive: true });
  const resume = hasFlag("resume");
  const { standingRules, briefs } = parseBriefs();

  let all = briefs;
  if (arg("slug")) all = briefs.filter((b) => b.slug === arg("slug"));
  else if (arg("limit")) all = briefs.slice(0, Number(arg("limit")));
  if (!all.length) {
    log(`No briefs matched. Available: ${briefs.map((b) => b.slug).join(", ")}`);
    return;
  }

  log(`=== SUPERVISOR START: ${all.length} brief(s), resume=${resume} ===`);
  const model = await probeModel();
  status.model = model;
  saveStatus();
  log(`Writing model: ${model}`);

  const exists = (dir, slug) => fs.existsSync(path.join(dir, `${slug}.md`));

  // ---- Round 1: draft (web search) ----
  const sys1 = P.round1System(standingRules);
  const r1items = resume ? all.filter((b) => !exists(DIRS.round1, b.slug)) : all;
  if (resume && r1items.length < all.length)
    log(`(resume) round1: skipping ${all.length - r1items.length} already-written`);
  const r1ok = await doRound(
    "round1_draft",
    model,
    r1items,
    (b) => ({ system: sys1, userContent: P.round1User(b), max_tokens: 16000, tools: [WEB_SEARCH] }),
    (slug, text) => fs.writeFileSync(path.join(DIRS.round1, `${slug}.md`), text),
  );
  // Items eligible for round 2 = anything with a round1 draft (this run or prior).
  const haveR1 = all.filter((b) => exists(DIRS.round1, b.slug));

  // ---- Round 2: SEO rewrite using Chicago-IL intel ----
  const sys2 = P.round2System(standingRules);
  const r2items = resume ? haveR1.filter((b) => !exists(DIRS.round2, b.slug)) : haveR1;
  await doRound(
    "round2_seo",
    model,
    r2items,
    (b) => ({
      system: sys2,
      userContent: P.round2User(
        b,
        readMaybe(path.join(DIRS.round1, `${b.slug}.md`)),
        readJson(path.join(DIRS.seo, b.slug, "intel.json")),
      ),
      max_tokens: 16000,
    }),
    (slug, text) => fs.writeFileSync(path.join(DIRS.round2, `${slug}.md`), text),
  );
  const haveR2 = all.filter((b) => exists(DIRS.round2, b.slug));

  // ---- Round 3: editorial polish -> final/ ----
  const sys3 = P.round3System(standingRules);
  const r3items = resume ? haveR2.filter((b) => !exists(DIRS.round3, b.slug)) : haveR2;
  await doRound(
    "round3_final",
    model,
    r3items,
    (b) => ({ system: sys3, userContent: P.round3User(readMaybe(path.join(DIRS.round2, `${b.slug}.md`))), max_tokens: 16000 }),
    (slug, text) => fs.writeFileSync(path.join(DIRS.round3, `${slug}.md`), text),
  );

  // ---- Final index ----
  const finals = all.filter((b) => exists(DIRS.round3, b.slug));
  const index = ["# Northern Illinois Recovery Tier A — Final Posts", ""];
  for (const b of finals)
    index.push(`- [${b.workingTitle}](${b.slug}.md) — \`${b.targetKeyword}\``);
  fs.writeFileSync(path.join(DIRS.round3, "index.md"), index.join("\n") + "\n");

  status.finished_at = new Date().toISOString();
  saveStatus();
  log(
    `=== SUPERVISOR DONE: round1 ${r1ok.length}/${r1items.length} · final posts ${finals.length}/${all.length} → final/ ===`,
  );
}

main().catch((e) => {
  log(`FATAL: ${e.stack || e}`);
  saveStatus();
  process.exit(1);
});