diff --git a/.env.example b/.env.example index 8183617..f54126b 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,40 @@ ACTIVE_CLIENT=saas-consultoria-imagem LLM_DEFAULT=claude LLM_FALLBACK=codex LLM_CHEAP=deepseek + +# LLM providers OPENAI_API_KEY= DEEPSEEK_API_KEY= ANTHROPIC_API_KEY= +# Local fallback — leave OLLAMA_HOST blank to skip +OLLAMA_HOST=http://localhost:11434 +OLLAMA_MODEL=llama3.2 + +# Image / video providers HIGGSFIELD_MCP_ACTIVE=true TOPVIEW_API_KEY= WAVESPEED_API_KEY= + +# Publish + ads ADAPTLYPOST_API_KEY= META_ADS_MCP_ACTIVE=true + +# Analytics (production fetchers; DRY_RUN keeps deterministic synthetic data) +META_ACCESS_TOKEN= +META_PAGE_ID= +TIKTOK_ACCESS_TOKEN= +YOUTUBE_API_KEY= +YOUTUBE_CHANNEL_ID= + +# Calendar NOTION_TOKEN= NOTION_CALENDAR_DB_ID= + +# Optional observability webhook (Slack/Discord/generic POST) +ALERT_WEBHOOK_URL= + +# Compliance: when false, enables an extra LLM secondary pass on the report +COMPLIANCE_LLM_SECONDARY=false + +# Always start in DRY_RUN. Flip to false only after a human review of one piece. DRY_RUN=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c99c1..f3ea031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,5 +11,6 @@ jobs: cache: "npm" - run: npm ci - run: npm run typecheck - - run: npx playwright install --with-deps chromium + # Specs run on the Node test runner, not a browser context, so chromium is + # not required. Avoids the --with-deps apt step that breaks on Ubuntu noble. - run: npm run test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2352778 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release +on: + push: + tags: + - "v1.*" + - "v2.*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + cache: "npm" + - run: npm ci + - run: npm run typecheck + - run: npm run test:e2e + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..54d95fe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# Changelog + +All notable changes to this project are documented here. Format based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `lib/providers/matrix.ts` parses `.specs/architecture/PROVIDERS.md` as the single + source of routing truth (ADR-001). Embedded defaults are used when the file is + missing or malformed, with a stderr warning. +- `lib/providers/policy.ts` — shared retry/backoff/timeout helper (`withRetry`), + per-provider pricing table and `estimateCost`/`estimateTokens` helpers. +- `lib/router.ts` exposes `runWithFallback` — wraps any provider call with the + primary/fallback chain documented in `PROVIDERS.md` and writes one structured + line per attempt to `data/llm-usage.jsonl` (`ok`, `fallback_used`, `attempt`, + `latency_ms`). +- `lib/data/runs.ts` and `lib/data/manifest.ts` write the artefacts the AGENTS + Definition of Done requires: `data/runs.jsonl` per piece run and + `outputs////manifest.json` per piece. +- `lib/pieces/{id,frontmatter,store}.ts` — piece engine. ISO-8601 week-numbered + IDs (`PIECE-YYYYWww-NNN`), zero-dependency YAML frontmatter parser, state + machine (`draft → scheduled → published → measured`, side state `review`). +- `lib/cli/generate.ts` — real generation loop that reads `pieces/`, routes + every task through the matrix + fallback chain, runs inline compliance, + writes outputs/script/captions/compliance.json/manifest.json, transitions + status, and appends to `data/runs.jsonl`. +- `lib/cli/promote.ts` — real promotion loop that classifies `data/analytics.jsonl` + (top/bottom 20% by save rate, ≥100 impressions), writes `ads-draft.json` + for winners, appends `data/learnings.md` for losers. +- `lib/calendar/notion.ts` — Notion calendar reader (`pullCalendar`, `syncToLocal`, + `pushStatus`). DRY_RUN-safe. +- `lib/publish/adaptlypost.ts` — real AdaptlyPost wiring with retry/backoff; + DRY_RUN still writes `adaptlypost-draft.json` locally. +- `lib/publish/meta-ads.ts` — Meta Ads draft builder + `data/promotions.jsonl` + writer. Real Meta API call is a stub (calls into the meta-ads MCP layer when + available). +- `lib/analytics/{meta,tiktok,youtube}.ts` — DRY_RUN keeps deterministic synthetic + data; non-DRY paths call Graph / TikTok Business / YouTube Data APIs. +- `lib/compliance/{generic,loader}.ts` — executable cross-vertical audit, writes + `data/compliance/.json`, escalates blocks to `data/compliance-blocked/`, + exposes `detectStreaks` for the alerts module. +- `lib/skills/{humanizer,brand-voice}.ts` — executable critic skills (regex pass + + LLM secondary stub, voice-axis distance scoring). +- `lib/qa/tech-specs.ts` — ffprobe/identify wrapper with filename fallback; + per-platform validation against `CHANNELS.md` specs. +- `lib/observability/{cost,ab-report,failures}.ts` — cost summary + HTML report, + A/B per (task, provider) ROI table, failure-rate detector + webhook poster. +- `lib/schedule/cron.ts` — cron / launchd install / uninstall / status helpers + with marker block isolation. +- CLI gains: `new-piece`, `status`, `logs`, `cost`, `ab-report`, `alerts`, + `sync`, `schedule`. Each delegates to a TypeScript module via the bundled + `tsx` runtime. +- E2E coverage (Playwright) across all of the above: matrix parsing, + fallback chain, policy retry/timeout/cost, pieces engine, init/scan/generate + loop, promote loop, compliance, qa-tech-specs, observability, CLI surface. + +### Changed + +- `lib/router.ts` no longer hardcodes routing tables; everything resolves via + `lib/providers/matrix.ts`. +- `lib/providers/{llm,image,video}.ts` now contain real adapter classes that + call concrete APIs. Mock variants live under `lib/providers/__mocks__/` and + the factory switches based on `DRY_RUN`. +- `bin/marketing-engine.mjs` `generate` / `promote` are no longer placeholders. + +### Notes + +- All new external behavior is gated by `DRY_RUN=true` (the default) — CI never + reaches real API endpoints. +- The mock providers preserve the original deterministic output, so previously + authored e2e specs continue to pass. + +## [0.1.0] - 2026-05-08 + +### Added + +- CLI scaffold (`bin/marketing-engine.mjs`): `init`, `scan`, `check` commands. +- Provider type interfaces (`lib/providers/{types,llm,image,video}.ts`) and + mock implementations. +- Publish + analytics scaffolds (`lib/publish/adaptlypost.ts`, + `lib/analytics/{meta,tiktok,youtube}.ts`). +- Specs tree under `.specs/`: architecture (DESIGN, PROVIDERS, ROUTING-MATRIX, + ADR-001), product (BRAND, CHANNELS, COMPLIANCE, PERSONAS, PILLARS), piece + + campaign templates, client `_template/` overrides. +- 11 SKILL.md documents under `.skills/` (provider-neutral skills). +- E2E suite (Playwright): CLI, compliance, caption-format, tech-specs, + provider-router, AdaptlyPost-publish. +- CI workflow + DoD workflow. +- Remotion video explainer (PT-BR + EN) under `video/`. +- Bilingual README + SETUP + CONTRIBUTING + AGENTS.md (charter) + Apache-2.0 + LICENSE. diff --git a/bin/marketing-engine.mjs b/bin/marketing-engine.mjs index be02400..d33c4d5 100755 --- a/bin/marketing-engine.mjs +++ b/bin/marketing-engine.mjs @@ -17,22 +17,38 @@ Usage: marketing-engine [options] Commands: - init Scaffold .marketing-engine/ in current host project - scan Re-scan host project to refresh draft specs - check Validate provider env keys - generate Run generation loop (DRY_RUN-safe) - promote Run promotion loop - help Show this message + init Scaffold .marketing-engine/ in current host project + scan Re-scan host project to refresh draft specs + check Validate provider env keys + generate Run generation loop (DRY_RUN-safe) + promote Run promotion loop + new-piece Create a new piece markdown from the template + status Show pipeline state (counts + recent runs + 24h cost) + logs Tail data/llm-usage.jsonl + cost Aggregate llm-usage.jsonl over a window + ab-report Join llm-usage + analytics; per-(task,provider) ROI + alerts Tail recent failures from runs + usage logs + sync Pull pieces from Notion calendar + schedule Install/uninstall cron / launchd entries + help Show this message Options: - --force Overwrite existing files during init - --root Override host project root (default: cwd) + --force Overwrite existing files during init + --root Override host project root (default: cwd) + --max-iter Cap iterations on generate / promote + --window Window for promote / cost / ab-report (default 7d) Docs: https://github.com/wesleysimplicio/marketing-engine `; function parseArgs(argv) { - const args = { _: [], force: false, root: null }; + const args = { + _: [], + force: false, + root: null, + maxIter: null, + window: null, + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--force") { @@ -41,6 +57,14 @@ function parseArgs(argv) { args.root = argv[++i] ?? null; } else if (a.startsWith("--root=")) { args.root = a.slice("--root=".length); + } else if (a === "--max-iter") { + args.maxIter = Number(argv[++i]); + } else if (a.startsWith("--max-iter=")) { + args.maxIter = Number(a.slice("--max-iter=".length)); + } else if (a === "--window") { + args.window = argv[++i] ?? null; + } else if (a.startsWith("--window=")) { + args.window = a.slice("--window=".length); } else { args._.push(a); } @@ -552,20 +576,119 @@ function runShellCheck() { } void runShellCheck; -function commandGenerate() { - console.log( - "Generation loop is a placeholder in v0.1. Set DRY_RUN=true and run " + - "`bash .ralph/loop-generation.sh` from the marketing-engine repo root for current behavior. " + - "Future: this will invoke the loop with the host project's .marketing-engine/ as context.", - ); +function resolveTsx() { + const candidates = [ + join(PACKAGE_ROOT, "node_modules", "tsx", "dist", "cli.mjs"), + join(PACKAGE_ROOT, "node_modules", ".bin", "tsx"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; } -function commandPromote() { - console.log( - "Promotion loop is a placeholder in v0.1. Set DRY_RUN=true and run " + - "`bash .ralph/loop-promotion.sh` from the marketing-engine repo root for current behavior. " + - "Future: this will invoke the loop with the host project's .marketing-engine/ as context.", - ); +function spawnTsx(scriptPath, extraArgs, hostRoot) { + const tsx = resolveTsx(); + if (!tsx) { + console.error( + "ERROR: tsx not found. Run `npm install` inside the marketing-engine package.", + ); + process.exit(2); + } + const env = { ...process.env }; + const hostEnv = join(hostRoot, ".marketing-engine", ".env"); + const hostRootEnv = join(hostRoot, ".env"); + const packageEnv = join(PACKAGE_ROOT, ".env"); + let envFile = null; + if (existsSync(hostEnv)) envFile = hostEnv; + else if (existsSync(hostRootEnv)) envFile = hostRootEnv; + else if (existsSync(packageEnv)) envFile = packageEnv; + if (envFile) { + const parsed = parseDotenv(readFileSync(envFile, "utf8")); + for (const [k, v] of Object.entries(parsed)) { + if (env[k] === undefined) env[k] = v; + } + } + // The CLI scripts assume process.cwd() is the host root. + // When running scripts inside lib/, package context is the marketing-engine repo, + // so propagate host paths via env to keep `lib/` provider-agnostic. + env.MARKETING_ENGINE_HOST_ROOT = hostRoot; + // Default to DRY_RUN=true unless explicitly set in .env or shell. + if (env.DRY_RUN === undefined) env.DRY_RUN = "true"; + const result = spawnSync(process.execPath, [tsx, scriptPath, ...extraArgs], { + cwd: hostRoot, + env, + stdio: "inherit", + }); + process.exit(result.status ?? 1); +} + +function commandGenerate(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "generate.ts"); + const extra = []; + if (args.maxIter !== null && args.maxIter !== undefined) { + extra.push("--max-iter", String(args.maxIter)); + } + spawnTsx(script, extra, hostRoot); +} + +function commandPromote(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "promote.ts"); + const extra = []; + if (args.window) extra.push("--window", args.window); + spawnTsx(script, extra, hostRoot); +} + +function commandNewPiece(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "new-piece.ts"); + spawnTsx(script, args._.slice(1), hostRoot); +} + +function commandStatus(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "status.ts"); + spawnTsx(script, [], hostRoot); +} + +function commandLogs(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "logs.ts"); + spawnTsx(script, args._.slice(1), hostRoot); +} + +function commandCost(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "cost.ts"); + const extra = []; + if (args.window) extra.push("--window", args.window); + spawnTsx(script, extra, hostRoot); +} + +function commandAbReport(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "ab-report.ts"); + spawnTsx(script, args._.slice(1), hostRoot); +} + +function commandAlerts(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "alerts.ts"); + spawnTsx(script, args._.slice(1), hostRoot); +} + +function commandSync(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "sync.ts"); + spawnTsx(script, args._.slice(1), hostRoot); +} + +function commandSchedule(args) { + const hostRoot = resolveHostRoot(args); + const script = join(PACKAGE_ROOT, "lib", "cli", "schedule.ts"); + spawnTsx(script, args._.slice(1), hostRoot); } function main() { @@ -589,10 +712,34 @@ function main() { commandCheck(args); return; case "generate": - commandGenerate(); + commandGenerate(args); return; case "promote": - commandPromote(); + commandPromote(args); + return; + case "new-piece": + commandNewPiece(args); + return; + case "status": + commandStatus(args); + return; + case "logs": + commandLogs(args); + return; + case "cost": + commandCost(args); + return; + case "ab-report": + commandAbReport(args); + return; + case "alerts": + commandAlerts(args); + return; + case "sync": + commandSync(args); + return; + case "schedule": + commandSchedule(args); return; default: console.error(`Unknown command: ${cmd}`); diff --git a/e2e/cli-extras.spec.ts b/e2e/cli-extras.spec.ts new file mode 100644 index 0000000..1c56dea --- /dev/null +++ b/e2e/cli-extras.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from "@playwright/test"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const CLI = resolve(__filename, "..", "..", "bin", "marketing-engine.mjs"); + +function run(args: string[], cwd?: string) { + return spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, DRY_RUN: "true" }, + }); +} + +test("help shows the new commands", () => { + const r = run(["help"]); + expect(r.status).toBe(0); + for (const c of [ + "new-piece", + "status", + "logs", + "cost", + "ab-report", + "alerts", + "sync", + "schedule", + ]) { + expect(r.stdout).toContain(c); + } +}); + +test("new-piece creates a markdown file with correct frontmatter", () => { + const host = mkdtempSync(join(tmpdir(), "me-newpiece-")); + const r = run( + [ + "new-piece", + "--client", + "acme", + "--pillar", + "education", + "--channel", + "instagram", + "--date", + "2026-05-08", + ], + host, + ); + expect(r.status).toBe(0); + const files = readdirSync(join(host, "pieces")).filter((f) => f.endsWith(".md")); + expect(files.length).toBe(1); +}); + +test("status command exits 0 against an empty host", () => { + const host = mkdtempSync(join(tmpdir(), "me-status-")); + const r = run(["status"], host); + expect(r.status).toBe(0); + expect(r.stdout).toContain("Pieces"); +}); + +test("logs command exits 0 even with no log file", () => { + const host = mkdtempSync(join(tmpdir(), "me-logs-")); + const r = run(["logs"], host); + expect(r.status).toBe(0); + expect(r.stdout.toLowerCase()).toContain("no log file"); +}); + +test("cost command runs and surfaces totals", () => { + const host = mkdtempSync(join(tmpdir(), "me-cost-cli-")); + mkdirSync(join(host, "data"), { recursive: true }); + writeFileSync( + join(host, "data", "llm-usage.jsonl"), + JSON.stringify({ + timestamp: new Date().toISOString(), + task: "caption", + provider: "deepseek", + tokens: 100, + cost_usd: 0.001, + ok: true, + }) + "\n", + ); + const r = run(["cost"], host); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/total: \$/); + expect(r.stdout).toContain("deepseek"); +}); + +test("schedule status command exits 0 (no install)", () => { + const host = mkdtempSync(join(tmpdir(), "me-sched-")); + const r = run(["schedule", "status"], host); + expect(r.status).toBe(0); +}); + +test("sync DRY_RUN path is a no-op safe", () => { + const host = mkdtempSync(join(tmpdir(), "me-sync-")); + const r = run(["sync"], host); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/DRY_RUN/); +}); + +test("alerts command exits 0 with no data", () => { + const host = mkdtempSync(join(tmpdir(), "me-alerts-")); + const r = run(["alerts"], host); + expect(r.status).toBe(0); + expect(r.stdout).toContain("alerts"); +}); + +test("ab-report command exits 0 with no data and produces a header", () => { + const host = mkdtempSync(join(tmpdir(), "me-ab-")); + const r = run(["ab-report"], host); + expect(r.status).toBe(0); + expect(r.stdout).toContain("Task"); +}); + +test("generate command runs and produces summary line", () => { + const host = mkdtempSync(join(tmpdir(), "me-gen-cli-")); + mkdirSync(join(host, "pieces"), { recursive: true }); + const r = run(["generate"], host); + // With no pieces, summary should be inspected=0 + expect(r.status === 0 || r.status === 2).toBe(true); + expect(r.stdout).toContain("generate:"); + void existsSync; +}); + +test("promote command runs and produces summary line", () => { + const host = mkdtempSync(join(tmpdir(), "me-prom-cli-")); + const r = run(["promote"], host); + expect(r.status).toBe(0); + expect(r.stdout).toContain("promote:"); +}); diff --git a/e2e/cli-init-scan.spec.ts b/e2e/cli-init-scan.spec.ts new file mode 100644 index 0000000..8e24aab --- /dev/null +++ b/e2e/cli-init-scan.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const CLI = resolve(__filename, "..", "..", "bin", "marketing-engine.mjs"); + +function run(args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); +} + +test("init scaffolds .marketing-engine/ tree in a tmp host root", () => { + const host = mkdtempSync(join(tmpdir(), "me-init-")); + writeFileSync( + join(host, "package.json"), + JSON.stringify({ name: "host-app", description: "demo" }), + ); + const r = run(["init", "--root", host]); + expect(r.status).toBe(0); + const root = join(host, ".marketing-engine"); + for (const f of [ + "BRAND.md", + "PILLARS.md", + "PERSONAS.md", + "COMPLIANCE.md", + "CHANNELS.md", + "README.md", + "pieces/.gitkeep", + "outputs/.gitkeep", + "data/.gitkeep", + ]) { + expect(existsSync(join(root, f))).toBe(true); + } + const gi = readFileSync(join(host, ".gitignore"), "utf8"); + expect(gi).toContain(".marketing-engine/.env"); + expect(gi).toContain(".marketing-engine/outputs/*"); +}); + +test("scan regenerates draft files based on package.json signals", () => { + const host = mkdtempSync(join(tmpdir(), "me-scan-")); + writeFileSync( + join(host, "package.json"), + JSON.stringify({ + name: "scan-target", + description: "auto-scanned", + keywords: ["ai", "marketing"], + }), + ); + writeFileSync(join(host, "README.md"), "# scan-target\n\nA test app.\n"); + expect(run(["init", "--root", host]).status).toBe(0); + const r = run(["scan", "--root", host]); + expect(r.status).toBe(0); + const draft = readFileSync( + join(host, ".marketing-engine", "BRAND.draft.md"), + "utf8", + ); + expect(draft).toContain("scan-target"); + expect(draft).toContain("auto-scanned"); +}); diff --git a/e2e/cli.spec.ts b/e2e/cli.spec.ts index 780fa52..99f6651 100644 --- a/e2e/cli.spec.ts +++ b/e2e/cli.spec.ts @@ -50,14 +50,14 @@ test("unknown command exits 1 and surfaces the bad name", () => { expect(result.stderr).toContain("Usage:"); }); -test("generate command prints DRY_RUN-safe placeholder and exits 0", () => { +test("generate command runs under DRY_RUN and prints a summary line", () => { const result = run(["generate"]); - expect(result.status).toBe(0); - expect(result.stdout).toContain("Generation loop is a placeholder"); + expect(result.status === 0 || result.status === 2).toBe(true); + expect(result.stdout).toContain("generate:"); }); -test("promote command prints DRY_RUN-safe placeholder and exits 0", () => { +test("promote command runs under DRY_RUN and prints a summary line", () => { const result = run(["promote"]); expect(result.status).toBe(0); - expect(result.stdout).toContain("Promotion loop is a placeholder"); + expect(result.stdout).toContain("promote:"); }); diff --git a/e2e/compliance-generic.spec.ts b/e2e/compliance-generic.spec.ts new file mode 100644 index 0000000..7825fb7 --- /dev/null +++ b/e2e/compliance-generic.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { auditSync } from "../lib/compliance/generic"; +import { runAudit } from "../lib/compliance/loader"; +import { humanizeSync } from "../lib/skills/humanizer"; +import { scoreBrandVoice } from "../lib/skills/brand-voice"; + +test("auditSync flags guaranteed return", () => { + const r = auditSync({ + piece_id: "p1", + text: "Invista hoje e garantimos 12% ao ano sem risco.", + vertical: "finance", + }); + expect(r.pass).toBe(false); + expect(r.violations.some((v) => v.rule_id === "finance.guaranteed_return")).toBe(true); +}); + +test("auditSync flags clinically proven without source", () => { + const r = auditSync({ piece_id: "p2", text: "Our cream is clinically proven to whiten." }); + expect(r.pass).toBe(false); + expect(r.violations.some((v) => v.rule_id === "health.clinically_proven")).toBe(true); +}); + +test("auditSync requires disclaimer when before/after appears", () => { + const r = auditSync({ piece_id: "p3", text: "Look at this before/after transformation!" }); + expect(r.pass).toBe(false); + expect(r.violations.some((v) => v.rule_id.includes("before_after"))).toBe(true); + + const ok = auditSync({ + piece_id: "p3b", + text: "Look at this before/after transformation!", + before_after_disclaimer: true, + }); + expect(ok.pass).toBe(true); +}); + +test("auditSync passes clean copy", () => { + const r = auditSync({ + piece_id: "p4", + text: "Free shipping over $200. See the new collection.", + }); + expect(r.pass).toBe(true); +}); + +test("runAudit writes report to data/compliance/.json", async () => { + const host = mkdtempSync(join(tmpdir(), "me-comp-")); + const r = await runAudit({ + piece_id: "p1", + text: "clean caption text", + root: host, + }); + expect(r.report.pass).toBe(true); + expect(existsSync(r.report_path)).toBe(true); + const file = JSON.parse(readFileSync(r.report_path, "utf8")); + expect(file.piece_id).toBe("p1"); +}); + +test("humanizeSync removes em-dashes and triadic patterns", () => { + const r = humanizeSync( + "In conclusion, color analysis is fast, simple, and effective — really.", + ); + expect(r.text).not.toContain("—"); + expect(r.text).not.toContain("In conclusion"); + expect(r.changes.length).toBeGreaterThan(0); +}); + +test("scoreBrandVoice returns score in [0,1] and detects banned terms", () => { + const r = scoreBrandVoice("This is delve-tastic leverage!", { + voice_axes: { tone: 3, formality: 2, energy: 3, warmth: 3 }, + lexicon: { avoid: ["delve", "leverage"] }, + }); + expect(r.score).toBeGreaterThanOrEqual(0); + expect(r.score).toBeLessThanOrEqual(1); + expect(r.notes.some((n) => n.includes("delve"))).toBe(true); + expect(r.notes.some((n) => n.includes("leverage"))).toBe(true); +}); diff --git a/e2e/generate-loop.spec.ts b/e2e/generate-loop.spec.ts new file mode 100644 index 0000000..7d9a3a2 --- /dev/null +++ b/e2e/generate-loop.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from "@playwright/test"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runGenerateLoop } from "../lib/cli/generate"; +import { serializePiece, type PieceFrontmatter } from "../lib/pieces/frontmatter"; +import { resetMatrixCache } from "../lib/providers/matrix"; + +test("generate loop processes a draft piece end-to-end with mocks under DRY_RUN", async () => { + process.env.DRY_RUN = "true"; + const host = mkdtempSync(join(tmpdir(), "me-gen-")); + const piecesDir = join(host, "pieces"); + const outputsDir = join(host, "outputs"); + mkdirSync(piecesDir, { recursive: true }); + mkdirSync(outputsDir, { recursive: true }); + mkdirSync(join(host, "data"), { recursive: true }); + // Copy PROVIDERS.md so loader sees the real matrix (we point the loader at the embedded defaults via missing file). + resetMatrixCache(); + + const fm: PieceFrontmatter = { + id: "PIECE-test-001", + client: "acme", + date: "2026-05-08", + status: "draft", + type: "reel", + pillar: "education", + platforms: ["instagram", "tiktok"], + locale: "en", + }; + writeFileSync( + join(piecesDir, "PIECE-test-001.md"), + serializePiece(fm, "# Brief\n\nLaunch our new product.\n"), + ); + const prevCwd = process.cwd(); + process.chdir(host); + try { + const summary = await runGenerateLoop({ + root: host, + piecesDir, + outputsDir, + }); + expect(summary.inspected).toBe(1); + expect(summary.advanced).toBe(1); + const pieceDir = resolve(outputsDir, "acme", "2026-05-08", "PIECE-test-001"); + expect(existsSync(join(pieceDir, "manifest.json"))).toBe(true); + expect(existsSync(join(pieceDir, "script.md"))).toBe(true); + expect(existsSync(join(pieceDir, "captions.json"))).toBe(true); + expect(existsSync(join(pieceDir, "compliance.json"))).toBe(true); + const manifest = JSON.parse( + readFileSync(join(pieceDir, "manifest.json"), "utf8"), + ); + expect(manifest.piece_id).toBe("PIECE-test-001"); + expect(manifest.providers.llm).toBeDefined(); + expect(typeof manifest.cost_estimate_usd).toBe("number"); + const runsLog = readFileSync(join(host, "data", "runs.jsonl"), "utf8"); + expect(runsLog).toContain("PIECE-test-001"); + expect(runsLog).toContain("success"); + const usage = readFileSync(join(host, "data", "llm-usage.jsonl"), "utf8"); + expect(usage.length).toBeGreaterThan(0); + const updated = readFileSync( + join(piecesDir, "PIECE-test-001.md"), + "utf8", + ); + expect(updated).toMatch(/status: scheduled/); + } finally { + process.chdir(prevCwd); + } +}); + +test("generate loop blocks pieces failing compliance and does not transition", async () => { + process.env.DRY_RUN = "true"; + const host = mkdtempSync(join(tmpdir(), "me-gen-block-")); + const piecesDir = join(host, "pieces"); + mkdirSync(piecesDir, { recursive: true }); + mkdirSync(join(host, "data"), { recursive: true }); + + const fm: PieceFrontmatter = { + id: "PIECE-test-002", + client: "acme", + date: "2026-05-08", + status: "draft", + type: "carousel", + pillar: "education", + platforms: ["instagram"], + locale: "en", + }; + writeFileSync( + join(piecesDir, "PIECE-test-002.md"), + serializePiece( + fm, + "# Brief\n\nWe guarantee 12% return per year, risk-free clinically proven.\n", + ), + ); + const prevCwd = process.cwd(); + process.chdir(host); + try { + const summary = await runGenerateLoop({ + root: host, + piecesDir, + outputsDir: join(host, "outputs"), + }); + expect(summary.blocked).toBeGreaterThanOrEqual(1); + const updated = readFileSync( + join(piecesDir, "PIECE-test-002.md"), + "utf8", + ); + expect(updated).toMatch(/status: draft/); + expect(updated).toMatch(/compliance_block:/); + } finally { + process.chdir(prevCwd); + } +}); diff --git a/e2e/matrix.spec.ts b/e2e/matrix.spec.ts new file mode 100644 index 0000000..793926f --- /dev/null +++ b/e2e/matrix.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; +import { + parseProvidersMarkdown, + loadProviderMatrix, + llmRow, + imageRow, + videoRow, + resetMatrixCache, +} from "../lib/providers/matrix"; + +test("parses LLM table from PROVIDERS.md markdown", () => { + const md = ` +## LLM Routing + +| Task | Default | Fallback | Reason | +|------|---------|----------|--------| +| Caption | deepseek | claude | cheap | +| Script | claude | codex | quality | +`; + const m = parseProvidersMarkdown(md); + expect(m.llm.caption?.default).toBe("deepseek"); + expect(m.llm.caption?.fallback).toBe("claude"); + expect(m.llm.script?.default).toBe("claude"); +}); + +test("parses image + video tables", () => { + const md = ` +## Image Routing + +| Task | Provider | Reason | +|------|----------|--------| +| Quote card / typography | gpt-image | typo | +| Cinematic / editorial | higgsfield | cinema | + +## Video Routing + +| Task | Provider | Reason | +|------|----------|--------| +| Cinematic reel | higgsfield | seedance | +`; + const m = parseProvidersMarkdown(md); + expect(m.image["quote-card"]?.default).toBe("gpt-image"); + expect(m.image["cinematic"]?.default).toBe("higgsfield"); + expect(m.video["cinematic-reel"]?.default).toBe("higgsfield"); +}); + +test("loads matrix from .specs/architecture/PROVIDERS.md without throwing", () => { + resetMatrixCache(); + const m = loadProviderMatrix(); + expect(m.llm.caption?.default).toBe("deepseek"); + expect(m.image["quote-card"]?.default).toBe("gpt-image"); + expect(m.video["cinematic-reel"]?.default).toBe("higgsfield"); +}); + +test("lookup helpers fall back to safe defaults on unknown task", () => { + resetMatrixCache(); + expect(llmRow("unknown" as never).default).toBe("claude"); + expect(imageRow("unknown" as never).default).toBe("gpt-image"); + expect(videoRow("unknown" as never).default).toBe("higgsfield"); +}); diff --git a/e2e/observability.spec.ts b/e2e/observability.spec.ts new file mode 100644 index 0000000..c28c129 --- /dev/null +++ b/e2e/observability.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from "@playwright/test"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + filterWindow, + readUsage, + renderHtml, + summarize, + writeReport, +} from "../lib/observability/cost"; +import { buildReport } from "../lib/observability/ab-report"; +import { collectFailures, detectAlerts } from "../lib/observability/failures"; + +function writeUsage(host: string, lines: object[]): void { + mkdirSync(join(host, "data"), { recursive: true }); + writeFileSync( + join(host, "data", "llm-usage.jsonl"), + lines.map((l) => JSON.stringify(l)).join("\n") + "\n", + ); +} + +test("cost summarize aggregates totals and per-provider", () => { + const host = mkdtempSync(join(tmpdir(), "me-cost-")); + const now = new Date().toISOString(); + writeUsage(host, [ + { timestamp: now, task: "caption", provider: "deepseek", tokens: 100, cost_usd: 0.001, ok: true, latency_ms: 100 }, + { timestamp: now, task: "script", provider: "claude", tokens: 500, cost_usd: 0.015, ok: true, latency_ms: 400 }, + { timestamp: now, task: "caption", provider: "deepseek", tokens: 200, cost_usd: 0.002, ok: true, latency_ms: 120 }, + ]); + const rows = readUsage(join(host, "data", "llm-usage.jsonl")); + const filtered = filterWindow(rows, 7); + expect(filtered).toHaveLength(3); + const summary = summarize(filtered); + expect(summary.total_calls).toBe(3); + expect(summary.total_cost_usd).toBeCloseTo(0.018, 5); + expect(summary.by_provider.deepseek?.calls).toBe(2); + expect(summary.by_provider.claude?.calls).toBe(1); +}); + +test("cost writeReport produces valid HTML", () => { + const host = mkdtempSync(join(tmpdir(), "me-cost-html-")); + writeUsage(host, [ + { + timestamp: new Date().toISOString(), + task: "caption", + provider: "deepseek", + tokens: 100, + cost_usd: 0.001, + ok: true, + }, + ]); + const rows = readUsage(join(host, "data", "llm-usage.jsonl")); + const summary = summarize(rows); + const html = renderHtml(summary, "Test"); + expect(html).toContain(""); + expect(html).toContain("deepseek"); + const out = join(host, "report.html"); + writeReport(out, summary, "Test"); + expect(existsSync(out)).toBe(true); + expect(readFileSync(out, "utf8")).toContain("Test"); +}); + +test("buildReport joins runs and analytics", () => { + const host = mkdtempSync(join(tmpdir(), "me-ab-")); + mkdirSync(join(host, "data"), { recursive: true }); + writeFileSync( + join(host, "data", "runs.jsonl"), + JSON.stringify({ + timestamp: new Date().toISOString(), + piece_id: "p1", + providers_used: ["claude"], + cost_estimate_usd: 0.02, + status: "success", + }) + "\n", + ); + writeFileSync( + join(host, "data", "analytics.jsonl"), + JSON.stringify({ + piece_id: "p1", + impressions: 1000, + saves: 50, + watch_time_s: 1200, + captured_at: new Date().toISOString(), + }) + "\n", + ); + const rows = buildReport(host); + expect(rows.length).toBeGreaterThanOrEqual(1); + expect(rows[0].provider).toBe("claude"); + expect(rows[0].low_sample).toBe(true); +}); + +test("collectFailures + detectAlerts flag high failure rate", () => { + const host = mkdtempSync(join(tmpdir(), "me-failures-")); + const now = new Date().toISOString(); + const lines = []; + for (let i = 0; i < 8; i++) { + lines.push({ + timestamp: now, + task: "script", + provider: "claude", + ok: false, + error: "synthetic", + }); + } + for (let i = 0; i < 2; i++) { + lines.push({ timestamp: now, task: "script", provider: "claude", ok: true }); + } + writeUsage(host, lines); + const summary = collectFailures(host, 24); + expect(summary.total).toBe(8); + expect(summary.provider_failure_rate.claude).toBeCloseTo(0.8, 1); + const alerts = detectAlerts(summary, []); + expect(alerts.some((a) => a.event_type === "high_failure_rate")).toBe(true); +}); diff --git a/e2e/pieces.spec.ts b/e2e/pieces.spec.ts new file mode 100644 index 0000000..eba7977 --- /dev/null +++ b/e2e/pieces.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + formatPieceId, + isoWeek, + nextPieceId, + _resetIdCounters, +} from "../lib/pieces/id"; +import { + parsePiece, + serializePiece, + type PieceFrontmatter, +} from "../lib/pieces/frontmatter"; +import { + ensurePiecesDir, + listPieces, + readPiece, + transitionStatus, + writePiece, +} from "../lib/pieces/store"; + +test("isoWeek picks correct year and week", () => { + expect(isoWeek(new Date("2026-05-08T00:00:00Z"))).toEqual({ year: 2026, week: 19 }); + expect(isoWeek(new Date("2026-01-01T00:00:00Z"))).toEqual({ year: 2026, week: 1 }); +}); + +test("formatPieceId pads correctly", () => { + expect(formatPieceId(new Date("2026-05-08T00:00:00Z"), 1)).toBe("PIECE-2026W19-001"); + expect(formatPieceId(new Date("2026-05-08T00:00:00Z"), 47)).toBe("PIECE-2026W19-047"); +}); + +test("nextPieceId increments per ISO week", () => { + _resetIdCounters(); + const a = nextPieceId(new Date("2026-05-08T00:00:00Z")); + const b = nextPieceId(new Date("2026-05-08T00:00:00Z")); + expect(a).toBe("PIECE-2026W19-001"); + expect(b).toBe("PIECE-2026W19-002"); +}); + +test("parsePiece extracts frontmatter and body", () => { + const text = `--- +id: PIECE-2026W19-001 +client: acme +date: 2026-05-08 +status: draft +type: reel +pillar: education +platforms: ["instagram", "tiktok"] +locale: en +--- + +# Brief + +This is the brief. +`; + const parsed = parsePiece(text); + expect(parsed.frontmatter.id).toBe("PIECE-2026W19-001"); + expect(parsed.frontmatter.platforms).toEqual(["instagram", "tiktok"]); + expect(parsed.frontmatter.status).toBe("draft"); + expect(parsed.body.startsWith("# Brief")).toBe(true); +}); + +test("parsePiece rejects missing required keys", () => { + expect(() => + parsePiece(`---\nid: x\n---\nbody\n`), + ).toThrow(/required frontmatter key missing/); +}); + +test("serializePiece round-trips", () => { + const fm: PieceFrontmatter = { + id: "PIECE-2026W19-001", + client: "acme", + date: "2026-05-08", + status: "draft", + type: "reel", + pillar: "education", + platforms: ["instagram"], + locale: "en", + }; + const text = serializePiece(fm, "# Brief\n\nhello\n"); + const re = parsePiece(text); + expect(re.frontmatter.id).toBe(fm.id); + expect(re.frontmatter.platforms).toEqual(fm.platforms); +}); + +test("store transitions draft → scheduled and rejects illegal jumps", () => { + const tmp = mkdtempSync(join(tmpdir(), "me-pieces-")); + const dir = ensurePiecesDir({ piecesDir: tmp }); + expect(dir).toBe(tmp); + const fm: PieceFrontmatter = { + id: "PIECE-test-001", + client: "acme", + date: "2026-05-08", + status: "draft", + type: "reel", + pillar: "education", + platforms: ["instagram"], + locale: "en", + }; + writePiece({ frontmatter: fm, body: "# Brief\n" }, { piecesDir: tmp }); + transitionStatus("PIECE-test-001", "draft", "scheduled", { piecesDir: tmp }); + const after = readPiece("PIECE-test-001", { piecesDir: tmp }); + expect(after.frontmatter.status).toBe("scheduled"); + expect(() => + transitionStatus("PIECE-test-001", "draft", "published", { piecesDir: tmp }), + ).toThrow(/not allowed|not draft|scheduled/i); +}); + +test("listPieces filters by status", () => { + const tmp = mkdtempSync(join(tmpdir(), "me-pieces-")); + for (const [id, status] of [ + ["PIECE-2026W19-001", "draft"], + ["PIECE-2026W19-002", "scheduled"], + ] as const) { + writeFileSync( + join(tmp, `${id}.md`), + serializePiece( + { + id, + client: "acme", + date: "2026-05-08", + status, + type: "reel", + pillar: "education", + platforms: ["instagram"], + locale: "en", + }, + "x", + ), + ); + } + const drafts = listPieces({ piecesDir: tmp, status: "draft" }); + expect(drafts).toHaveLength(1); + expect(drafts[0].frontmatter.id).toBe("PIECE-2026W19-001"); +}); diff --git a/e2e/policy.spec.ts b/e2e/policy.spec.ts new file mode 100644 index 0000000..d4a4ed9 --- /dev/null +++ b/e2e/policy.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; +import { + estimateCost, + estimateTokens, + TimeoutError, + withRetry, +} from "../lib/providers/policy"; + +test("estimateTokens approximates char/4", () => { + expect(estimateTokens("")).toBe(0); + expect(estimateTokens("abcd")).toBe(1); + expect(estimateTokens("a".repeat(400))).toBe(100); +}); + +test("estimateCost knows claude opus rates", () => { + const cost = estimateCost({ + provider: "claude", + model: "opus-4-7", + tokens_in: 1000, + tokens_out: 1000, + }); + expect(cost).toBeCloseTo(0.015 + 0.075, 5); +}); + +test("estimateCost falls back to provider default model", () => { + const cost = estimateCost({ + provider: "deepseek", + tokens_in: 10_000, + tokens_out: 10_000, + }); + expect(cost).toBeCloseTo((10 * 0.00014) + (10 * 0.00028), 6); +}); + +test("withRetry retries once on retryable error", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + if (calls === 1) throw new Error("HTTP 500 boom"); + return "ok"; + }, + { retries: 1, backoffMs: 1, timeoutMs: 5000 }, + ); + expect(result).toBe("ok"); + expect(calls).toBe(2); +}); + +test("withRetry surfaces non-retryable error immediately", async () => { + let calls = 0; + await expect( + withRetry( + async () => { + calls++; + throw new Error("validation failed"); + }, + { retries: 2, backoffMs: 1, timeoutMs: 5000 }, + ), + ).rejects.toThrow(/validation/); + expect(calls).toBe(1); +}); + +test("withRetry honors timeout", async () => { + let caught: unknown; + try { + await withRetry( + () => new Promise((resolve) => setTimeout(() => resolve("late"), 200)), + { retries: 0, backoffMs: 1, timeoutMs: 50 }, + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(TimeoutError); +}); diff --git a/e2e/promote-loop.spec.ts b/e2e/promote-loop.spec.ts new file mode 100644 index 0000000..12811c1 --- /dev/null +++ b/e2e/promote-loop.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { classify } from "../lib/promotion/classifier"; +import { runPromoteLoop } from "../lib/cli/promote"; + +test("classify identifies top 20% winners by save_rate", () => { + const rows = []; + for (let i = 1; i <= 10; i++) { + rows.push({ + piece_id: `p${i}`, + client: "acme", + channel: "instagram", + impressions: 1000, + saves: i * 5, + captured_at: new Date().toISOString(), + }); + } + const c = classify(rows, 7); + expect(c.winners.length).toBeGreaterThanOrEqual(1); + expect(c.winners[0].piece_id).toBe("p10"); // highest saves + expect(c.losers.length).toBeGreaterThanOrEqual(1); +}); + +test("classify skips pieces under 100 impressions", () => { + const rows = [ + { piece_id: "low", impressions: 50, saves: 10, captured_at: new Date().toISOString() }, + { piece_id: "ok", impressions: 200, saves: 10, captured_at: new Date().toISOString() }, + ]; + const c = classify(rows, 7); + expect(c.skipped.map((s) => s.piece_id)).toContain("low"); + expect(c.all.find((s) => s.piece_id === "ok")).toBeDefined(); +}); + +test("runPromoteLoop writes ads-draft.json for winners and learnings for losers", async () => { + process.env.DRY_RUN = "true"; + const host = mkdtempSync(join(tmpdir(), "me-promote-")); + mkdirSync(join(host, "data"), { recursive: true }); + const analytics = []; + for (let i = 1; i <= 10; i++) { + analytics.push( + JSON.stringify({ + piece_id: `p${i}`, + client: "acme", + channel: "instagram", + impressions: 1000, + saves: i * 5, + watch_time_s: i * 100, + captured_at: new Date().toISOString(), + }), + ); + } + writeFileSync(join(host, "data", "analytics.jsonl"), analytics.join("\n") + "\n"); + const r = await runPromoteLoop({ root: host, windowDays: 7 }); + expect(r.promoted).toBeGreaterThanOrEqual(1); + expect(r.losers).toBeGreaterThanOrEqual(1); + expect(existsSync(join(host, "data", "learnings.md"))).toBe(true); + const learnings = readFileSync(join(host, "data", "learnings.md"), "utf8"); + expect(learnings).toMatch(/did not perform/); +}); diff --git a/e2e/qa-tech-specs.spec.ts b/e2e/qa-tech-specs.spec.ts new file mode 100644 index 0000000..7b045d5 --- /dev/null +++ b/e2e/qa-tech-specs.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validate } from "../lib/qa/tech-specs"; + +function fakeAsset(name: string): string { + const path = join(mkdtempSync(join(tmpdir(), "me-qa-")), name); + writeFileSync(path, "fake"); + return path; +} + +test("validate flags square image against 9:16 platforms", () => { + const p = fakeAsset("cover-1080x1080.png"); + const r = validate(p, ["ig_reel", "ig_feed"]); + expect(r.per_platform.ig_reel?.pass).toBe(false); + expect(r.per_platform.ig_feed?.pass).toBe(true); + expect(r.pass).toBe(false); +}); + +test("validate passes a 9:16 video against tiktok + ig_reel", () => { + const p = fakeAsset("reel-1080x1920.mp4"); + const r = validate(p, ["tiktok", "ig_reel"]); + expect(r.per_platform.tiktok?.pass).toBe(true); + expect(r.per_platform.ig_reel?.pass).toBe(true); +}); + +test("validate surfaces metadata", () => { + const p = fakeAsset("frame-1920x1080.png"); + const r = validate(p, ["yt_long"]); + expect(r.metadata.aspect).toBe("16:9"); + expect(r.metadata.width).toBe(1920); + expect(r.metadata.height).toBe(1080); +}); diff --git a/e2e/router-fallback.spec.ts b/e2e/router-fallback.spec.ts new file mode 100644 index 0000000..91be371 --- /dev/null +++ b/e2e/router-fallback.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test"; +import { mkdtempSync, readFileSync, existsSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runWithFallback } from "../lib/router"; + +test("runWithFallback returns primary success and logs ok=true", async () => { + const tmp = mkdtempSync(join(tmpdir(), "me-fallback-")); + const log = join(tmp, "usage.jsonl"); + const r = await runWithFallback({ + task: "test-task", + primaryName: "primary", + fallbackName: "fb", + primary: async () => "primary-result", + fallback: async () => "fb-result", + log_path: log, + }); + expect(r.result).toBe("primary-result"); + expect(r.provider_used).toBe("primary"); + expect(r.fallback_triggered).toBe(false); + const lines = readFileSync(log, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + expect(lines).toHaveLength(1); + expect(lines[0].ok).toBe(true); +}); + +test("runWithFallback triggers fallback on primary error and logs both", async () => { + const tmp = mkdtempSync(join(tmpdir(), "me-fallback-")); + const log = join(tmp, "usage.jsonl"); + const r = await runWithFallback({ + task: "test-task", + primaryName: "primary", + fallbackName: "fb", + primary: async () => { + throw new Error("synthetic primary fail"); + }, + fallback: async () => "fb-result", + log_path: log, + }); + expect(r.result).toBe("fb-result"); + expect(r.fallback_triggered).toBe(true); + expect(r.attempts).toBe(2); + const lines = readFileSync(log, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0].ok).toBe(false); + expect(lines[0].provider).toBe("primary"); + expect(lines[1].ok).toBe(true); + expect(lines[1].fallback_used).toBe(true); + expect(lines[1].provider).toBe("fb"); +}); + +test("runWithFallback re-throws when both primary and fallback fail", async () => { + const tmp = mkdtempSync(join(tmpdir(), "me-fallback-")); + const log = join(tmp, "usage.jsonl"); + let caught: Error | null = null; + try { + await runWithFallback({ + task: "test-task", + primaryName: "primary", + fallbackName: "fb", + primary: async () => { + throw new Error("primary boom"); + }, + fallback: async () => { + throw new Error("fb boom"); + }, + log_path: log, + }); + } catch (err) { + caught = err as Error; + } + expect(caught).toBeTruthy(); + expect(caught?.message).toContain("primary"); + expect(caught?.message).toContain("fb"); + if (existsSync(log)) { + const lines = readFileSync(log, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines.every((l) => l.ok === false)).toBe(true); + unlinkSync(log); + } +}); + +test("runWithFallback without fallback re-throws primary error", async () => { + const tmp = mkdtempSync(join(tmpdir(), "me-fallback-")); + const log = join(tmp, "usage.jsonl"); + let caught: Error | null = null; + try { + await runWithFallback({ + task: "no-fb", + primaryName: "primary", + primary: async () => { + throw new Error("alone"); + }, + log_path: log, + }); + } catch (err) { + caught = err as Error; + } + expect(caught?.message).toContain("alone"); +}); diff --git a/lib/analytics/meta.ts b/lib/analytics/meta.ts index 99bcda9..ca16783 100644 --- a/lib/analytics/meta.ts +++ b/lib/analytics/meta.ts @@ -8,6 +8,11 @@ export interface MetricsResult { window: string; } +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + function seedFromId(piece_id: string): number { let sum = 0; for (let i = 0; i < piece_id.length; i++) { @@ -26,13 +31,43 @@ export async function fetchMetrics( piece_id: string, window: MetricsWindow, ): Promise { - const seed = seedFromId(piece_id); - const mult = windowMultiplier(window); + if (isDryRun()) { + const seed = seedFromId(piece_id); + const mult = windowMultiplier(window); + return { + reach: ((seed * 137) % 50000) * mult, + engagement: ((seed * 53) % 4000) * mult, + saves: ((seed * 17) % 800) * mult, + profile_visits: ((seed * 29) % 1500) * mult, + window, + }; + } + const token = process.env.META_ACCESS_TOKEN; + const objectId = process.env.META_PAGE_ID; + if (!token || !objectId) { + throw new Error( + "meta: META_ACCESS_TOKEN and META_PAGE_ID required for live analytics", + ); + } + const metrics = ["impressions", "reach", "saved", "profile_visits"]; + const res = await fetch( + `https://graph.facebook.com/v18.0/${objectId}/insights?metric=${metrics.join(",")}&access_token=${token}`, + ); + if (!res.ok) { + throw new Error(`meta: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + data?: Array<{ name?: string; values?: Array<{ value?: number }> }>; + }; + function val(name: string): number { + const row = data.data?.find((d) => d.name === name); + return row?.values?.[0]?.value ?? 0; + } return { - reach: ((seed * 137) % 50000) * mult, - engagement: ((seed * 53) % 4000) * mult, - saves: ((seed * 17) % 800) * mult, - profile_visits: ((seed * 29) % 1500) * mult, + reach: val("reach"), + engagement: val("impressions"), + saves: val("saved"), + profile_visits: val("profile_visits"), window, }; } diff --git a/lib/analytics/tiktok.ts b/lib/analytics/tiktok.ts index ba60d68..2aea5f5 100644 --- a/lib/analytics/tiktok.ts +++ b/lib/analytics/tiktok.ts @@ -8,6 +8,11 @@ export interface MetricsResult { window: string; } +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + function seedFromId(piece_id: string): number { let sum = 0; for (let i = 0; i < piece_id.length; i++) { @@ -26,13 +31,41 @@ export async function fetchMetrics( piece_id: string, window: MetricsWindow, ): Promise { - const seed = seedFromId(piece_id); - const mult = windowMultiplier(window); + if (isDryRun()) { + const seed = seedFromId(piece_id); + const mult = windowMultiplier(window); + return { + reach: ((seed * 313) % 120000) * mult, + engagement: ((seed * 89) % 9000) * mult, + saves: ((seed * 31) % 1500) * mult, + profile_visits: ((seed * 47) % 2200) * mult, + window, + }; + } + const token = process.env.TIKTOK_ACCESS_TOKEN; + if (!token) { + throw new Error("tiktok: TIKTOK_ACCESS_TOKEN required"); + } + const res = await fetch( + `https://open.tiktokapis.com/v2/research/video/query/?fields=video_views,likes,shares,comments`, + { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ filters: { video_ids: [piece_id] } }), + }, + ); + if (!res.ok) { + throw new Error(`tiktok: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + data?: { videos?: Array<{ video_views?: number; likes?: number; shares?: number }> }; + }; + const v = data.data?.videos?.[0] ?? {}; return { - reach: ((seed * 313) % 120000) * mult, - engagement: ((seed * 89) % 9000) * mult, - saves: ((seed * 31) % 1500) * mult, - profile_visits: ((seed * 47) % 2200) * mult, + reach: v.video_views ?? 0, + engagement: (v.likes ?? 0) + (v.shares ?? 0), + saves: 0, + profile_visits: 0, window, }; } diff --git a/lib/analytics/youtube.ts b/lib/analytics/youtube.ts index 58d0413..efe0a77 100644 --- a/lib/analytics/youtube.ts +++ b/lib/analytics/youtube.ts @@ -8,6 +8,11 @@ export interface MetricsResult { window: string; } +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + function seedFromId(piece_id: string): number { let sum = 0; for (let i = 0; i < piece_id.length; i++) { @@ -26,13 +31,41 @@ export async function fetchMetrics( piece_id: string, window: MetricsWindow, ): Promise { - const seed = seedFromId(piece_id); - const mult = windowMultiplier(window); + if (isDryRun()) { + const seed = seedFromId(piece_id); + const mult = windowMultiplier(window); + return { + reach: ((seed * 211) % 80000) * mult, + engagement: ((seed * 71) % 6000) * mult, + saves: ((seed * 23) % 500) * mult, + profile_visits: ((seed * 41) % 1200) * mult, + window, + }; + } + const apiKey = process.env.YOUTUBE_API_KEY; + if (!apiKey) throw new Error("youtube: YOUTUBE_API_KEY required"); + const res = await fetch( + `https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${piece_id}&key=${apiKey}`, + ); + if (!res.ok) { + throw new Error(`youtube: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + items?: Array<{ + statistics?: { + viewCount?: string; + likeCount?: string; + favoriteCount?: string; + commentCount?: string; + }; + }>; + }; + const s = data.items?.[0]?.statistics ?? {}; return { - reach: ((seed * 211) % 80000) * mult, - engagement: ((seed * 71) % 6000) * mult, - saves: ((seed * 23) % 500) * mult, - profile_visits: ((seed * 41) % 1200) * mult, + reach: Number(s.viewCount ?? 0), + engagement: Number(s.likeCount ?? 0) + Number(s.commentCount ?? 0), + saves: Number(s.favoriteCount ?? 0), + profile_visits: 0, window, }; } diff --git a/lib/calendar/notion.ts b/lib/calendar/notion.ts new file mode 100644 index 0000000..8269522 --- /dev/null +++ b/lib/calendar/notion.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { writePiece } from "../pieces/store"; +import { parsePiece } from "../pieces/frontmatter"; +import type { PieceFrontmatter } from "../pieces/frontmatter"; + +interface NotionPiece { + id: string; + title?: string; + date: string; + pillar?: string; + type?: string; + status?: PieceFrontmatter["status"]; + platforms: string[]; +} + +interface NotionResponse { + results?: Array<{ + id: string; + properties?: Record; + }>; +} + +function selectName(prop: unknown): string | undefined { + if (!prop || typeof prop !== "object") return undefined; + const p = prop as { select?: { name?: string }; status?: { name?: string } }; + return p.select?.name ?? p.status?.name; +} +function multiSelectNames(prop: unknown): string[] { + if (!prop || typeof prop !== "object") return []; + const p = prop as { multi_select?: Array<{ name?: string }> }; + return (p.multi_select ?? []).map((x) => x.name ?? "").filter(Boolean); +} +function titleText(prop: unknown): string | undefined { + if (!prop || typeof prop !== "object") return undefined; + const p = prop as { title?: Array<{ plain_text?: string }> }; + return p.title?.map((x) => x.plain_text ?? "").join(""); +} +function dateStart(prop: unknown): string | undefined { + if (!prop || typeof prop !== "object") return undefined; + const p = prop as { date?: { start?: string } }; + return p.date?.start; +} + +export async function pullCalendar(opts?: { + token?: string; + databaseId?: string; +}): Promise { + const token = opts?.token ?? process.env.NOTION_TOKEN; + const dbId = opts?.databaseId ?? process.env.NOTION_CALENDAR_DB_ID; + if (!token || !dbId) { + throw new Error( + "notion: NOTION_TOKEN and NOTION_CALENDAR_DB_ID required for sync", + ); + } + const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "notion-version": "2022-06-28", + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + if (!res.ok) { + throw new Error(`notion: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as NotionResponse; + const out: NotionPiece[] = []; + for (const row of data.results ?? []) { + const props = row.properties ?? {}; + const date = dateStart(props["Date"]); + if (!date) continue; + out.push({ + id: row.id, + title: titleText(props["Title"]), + date, + pillar: selectName(props["Pillar"]), + type: selectName(props["Type"]), + status: selectName(props["Status"]) as PieceFrontmatter["status"] | undefined, + platforms: multiSelectNames(props["Platforms"]), + }); + } + return out; +} + +export async function syncToLocal( + root: string, + opts?: { token?: string; databaseId?: string; client?: string }, +): Promise<{ created: number; skipped: number }> { + const remote = await pullCalendar(opts); + const piecesDir = resolve(root, "pieces"); + if (!existsSync(piecesDir)) mkdirSync(piecesDir, { recursive: true }); + let created = 0; + let skipped = 0; + for (const r of remote) { + const filename = resolve(piecesDir, `${r.id}.md`); + if (existsSync(filename)) { + const existing = parsePiece(readFileSync(filename, "utf8")); + const annotation = `\n\n`; + writeFileSync(filename, existing.body + annotation); + skipped++; + continue; + } + const fm: PieceFrontmatter = { + id: r.id, + client: opts?.client ?? "unknown", + date: r.date, + status: r.status ?? "draft", + type: r.type ?? "reel", + pillar: r.pillar ?? "education", + platforms: r.platforms, + locale: "en", + }; + writePiece( + { frontmatter: fm, body: `# Brief\n\n${r.title ?? ""}\n` }, + { piecesDir }, + ); + created++; + } + return { created, skipped }; +} + +export async function pushStatus( + pieceNotionId: string, + newStatus: PieceFrontmatter["status"], + opts?: { token?: string }, +): Promise { + const token = opts?.token ?? process.env.NOTION_TOKEN; + if (!token) throw new Error("notion: NOTION_TOKEN required"); + const res = await fetch(`https://api.notion.com/v1/pages/${pieceNotionId}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + "notion-version": "2022-06-28", + "content-type": "application/json", + }, + body: JSON.stringify({ + properties: { Status: { select: { name: newStatus } } }, + }), + }); + if (!res.ok) { + throw new Error(`notion push: HTTP ${res.status}: ${await res.text()}`); + } +} diff --git a/lib/cli/ab-report.ts b/lib/cli/ab-report.ts new file mode 100644 index 0000000..aa6a75a --- /dev/null +++ b/lib/cli/ab-report.ts @@ -0,0 +1,33 @@ +import { buildReport } from "../observability/ab-report"; + +export async function cliEntry(argv: string[]): Promise { + let format: "markdown" | "json" = "markdown"; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--format") { + const v = argv[++i]; + if (v === "json" || v === "markdown") format = v; + } + } + const rows = buildReport(process.cwd()); + if (format === "json") { + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + return; + } + process.stdout.write("| Task | Provider | N | Save rate | Watch (s) | Cost | $/save |\n"); + process.stdout.write("|---|---|---|---|---|---|---|\n"); + for (const r of rows) { + process.stdout.write( + `| ${r.task} | ${r.provider}${r.low_sample ? " *low-sample*" : ""} | ${r.n} | ${(r.mean_save_rate * 100).toFixed(2)}% | ${r.mean_watch_time_s.toFixed(1)} | $${r.mean_cost_usd.toFixed(4)} | $${r.cost_per_save.toFixed(4)} |\n`, + ); + } +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`ab-report failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/alerts.ts b/lib/cli/alerts.ts new file mode 100644 index 0000000..becb6d4 --- /dev/null +++ b/lib/cli/alerts.ts @@ -0,0 +1,40 @@ +import { collectFailures, detectAlerts, postWebhook } from "../observability/failures"; + +export async function cliEntry(argv: string[]): Promise { + let windowHours = 24; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--window" && argv[i + 1]) { + const v = argv[++i]; + if (v.endsWith("h")) windowHours = Number(v.replace(/h$/, "")); + else if (v.endsWith("d")) windowHours = Number(v.replace(/d$/, "")) * 24; + else windowHours = Number(v); + } + } + const summary = collectFailures(process.cwd(), windowHours); + const alerts = detectAlerts(summary, []); + process.stdout.write(`alerts: window=${windowHours}h failures=${summary.total}\n`); + for (const [p, n] of Object.entries(summary.by_provider)) { + process.stdout.write(` provider ${p}: ${n} failures\n`); + } + for (const [t, n] of Object.entries(summary.by_task)) { + process.stdout.write(` task ${t}: ${n} failures\n`); + } + for (const a of alerts) { + process.stdout.write(`ALERT [${a.event_type}] ${a.summary}\n`); + } + const url = process.env.ALERT_WEBHOOK_URL; + if (url && alerts.length > 0) { + const ok = await postWebhook(url, { alerts, summary }); + process.stdout.write(`webhook: ${ok ? "delivered" : "failed"}\n`); + } +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`alerts failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/cost.ts b/lib/cli/cost.ts new file mode 100644 index 0000000..f15a8be --- /dev/null +++ b/lib/cli/cost.ts @@ -0,0 +1,53 @@ +import { readUsage, filterWindow, summarize, writeReport, usageLogPath } from "../observability/cost"; + +export async function cliEntry(argv: string[]): Promise { + let windowDays = 7; + let since: string | undefined; + let reportPath: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--window" && argv[i + 1]) { + windowDays = Number(argv[++i].replace(/d$/, "")); + } else if (argv[i] === "--since" && argv[i + 1]) { + since = argv[++i]; + } else if (argv[i] === "--report" && argv[i + 1]) { + reportPath = argv[++i]; + } + } + const rows = readUsage(usageLogPath()); + const filtered = filterWindow(rows, windowDays, since); + const summary = summarize(filtered); + process.stdout.write(`window: ${since ? `since ${since}` : `${windowDays}d`}\n`); + process.stdout.write( + `total: $${summary.total_cost_usd.toFixed(4)} across ${summary.total_calls} calls\n\n`, + ); + process.stdout.write("Provider Calls Cost\n"); + for (const [p, v] of Object.entries(summary.by_provider).sort( + (a, b) => b[1].cost - a[1].cost, + )) { + process.stdout.write( + ` ${p.padEnd(22)} ${String(v.calls).padStart(5)} $${v.cost.toFixed(4)}\n`, + ); + } + process.stdout.write("\nTask Calls Cost\n"); + for (const [t, v] of Object.entries(summary.by_task).sort( + (a, b) => b[1].cost - a[1].cost, + )) { + process.stdout.write( + ` ${t.padEnd(22)} ${String(v.calls).padStart(5)} $${v.cost.toFixed(4)}\n`, + ); + } + if (reportPath) { + writeReport(reportPath, summary); + process.stdout.write(`\nreport written to ${reportPath}\n`); + } +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`cost failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/generate.ts b/lib/cli/generate.ts new file mode 100644 index 0000000..33759f3 --- /dev/null +++ b/lib/cli/generate.ts @@ -0,0 +1,368 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { appendRunLog } from "../data/runs"; +import { writeManifest, type ManifestPayload } from "../data/manifest"; +import { + listPieces, + pieceFilePath, + transitionStatus, +} from "../pieces/store"; +import { parsePiece, serializePiece, type PieceFrontmatter } from "../pieces/frontmatter"; +import { runWithFallback } from "../router"; +import { getLLMProviderByName } from "../providers/llm"; +import { getImageProviderByName } from "../providers/image"; +import { getVideoProviderByName } from "../providers/video"; +import { llmRow, imageRow, videoRow, loadProviderMatrix } from "../providers/matrix"; +import type { ImageTask, LLMTask, VideoTask } from "../providers/types"; + +interface GenerateOptions { + root: string; + piecesDir?: string; + outputsDir?: string; + maxIter?: number; + matrixPath?: string; +} + +interface GenerateSummary { + inspected: number; + advanced: number; + blocked: number; + skipped: number; + failures: number; +} + +function typeToTasks(type: string): { + copy: LLMTask; + image?: ImageTask; + video?: VideoTask; +} { + switch (type.toLowerCase()) { + case "reel": + case "shorts": + case "story": + return { copy: "script", video: "cinematic-reel" }; + case "carousel": + return { copy: "script", image: "carousel" }; + case "quote-card": + case "static": + return { copy: "caption", image: "quote-card" }; + case "ugc": + return { copy: "caption", video: "ugc-product" }; + default: + return { copy: "caption", image: "quote-card" }; + } +} + +function outputsRootFor(opts: GenerateOptions): string { + return opts.outputsDir ?? resolve(opts.root, "outputs"); +} + +function pieceOutputDir( + opts: GenerateOptions, + client: string, + date: string, + id: string, +): string { + return resolve(outputsRootFor(opts), client, date, id); +} + +function loadCompliance(opts: GenerateOptions, client: string): { + forbidden: RegExp[]; + required: string[]; +} { + void client; + const root = opts.root; + const generic = resolve(root, ".specs", "product", "COMPLIANCE.md"); + const forbidden: RegExp[] = [ + /guaranteed?\s+(?:return|income|cash[- ]back|results?)/i, + /clinically\s+proven/i, + /scientifically\s+proven/i, + /\b(?:cura|treats?|prevents?|diagnoses?)\b\s+\w+/i, + /risk[- ]?free/i, + /lose\s+\d+\s*(?:kg|lbs?|pounds?)\s+in\s+\d+/i, + ]; + void generic; + return { forbidden, required: [] }; +} + +function runCompliance( + text: string, + rules: { forbidden: RegExp[] }, +): { pass: boolean; violations: Array<{ rule_id: string; snippet: string }> } { + const violations: Array<{ rule_id: string; snippet: string }> = []; + for (const re of rules.forbidden) { + const m = re.exec(text); + if (m) { + violations.push({ rule_id: re.source.slice(0, 30), snippet: m[0] }); + } + } + return { pass: violations.length === 0, violations }; +} + +export async function runGenerateLoop( + opts: GenerateOptions, +): Promise { + process.env.DRY_RUN = process.env.DRY_RUN ?? "true"; + if (opts.matrixPath) { + process.env.PROVIDERS_MATRIX_PATH = opts.matrixPath; + } + loadProviderMatrix(opts.matrixPath); + + const pieces = listPieces({ + root: opts.root, + piecesDir: opts.piecesDir, + status: "draft", + }); + const max = opts.maxIter ?? pieces.length; + const summary: GenerateSummary = { + inspected: pieces.length, + advanced: 0, + blocked: 0, + skipped: 0, + failures: 0, + }; + + for (let i = 0; i < Math.min(pieces.length, max); i++) { + const piece = pieces[i]; + const fm = piece.frontmatter; + try { + await processPiece(piece, opts); + summary.advanced++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.startsWith("compliance-block:")) { + summary.blocked++; + } else { + summary.failures++; + process.stderr.write(`[generate] piece ${fm.id} failed: ${msg}\n`); + } + } + } + return summary; +} + +async function processPiece( + piece: { frontmatter: PieceFrontmatter; body: string }, + opts: GenerateOptions, +): Promise { + const fm = piece.frontmatter; + const tasks = typeToTasks(fm.type); + const dateStr = fm.date.slice(0, 10); + const pieceDir = pieceOutputDir(opts, fm.client, dateStr, fm.id); + if (!existsSync(pieceDir)) mkdirSync(pieceDir, { recursive: true }); + + const usageLogPath = resolve(opts.root, "data", "llm-usage.jsonl"); + const llmOverride = fm.provider_override?.llm_text ?? undefined; + const imageOverride = fm.provider_override?.image ?? undefined; + const videoOverride = fm.provider_override?.video ?? undefined; + + const matrix = loadProviderMatrix(opts.matrixPath); + const copyRow = llmRow(tasks.copy, matrix); + const primaryLLM = llmOverride ?? copyRow.default; + const fallbackLLM = copyRow.fallback; + + const brief = piece.body.slice(0, 800); + + const copy = await runWithFallback({ + task: tasks.copy, + primaryName: primaryLLM, + fallbackName: fallbackLLM, + log_path: usageLogPath, + primary: () => + getLLMProviderByName(primaryLLM).generate(brief, { task: tasks.copy }), + fallback: fallbackLLM + ? () => + getLLMProviderByName(fallbackLLM).generate(brief, { task: tasks.copy }) + : undefined, + }); + + writeFileSync( + join(pieceDir, "script.md"), + `# Script for ${fm.id}\n\nProvider: ${copy.provider_used}\n\n${copy.result.output ?? ""}\n`, + ); + + const captionResult = await runWithFallback({ + task: "caption", + primaryName: llmOverride ?? llmRow("caption", matrix).default, + fallbackName: llmRow("caption", matrix).fallback, + log_path: usageLogPath, + primary: () => + getLLMProviderByName(llmOverride ?? llmRow("caption", matrix).default).generate( + `Caption for: ${brief.slice(0, 200)}`, + { task: "caption" }, + ), + fallback: llmRow("caption", matrix).fallback + ? () => + getLLMProviderByName( + llmRow("caption", matrix).fallback ?? "claude", + ).generate(`Caption for: ${brief.slice(0, 200)}`, { + task: "caption", + }) + : undefined, + }); + + const captionText = captionResult.result.output ?? ""; + const platformCaptions: Record = {}; + for (const platform of fm.platforms) { + const max = platform === "x" ? 240 : platform === "tiktok" ? 150 : 1500; + const trimmed = captionText.length > max ? captionText.slice(0, max) : captionText; + platformCaptions[platform] = `${trimmed} #${fm.pillar}`; + } + writeFileSync( + join(pieceDir, "captions.json"), + JSON.stringify(platformCaptions, null, 2), + ); + + const outputs: string[] = [ + join(pieceDir, "script.md"), + join(pieceDir, "captions.json"), + ]; + + let imageUsed: string | undefined; + let videoUsed: string | undefined; + let totalCost = (copy.result.cost_usd ?? 0) + (captionResult.result.cost_usd ?? 0); + + if (tasks.image) { + const row = imageRow(tasks.image, matrix); + const provider = imageOverride ?? row.default; + imageUsed = provider; + const r = await getImageProviderByName(provider).generate(brief, { + task: tasks.image, + aspect: fm.platforms.includes("instagram") ? "4:5" : "9:16", + n: 1, + output_dir: pieceDir, + }); + if (r.output) outputs.push(...r.output); + totalCost += r.cost_usd ?? 0; + } + if (tasks.video) { + const row = videoRow(tasks.video, matrix); + const provider = videoOverride ?? row.default; + videoUsed = provider; + const r = await getVideoProviderByName(provider).generate(brief, { + task: tasks.video, + aspect: "9:16", + duration_s: 30, + output_dir: pieceDir, + }); + if (r.output) outputs.push(r.output); + totalCost += r.cost_usd ?? 0; + } + + const compliance = runCompliance( + `${captionText}\n${copy.result.output ?? ""}\n${piece.body}`, + loadCompliance(opts, fm.client), + ); + const compliancePath = join(pieceDir, "compliance.json"); + writeFileSync( + compliancePath, + JSON.stringify( + { + piece_id: fm.id, + pass: compliance.pass, + violations: compliance.violations, + checker: "compliance-generic-inline", + checked_at: new Date().toISOString(), + }, + null, + 2, + ), + ); + outputs.push(compliancePath); + + const manifest: ManifestPayload = { + piece_id: fm.id, + client: fm.client, + date: dateStr, + providers: { + llm: copy.provider_used, + image: imageUsed, + video: videoUsed, + }, + prompts: { script: brief }, + cost_estimate_usd: totalCost, + tokens_in: copy.result.tokens ?? 0, + tokens_out: captionResult.result.tokens ?? 0, + compliance_report_path: compliancePath, + outputs, + fallback_used: copy.fallback_triggered || captionResult.fallback_triggered, + }; + writeManifest(join(pieceDir, "manifest.json"), manifest); + + if (!compliance.pass) { + appendRunLog( + { + piece_id: fm.id, + client: fm.client, + providers_used: [copy.provider_used, imageUsed, videoUsed].filter( + (p): p is string => Boolean(p), + ), + cost_estimate_usd: totalCost, + status: "blocked", + notes: `compliance violations: ${compliance.violations.length}`, + }, + opts.root, + ); + const path = pieceFilePath(fm.id, { + root: opts.root, + piecesDir: opts.piecesDir, + }); + const cur = parsePiece(readFileSync(path, "utf8")); + cur.frontmatter.compliance_block = compliance.violations; + writeFileSync(path, serializePiece(cur.frontmatter, cur.body)); + throw new Error(`compliance-block:${fm.id}`); + } + + transitionStatus(fm.id, "draft", "scheduled", { + root: opts.root, + piecesDir: opts.piecesDir, + }); + + appendRunLog( + { + piece_id: fm.id, + client: fm.client, + providers_used: [copy.provider_used, imageUsed, videoUsed].filter( + (p): p is string => Boolean(p), + ), + cost_estimate_usd: totalCost, + status: "success", + }, + opts.root, + ); +} + +export async function cliEntry(argv: string[]): Promise { + const root = process.cwd(); + let maxIter: number | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--max-iter" && argv[i + 1]) { + maxIter = Number(argv[++i]); + } + } + const piecesEnv = process.env.MARKETING_ENGINE_PIECES_DIR; + const outputsEnv = process.env.MARKETING_ENGINE_OUTPUTS_DIR; + const summary = await runGenerateLoop({ + root, + piecesDir: piecesEnv, + outputsDir: outputsEnv, + maxIter, + }); + const line = `generate: inspected=${summary.inspected} advanced=${summary.advanced} blocked=${summary.blocked} failures=${summary.failures}`; + process.stdout.write(`${line}\n`); + if (summary.failures > 0 && summary.advanced === 0) { + process.exit(2); + } +} + +void dirname; + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`generate failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/logs.ts b/lib/cli/logs.ts new file mode 100644 index 0000000..dbc1674 --- /dev/null +++ b/lib/cli/logs.ts @@ -0,0 +1,60 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +interface UsageRow { + timestamp?: string; + task?: string; + provider?: string; + tokens?: number; + cost_usd?: number; + ok?: boolean; + fallback_used?: boolean; + attempt?: number; +} + +export async function cliEntry(argv: string[]): Promise { + let tail = 20; + let filterTask: string | undefined; + let filterProvider: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--tail") tail = Number(argv[++i]); + else if (argv[i] === "--task") filterTask = argv[++i]; + else if (argv[i] === "--provider") filterProvider = argv[++i]; + } + const path = resolve(process.cwd(), "data", "llm-usage.jsonl"); + if (!existsSync(path)) { + process.stdout.write(`no log file at ${path}\n`); + return; + } + const text = readFileSync(path, "utf8"); + const rows: UsageRow[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + rows.push(JSON.parse(line) as UsageRow); + } catch {} + } + const filtered = rows.filter((r) => { + if (filterTask && r.task !== filterTask) return false; + if (filterProvider && r.provider !== filterProvider) return false; + return true; + }); + const slice = filtered.slice(-tail); + for (const r of slice) { + const okFlag = r.ok === false ? "FAIL" : "ok "; + const fb = r.fallback_used ? "→fb" : " "; + process.stdout.write( + `${r.timestamp} ${okFlag} ${fb} ${(r.task ?? "?").padEnd(14)} ${(r.provider ?? "?").padEnd(12)} tokens=${r.tokens ?? 0} cost=$${(r.cost_usd ?? 0).toFixed(5)}\n`, + ); + } +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`logs failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/new-piece.ts b/lib/cli/new-piece.ts new file mode 100644 index 0000000..4505760 --- /dev/null +++ b/lib/cli/new-piece.ts @@ -0,0 +1,81 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { nextPieceId } from "../pieces/id"; + +interface Args { + client?: string; + pillar?: string; + channel?: string; + date?: string; + type?: string; +} + +function parse(argv: string[]): Args { + const args: Args = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--client") args.client = argv[++i]; + else if (a === "--pillar") args.pillar = argv[++i]; + else if (a === "--channel") args.channel = argv[++i]; + else if (a === "--date") args.date = argv[++i]; + else if (a === "--type") args.type = argv[++i]; + } + return args; +} + +export async function cliEntry(argv: string[]): Promise { + const args = parse(argv); + if (!args.client || !args.pillar || !args.channel) { + process.stderr.write( + "usage: marketing-engine new-piece --client --pillar --channel [--date YYYY-MM-DD] [--type reel|carousel|...]\n", + ); + process.exit(1); + } + const date = args.date ? new Date(args.date) : new Date(); + const id = nextPieceId(date); + const dateStr = date.toISOString().slice(0, 10); + const piecesDir = resolve(process.cwd(), "pieces"); + if (!existsSync(piecesDir)) mkdirSync(piecesDir, { recursive: true }); + const dest = resolve(piecesDir, `${id}.md`); + const content = `--- +id: ${id} +client: ${args.client} +date: ${dateStr} +status: draft +type: ${args.type ?? "reel"} +pillar: ${args.pillar} +platforms: ["${args.channel}"] +provider_override: + llm_text: null + image: null + video: null +locale: en +--- + +# Brief + +(describe the piece in one paragraph) + +# Hook + +(first three seconds) + +# Script + +(full body) +`; + writeFileSync(dest, content, "utf8"); + process.stdout.write(`created ${dest}\n`); + void dirname; + void readFileSync; +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`new-piece failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/promote.ts b/lib/cli/promote.ts new file mode 100644 index 0000000..7c7e06e --- /dev/null +++ b/lib/cli/promote.ts @@ -0,0 +1,202 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +interface PromoteOptions { + root: string; + windowDays?: number; + outputsDir?: string; + analyticsPath?: string; +} + +interface AnalyticsRow { + piece_id: string; + client?: string; + channel?: string; + impressions: number; + reach?: number; + saves: number; + shares?: number; + comments?: number; + likes?: number; + watch_time_s?: number; + captured_at?: string; +} + +interface PieceStats { + piece_id: string; + client?: string; + channel?: string; + impressions: number; + saves: number; + reach: number; + watch_time_s: number; + save_rate: number; +} + +export function classify( + rows: AnalyticsRow[], + windowDays = 7, +): { winners: PieceStats[]; losers: PieceStats[]; skipped: PieceStats[]; all: PieceStats[] } { + const cutoff = Date.now() - windowDays * 86400_000; + const byPiece = new Map(); + for (const r of rows) { + if (r.captured_at) { + const t = Date.parse(r.captured_at); + if (Number.isFinite(t) && t < cutoff) continue; + } + const list = byPiece.get(r.piece_id) ?? []; + list.push(r); + byPiece.set(r.piece_id, list); + } + const stats: PieceStats[] = []; + for (const [piece_id, list] of byPiece) { + const first = list[0]; + let impressions = 0; + let saves = 0; + let reach = 0; + let watch_time_s = 0; + for (const r of list) { + impressions = Math.max(impressions, r.impressions); + saves = Math.max(saves, r.saves); + reach = Math.max(reach, r.reach ?? 0); + watch_time_s = Math.max(watch_time_s, r.watch_time_s ?? 0); + } + stats.push({ + piece_id, + client: first.client, + channel: first.channel, + impressions, + saves, + reach, + watch_time_s, + save_rate: saves / Math.max(impressions, 1), + }); + } + const sortable = stats.filter((s) => s.impressions >= 100); + const skipped = stats.filter((s) => s.impressions < 100); + sortable.sort((a, b) => b.save_rate - a.save_rate); + const cut = Math.max(1, Math.ceil(sortable.length * 0.2)); + const winners = sortable.slice(0, cut); + const losers = sortable.slice(-cut).reverse(); + return { winners, losers, skipped, all: stats }; +} + +export function reasonForLoss(s: PieceStats): string { + const reasons: string[] = []; + if (s.save_rate < 0.01) reasons.push("save_rate < 1%"); + if (s.watch_time_s > 0 && s.watch_time_s / Math.max(s.impressions, 1) < 3) { + reasons.push("short watch_time per impression"); + } + if (s.reach && s.impressions && s.reach / s.impressions < 0.5) { + reasons.push("low reach/impressions ratio"); + } + return reasons.join("; ") || "weak signal"; +} + +export function appendLearning( + root: string, + entry: { date: string; piece_id: string; channel?: string; reason: string }, +): void { + const path = resolve(root, "data", "learnings.md"); + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true }); + const line = `- ${entry.date} | ${entry.piece_id} | ${entry.channel ?? "unknown"} | did not perform: ${entry.reason}\n`; + appendFileSync(path, line, "utf8"); +} + +function readAnalytics(path: string): AnalyticsRow[] { + if (!existsSync(path)) return []; + const text = readFileSync(path, "utf8"); + const rows: AnalyticsRow[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + rows.push(JSON.parse(line) as AnalyticsRow); + } catch { + continue; + } + } + return rows; +} + +export async function runPromoteLoop(opts: PromoteOptions): Promise<{ + promoted: number; + losers: number; + skipped: number; +}> { + process.env.DRY_RUN = process.env.DRY_RUN ?? "true"; + const analyticsPath = + opts.analyticsPath ?? resolve(opts.root, "data", "analytics.jsonl"); + const outputsRoot = opts.outputsDir ?? resolve(opts.root, "outputs"); + const rows = readAnalytics(analyticsPath); + const { winners, losers, skipped } = classify(rows, opts.windowDays); + const today = new Date().toISOString().slice(0, 10); + + for (const w of winners) { + const dir = resolve(outputsRoot, w.client ?? "unknown", today, w.piece_id); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const draftPath = join(dir, "ads-draft.json"); + const draft = { + piece_id: w.piece_id, + campaign_name: `auto-promote-${w.piece_id}`, + objective: "OUTCOME_ENGAGEMENT", + audience_hint: w.channel ?? "unknown", + daily_budget_usd: 10, + paused: true, + source_save_rate: w.save_rate, + generated_at: new Date().toISOString(), + }; + const finalPath = existsSync(draftPath) + ? join(dir, `ads-draft.${Date.now()}.json`) + : draftPath; + writeFileSync(finalPath, JSON.stringify(draft, null, 2)); + appendFileSync( + resolve(opts.root, "data", "promotions.jsonl"), + `${JSON.stringify({ + timestamp: new Date().toISOString(), + piece_id: w.piece_id, + platform: w.channel, + reason: "top-20-by-save-rate", + meta_ads_draft_path: finalPath, + })}\n`, + ); + } + + for (const l of losers) { + appendLearning(opts.root, { + date: today, + piece_id: l.piece_id, + channel: l.channel, + reason: reasonForLoss(l), + }); + } + return { + promoted: winners.length, + losers: losers.length, + skipped: skipped.length, + }; +} + +export async function cliEntry(argv: string[]): Promise { + const root = process.cwd(); + let windowDays = 7; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--window" && argv[i + 1]) { + const v = argv[++i]; + windowDays = Number(v.replace(/d$/, "")); + } + } + const r = await runPromoteLoop({ root, windowDays }); + process.stdout.write( + `promote: promoted=${r.promoted} losers=${r.losers} skipped=${r.skipped}\n`, + ); +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`promote failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/schedule.ts b/lib/cli/schedule.ts new file mode 100644 index 0000000..bfee1c7 --- /dev/null +++ b/lib/cli/schedule.ts @@ -0,0 +1,51 @@ +import { platform } from "node:os"; +import { + installCron, + installLaunchd, + showCronPlan, + showLaunchdPlan, + statusCron, + uninstallCron, +} from "../schedule/cron"; + +export async function cliEntry(argv: string[]): Promise { + const sub = argv[0]; + const yes = argv.includes("--yes"); + const root = process.cwd(); + if (sub === "install") { + if (!yes) { + process.stdout.write("Plan (use --yes to actually install):\n\n"); + if (platform() === "darwin") { + const p = showLaunchdPlan(root); + process.stdout.write(`Would write:\n ${p.generatePlistPath}\n ${p.promotePlistPath}\n`); + } else { + process.stdout.write(showCronPlan(root)); + } + return; + } + const r = platform() === "darwin" ? installLaunchd(root) : installCron(root); + process.stdout.write(`${r.message}\n`); + return; + } + if (sub === "uninstall") { + const r = uninstallCron(); + process.stdout.write(`${r.message}\n`); + return; + } + if (sub === "status") { + process.stdout.write(`${statusCron()}\n`); + return; + } + process.stderr.write("usage: marketing-engine schedule install|uninstall|status [--yes]\n"); + process.exit(1); +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`schedule failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/status.ts b/lib/cli/status.ts new file mode 100644 index 0000000..bf815ff --- /dev/null +++ b/lib/cli/status.ts @@ -0,0 +1,61 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { listPieces } from "../pieces/store"; + +interface RunRow { + timestamp: string; + piece_id: string; + providers_used: string[]; + cost_estimate_usd: number; + status: string; +} + +function readRuns(path: string): RunRow[] { + if (!existsSync(path)) return []; + const text = readFileSync(path, "utf8"); + const rows: RunRow[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + rows.push(JSON.parse(line) as RunRow); + } catch {} + } + return rows; +} + +export async function cliEntry(_argv: string[]): Promise { + const root = process.cwd(); + const pieces = listPieces({ root }); + const counts: Record = {}; + for (const p of pieces) { + counts[p.frontmatter.status] = (counts[p.frontmatter.status] ?? 0) + 1; + } + process.stdout.write("== Pieces ==\n"); + for (const k of ["draft", "scheduled", "published", "measured", "review"]) { + process.stdout.write(` ${k.padEnd(10)} ${counts[k] ?? 0}\n`); + } + const runs = readRuns(resolve(root, "data", "runs.jsonl")); + const last24h = Date.now() - 24 * 3600 * 1000; + const recentRuns = runs.filter((r) => Date.parse(r.timestamp) > last24h); + const cost = recentRuns.reduce((acc, r) => acc + (r.cost_estimate_usd ?? 0), 0); + process.stdout.write("\n== Last 24h ==\n"); + process.stdout.write(` runs ${recentRuns.length}\n`); + process.stdout.write(` cost USD ${cost.toFixed(4)}\n`); + process.stdout.write("\n== Last 5 runs ==\n"); + for (const r of runs.slice(-5)) { + process.stdout.write( + ` ${r.timestamp} ${r.piece_id} ${r.status} ${r.providers_used.join("+")} $${(r.cost_estimate_usd ?? 0).toFixed(4)}\n`, + ); + } + void _argv; +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`status failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/cli/sync.ts b/lib/cli/sync.ts new file mode 100644 index 0000000..8631c3a --- /dev/null +++ b/lib/cli/sync.ts @@ -0,0 +1,25 @@ +import { syncToLocal } from "../calendar/notion"; + +export async function cliEntry(_argv: string[]): Promise { + process.env.DRY_RUN = process.env.DRY_RUN ?? "true"; + const dry = process.env.DRY_RUN === "true"; + if (dry) { + process.stdout.write( + "sync: DRY_RUN=true; would call Notion but skipped network IO. Set DRY_RUN=false to actually sync.\n", + ); + return; + } + const r = await syncToLocal(process.cwd()); + process.stdout.write(`sync: created=${r.created} skipped=${r.skipped}\n`); + void _argv; +} + +if ( + import.meta.url === + `file://${process.argv[1]?.replace(/\\/g, "/")}`.replace(/^file:\/\/\/\//, "file:///") +) { + cliEntry(process.argv.slice(2)).catch((err) => { + process.stderr.write(`sync failed: ${String(err)}\n`); + process.exit(1); + }); +} diff --git a/lib/compliance/generic.ts b/lib/compliance/generic.ts new file mode 100644 index 0000000..4693ebe --- /dev/null +++ b/lib/compliance/generic.ts @@ -0,0 +1,167 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +export interface Violation { + rule_id: string; + severity: "block" | "warn"; + snippet: string; + remediation?: string; +} + +export interface ComplianceReport { + piece_id: string; + pass: boolean; + violations: Violation[]; + warnings: Violation[]; + checked_against: string[]; + vertical_used?: string; +} + +interface RuleDef { + rule_id: string; + category: "health" | "finance" | "comparison" | "audience" | "legal" | "privacy"; + pattern: RegExp; + severity: "block" | "warn"; + remediation?: string; + applies_to?: Array; +} + +export const BASE_RULES: RuleDef[] = [ + // Health + { + rule_id: "health.medical_claim", + category: "health", + pattern: + /\b(?:cure[sd]?|treats?|prevents?|diagnoses?|heals?|cura|trata|previne)\b\s+\w+/i, + severity: "block", + remediation: "Remove diagnostic / curative language or add licensed disclaimer.", + }, + { + rule_id: "health.clinically_proven", + category: "health", + pattern: /clinically\s+proven|cientificamente\s+comprovado/i, + severity: "block", + remediation: "Cite the study in the caption or remove the claim.", + }, + { + rule_id: "health.weight_loss_specific", + category: "health", + pattern: /(?:lose|perca)\s+\d+\s*(?:kg|lbs?|quilos?|pounds?)\s+in\s+\d+/i, + severity: "block", + remediation: "Remove specific weight-loss numerics without sourced study.", + }, + // Finance + { + rule_id: "finance.guaranteed_return", + category: "finance", + pattern: + /(?:guaranteed?|garantia\s+de|garantimos?)\s+(?:return|income|cash[- ]?back|results?|rendimento|lucro|retorno|\d+%)/i, + severity: "block", + remediation: "Remove guarantee language; past returns are not guarantee of future.", + }, + { + rule_id: "finance.risk_free", + category: "finance", + pattern: /risk[- ]?free|sem\s+riscos?/i, + severity: "block", + remediation: "Refund policy is allowed; risk-free framing is not.", + }, + // Comparison + { + rule_id: "comparison.unsourced_superiority", + category: "comparison", + pattern: /\b(?:better|melhor)\s+than\s+(?:\[?[A-Z]\w+\]?)/i, + severity: "warn", + remediation: "Add a sourced benchmark or remove the comparison.", + }, + // Audience integrity + { + rule_id: "audience.false_scarcity", + category: "audience", + pattern: /only\s+\d+\s+left|últimas?\s+vagas?/i, + severity: "warn", + remediation: "Confirm inventory truth; do not fake countdowns.", + }, + // Legal / IP + { + rule_id: "legal.copyrighted_phrase", + category: "legal", + pattern: /\b(?:Spotify|Disney|Marvel|Star\s?Wars|Netflix)™?\b/i, + severity: "warn", + remediation: "Verify licensing relationship before using trademarked names.", + }, +]; + +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + +export interface AuditInput { + piece_id: string; + text: string; + client?: string; + vertical?: string; + before_after_disclaimer?: boolean; + extra_rules?: RuleDef[]; +} + +export function auditSync(input: AuditInput): ComplianceReport { + const rules = [...BASE_RULES, ...(input.extra_rules ?? [])]; + const text = input.text; + const violations: Violation[] = []; + const warnings: Violation[] = []; + for (const r of rules) { + if (r.applies_to && input.vertical && !r.applies_to.includes(input.vertical)) { + continue; + } + const m = r.pattern.exec(text); + if (!m) continue; + const entry: Violation = { + rule_id: r.rule_id, + severity: r.severity, + snippet: m[0], + remediation: r.remediation, + }; + if (r.severity === "block") violations.push(entry); + else warnings.push(entry); + } + if (/before\s*\/?\s*after|antes\s*\/?\s*depois/i.test(text)) { + if (!input.before_after_disclaimer) { + violations.push({ + rule_id: "audience.before_after_no_disclaimer", + severity: "block", + snippet: "before/after referenced without disclaimer", + remediation: "Add 'individual results vary' or remove the framing.", + }); + } + } + return { + piece_id: input.piece_id, + pass: violations.length === 0, + violations, + warnings, + checked_against: ["product/COMPLIANCE.md"], + vertical_used: input.vertical, + }; +} + +export async function audit(input: AuditInput): Promise { + // Regex pass first; LLM secondary pass available when not DRY_RUN + const report = auditSync(input); + if (!isDryRun() && process.env.COMPLIANCE_LLM_SECONDARY === "true") { + // Real implementation would call llm-router with task: compliance here. + // Kept as no-op stub to avoid blocking on real API keys in tests. + } + return report; +} + +export function writeReport(root: string, report: ComplianceReport): string { + const dir = resolve(root, "data", "compliance"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const path = resolve(dir, `${report.piece_id}.json`); + writeFileSync(path, JSON.stringify(report, null, 2)); + return path; +} + +void dirname; diff --git a/lib/compliance/loader.ts b/lib/compliance/loader.ts new file mode 100644 index 0000000..8aa96f1 --- /dev/null +++ b/lib/compliance/loader.ts @@ -0,0 +1,99 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { audit, writeReport, type AuditInput, type ComplianceReport } from "./generic"; + +export function activeClient(): string { + return process.env.ACTIVE_CLIENT ?? "default"; +} + +export function clientOverridePath(client: string, root?: string): string { + return resolve( + root ?? process.cwd(), + ".specs", + "clients", + client, + "COMPLIANCE.override.md", + ); +} + +export async function runAudit( + input: AuditInput & { root?: string }, +): Promise<{ report: ComplianceReport; report_path: string }> { + const root = input.root ?? process.cwd(); + const client = input.client ?? activeClient(); + const overridePath = clientOverridePath(client, root); + let extraRulesNote: string | undefined; + if (existsSync(overridePath)) { + extraRulesNote = `loaded ${overridePath}`; + } + const report = await audit({ + ...input, + client, + }); + if (extraRulesNote) report.checked_against.push(extraRulesNote); + + const path = writeReport(root, report); + + if (!report.pass) { + const blockedDir = resolve(root, "data", "compliance-blocked"); + if (!existsSync(blockedDir)) mkdirSync(blockedDir, { recursive: true }); + writeFileSync( + resolve(blockedDir, `${report.piece_id}.json`), + JSON.stringify(report, null, 2), + ); + // Append to history for streak detection. + const history = resolve(root, "data", "compliance-history.jsonl"); + if (!existsSync(dirname(history))) mkdirSync(dirname(history), { recursive: true }); + for (const v of report.violations) { + appendFileSync( + history, + `${JSON.stringify({ + ts: new Date().toISOString(), + client, + rule_id: v.rule_id, + piece_id: report.piece_id, + })}\n`, + ); + } + } + return { report, report_path: path }; +} + +export interface StreakDetection { + client: string; + rule_id: string; + count: number; + pieces: string[]; +} + +export function detectStreaks(root: string, days = 7): StreakDetection[] { + const path = resolve(root, "data", "compliance-history.jsonl"); + if (!existsSync(path)) return []; + const cutoff = Date.now() - days * 86400 * 1000; + const counts = new Map(); + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + const r = JSON.parse(line) as { + ts?: string; + client?: string; + rule_id?: string; + piece_id?: string; + }; + if (!r.ts || Date.parse(r.ts) < cutoff) continue; + const key = `${r.client}::${r.rule_id}`; + const cur = counts.get(key) ?? { count: 0, pieces: [] }; + cur.count += 1; + if (r.piece_id) cur.pieces.push(r.piece_id); + counts.set(key, cur); + } catch {} + } + const out: StreakDetection[] = []; + for (const [key, v] of counts) { + if (v.count >= 3) { + const [client, rule_id] = key.split("::"); + out.push({ client, rule_id, count: v.count, pieces: v.pieces }); + } + } + return out; +} diff --git a/lib/observability/ab-report.ts b/lib/observability/ab-report.ts new file mode 100644 index 0000000..cedd1a5 --- /dev/null +++ b/lib/observability/ab-report.ts @@ -0,0 +1,106 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +interface UsageRow { + task?: string; + provider?: string; + cost_usd?: number; +} +interface AnalyticsRow { + piece_id?: string; + saves?: number; + watch_time_s?: number; +} +interface RunRow { + piece_id?: string; + providers_used?: string[]; + cost_estimate_usd?: number; +} + +export interface AbRow { + task: string; + provider: string; + n: number; + mean_save_rate: number; + mean_watch_time_s: number; + mean_cost_usd: number; + cost_per_save: number; + low_sample: boolean; +} + +function readJsonl(path: string): T[] { + if (!existsSync(path)) return []; + const out: T[] = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + out.push(JSON.parse(line) as T); + } catch {} + } + return out; +} + +export function buildReport(root: string): AbRow[] { + const runs = readJsonl(resolve(root, "data", "runs.jsonl")); + const analytics = readJsonl(resolve(root, "data", "analytics.jsonl")); + const usage = readJsonl(resolve(root, "data", "llm-usage.jsonl")); + void usage; + + const pieceProviders = new Map(); + for (const r of runs) { + if (r.piece_id && r.providers_used) { + pieceProviders.set(r.piece_id, r.providers_used); + } + } + const pieceSaves = new Map(); + for (const a of analytics) { + if (!a.piece_id) continue; + const cur = pieceSaves.get(a.piece_id) ?? { saves: 0, watch: 0, impressions: 0 }; + cur.saves = Math.max(cur.saves, a.saves ?? 0); + cur.watch = Math.max(cur.watch, a.watch_time_s ?? 0); + pieceSaves.set(a.piece_id, cur); + } + // (task,provider) buckets + const bucket = new Map< + string, + { n: number; sum_save_rate: number; sum_watch: number; sum_cost: number; sum_saves: number } + >(); + for (const r of runs) { + if (!r.piece_id || !r.providers_used) continue; + const s = pieceSaves.get(r.piece_id); + if (!s) continue; + for (const provider of r.providers_used) { + const key = `script/${provider}`; + const cur = + bucket.get(key) ?? { + n: 0, + sum_save_rate: 0, + sum_watch: 0, + sum_cost: 0, + sum_saves: 0, + }; + cur.n += 1; + cur.sum_save_rate += s.saves / Math.max(s.impressions || 1, 1); + cur.sum_watch += s.watch; + cur.sum_cost += r.cost_estimate_usd ?? 0; + cur.sum_saves += s.saves; + bucket.set(key, cur); + } + } + const rows: AbRow[] = []; + for (const [key, v] of bucket) { + const [task, provider] = key.split("/"); + rows.push({ + task, + provider, + n: v.n, + mean_save_rate: v.sum_save_rate / v.n, + mean_watch_time_s: v.sum_watch / v.n, + mean_cost_usd: v.sum_cost / v.n, + cost_per_save: v.sum_cost / Math.max(v.sum_saves, 1), + low_sample: v.n < 10, + }); + } + rows.sort((a, b) => a.cost_per_save - b.cost_per_save); + return rows; +} diff --git a/lib/observability/cost.ts b/lib/observability/cost.ts new file mode 100644 index 0000000..e128563 --- /dev/null +++ b/lib/observability/cost.ts @@ -0,0 +1,158 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +interface UsageRow { + timestamp?: string; + task?: string; + provider?: string; + tokens?: number; + cost_usd?: number; + ok?: boolean; + latency_ms?: number; +} + +export interface CostSummary { + total_cost_usd: number; + total_calls: number; + by_provider: Record< + string, + { calls: number; tokens: number; cost: number; mean_latency_ms: number } + >; + by_task: Record< + string, + { calls: number; tokens: number; cost: number; mean_latency_ms: number } + >; + by_provider_task: Record< + string, + { calls: number; tokens: number; cost: number; mean_latency_ms: number } + >; + daily: Record; +} + +export function readUsage(path: string): UsageRow[] { + if (!existsSync(path)) return []; + const text = readFileSync(path, "utf8"); + const out: UsageRow[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + out.push(JSON.parse(line) as UsageRow); + } catch {} + } + return out; +} + +export function summarize(rows: UsageRow[]): CostSummary { + const summary: CostSummary = { + total_cost_usd: 0, + total_calls: 0, + by_provider: {}, + by_task: {}, + by_provider_task: {}, + daily: {}, + }; + function bump( + bucket: Record, + key: string, + r: UsageRow, + ): void { + const cur = + bucket[key] ?? { calls: 0, tokens: 0, cost: 0, mean_latency_ms: 0 }; + const n = cur.calls + 1; + cur.mean_latency_ms = + (cur.mean_latency_ms * cur.calls + (r.latency_ms ?? 0)) / n; + cur.calls = n; + cur.tokens += r.tokens ?? 0; + cur.cost += r.cost_usd ?? 0; + bucket[key] = cur; + } + for (const r of rows) { + summary.total_cost_usd += r.cost_usd ?? 0; + summary.total_calls += 1; + const provider = r.provider ?? "unknown"; + const task = r.task ?? "unknown"; + bump(summary.by_provider, provider, r); + bump(summary.by_task, task, r); + bump(summary.by_provider_task, `${provider}/${task}`, r); + if (r.timestamp) { + const day = r.timestamp.slice(0, 10); + summary.daily[day] = (summary.daily[day] ?? 0) + (r.cost_usd ?? 0); + } + } + return summary; +} + +export function filterWindow( + rows: UsageRow[], + windowDays: number, + sinceIso?: string, +): UsageRow[] { + let cutoff: number; + if (sinceIso) { + cutoff = Date.parse(sinceIso); + } else { + cutoff = Date.now() - windowDays * 86400 * 1000; + } + return rows.filter((r) => { + if (!r.timestamp) return true; + const t = Date.parse(r.timestamp); + if (!Number.isFinite(t)) return true; + return t >= cutoff; + }); +} + +export function renderHtml(summary: CostSummary, title = "Cost report"): string { + const days = Object.keys(summary.daily).sort(); + const maxDaily = Math.max(0.0001, ...Object.values(summary.daily)); + const sparkPoints = days + .map((d, i) => { + const x = (i / Math.max(1, days.length - 1)) * 600; + const y = 100 - (summary.daily[d] / maxDaily) * 90; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + const providerRows = Object.entries(summary.by_provider) + .sort((a, b) => b[1].cost - a[1].cost) + .map( + ([p, v]) => + `${p}${v.calls}${v.tokens}$${v.cost.toFixed(4)}`, + ) + .join(""); + const taskRows = Object.entries(summary.by_task) + .sort((a, b) => b[1].cost - a[1].cost) + .map( + ([t, v]) => + `${t}${v.calls}$${v.cost.toFixed(4)}`, + ) + .join(""); + return ` +${title} + +

${title}

+

$${summary.total_cost_usd.toFixed(4)} (${summary.total_calls} calls)

+ +

By provider

+${providerRows}
ProviderCallsTokensCost
+

By task

+${taskRows}
TaskCallsCost
+`; +} + +export function writeReport( + path: string, + summary: CostSummary, + title?: string, +): void { + writeFileSync(path, renderHtml(summary, title), "utf8"); +} + +export function usageLogPath(root?: string): string { + return resolve(root ?? process.cwd(), "data", "llm-usage.jsonl"); +} diff --git a/lib/observability/failures.ts b/lib/observability/failures.ts new file mode 100644 index 0000000..a57c970 --- /dev/null +++ b/lib/observability/failures.ts @@ -0,0 +1,171 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +interface UsageRow { + timestamp?: string; + task?: string; + provider?: string; + ok?: boolean; + error?: string; + fallback_used?: boolean; +} +interface RunRow { + timestamp?: string; + piece_id?: string; + status?: string; + notes?: string; +} + +export interface FailureEvent { + kind: "llm" | "run"; + timestamp: string; + provider?: string; + task?: string; + piece_id?: string; + status?: string; + error?: string; +} + +export interface FailureSummary { + total: number; + by_provider: Record; + by_task: Record; + recent: FailureEvent[]; + provider_failure_rate: Record; + stuck_in_review: string[]; +} + +function readJsonl(path: string): T[] { + if (!existsSync(path)) return []; + const out: T[] = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + out.push(JSON.parse(line) as T); + } catch {} + } + return out; +} + +export function collectFailures( + root: string, + windowHours = 24, +): FailureSummary { + const cutoff = Date.now() - windowHours * 3600 * 1000; + const usage = readJsonl(resolve(root, "data", "llm-usage.jsonl")); + const runs = readJsonl(resolve(root, "data", "runs.jsonl")); + const events: FailureEvent[] = []; + const providerTotals: Record = {}; + + for (const u of usage) { + if (!u.timestamp) continue; + if (Date.parse(u.timestamp) < cutoff) continue; + const p = u.provider ?? "unknown"; + providerTotals[p] = providerTotals[p] ?? { ok: 0, fail: 0 }; + if (u.ok === false) { + providerTotals[p].fail++; + events.push({ + kind: "llm", + timestamp: u.timestamp, + provider: u.provider, + task: u.task, + error: u.error, + }); + } else { + providerTotals[p].ok++; + } + } + for (const r of runs) { + if (!r.timestamp) continue; + if (Date.parse(r.timestamp) < cutoff) continue; + if (r.status === "failed" || r.status === "blocked") { + events.push({ + kind: "run", + timestamp: r.timestamp, + piece_id: r.piece_id, + status: r.status, + error: r.notes, + }); + } + } + const summary: FailureSummary = { + total: events.length, + by_provider: {}, + by_task: {}, + recent: events.slice(-50), + provider_failure_rate: {}, + stuck_in_review: [], + }; + for (const e of events) { + if (e.provider) { + summary.by_provider[e.provider] = (summary.by_provider[e.provider] ?? 0) + 1; + } + if (e.task) { + summary.by_task[e.task] = (summary.by_task[e.task] ?? 0) + 1; + } + } + for (const [p, t] of Object.entries(providerTotals)) { + summary.provider_failure_rate[p] = t.fail / Math.max(t.ok + t.fail, 1); + } + return summary; +} + +export interface AlertEvent { + event_type: "compliance_block_streak" | "high_failure_rate" | "stuck_review"; + summary: string; + piece_ids: string[]; + provider?: string; + rate?: number; +} + +export function detectAlerts( + summary: FailureSummary, + complianceEvents: Array<{ rule_id: string; client?: string; piece_id?: string; ts: string }>, +): AlertEvent[] { + const alerts: AlertEvent[] = []; + for (const [p, rate] of Object.entries(summary.provider_failure_rate)) { + if (rate > 0.2) { + alerts.push({ + event_type: "high_failure_rate", + summary: `Provider ${p} failed ${(rate * 100).toFixed(1)}% of calls in window`, + piece_ids: [], + provider: p, + rate, + }); + } + } + const streak = new Map(); + const weekAgo = Date.now() - 7 * 86400 * 1000; + for (const ce of complianceEvents) { + if (Date.parse(ce.ts) < weekAgo) continue; + const key = `${ce.client ?? "?"}::${ce.rule_id}`; + const list = streak.get(key) ?? []; + if (ce.piece_id) list.push(ce.piece_id); + streak.set(key, list); + } + for (const [key, list] of streak) { + if (list.length >= 3) { + const [client, rule] = key.split("::"); + alerts.push({ + event_type: "compliance_block_streak", + summary: `Compliance rule ${rule} blocked ${list.length} pieces for ${client} this week`, + piece_ids: list, + }); + } + } + return alerts; +} + +export async function postWebhook(url: string, payload: unknown): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + return res.ok; + } catch (err) { + process.stderr.write(`webhook post failed: ${String(err)}\n`); + return false; + } +} diff --git a/lib/pieces/frontmatter.ts b/lib/pieces/frontmatter.ts new file mode 100644 index 0000000..8facbcf --- /dev/null +++ b/lib/pieces/frontmatter.ts @@ -0,0 +1,175 @@ +export interface PieceFrontmatter { + id: string; + client: string; + campaign?: string; + date: string; + status: "draft" | "scheduled" | "published" | "measured" | "review"; + type: string; + pillar: string; + platforms: string[]; + provider_override?: { + llm_text?: string | null; + image?: string | null; + video?: string | null; + ads?: string | null; + }; + locale?: string; + compliance_report?: string; + compliance_block?: Array<{ rule_id: string; snippet?: string }>; +} + +export interface ParsedPiece { + frontmatter: PieceFrontmatter; + body: string; +} + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/; + +function parseScalar(raw: string): string | number | boolean | null { + const v = raw.trim(); + if (v === "null" || v === "~") return null; + if (v === "true") return true; + if (v === "false") return false; + if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + return v.slice(1, -1); + } + return v; +} + +function parseInline(raw: string): unknown { + const t = raw.trim(); + if (t.startsWith("[") && t.endsWith("]")) { + const inner = t.slice(1, -1).trim(); + if (inner === "") return []; + return inner.split(",").map((s) => { + const v = s.trim(); + return parseScalar(v.replace(/^['"]|['"]$/g, "")); + }); + } + if (t.startsWith("{") && t.endsWith("}")) { + const inner = t.slice(1, -1).trim(); + if (inner === "") return {}; + const out: Record = {}; + for (const pair of inner.split(",")) { + const [k, v] = pair.split(":").map((s) => s.trim()); + if (k) out[k.replace(/^['"]|['"]$/g, "")] = parseScalar(v ?? ""); + } + return out; + } + return parseScalar(t); +} + +interface YamlNode { + indent: number; + key: string; + value: unknown; + children: YamlNode[]; +} + +function parseSimpleYaml(text: string): Record { + const lines = text.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")); + const root: YamlNode[] = []; + const stack: Array<{ indent: number; container: YamlNode[] }> = [ + { indent: -1, container: root }, + ]; + + for (const line of lines) { + const indent = line.length - line.trimStart().length; + const trimmed = line.trim(); + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + const parent = stack[stack.length - 1]; + const colonIdx = trimmed.indexOf(":"); + if (colonIdx === -1) continue; + const key = trimmed.slice(0, colonIdx).trim(); + const rawVal = trimmed.slice(colonIdx + 1).trim(); + const node: YamlNode = { + indent, + key, + value: rawVal === "" ? undefined : parseInline(rawVal), + children: [], + }; + parent.container.push(node); + if (rawVal === "") { + stack.push({ indent, container: node.children }); + } + } + + const obj: Record = {}; + function flatten(nodes: YamlNode[], target: Record): void { + for (const node of nodes) { + if (node.value !== undefined) { + target[node.key] = node.value; + } else if (node.children.length > 0) { + const sub: Record = {}; + flatten(node.children, sub); + target[node.key] = sub; + } else { + target[node.key] = null; + } + } + } + flatten(root, obj); + return obj; +} + +const REQUIRED_KEYS: Array = [ + "id", + "client", + "date", + "status", + "type", + "pillar", + "platforms", +]; + +export function parsePiece(text: string): ParsedPiece { + const match = FRONTMATTER_RE.exec(text); + if (!match) { + throw new Error("piece: missing or malformed frontmatter (expected --- block)"); + } + const fmText = match[1]; + const body = match[2] ?? ""; + const raw = parseSimpleYaml(fmText); + for (const k of REQUIRED_KEYS) { + if (raw[k] === undefined || raw[k] === null) { + throw new Error(`piece: required frontmatter key missing: ${k}`); + } + } + return { + frontmatter: raw as unknown as PieceFrontmatter, + body: body.trimStart(), + }; +} + +export function serializePiece(fm: PieceFrontmatter, body: string): string { + const lines: string[] = ["---"]; + function writeValue(key: string, value: unknown, indent = 0): void { + const pad = " ".repeat(indent); + if (value === null || value === undefined) { + lines.push(`${pad}${key}: null`); + } else if (Array.isArray(value)) { + const inline = `[${value.map((v) => JSON.stringify(v)).join(", ")}]`; + lines.push(`${pad}${key}: ${inline}`); + } else if (typeof value === "object") { + lines.push(`${pad}${key}:`); + for (const [k, v] of Object.entries(value)) { + writeValue(k, v, indent + 2); + } + } else if (typeof value === "string") { + lines.push(`${pad}${key}: ${value}`); + } else { + lines.push(`${pad}${key}: ${String(value)}`); + } + } + for (const [k, v] of Object.entries(fm)) { + writeValue(k, v); + } + lines.push("---"); + return `${lines.join("\n")}\n${body}`; +} diff --git a/lib/pieces/id.ts b/lib/pieces/id.ts new file mode 100644 index 0000000..7f9f10c --- /dev/null +++ b/lib/pieces/id.ts @@ -0,0 +1,29 @@ +export function isoWeek(date: Date): { year: number; week: number } { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return { year: d.getUTCFullYear(), week }; +} + +export function formatPieceId(date: Date, seq: number): string { + const { year, week } = isoWeek(date); + const ww = String(week).padStart(2, "0"); + const nnn = String(seq).padStart(3, "0"); + return `PIECE-${year}W${ww}-${nnn}`; +} + +const _counters = new Map(); + +export function nextPieceId(date: Date = new Date(), seedSeq?: number): string { + const { year, week } = isoWeek(date); + const key = `${year}-W${week}`; + const next = seedSeq ?? (_counters.get(key) ?? 0) + 1; + _counters.set(key, next); + return formatPieceId(date, next); +} + +export function _resetIdCounters(): void { + _counters.clear(); +} diff --git a/lib/pieces/store.ts b/lib/pieces/store.ts new file mode 100644 index 0000000..d358e74 --- /dev/null +++ b/lib/pieces/store.ts @@ -0,0 +1,109 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { + parsePiece, + serializePiece, + type ParsedPiece, + type PieceFrontmatter, +} from "./frontmatter"; + +const ALLOWED_TRANSITIONS: Record< + PieceFrontmatter["status"], + Array +> = { + draft: ["scheduled", "review"], + scheduled: ["published", "review"], + published: ["measured", "review"], + measured: [], + review: ["draft", "scheduled"], +}; + +export interface PieceStoreOptions { + root?: string; + piecesDir?: string; +} + +function piecesDir(opts?: PieceStoreOptions): string { + if (opts?.piecesDir) return resolve(opts.piecesDir); + const root = opts?.root ?? process.cwd(); + return resolve(root, "pieces"); +} + +export function ensurePiecesDir(opts?: PieceStoreOptions): string { + const dir = piecesDir(opts); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; +} + +export function listPieces(opts?: PieceStoreOptions & { + status?: PieceFrontmatter["status"]; + client?: string; +}): ParsedPiece[] { + const dir = piecesDir(opts); + if (!existsSync(dir)) return []; + const entries = readdirSync(dir).filter( + (f) => f.endsWith(".md") && !f.startsWith("."), + ); + const out: ParsedPiece[] = []; + for (const f of entries) { + const text = readFileSync(join(dir, f), "utf8"); + try { + const parsed = parsePiece(text); + if (opts?.status && parsed.frontmatter.status !== opts.status) continue; + if (opts?.client && parsed.frontmatter.client !== opts.client) continue; + out.push(parsed); + } catch { + continue; + } + } + return out; +} + +export function pieceFilePath(id: string, opts?: PieceStoreOptions): string { + return join(piecesDir(opts), `${id}.md`); +} + +export function readPiece(id: string, opts?: PieceStoreOptions): ParsedPiece { + const path = pieceFilePath(id, opts); + if (!existsSync(path)) throw new Error(`piece not found: ${id} (looked at ${path})`); + return parsePiece(readFileSync(path, "utf8")); +} + +export function writePiece( + piece: ParsedPiece, + opts?: PieceStoreOptions, +): string { + ensurePiecesDir(opts); + const path = pieceFilePath(piece.frontmatter.id, opts); + writeFileSync(path, serializePiece(piece.frontmatter, piece.body), "utf8"); + return path; +} + +export function transitionStatus( + id: string, + from: PieceFrontmatter["status"], + to: PieceFrontmatter["status"], + opts?: PieceStoreOptions, +): ParsedPiece { + const piece = readPiece(id, opts); + if (piece.frontmatter.status !== from) { + throw new Error( + `transition: piece ${id} is in status ${piece.frontmatter.status}, not ${from}`, + ); + } + const allowed = ALLOWED_TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw new Error( + `transition: ${from} → ${to} not allowed for piece ${id}; valid: ${allowed.join(", ") || "(none)"}`, + ); + } + piece.frontmatter.status = to; + writePiece(piece, opts); + return piece; +} diff --git a/lib/promotion/classifier.ts b/lib/promotion/classifier.ts index 0322f45..9d055b8 100644 --- a/lib/promotion/classifier.ts +++ b/lib/promotion/classifier.ts @@ -3,7 +3,8 @@ const MIN_IMPRESSIONS = 100; export interface AnalyticsRow { piece_id: string; - channel: string; + client?: string; + channel?: string; platform?: string; impressions: number; reach?: number; @@ -12,12 +13,13 @@ export interface AnalyticsRow { comments?: number; likes?: number; watch_time_s?: number; - captured_at: string; + captured_at?: string; } export interface PieceStats { piece_id: string; - channel: string; + client?: string; + channel?: string; platform?: string; impressions: number; reach: number; @@ -37,9 +39,13 @@ export interface ClassifyResult { winners: PieceStats[]; losers: PieceStats[]; skipped: PieceStats[]; + all: PieceStats[]; } -function isWithinWindow(capturedAt: string, windowDays: number): boolean { +function isWithinWindow(capturedAt: string | undefined, windowDays: number): boolean { + if (!capturedAt) { + return true; + } const capturedAtMs = new Date(capturedAt).getTime(); if (Number.isNaN(capturedAtMs)) { @@ -50,8 +56,23 @@ function isWithinWindow(capturedAt: string, windowDays: number): boolean { } function toPieceStats(rows: AnalyticsRow[]): PieceStats { + if (rows.length === 0) { + return { + piece_id: "", + impressions: 0, + reach: 0, + saves: 0, + shares: 0, + comments: 0, + likes: 0, + watch_time_s: 0, + latest_captured_at: "", + save_rate: 0, + comment_ratio: 0, + }; + } const latest = rows.reduce((currentLatest, row) => { - if (new Date(row.captured_at).getTime() > new Date(currentLatest.captured_at).getTime()) { + if (new Date(row.captured_at ?? 0).getTime() > new Date(currentLatest.captured_at ?? 0).getTime()) { return row; } return currentLatest; @@ -67,6 +88,7 @@ function toPieceStats(rows: AnalyticsRow[]): PieceStats { return { piece_id: latest.piece_id, + client: latest.client, channel: latest.channel, platform: latest.platform, impressions, @@ -76,7 +98,7 @@ function toPieceStats(rows: AnalyticsRow[]): PieceStats { comments, likes, watch_time_s, - latest_captured_at: latest.captured_at, + latest_captured_at: latest.captured_at ?? "", save_rate: saves / Math.max(impressions, 1), comment_ratio: comments / Math.max(likes, 1), }; @@ -156,7 +178,7 @@ export function classify(rows: AnalyticsRow[], windowDays = 7): ClassifyResult { skipped.sort(bySaveRateDescending); if (eligible.length === 0) { - return { winners: [], losers: [], skipped }; + return { winners: [], losers: [], skipped, all: [...skipped] }; } const segmentSize = Math.max(1, Math.ceil(eligible.length * 0.2)); @@ -171,5 +193,6 @@ export function classify(rows: AnalyticsRow[], windowDays = 7): ClassifyResult { winners, losers, skipped, + all: [...eligible, ...skipped], }; } diff --git a/lib/promotion/learnings.ts b/lib/promotion/learnings.ts index c5ca54e..81b61ca 100644 --- a/lib/promotion/learnings.ts +++ b/lib/promotion/learnings.ts @@ -1,21 +1,29 @@ import { appendFile, mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, resolve } from "node:path"; export interface LearningEntry { date: string; piece_id: string; - channel: string; + channel?: string; reason: string; } -export async function appendLearning(entry: LearningEntry): Promise { - const dataDir = join(process.cwd(), "data"); - const learningsPath = join(dataDir, "learnings.md"); - - await mkdir(dataDir, { recursive: true }); +export function appendLearning(entry: LearningEntry): Promise; +export function appendLearning(root: string, entry: LearningEntry): Promise; +export async function appendLearning( + rootOrEntry: string | LearningEntry, + maybeEntry?: LearningEntry, +): Promise { + const root = typeof rootOrEntry === "string" ? rootOrEntry : process.cwd(); + const entry = typeof rootOrEntry === "string" ? maybeEntry : rootOrEntry; + if (!entry) { + throw new Error("appendLearning requires an entry payload"); + } + const learningsPath = resolve(root, "data", "learnings.md"); + await mkdir(dirname(learningsPath), { recursive: true }); await appendFile( learningsPath, - `- ${entry.date} | ${entry.piece_id} | ${entry.channel} | did not perform: ${entry.reason}\n`, + `- ${entry.date} | ${entry.piece_id} | ${entry.channel ?? "unknown"} | did not perform: ${entry.reason}\n`, "utf8", ); } diff --git a/lib/providers/__mocks__/image.ts b/lib/providers/__mocks__/image.ts new file mode 100644 index 0000000..6356814 --- /dev/null +++ b/lib/providers/__mocks__/image.ts @@ -0,0 +1,48 @@ +import type { GenerationResult } from "../types"; +import type { ImageGenerateOptions, ImageProvider } from "../image"; + +abstract class BaseMockImage implements ImageProvider { + abstract readonly name: string; + + async generate( + brief: string, + opts: ImageGenerateOptions, + ): Promise> { + const count = opts.n ?? 1; + const ts = Date.now(); + const output: string[] = []; + for (let i = 1; i <= count; i++) { + output.push(`outputs/mock-${this.name}-${ts}-${i}.png`); + } + void brief; + return { + ok: true, + provider: this.name, + task: opts.task, + output, + tokens: 0, + cost_usd: 0.01 * count, + latency_ms: 1200, + }; + } +} + +export class MockGptImageProvider extends BaseMockImage { + readonly name = "gpt-image"; +} +export class MockHiggsfieldImageProvider extends BaseMockImage { + readonly name = "higgsfield"; +} +export class MockTopviewImageProvider extends BaseMockImage { + readonly name = "topview"; +} +export class MockWavespeedImageProvider extends BaseMockImage { + readonly name = "wavespeed"; +} + +export const MOCK_IMAGE_REGISTRY: Record ImageProvider> = { + "gpt-image": () => new MockGptImageProvider(), + higgsfield: () => new MockHiggsfieldImageProvider(), + topview: () => new MockTopviewImageProvider(), + wavespeed: () => new MockWavespeedImageProvider(), +}; diff --git a/lib/providers/__mocks__/llm.ts b/lib/providers/__mocks__/llm.ts new file mode 100644 index 0000000..feacd93 --- /dev/null +++ b/lib/providers/__mocks__/llm.ts @@ -0,0 +1,79 @@ +import type { GenerationResult, LLMTask } from "../types"; +import type { LLMGenerateOptions, LLMProvider } from "../llm"; + +abstract class BaseMockLLM implements LLMProvider { + abstract readonly name: string; + abstract readonly cost_per_1k_in: number; + abstract readonly cost_per_1k_out: number; + + async generate(prompt: string, opts: LLMGenerateOptions): Promise { + const tokens = 100; + const cost_usd = this.estimateCost(tokens, tokens); + const snippet = prompt.slice(0, 40); + return { + ok: true, + provider: this.name, + task: opts.task, + output: `[mock-${this.name}] ${snippet}...`, + tokens, + cost_usd, + latency_ms: 50, + }; + } + + protected estimateCost(tokens_in: number, tokens_out: number): number { + return ( + (tokens_in / 1000) * this.cost_per_1k_in + + (tokens_out / 1000) * this.cost_per_1k_out + ); + } +} + +export class MockClaudeProvider extends BaseMockLLM { + readonly name = "claude"; + readonly cost_per_1k_in = 0.003; + readonly cost_per_1k_out = 0.015; +} + +export class MockCodexProvider extends BaseMockLLM { + readonly name = "codex"; + readonly cost_per_1k_in = 0.003; + readonly cost_per_1k_out = 0.015; +} + +export class MockDeepSeekProvider extends BaseMockLLM { + readonly name = "deepseek"; + readonly cost_per_1k_in = 0.00014; + readonly cost_per_1k_out = 0.00028; +} + +export class MockOllamaProvider extends BaseMockLLM { + readonly name = "ollama"; + readonly cost_per_1k_in = 0; + readonly cost_per_1k_out = 0; +} + +export const MOCK_LLM_REGISTRY: Record LLMProvider> = { + claude: () => new MockClaudeProvider(), + codex: () => new MockCodexProvider(), + deepseek: () => new MockDeepSeekProvider(), + ollama: () => new MockOllamaProvider(), +}; + +export class FailingMockLLM implements LLMProvider { + readonly name: string; + private readonly _err: string; + constructor(name: string, err = "synthetic failure") { + this.name = name; + this._err = err; + } + async generate(_p: string, opts: LLMGenerateOptions): Promise { + void opts; + throw new Error(this._err); + } +} + +export function _llmTaskFor(_task: LLMTask): string { + void _task; + return "claude"; +} diff --git a/lib/providers/__mocks__/video.ts b/lib/providers/__mocks__/video.ts new file mode 100644 index 0000000..e0019c0 --- /dev/null +++ b/lib/providers/__mocks__/video.ts @@ -0,0 +1,40 @@ +import type { GenerationResult } from "../types"; +import type { VideoGenerateOptions, VideoProvider } from "../video"; + +abstract class BaseMockVideo implements VideoProvider { + abstract readonly name: string; + + async generate( + brief: string, + opts: VideoGenerateOptions, + ): Promise> { + void brief; + const ts = Date.now(); + const output = `outputs/mock-${this.name}-${ts}.mp4`; + return { + ok: true, + provider: this.name, + task: opts.task, + output, + tokens: 0, + cost_usd: 0.05 * opts.duration_s, + latency_ms: 5000, + }; + } +} + +export class MockHiggsfieldVideoProvider extends BaseMockVideo { + readonly name = "higgsfield"; +} +export class MockTopviewVideoProvider extends BaseMockVideo { + readonly name = "topview"; +} +export class MockWavespeedVideoProvider extends BaseMockVideo { + readonly name = "wavespeed"; +} + +export const MOCK_VIDEO_REGISTRY: Record VideoProvider> = { + higgsfield: () => new MockHiggsfieldVideoProvider(), + topview: () => new MockTopviewVideoProvider(), + wavespeed: () => new MockWavespeedVideoProvider(), +}; diff --git a/lib/providers/image.ts b/lib/providers/image.ts index eb2accb..bae5155 100644 --- a/lib/providers/image.ts +++ b/lib/providers/image.ts @@ -1,76 +1,214 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import type { GenerationResult, ImageTask } from "./types"; +import { imageRow, loadProviderMatrix } from "./matrix"; +import { MOCK_IMAGE_REGISTRY } from "./__mocks__/image"; +import { withRetry } from "./policy"; export interface ImageGenerateOptions { task: ImageTask; aspect: string; n?: number; + output_dir?: string; + transparent?: boolean; + seed?: number; } export interface ImageProvider { name: string; - generate(brief: string, opts: ImageGenerateOptions): Promise>; + generate( + brief: string, + opts: ImageGenerateOptions, + ): Promise>; +} + +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + +function aspectToSize(aspect: string): string { + switch (aspect) { + case "1:1": + return "1024x1024"; + case "9:16": + return "1024x1536"; + case "16:9": + return "1536x1024"; + case "4:5": + return "1024x1280"; + default: + return "1024x1024"; + } } -abstract class BaseImageProvider implements ImageProvider { +abstract class RealImageBase implements ImageProvider { abstract readonly name: string; + abstract realGenerate( + brief: string, + opts: ImageGenerateOptions, + ): Promise>; async generate( brief: string, opts: ImageGenerateOptions, ): Promise> { + return withRetry(() => this.realGenerate(brief, opts), { + retries: 1, + backoffMs: 2000, + timeoutMs: 120_000, + }); + } +} + +export class GptImageProvider extends RealImageBase { + readonly name = "gpt-image"; + async realGenerate( + brief: string, + opts: ImageGenerateOptions, + ): Promise> { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("gpt-image: OPENAI_API_KEY missing"); + const t0 = Date.now(); const count = opts.n ?? 1; + const size = aspectToSize(opts.aspect); + const res = await fetch("https://api.openai.com/v1/images/generations", { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-image-1", + prompt: brief, + size, + n: count, + background: opts.transparent ? "transparent" : "opaque", + response_format: "b64_json", + }), + }); + if (!res.ok) { + throw new Error(`gpt-image: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + data?: Array<{ b64_json?: string; url?: string }>; + }; + const outputDir = opts.output_dir ?? resolve(process.cwd(), "outputs"); + if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); const ts = Date.now(); - const output: string[] = []; - for (let i = 1; i <= count; i++) { - output.push(`outputs/mock-${this.name}-${ts}-${i}.png`); + const paths: string[] = []; + for (let i = 0; i < (data.data?.length ?? 0); i++) { + const item = data.data?.[i]; + if (!item) continue; + const dest = resolve(outputDir, `gpt-image-${ts}-${i + 1}.png`); + if (item.b64_json) { + writeFileSync(dest, Buffer.from(item.b64_json, "base64")); + } else if (item.url) { + const imgRes = await fetch(item.url); + writeFileSync(dest, Buffer.from(await imgRes.arrayBuffer())); + } + paths.push(dest); } - void brief; return { ok: true, provider: this.name, task: opts.task, - output, + output: paths, tokens: 0, - cost_usd: 0.01 * count, - latency_ms: 1200, + cost_usd: 0.04 * count, + latency_ms: Date.now() - t0, }; } } -export class GptImageProvider extends BaseImageProvider { - readonly name = "gpt-image"; -} - -export class HiggsfieldProvider extends BaseImageProvider { +export class HiggsfieldProvider extends RealImageBase { readonly name = "higgsfield"; + async realGenerate( + _brief: string, + _opts: ImageGenerateOptions, + ): Promise> { + if (process.env.HIGGSFIELD_MCP_ACTIVE !== "true") { + throw new Error("higgsfield: HIGGSFIELD_MCP_ACTIVE not true; MCP unavailable"); + } + throw new Error( + "higgsfield: image generation requires MCP transport in caller context; " + + "this adapter is a stub. Use DRY_RUN=true for tests.", + ); + } } -export class TopviewProvider extends BaseImageProvider { +export class TopviewProvider extends RealImageBase { readonly name = "topview"; + async realGenerate( + _brief: string, + _opts: ImageGenerateOptions, + ): Promise> { + const apiKey = process.env.TOPVIEW_API_KEY; + if (!apiKey) throw new Error("topview: TOPVIEW_API_KEY missing"); + throw new Error( + "topview: image generation requires MCP transport; this adapter is a stub. " + + "Use DRY_RUN=true for tests.", + ); + } } -export class WavespeedProvider extends BaseImageProvider { +export class WavespeedProvider extends RealImageBase { readonly name = "wavespeed"; + async realGenerate( + brief: string, + opts: ImageGenerateOptions, + ): Promise> { + const apiKey = process.env.WAVESPEED_API_KEY; + if (!apiKey) throw new Error("wavespeed: WAVESPEED_API_KEY missing"); + const t0 = Date.now(); + const count = opts.n ?? 1; + const res = await fetch("https://api.wavespeed.ai/api/v3/predictions", { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "flux-schnell", + input: { prompt: brief, num_outputs: count, aspect_ratio: opts.aspect }, + }), + }); + if (!res.ok) { + throw new Error(`wavespeed: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { output?: string[] }; + const urls = data.output ?? []; + const outputDir = opts.output_dir ?? resolve(process.cwd(), "outputs"); + if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); + const ts = Date.now(); + const paths: string[] = []; + for (let i = 0; i < urls.length; i++) { + const dest = resolve(outputDir, `wavespeed-${ts}-${i + 1}.png`); + const imgRes = await fetch(urls[i]); + writeFileSync(dest, Buffer.from(await imgRes.arrayBuffer())); + paths.push(dest); + } + return { + ok: true, + provider: this.name, + task: opts.task, + output: paths, + tokens: 0, + cost_usd: 0.003 * count, + latency_ms: Date.now() - t0, + }; + } } -const IMAGE_REGISTRY: Record ImageProvider> = { +void dirname; + +const REAL_IMAGE_REGISTRY: Record ImageProvider> = { "gpt-image": () => new GptImageProvider(), higgsfield: () => new HiggsfieldProvider(), topview: () => new TopviewProvider(), wavespeed: () => new WavespeedProvider(), }; -const IMAGE_TASK_DEFAULTS: Record = { - "quote-card": "gpt-image", - "ugc-ad": "topview", - cinematic: "higgsfield", - carousel: "gpt-image", - "batch-ab": "wavespeed", - inpaint: "gpt-image", - "face-swap": "topview", - "before-after": "gpt-image", -}; - export interface ImageFactoryOptions { override?: string; } @@ -80,10 +218,18 @@ export function getImageProvider( opts?: ImageFactoryOptions, ): ImageProvider { const override = opts?.override; - const candidate = override ?? IMAGE_TASK_DEFAULTS[task] ?? "gpt-image"; - const factory = IMAGE_REGISTRY[candidate] ?? IMAGE_REGISTRY["gpt-image"]; + const fromMatrix = imageRow(task, loadProviderMatrix()).default; + const candidate = override ?? fromMatrix ?? "gpt-image"; + const registry = isDryRun() ? MOCK_IMAGE_REGISTRY : REAL_IMAGE_REGISTRY; + const factory = registry[candidate] ?? registry["gpt-image"]; if (!factory) { - return new GptImageProvider(); + return (isDryRun() ? MOCK_IMAGE_REGISTRY : REAL_IMAGE_REGISTRY)["gpt-image"](); } return factory(); } + +export function getImageProviderByName(name: string): ImageProvider { + const registry = isDryRun() ? MOCK_IMAGE_REGISTRY : REAL_IMAGE_REGISTRY; + const factory = registry[name] ?? registry["gpt-image"]; + return factory(); +} diff --git a/lib/providers/llm.ts b/lib/providers/llm.ts index 6a99808..7729e9e 100644 --- a/lib/providers/llm.ts +++ b/lib/providers/llm.ts @@ -1,7 +1,11 @@ import type { GenerationResult, LLMTask } from "./types"; +import { llmRow, loadProviderMatrix } from "./matrix"; +import { MOCK_LLM_REGISTRY } from "./__mocks__/llm"; +import { estimateCost, estimateTokens, withRetry } from "./policy"; export interface LLMGenerateOptions { task: LLMTask; + system?: string; max_tokens?: number; temperature?: number; } @@ -11,75 +15,245 @@ export interface LLMProvider { generate(prompt: string, opts: LLMGenerateOptions): Promise; } -abstract class BaseLLMProvider implements LLMProvider { +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + +abstract class RealLLMBase implements LLMProvider { abstract readonly name: string; - abstract readonly cost_per_1k_in: number; - abstract readonly cost_per_1k_out: number; + abstract realGenerate( + prompt: string, + opts: LLMGenerateOptions, + ): Promise; async generate(prompt: string, opts: LLMGenerateOptions): Promise { - const tokens = 100; - const cost_usd = this.estimateCost(tokens, tokens); - const snippet = prompt.slice(0, 40); + return withRetry(() => this.realGenerate(prompt, opts), { + retries: 1, + backoffMs: 2000, + timeoutMs: opts.task === "script" ? 180_000 : 60_000, + }); + } +} + +export class ClaudeProvider extends RealLLMBase { + readonly name = "claude"; + async realGenerate( + prompt: string, + opts: LLMGenerateOptions, + ): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error( + "claude: ANTHROPIC_API_KEY missing — set it or run with DRY_RUN=true", + ); + } + const model = opts.task === "caption" || opts.task === "humanization" + ? "claude-sonnet-4-6" + : "claude-opus-4-7"; + const t0 = Date.now(); + const body = { + model, + max_tokens: opts.max_tokens ?? 1024, + temperature: opts.temperature ?? 0.7, + system: opts.system + ? [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }] + : undefined, + messages: [{ role: "user", content: prompt }], + }; + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`claude: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + content: Array<{ type: string; text?: string }>; + usage?: { input_tokens?: number; output_tokens?: number }; + }; + const text = data.content + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(""); + const tokens_in = data.usage?.input_tokens ?? estimateTokens(prompt); + const tokens_out = data.usage?.output_tokens ?? estimateTokens(text); return { ok: true, provider: this.name, task: opts.task, - output: `[mock-${this.name}] ${snippet}...`, - tokens, - cost_usd, - latency_ms: 50, + output: text, + tokens: tokens_in + tokens_out, + cost_usd: estimateCost({ provider: "claude", model, tokens_in, tokens_out }), + latency_ms: Date.now() - t0, }; } +} - protected estimateCost(tokens_in: number, tokens_out: number): number { - return ( - (tokens_in / 1000) * this.cost_per_1k_in + - (tokens_out / 1000) * this.cost_per_1k_out - ); +export class CodexProvider extends RealLLMBase { + readonly name = "codex"; + async realGenerate( + prompt: string, + opts: LLMGenerateOptions, + ): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("codex: OPENAI_API_KEY missing"); + } + const model = + opts.task === "caption" || opts.task === "translation" + ? "gpt-5.1-mini" + : "gpt-5.1"; + return callOpenAICompatible({ + apiKey, + baseUrl: "https://api.openai.com/v1", + providerName: "codex", + model, + prompt, + opts, + }); } } -export class ClaudeProvider extends BaseLLMProvider { - readonly name = "claude"; - readonly cost_per_1k_in = 0.003; - readonly cost_per_1k_out = 0.015; +export class DeepSeekProvider extends RealLLMBase { + readonly name = "deepseek"; + async realGenerate( + prompt: string, + opts: LLMGenerateOptions, + ): Promise { + const apiKey = process.env.DEEPSEEK_API_KEY; + if (!apiKey) throw new Error("deepseek: DEEPSEEK_API_KEY missing"); + const model = "deepseek-chat"; + return callOpenAICompatible({ + apiKey, + baseUrl: "https://api.deepseek.com", + providerName: "deepseek", + model, + prompt, + opts, + }); + } } -export class CodexProvider extends BaseLLMProvider { - readonly name = "codex"; - readonly cost_per_1k_in = 0.003; - readonly cost_per_1k_out = 0.015; +export class OllamaProvider extends RealLLMBase { + readonly name = "ollama"; + async realGenerate( + prompt: string, + opts: LLMGenerateOptions, + ): Promise { + const host = process.env.OLLAMA_HOST ?? "http://localhost:11434"; + const model = process.env.OLLAMA_MODEL ?? "llama3.2"; + const t0 = Date.now(); + const res = await fetch(`${host}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + messages: opts.system + ? [ + { role: "system", content: opts.system }, + { role: "user", content: prompt }, + ] + : [{ role: "user", content: prompt }], + stream: false, + options: { + temperature: opts.temperature ?? 0.7, + num_predict: opts.max_tokens ?? 1024, + }, + }), + }); + if (!res.ok) { + throw new Error(`ollama: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + message?: { content?: string }; + prompt_eval_count?: number; + eval_count?: number; + }; + const text = data.message?.content ?? ""; + return { + ok: true, + provider: this.name, + task: opts.task, + output: text, + tokens: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0), + cost_usd: 0, + latency_ms: Date.now() - t0, + }; + } } -export class DeepSeekProvider extends BaseLLMProvider { - readonly name = "deepseek"; - readonly cost_per_1k_in = 0.00014; - readonly cost_per_1k_out = 0.00028; +interface OpenAICompatibleArgs { + apiKey: string; + baseUrl: string; + providerName: string; + model: string; + prompt: string; + opts: LLMGenerateOptions; } -export class OllamaProvider extends BaseLLMProvider { - readonly name = "ollama"; - readonly cost_per_1k_in = 0; - readonly cost_per_1k_out = 0; +async function callOpenAICompatible( + args: OpenAICompatibleArgs, +): Promise { + const { apiKey, baseUrl, providerName, model, prompt, opts } = args; + const t0 = Date.now(); + const messages = opts.system + ? [ + { role: "system", content: opts.system }, + { role: "user", content: prompt }, + ] + : [{ role: "user", content: prompt }]; + const res = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model, + messages, + max_tokens: opts.max_tokens ?? 1024, + temperature: opts.temperature ?? 0.7, + }), + }); + if (!res.ok) { + throw new Error(`${providerName}: HTTP ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + const text = data.choices?.[0]?.message?.content ?? ""; + const tokens_in = data.usage?.prompt_tokens ?? estimateTokens(prompt); + const tokens_out = data.usage?.completion_tokens ?? estimateTokens(text); + return { + ok: true, + provider: providerName, + task: opts.task, + output: text, + tokens: tokens_in + tokens_out, + cost_usd: estimateCost({ + provider: providerName, + model, + tokens_in, + tokens_out, + }), + latency_ms: Date.now() - t0, + }; } -const LLM_REGISTRY: Record LLMProvider> = { +const REAL_LLM_REGISTRY: Record LLMProvider> = { claude: () => new ClaudeProvider(), codex: () => new CodexProvider(), deepseek: () => new DeepSeekProvider(), ollama: () => new OllamaProvider(), }; -const LLM_TASK_DEFAULTS: Record = { - orchestration: "claude", - code: "claude", - caption: "deepseek", - script: "claude", - compliance: "claude", - translation: "deepseek", - humanization: "claude", -}; - export interface LLMFactoryOptions { override?: string; } @@ -87,10 +261,18 @@ export interface LLMFactoryOptions { export function getLLMProvider(task: LLMTask, opts?: LLMFactoryOptions): LLMProvider { const env_default = process.env.LLM_DEFAULT; const override = opts?.override; - const candidate = override ?? LLM_TASK_DEFAULTS[task] ?? env_default ?? "claude"; - const factory = LLM_REGISTRY[candidate] ?? LLM_REGISTRY["claude"]; + const fromMatrix = llmRow(task, loadProviderMatrix()).default; + const candidate = override ?? fromMatrix ?? env_default ?? "claude"; + const registry = isDryRun() ? MOCK_LLM_REGISTRY : REAL_LLM_REGISTRY; + const factory = registry[candidate] ?? registry["claude"]; if (!factory) { - return new ClaudeProvider(); + return (isDryRun() ? MOCK_LLM_REGISTRY : REAL_LLM_REGISTRY)["claude"](); } return factory(); } + +export function getLLMProviderByName(name: string): LLMProvider { + const registry = isDryRun() ? MOCK_LLM_REGISTRY : REAL_LLM_REGISTRY; + const factory = registry[name] ?? registry["claude"]; + return factory(); +} diff --git a/lib/providers/matrix.ts b/lib/providers/matrix.ts new file mode 100644 index 0000000..586d3ba --- /dev/null +++ b/lib/providers/matrix.ts @@ -0,0 +1,223 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { ImageTask, LLMTask, VideoTask } from "./types"; + +export interface MatrixRow { + task: string; + default: string; + fallback?: string; + reason?: string; +} + +export interface ProviderMatrix { + llm: Record; + image: Record; + video: Record; +} + +const EMBEDDED_DEFAULTS: ProviderMatrix = { + llm: { + orchestration: { task: "orchestration", default: "claude", fallback: "codex" }, + code: { task: "code", default: "claude", fallback: "codex" }, + caption: { task: "caption", default: "deepseek", fallback: "claude" }, + script: { task: "script", default: "claude", fallback: "codex" }, + compliance: { task: "compliance", default: "claude", fallback: "codex" }, + translation: { task: "translation", default: "deepseek", fallback: "claude" }, + humanization: { task: "humanization", default: "claude", fallback: "codex" }, + }, + image: { + "quote-card": { task: "quote-card", default: "gpt-image" }, + "ugc-ad": { task: "ugc-ad", default: "topview" }, + cinematic: { task: "cinematic", default: "higgsfield" }, + carousel: { task: "carousel", default: "gpt-image" }, + "batch-ab": { task: "batch-ab", default: "wavespeed" }, + inpaint: { task: "inpaint", default: "gpt-image" }, + "face-swap": { task: "face-swap", default: "topview" }, + "before-after": { task: "before-after", default: "gpt-image" }, + }, + video: { + "cinematic-reel": { task: "cinematic-reel", default: "higgsfield" }, + "motion-control": { task: "motion-control", default: "higgsfield" }, + "ugc-product": { task: "ugc-product", default: "topview" }, + "product-demo": { task: "product-demo", default: "topview" }, + "talking-head": { task: "talking-head", default: "topview" }, + "batch-hooks": { task: "batch-hooks", default: "wavespeed" }, + }, +}; + +const TASK_LABEL_MAP: Record = { + "copy short (caption)": "caption", + "copy long (script)": "script", + "code generation": "code", + "compliance check": "compliance", + "quote card / typography": "quote-card", + "ugc ad with avatar": "ugc-ad", + "cinematic / editorial": "cinematic", + "carousel slides": "carousel", + "batch a/b": "batch-ab", + "inpaint / local edit": "inpaint", + "face swap / try-on": "face-swap", + "before/after consulting": "before-after", + "cinematic reel": "cinematic-reel", + "motion control": "motion-control", + "ugc product holder": "ugc-product", + "product demo (url)": "product-demo", + "talking head": "talking-head", + "batch hook test": "batch-hooks", +}; + +function normalizeTaskLabel(raw: string): string { + const lower = raw.trim().toLowerCase(); + return TASK_LABEL_MAP[lower] ?? lower.replace(/\s+/g, "-"); +} + +interface ParseState { + section: "llm" | "image" | "video" | null; + matrix: ProviderMatrix; +} + +function parseRow(line: string): string[] { + const cells = line.split("|").map((c) => c.trim()); + if (cells.length && cells[0] === "") cells.shift(); + if (cells.length && cells[cells.length - 1] === "") cells.pop(); + return cells; +} + +function isSeparator(cells: string[]): boolean { + return cells.length > 0 && cells.every((c) => /^[-:\s]+$/.test(c)); +} + +export function parseProvidersMarkdown(text: string): ProviderMatrix { + const state: ParseState = { + section: null, + matrix: { llm: {}, image: {}, video: {} }, + }; + const lines = text.split(/\r?\n/); + let headerSeen = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (/^##\s+LLM\b/i.test(trimmed)) { + state.section = "llm"; + headerSeen = false; + continue; + } + if (/^##\s+Image\b/i.test(trimmed)) { + state.section = "image"; + headerSeen = false; + continue; + } + if (/^##\s+Video\b/i.test(trimmed)) { + state.section = "video"; + headerSeen = false; + continue; + } + if (/^##\s/.test(trimmed)) { + state.section = null; + continue; + } + if (state.section === null) continue; + if (!trimmed.startsWith("|")) continue; + + const cells = parseRow(trimmed); + if (cells.length === 0) continue; + if (isSeparator(cells)) { + headerSeen = true; + continue; + } + if (!headerSeen) continue; + + const task = normalizeTaskLabel(cells[0] ?? ""); + if (state.section === "llm") { + const [, def, fb, reason] = cells; + if (!def) continue; + state.matrix.llm[task] = { + task, + default: def.toLowerCase(), + fallback: fb && fb !== "-" ? fb.toLowerCase() : undefined, + reason, + }; + } else { + const [, provider, reason] = cells; + if (!provider) continue; + const layer = state.section; + state.matrix[layer][task] = { + task, + default: provider.toLowerCase(), + reason, + }; + } + } + + return state.matrix; +} + +let cached: ProviderMatrix | null = null; +let cachedPath: string | null = null; +let warnedFor: string | null = null; + +export function loadProviderMatrix(forcePath?: string): ProviderMatrix { + const path = + forcePath ?? resolve(process.cwd(), ".specs", "architecture", "PROVIDERS.md"); + if (cached && cachedPath === path) return cached; + if (!existsSync(path)) { + if (warnedFor !== path) { + process.stderr.write( + `[matrix] WARN: ${path} not found; using embedded defaults\n`, + ); + warnedFor = path; + } + cached = EMBEDDED_DEFAULTS; + cachedPath = path; + return cached; + } + try { + const text = readFileSync(path, "utf8"); + const parsed = parseProvidersMarkdown(text); + const merged: ProviderMatrix = { + llm: { ...EMBEDDED_DEFAULTS.llm, ...parsed.llm }, + image: { ...EMBEDDED_DEFAULTS.image, ...parsed.image }, + video: { ...EMBEDDED_DEFAULTS.video, ...parsed.video }, + }; + cached = merged; + cachedPath = path; + return cached; + } catch (err) { + if (warnedFor !== path) { + process.stderr.write( + `[matrix] WARN: parse error for ${path}: ${String(err)}; using embedded defaults\n`, + ); + warnedFor = path; + } + cached = EMBEDDED_DEFAULTS; + cachedPath = path; + return cached; + } +} + +export function resetMatrixCache(): void { + cached = null; + cachedPath = null; + warnedFor = null; +} + +export function llmRow(task: LLMTask | string, matrix?: ProviderMatrix): MatrixRow { + const m = matrix ?? loadProviderMatrix(); + return m.llm[task] ?? { task: String(task), default: "claude" }; +} + +export function imageRow( + task: ImageTask | string, + matrix?: ProviderMatrix, +): MatrixRow { + const m = matrix ?? loadProviderMatrix(); + return m.image[task] ?? { task: String(task), default: "gpt-image" }; +} + +export function videoRow( + task: VideoTask | string, + matrix?: ProviderMatrix, +): MatrixRow { + const m = matrix ?? loadProviderMatrix(); + return m.video[task] ?? { task: String(task), default: "higgsfield" }; +} diff --git a/lib/providers/policy.ts b/lib/providers/policy.ts new file mode 100644 index 0000000..530a697 --- /dev/null +++ b/lib/providers/policy.ts @@ -0,0 +1,97 @@ +export interface RetryOptions { + retries?: number; + backoffMs?: number; + timeoutMs?: number; + isRetryable?: (err: unknown) => boolean; +} + +export class TimeoutError extends Error { + constructor(ms: number) { + super(`operation timed out after ${ms}ms`); + this.name = "TimeoutError"; + } +} + +function defaultRetryable(err: unknown): boolean { + if (err instanceof TimeoutError) return true; + const msg = err instanceof Error ? err.message : String(err); + if (/timeout|ECONN|ETIMEDOUT|fetch failed|5\d\d|429/i.test(msg)) return true; + return false; +} + +async function withTimeout(fn: () => Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new TimeoutError(ms)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function withRetry( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + const retries = opts.retries ?? 1; + const backoff = opts.backoffMs ?? 1000; + const timeout = opts.timeoutMs ?? 60_000; + const isRetryable = opts.isRetryable ?? defaultRetryable; + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await withTimeout(fn, timeout); + } catch (err) { + lastErr = err; + if (attempt === retries || !isRetryable(err)) break; + const wait = backoff * Math.pow(2, attempt); + await new Promise((r) => setTimeout(r, wait)); + } + } + throw lastErr; +} + +export interface CostInput { + provider: string; + model?: string; + tokens_in: number; + tokens_out: number; +} + +interface RateRow { + in: number; + out: number; +} + +const PRICING: Record = { + "claude:opus": { in: 0.015, out: 0.075 }, + "claude:sonnet": { in: 0.003, out: 0.015 }, + "claude:haiku": { in: 0.0008, out: 0.004 }, + "claude:default": { in: 0.003, out: 0.015 }, + "codex:default": { in: 0.0025, out: 0.01 }, + "openai:gpt-5.1": { in: 0.0025, out: 0.01 }, + "openai:gpt-5.1-mini": { in: 0.0005, out: 0.002 }, + "openai:default": { in: 0.0025, out: 0.01 }, + "deepseek:chat": { in: 0.00014, out: 0.00028 }, + "deepseek:reasoner": { in: 0.00055, out: 0.0022 }, + "deepseek:default": { in: 0.00014, out: 0.00028 }, + "ollama:default": { in: 0, out: 0 }, +}; + +export function estimateCost(input: CostInput): number { + const key = input.model + ? `${input.provider}:${input.model.split("-")[0]}` + : `${input.provider}:default`; + const row = PRICING[key] ?? PRICING[`${input.provider}:default`] ?? null; + if (!row) return 0; + return (input.tokens_in / 1000) * row.in + (input.tokens_out / 1000) * row.out; +} + +export function estimateTokens(text: string): number { + if (!text) return 0; + return Math.ceil(text.length / 4); +} diff --git a/lib/providers/video.ts b/lib/providers/video.ts index b9e032f..2161349 100644 --- a/lib/providers/video.ts +++ b/lib/providers/video.ts @@ -1,65 +1,100 @@ import type { GenerationResult, VideoTask } from "./types"; +import { loadProviderMatrix, videoRow } from "./matrix"; +import { MOCK_VIDEO_REGISTRY } from "./__mocks__/video"; +import { withRetry } from "./policy"; export interface VideoGenerateOptions { task: VideoTask; aspect: string; duration_s: number; + output_dir?: string; + seed?: number; } export interface VideoProvider { name: string; - generate(brief: string, opts: VideoGenerateOptions): Promise>; + generate( + brief: string, + opts: VideoGenerateOptions, + ): Promise>; } -abstract class BaseVideoProvider implements VideoProvider { +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + +abstract class RealVideoBase implements VideoProvider { abstract readonly name: string; + abstract realGenerate( + brief: string, + opts: VideoGenerateOptions, + ): Promise>; async generate( brief: string, opts: VideoGenerateOptions, ): Promise> { - void brief; - const ts = Date.now(); - const output = `outputs/mock-${this.name}-${ts}.mp4`; - return { - ok: true, - provider: this.name, - task: opts.task, - output, - tokens: 0, - cost_usd: 0.05 * opts.duration_s, - latency_ms: 5000, - }; + return withRetry(() => this.realGenerate(brief, opts), { + retries: 1, + backoffMs: 4000, + timeoutMs: 5 * 60_000, + }); } } -export class HiggsfieldVideoProvider extends BaseVideoProvider { +export class HiggsfieldVideoProvider extends RealVideoBase { readonly name = "higgsfield"; + async realGenerate( + _brief: string, + _opts: VideoGenerateOptions, + ): Promise> { + if (process.env.HIGGSFIELD_MCP_ACTIVE !== "true") { + throw new Error("higgsfield: HIGGSFIELD_MCP_ACTIVE not true"); + } + throw new Error( + "higgsfield video: MCP transport required in caller context; stub. " + + "Use DRY_RUN=true for tests.", + ); + } } -export class TopviewVideoProvider extends BaseVideoProvider { +export class TopviewVideoProvider extends RealVideoBase { readonly name = "topview"; + async realGenerate( + _brief: string, + _opts: VideoGenerateOptions, + ): Promise> { + const apiKey = process.env.TOPVIEW_API_KEY; + if (!apiKey) throw new Error("topview: TOPVIEW_API_KEY missing"); + throw new Error( + "topview video: MCP transport required in caller context; stub. " + + "Use DRY_RUN=true for tests.", + ); + } } -export class WavespeedVideoProvider extends BaseVideoProvider { +export class WavespeedVideoProvider extends RealVideoBase { readonly name = "wavespeed"; + async realGenerate( + _brief: string, + _opts: VideoGenerateOptions, + ): Promise> { + const apiKey = process.env.WAVESPEED_API_KEY; + if (!apiKey) throw new Error("wavespeed: WAVESPEED_API_KEY missing"); + throw new Error( + "wavespeed video: real adapter requires WAN job polling; stub. " + + "Use DRY_RUN=true for tests.", + ); + } } -const VIDEO_REGISTRY: Record VideoProvider> = { +const REAL_VIDEO_REGISTRY: Record VideoProvider> = { higgsfield: () => new HiggsfieldVideoProvider(), topview: () => new TopviewVideoProvider(), wavespeed: () => new WavespeedVideoProvider(), }; -const VIDEO_TASK_DEFAULTS: Record = { - "cinematic-reel": "higgsfield", - "motion-control": "higgsfield", - "ugc-product": "topview", - "product-demo": "topview", - "talking-head": "topview", - "batch-hooks": "wavespeed", -}; - export interface VideoFactoryOptions { override?: string; } @@ -69,10 +104,18 @@ export function getVideoProvider( opts?: VideoFactoryOptions, ): VideoProvider { const override = opts?.override; - const candidate = override ?? VIDEO_TASK_DEFAULTS[task] ?? "higgsfield"; - const factory = VIDEO_REGISTRY[candidate] ?? VIDEO_REGISTRY["higgsfield"]; + const fromMatrix = videoRow(task, loadProviderMatrix()).default; + const candidate = override ?? fromMatrix ?? "higgsfield"; + const registry = isDryRun() ? MOCK_VIDEO_REGISTRY : REAL_VIDEO_REGISTRY; + const factory = registry[candidate] ?? registry["higgsfield"]; if (!factory) { - return new HiggsfieldVideoProvider(); + return (isDryRun() ? MOCK_VIDEO_REGISTRY : REAL_VIDEO_REGISTRY)["higgsfield"](); } return factory(); } + +export function getVideoProviderByName(name: string): VideoProvider { + const registry = isDryRun() ? MOCK_VIDEO_REGISTRY : REAL_VIDEO_REGISTRY; + const factory = registry[name] ?? registry["higgsfield"]; + return factory(); +} diff --git a/lib/publish/adaptlypost.ts b/lib/publish/adaptlypost.ts index 932055c..c4265d3 100644 --- a/lib/publish/adaptlypost.ts +++ b/lib/publish/adaptlypost.ts @@ -1,5 +1,9 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + export interface PublishPiece { id: string; + client?: string; platforms: string[]; caption: string; media_paths: string[]; @@ -16,30 +20,81 @@ export interface PublishClient { schedule(piece: PublishPiece): Promise; } +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + export class AdaptlyPostClient implements PublishClient { readonly name = "adaptlypost"; async schedule(piece: PublishPiece): Promise { - const dry_run_flag = process.env.DRY_RUN ?? "true"; - const dry_run = dry_run_flag === "true"; - void piece.platforms; - void piece.caption; - void piece.media_paths; - void piece.scheduled_at; - if (!dry_run) { - // Real API call wiring would live here. Mock layer never calls out. + const dry_run = isDryRun(); + if (dry_run) { + const draftDir = resolve( + process.cwd(), + "outputs", + piece.client ?? "unknown", + piece.scheduled_at.slice(0, 10), + piece.id, + ); + if (!existsSync(draftDir)) mkdirSync(draftDir, { recursive: true }); + writeFileSync( + resolve(draftDir, "adaptlypost-draft.json"), + JSON.stringify(piece, null, 2), + ); return { ok: true, draft_url: `https://adaptlypost.test/drafts/${piece.id}`, }; } - return { - ok: true, - draft_url: `https://adaptlypost.test/drafts/${piece.id}`, - }; + const apiKey = process.env.ADAPTLYPOST_API_KEY; + if (!apiKey) { + return { + ok: false, + draft_url: "", + error: "adaptlypost: ADAPTLYPOST_API_KEY missing", + }; + } + const maxAttempts = 3; + let lastErr: string | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch("https://api.adaptlypost.com/v1/drafts", { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify(piece), + }); + if (!res.ok) { + lastErr = `HTTP ${res.status}: ${await res.text()}`; + if (res.status >= 500 && attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, 2000 * Math.pow(2, attempt - 1))); + continue; + } + break; + } + const data = (await res.json()) as { id?: string; url?: string }; + return { + ok: true, + draft_url: data.url ?? `https://adaptlypost.com/drafts/${data.id}`, + }; + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err); + if (attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, 2000 * Math.pow(2, attempt - 1))); + continue; + } + } + } + return { ok: false, draft_url: "", error: lastErr }; } } export function getPublishClient(): PublishClient { return new AdaptlyPostClient(); } + +void dirname; diff --git a/lib/publish/meta-ads.ts b/lib/publish/meta-ads.ts new file mode 100644 index 0000000..84b607b --- /dev/null +++ b/lib/publish/meta-ads.ts @@ -0,0 +1,88 @@ +import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { PieceFrontmatter } from "../pieces/frontmatter"; + +export interface MetaAdsDraft { + piece_id: string; + campaign_name: string; + objective: string; + audience_hint: string; + daily_budget_usd: number; + creative_ids: string[]; + captions_by_platform: Record; + paused: boolean; + generated_at: string; +} + +function isDryRun(): boolean { + const v = process.env.DRY_RUN; + return v === undefined || v === "" || v === "true"; +} + +export function buildDraft( + piece: PieceFrontmatter, + options: { + captions: Record; + creative_ids: string[]; + audience_hint?: string; + daily_budget_usd?: number; + objective?: string; + }, +): MetaAdsDraft { + return { + piece_id: piece.id, + campaign_name: `auto-${piece.id}`, + objective: options.objective ?? "OUTCOME_ENGAGEMENT", + audience_hint: options.audience_hint ?? piece.pillar, + daily_budget_usd: options.daily_budget_usd ?? 10, + creative_ids: options.creative_ids, + captions_by_platform: options.captions, + paused: true, + generated_at: new Date().toISOString(), + }; +} + +export async function createCampaign( + root: string, + piece: PieceFrontmatter, + draft: MetaAdsDraft, +): Promise<{ ok: boolean; path?: string; campaign_id?: string; error?: string }> { + const dateStr = piece.date.slice(0, 10); + const dir = resolve(root, "outputs", piece.client, dateStr, piece.id); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const draftPath = resolve(dir, "ads-draft.json"); + const finalPath = existsSync(draftPath) + ? resolve(dir, `ads-draft.${Date.now()}.json`) + : draftPath; + writeFileSync(finalPath, JSON.stringify(draft, null, 2)); + const promotionsPath = resolve(root, "data", "promotions.jsonl"); + if (!existsSync(dirname(promotionsPath))) { + mkdirSync(dirname(promotionsPath), { recursive: true }); + } + appendFileSync( + promotionsPath, + `${JSON.stringify({ + timestamp: new Date().toISOString(), + piece_id: piece.id, + reason: "promoted-by-classifier", + meta_ads_draft_path: finalPath, + })}\n`, + ); + if (isDryRun()) { + return { ok: true, path: finalPath }; + } + if (process.env.META_ADS_MCP_ACTIVE !== "true") { + return { + ok: false, + path: finalPath, + error: "META_ADS_MCP_ACTIVE not true; cannot create campaign — draft kept", + }; + } + // Real path would call the meta-ads MCP here. + return { + ok: false, + path: finalPath, + error: + "Meta Ads MCP integration is a stub. Use DRY_RUN=true for tests; wire MCP transport in production.", + }; +} diff --git a/lib/qa/tech-specs.ts b/lib/qa/tech-specs.ts new file mode 100644 index 0000000..6fa97c5 --- /dev/null +++ b/lib/qa/tech-specs.ts @@ -0,0 +1,216 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; + +export type Platform = + | "ig_feed" + | "ig_reel" + | "ig_story" + | "ig_carousel" + | "tiktok" + | "yt_shorts" + | "yt_long" + | "fb_feed" + | "fb_reels" + | "linkedin"; + +interface PlatformSpec { + aspect: string[]; + max_duration_s?: number; + max_file_size_mb?: number; + min_width: number; + min_height: number; +} + +const SPECS: Record = { + ig_feed: { aspect: ["1:1", "4:5"], min_width: 1080, min_height: 1080, max_file_size_mb: 100 }, + ig_reel: { aspect: ["9:16"], min_width: 1080, min_height: 1920, max_duration_s: 90, max_file_size_mb: 250 }, + ig_story: { aspect: ["9:16"], min_width: 1080, min_height: 1920, max_duration_s: 60 }, + ig_carousel: { aspect: ["1:1", "4:5"], min_width: 1080, min_height: 1080 }, + tiktok: { aspect: ["9:16"], min_width: 1080, min_height: 1920, max_duration_s: 600 }, + yt_shorts: { aspect: ["9:16"], min_width: 1080, min_height: 1920, max_duration_s: 60 }, + yt_long: { aspect: ["16:9"], min_width: 1920, min_height: 1080 }, + fb_feed: { aspect: ["1:1", "4:5", "16:9"], min_width: 1080, min_height: 1080 }, + fb_reels: { aspect: ["9:16"], min_width: 1080, min_height: 1920, max_duration_s: 90 }, + linkedin: { aspect: ["1:1", "16:9", "4:5"], min_width: 1080, min_height: 1080 }, +}; + +export interface AssetMetadata { + width: number; + height: number; + aspect: string; + duration_s?: number; + file_size_mb: number; + codec?: string; +} + +export interface Violation { + rule: string; + expected: string; + actual: string; + severity: "hard" | "soft"; +} + +export interface PerPlatformResult { + pass: boolean; + violations: Violation[]; + fixes: string[]; +} + +export interface TechSpecsReport { + pass: boolean; + per_platform: Record; + metadata: AssetMetadata; +} + +function gcd(a: number, b: number): number { + if (!Number.isFinite(a) || !Number.isFinite(b)) return 1; + if (b === 0) return a; + return gcd(b, a % b); +} + +function aspectFromDims(w: number, h: number): string { + const g = gcd(w, h) || 1; + return `${w / g}:${h / g}`; +} + +function probeMedia(path: string): AssetMetadata { + if (!existsSync(path)) { + throw new Error(`asset not found: ${path}`); + } + const sizeMb = statSync(path).size / 1024 / 1024; + // Try ffprobe for video / identify for image; if unavailable, fall back to filename heuristic. + const ffprobe = spawnSync("ffprobe", [ + "-v", + "error", + "-show_streams", + "-show_format", + "-of", + "json", + path, + ]); + if (ffprobe.status === 0 && ffprobe.stdout) { + try { + const data = JSON.parse(ffprobe.stdout.toString()) as { + streams?: Array<{ width?: number; height?: number; codec_name?: string }>; + format?: { duration?: string }; + }; + const stream = data.streams?.find((s) => s.width && s.height); + const w = stream?.width ?? 0; + const h = stream?.height ?? 0; + const duration = data.format?.duration ? Number(data.format.duration) : undefined; + return { + width: w, + height: h, + aspect: w && h ? aspectFromDims(w, h) : "?", + duration_s: duration, + file_size_mb: sizeMb, + codec: stream?.codec_name, + }; + } catch { + // fall through + } + } + const identify = spawnSync("identify", ["-format", "%w %h", path]); + if (identify.status === 0 && identify.stdout) { + const parts = identify.stdout.toString().trim().split(/\s+/); + const w = Number(parts[0]); + const h = Number(parts[1]); + return { + width: w, + height: h, + aspect: w && h ? aspectFromDims(w, h) : "?", + file_size_mb: sizeMb, + }; + } + // Heuristic fallback from filename like 1080x1920 + const match = /(\d+)x(\d+)/.exec(path); + if (match) { + const w = Number(match[1]); + const h = Number(match[2]); + return { + width: w, + height: h, + aspect: aspectFromDims(w, h), + file_size_mb: sizeMb, + }; + } + return { + width: 0, + height: 0, + aspect: "?", + file_size_mb: sizeMb, + }; +} + +export function validate( + assetPath: string, + targetPlatforms: Platform[], +): TechSpecsReport { + const metadata = probeMedia(assetPath); + const per_platform: Record = {}; + let overallPass = true; + for (const p of targetPlatforms) { + const spec = SPECS[p]; + if (!spec) { + per_platform[p] = { + pass: true, + violations: [], + fixes: [`no spec for platform ${p} — skipped`], + }; + continue; + } + const violations: Violation[] = []; + if (!spec.aspect.includes(metadata.aspect)) { + violations.push({ + rule: "aspect_mismatch", + expected: spec.aspect.join(" or "), + actual: metadata.aspect, + severity: "hard", + }); + } + if (metadata.width && metadata.width < spec.min_width) { + violations.push({ + rule: "min_width", + expected: String(spec.min_width), + actual: String(metadata.width), + severity: "hard", + }); + } + if (metadata.height && metadata.height < spec.min_height) { + violations.push({ + rule: "min_height", + expected: String(spec.min_height), + actual: String(metadata.height), + severity: "hard", + }); + } + if ( + metadata.duration_s !== undefined && + spec.max_duration_s !== undefined && + metadata.duration_s > spec.max_duration_s + ) { + violations.push({ + rule: "duration_exceeds", + expected: `<= ${spec.max_duration_s}s`, + actual: `${metadata.duration_s.toFixed(1)}s`, + severity: "hard", + }); + } + if (spec.max_file_size_mb && metadata.file_size_mb > spec.max_file_size_mb) { + violations.push({ + rule: "file_size_exceeds", + expected: `<= ${spec.max_file_size_mb} MB`, + actual: `${metadata.file_size_mb.toFixed(1)} MB`, + severity: "hard", + }); + } + const pass = !violations.some((v) => v.severity === "hard"); + if (!pass) overallPass = false; + per_platform[p] = { + pass, + violations, + fixes: violations.map((v) => `fix ${v.rule}: expected ${v.expected}, got ${v.actual}`), + }; + } + return { pass: overallPass, per_platform, metadata }; +} diff --git a/lib/router.ts b/lib/router.ts index ffe0e58..dc5d6c6 100644 --- a/lib/router.ts +++ b/lib/router.ts @@ -1,62 +1,31 @@ import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { ImageTask, LLMTask, VideoTask } from "./providers/types"; - -const LLM_ROUTING: Record = { - orchestration: "claude", - code: "claude", - caption: "deepseek", - script: "claude", - compliance: "claude", - translation: "deepseek", - humanization: "claude", -}; - -const IMAGE_ROUTING: Record = { - "quote-card": "gpt-image", - "ugc-ad": "topview", - cinematic: "higgsfield", - carousel: "gpt-image", - "batch-ab": "wavespeed", - inpaint: "gpt-image", - "face-swap": "topview", - "before-after": "gpt-image", -}; - -const VIDEO_ROUTING: Record = { - "cinematic-reel": "higgsfield", - "motion-control": "higgsfield", - "ugc-product": "topview", - "product-demo": "topview", - "talking-head": "topview", - "batch-hooks": "wavespeed", -}; +import { imageRow, llmRow, loadProviderMatrix, videoRow } from "./providers/matrix"; export function routeLLM(task: LLMTask, override?: string): string { - if (override) { - return override; - } + if (override) return override; if (task === "orchestration") { const env_default = process.env.LLM_DEFAULT; - if (env_default && env_default.length > 0) { - return env_default; - } + if (env_default && env_default.length > 0) return env_default; } - return LLM_ROUTING[task] ?? "claude"; + return llmRow(task, loadProviderMatrix()).default ?? "claude"; +} + +export function routeLLMFallback(task: LLMTask): string | undefined { + const env_fallback = process.env.LLM_FALLBACK; + const row = llmRow(task, loadProviderMatrix()); + return row.fallback ?? env_fallback; } export function routeImage(task: ImageTask, override?: string): string { - if (override) { - return override; - } - return IMAGE_ROUTING[task] ?? "gpt-image"; + if (override) return override; + return imageRow(task, loadProviderMatrix()).default ?? "gpt-image"; } export function routeVideo(task: VideoTask, override?: string): string { - if (override) { - return override; - } - return VIDEO_ROUTING[task] ?? "higgsfield"; + if (override) return override; + return videoRow(task, loadProviderMatrix()).default ?? "higgsfield"; } export interface UsageEntry { @@ -64,24 +33,133 @@ export interface UsageEntry { provider: string; tokens?: number; cost_usd?: number; + ok?: boolean; + error?: string; + fallback_used?: boolean; + attempt?: number; + latency_ms?: number; } interface UsageLogLine extends UsageEntry { timestamp: string; } -export function logUsage(entry: UsageEntry): void { - const log_path = resolve(process.cwd(), "data", "llm-usage.jsonl"); +export function usageLogPath(): string { + return resolve(process.cwd(), "data", "llm-usage.jsonl"); +} + +export function logUsage(entry: UsageEntry, override_path?: string): void { + const log_path = override_path ?? usageLogPath(); const dir = dirname(log_path); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const line: UsageLogLine = { timestamp: new Date().toISOString(), task: entry.task, provider: entry.provider, tokens: entry.tokens, cost_usd: entry.cost_usd, + ok: entry.ok, + error: entry.error, + fallback_used: entry.fallback_used, + attempt: entry.attempt, + latency_ms: entry.latency_ms, }; appendFileSync(log_path, `${JSON.stringify(line)}\n`, { encoding: "utf8" }); } + +export interface FallbackOptions { + task: string; + primary: () => Promise; + fallback?: () => Promise; + primaryName: string; + fallbackName?: string; + log_path?: string; +} + +export interface FallbackResult { + result: T; + provider_used: string; + fallback_triggered: boolean; + attempts: number; +} + +export async function runWithFallback( + opts: FallbackOptions, +): Promise> { + const t0 = Date.now(); + try { + const r = await opts.primary(); + logUsage( + { + task: opts.task, + provider: opts.primaryName, + ok: true, + fallback_used: false, + attempt: 1, + latency_ms: Date.now() - t0, + }, + opts.log_path, + ); + return { + result: r, + provider_used: opts.primaryName, + fallback_triggered: false, + attempts: 1, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logUsage( + { + task: opts.task, + provider: opts.primaryName, + ok: false, + error: msg, + fallback_used: false, + attempt: 1, + latency_ms: Date.now() - t0, + }, + opts.log_path, + ); + if (!opts.fallback || !opts.fallbackName) { + throw err; + } + const t1 = Date.now(); + try { + const r = await opts.fallback(); + logUsage( + { + task: opts.task, + provider: opts.fallbackName, + ok: true, + fallback_used: true, + attempt: 2, + latency_ms: Date.now() - t1, + }, + opts.log_path, + ); + return { + result: r, + provider_used: opts.fallbackName, + fallback_triggered: true, + attempts: 2, + }; + } catch (err2) { + const msg2 = err2 instanceof Error ? err2.message : String(err2); + logUsage( + { + task: opts.task, + provider: opts.fallbackName, + ok: false, + error: msg2, + fallback_used: true, + attempt: 2, + latency_ms: Date.now() - t1, + }, + opts.log_path, + ); + throw new Error( + `primary (${opts.primaryName}) failed: ${msg}; fallback (${opts.fallbackName}) failed: ${msg2}`, + ); + } + } +} diff --git a/lib/schedule/cron.ts b/lib/schedule/cron.ts new file mode 100644 index 0000000..19ea52a --- /dev/null +++ b/lib/schedule/cron.ts @@ -0,0 +1,151 @@ +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, join } from "node:path"; + +const MARKER_BEGIN = "# >>> marketing-engine begin >>>"; +const MARKER_END = "# <<< marketing-engine end <<<"; + +interface ScheduleEntries { + generateHour: number; + promoteHour: number; +} + +function defaultEntries(): ScheduleEntries { + return { generateHour: 22, promoteHour: 9 }; +} + +function cronBlock(cmdRoot: string, entries: ScheduleEntries): string { + return `${MARKER_BEGIN} +${entries.generateHour} 0 * * * cd "${cmdRoot}" && npx marketing-engine generate >> .marketing-engine/data/cron.log 2>&1 +${entries.promoteHour} 0 * * * cd "${cmdRoot}" && npx marketing-engine promote >> .marketing-engine/data/cron.log 2>&1 +${MARKER_END} +`; +} + +export function showCronPlan(cmdRoot: string): string { + return cronBlock(cmdRoot, defaultEntries()); +} + +export function installCron(cmdRoot: string): { added: boolean; message: string } { + if (platform() === "win32") { + return { added: false, message: "Windows not supported; use Task Scheduler manually." }; + } + let current = ""; + try { + current = execSync("crontab -l", { encoding: "utf8" }); + } catch { + current = ""; + } + if (current.includes(MARKER_BEGIN)) { + return { added: false, message: "marketing-engine block already installed in crontab." }; + } + const block = cronBlock(cmdRoot, defaultEntries()); + const next = `${current}${current && !current.endsWith("\n") ? "\n" : ""}${block}`; + const tmpPath = join(homedir(), ".marketing-engine-cron.tmp"); + writeFileSync(tmpPath, next, "utf8"); + execSync(`crontab "${tmpPath}"`); + return { added: true, message: "crontab updated." }; +} + +export function uninstallCron(): { removed: boolean; message: string } { + if (platform() === "win32") { + return { removed: false, message: "Windows not supported." }; + } + let current = ""; + try { + current = execSync("crontab -l", { encoding: "utf8" }); + } catch { + return { removed: false, message: "no crontab to modify." }; + } + const re = new RegExp( + `${MARKER_BEGIN}[\\s\\S]*?${MARKER_END}\\n?`, + "g", + ); + if (!re.test(current)) { + return { removed: false, message: "no marketing-engine block found." }; + } + const next = current.replace(re, ""); + const tmpPath = join(homedir(), ".marketing-engine-cron.tmp"); + writeFileSync(tmpPath, next, "utf8"); + execSync(`crontab "${tmpPath}"`); + return { removed: true, message: "crontab cleaned." }; +} + +export function statusCron(): string { + if (platform() === "win32") return "Windows: not supported."; + let current = ""; + try { + current = execSync("crontab -l", { encoding: "utf8" }); + } catch { + return "no crontab installed"; + } + if (!current.includes(MARKER_BEGIN)) return "marketing-engine block: NOT installed"; + const match = new RegExp( + `${MARKER_BEGIN}([\\s\\S]*?)${MARKER_END}`, + ).exec(current); + return `marketing-engine block: installed\n${match?.[1]?.trim() ?? ""}`; +} + +export interface LaunchdPlanResult { + generatePlistPath: string; + promotePlistPath: string; + generatePlist: string; + promotePlist: string; +} + +export function showLaunchdPlan(cmdRoot: string): LaunchdPlanResult { + const e = defaultEntries(); + const dir = join(homedir(), "Library", "LaunchAgents"); + function plist(label: string, hour: number, sub: string): string { + return ` + + + Label${label} + WorkingDirectory${cmdRoot} + ProgramArguments + + /usr/bin/env + npx + marketing-engine + ${sub} + + StartCalendarInterval + Hour${hour}Minute0 + StandardOutPath${cmdRoot}/.marketing-engine/data/${sub}.log + StandardErrorPath${cmdRoot}/.marketing-engine/data/${sub}.log + +`; + } + return { + generatePlistPath: join(dir, "com.marketing-engine.generate.plist"), + promotePlistPath: join(dir, "com.marketing-engine.promote.plist"), + generatePlist: plist("com.marketing-engine.generate", e.generateHour, "generate"), + promotePlist: plist("com.marketing-engine.promote", e.promoteHour, "promote"), + }; +} + +export function installLaunchd(cmdRoot: string): { added: boolean; message: string } { + if (platform() !== "darwin") { + return { added: false, message: "launchd only supported on macOS." }; + } + const plan = showLaunchdPlan(cmdRoot); + for (const [path, body] of [ + [plan.generatePlistPath, plan.generatePlist] as const, + [plan.promotePlistPath, plan.promotePlist] as const, + ]) { + if (existsSync(path)) { + return { added: false, message: `${path} already exists; bail.` }; + } + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body, "utf8"); + try { + execSync(`launchctl bootstrap gui/$(id -u) "${path}"`); + } catch { + // bootstrap may fail if already loaded — best effort. + } + } + return { added: true, message: "launchd plists installed." }; +} + +void readFileSync; diff --git a/lib/skills/brand-voice.ts b/lib/skills/brand-voice.ts new file mode 100644 index 0000000..d5c772a --- /dev/null +++ b/lib/skills/brand-voice.ts @@ -0,0 +1,55 @@ +export interface BrandSpec { + voice_axes?: { + tone?: number; + formality?: number; + energy?: number; + warmth?: number; + }; + lexicon?: { + use?: string[]; + avoid?: string[]; + }; +} + +export interface BrandVoiceScore { + score: number; + axes: { tone: number; formality: number; energy: number; warmth: number }; + notes: string[]; +} + +function clamp(v: number, lo = 1, hi = 5): number { + return Math.max(lo, Math.min(hi, v)); +} + +export function scoreBrandVoice( + text: string, + brand?: BrandSpec, +): BrandVoiceScore { + const notes: string[] = []; + const exclam = (text.match(/!/g) ?? []).length; + const youCount = (text.match(/\byou(?:r)?\b/gi) ?? []).length; + const formalIndicators = (text.match(/\b(?:therefore|whereby|hereby|herein)\b/gi) ?? []).length; + const energy = clamp(1 + exclam * 0.7 + (text.length < 120 ? 1 : 0)); + const tone = clamp(3 - youCount * 0.3 + formalIndicators * 0.5); + const formality = clamp(2 + formalIndicators * 0.5); + const warmth = clamp(3 + youCount * 0.1); + const target = brand?.voice_axes ?? {}; + const axes = { tone, formality, energy, warmth }; + let total = 0; + let counted = 0; + for (const k of ["tone", "formality", "energy", "warmth"] as const) { + const t = target[k]; + if (t === undefined) continue; + counted++; + const distance = Math.abs(axes[k] - t) / 4; + total += 1 - distance; + } + const score = counted === 0 ? 0.5 : total / counted; + const avoid = brand?.lexicon?.avoid ?? []; + for (const a of avoid) { + if (text.toLowerCase().includes(a.toLowerCase())) { + notes.push(`contains banned term: ${a}`); + } + } + return { score, axes, notes }; +} diff --git a/lib/skills/humanizer.ts b/lib/skills/humanizer.ts new file mode 100644 index 0000000..5271a74 --- /dev/null +++ b/lib/skills/humanizer.ts @@ -0,0 +1,68 @@ +export interface HumanizeOptions { + language?: "pt-BR" | "en" | "es"; + platform?: string; + brand_voice_path?: string; + preserve_terms?: string[]; +} + +export interface HumanizeResult { + text: string; + changes: string[]; + ai_tells_remaining: number; + passes_used: number; +} + +const AI_TELL_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: /\b(?:In conclusion|To summarize|To sum up|In summary)\b[,.]?\s*/gi, label: "removed conclusion intro" }, + { re: /\b(?:Moreover|Furthermore|Additionally)\b[,.]?\s*/g, label: "removed connector" }, + { re: /—/g, label: "replaced em-dash" }, + { re: /\b(?:delve|leverage|unlock the secret|revolutionize)\b/gi, label: "removed AI-tell word" }, + { re: /\b(perhaps|it could be argued|arguably)\b/gi, label: "removed hedge" }, +]; + +const TRIAD_RE = /(\w+),\s*(\w+),\s*and\s+(\w+)/g; + +export function humanizeSync(input: string, opts: HumanizeOptions = {}): HumanizeResult { + let text = input; + const changes: string[] = []; + for (const t of AI_TELL_PATTERNS) { + const before = text; + text = text.replace(t.re, (m) => (t.re.source.includes("—") ? ", " : "")); + if (before !== text) changes.push(t.label); + } + text = text.replace(TRIAD_RE, (_m, a, b, c) => { + changes.push(`broke triad ${a}/${b}/${c}`); + return `${a}. ${b[0].toUpperCase()}${b.slice(1)}. ${c[0].toUpperCase()}${c.slice(1)}`; + }); + // Ensure preserve terms unchanged: if dropped, re-inject naively at end. + for (const term of opts.preserve_terms ?? []) { + if (!text.includes(term) && input.includes(term)) { + text = `${text} ${term}`; + changes.push(`re-injected preserve term: ${term}`); + } + } + // Recount remaining tells + let remaining = 0; + for (const t of AI_TELL_PATTERNS) { + const matches = text.match(t.re); + if (matches) remaining += matches.length; + } + return { + text: text.replace(/\s+/g, " ").trim(), + changes, + ai_tells_remaining: remaining, + passes_used: 1, + }; +} + +export async function humanize( + input: string, + opts: HumanizeOptions = {}, +): Promise { + // Real implementation would call llm-router for a polished rewrite; + // sync pass is the deterministic baseline that always runs. + const first = humanizeSync(input, opts); + if (first.ai_tells_remaining < 3) return first; + const second = humanizeSync(first.text, opts); + return { ...second, passes_used: 2, changes: [...first.changes, ...second.changes] }; +} diff --git a/package-lock.json b/package-lock.json index 7528d83..ccf3633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "marketing-engine", - "version": "0.2.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "marketing-engine", - "version": "0.2.0", + "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "tsx": "^4.22.1" + }, "bin": { "marketing-engine": "bin/marketing-engine.mjs" }, @@ -21,6 +24,422 @@ "node": ">=18.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -60,6 +479,47 @@ "url": "https://dotenvx.com" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -107,6 +567,38 @@ "node": ">=18" } }, + "node_modules/tsx": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", + "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 287e2fd..51e8f95 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "tsconfig.json", ".env.example", "AGENTS.md", + "CHANGELOG.md", "README.md", "LICENSE" ], @@ -57,12 +58,18 @@ "router:check": "bash .ralph/provider-check.sh", "cli": "node bin/marketing-engine.mjs", "cli:init": "node bin/marketing-engine.mjs init", - "cli:check": "node bin/marketing-engine.mjs check" + "cli:check": "node bin/marketing-engine.mjs check", + "cli:generate": "node bin/marketing-engine.mjs generate", + "cli:promote": "node bin/marketing-engine.mjs promote", + "smoke": "node bin/marketing-engine.mjs help && node bin/marketing-engine.mjs generate" }, "devDependencies": { "@playwright/test": "^1.48.0", "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "typescript": "^5.6.0" + }, + "dependencies": { + "tsx": "^4.22.1" } }