seedproject-web/content-pipeline/scripts/revise.mjs

92 lines
3.6 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// Auto-revise loop: feed the SEO audit's fixes back to the writer, re-audit, repeat until the
// draft passes the gate or maxReviseAttempts is reached.
// Usage: node content-pipeline/scripts/revise.mjs <slug>
import { existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson } from "./lib/util.mjs";
import { runSeoReview } from "./seo-review.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const failing = (audit, minScore) =>
!audit || audit.verdict === "fail" || (audit.overall ?? 0) < minScore;
/**
* Revise a draft until it passes the SEO gate (or attempts run out).
* @returns {Promise<{audit:object|null, attempts:number}>}
*/
export async function runRevise(slug, { maxAttempts } = {}) {
const max = maxAttempts ?? cfg.seo?.maxReviseAttempts ?? 2;
const minScore = cfg.seo?.minScore ?? 85;
const draftDir = join(root, cfg.paths.draftsDir, slug);
const mdxPath = join(draftDir, "index.mdx");
const auditPath = join(draftDir, "seo-review.json");
if (!existsSync(mdxPath)) {
console.error(`[revise] no draft at ${mdxPath}`);
return { audit: null, attempts: 0 };
}
const calPathForEntry = join(root, cfg.paths.calendar);
const calEntry = existsSync(calPathForEntry)
? loadJson(calPathForEntry).find((e) => e.slug === slug)
: null;
const firsthandNote = calEntry?.authorFirsthand
? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally. Do NOT strip or neutralize the first-person voice and subjective opinions — that is authentic Experience and must be kept. Only add citations for objective facts (addresses, prices, dates, hours) or frame them honestly.`
: "";
let audit = existsSync(auditPath) ? loadJson(auditPath) : await runSeoReview(slug, { published: false });
let attempts = 0;
while (failing(audit, minScore) && attempts < max) {
attempts++;
const keyword = audit?.primaryKeyword || slug.replace(/-/g, " ");
console.log(`[revise] attempt ${attempts}/${max} on ${slug} (current: ${audit?.verdict} ${audit?.overall})`);
const prompt = `Revise this draft to pass the SEO/EEAT gate (target ≥ ${minScore}).
- Draft: ${mdxPath}
- Citations file: ${join(draftDir, "sources.json")}
- Audit to address (work through blocking + topFixes): ${auditPath}
- Primary keyword: ${keyword}
- Existing published posts (for internal links): ${join(root, cfg.paths.blogContentDir)}${firsthandNote}
Follow the reviser instructions in your system prompt exactly. Edit the files in place.`;
await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/reviser.system.md"),
model: cfg.models.writer,
allowedTools: "Read Write Edit Glob Grep WebSearch",
addDirs: [root],
cwd: root,
logFile: join(root, cfg.paths.logsDir, `revise-${slug}.log`),
});
audit = await runSeoReview(slug, { published: false });
}
const ok = !failing(audit, minScore);
console.log(
`[revise] ${slug}: ${ok ? "PASS" : "still failing"} after ${attempts} attempt(s) — ` +
`${audit?.verdict} ${audit?.overall}`
);
return { audit, attempts };
}
// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
const slug = process.argv[2];
if (!slug) {
console.error("Usage: revise.mjs <slug>");
process.exit(1);
}
const { audit } = await runRevise(slug);
process.exit(audit && audit.verdict === "pass" ? 0 : 2);
}