← back to Crazy News Channel

daily-cartoons/common.py

102 lines

"""Shared helpers for the P24 daily-cartoon pipeline (TK-12153).
Cost guard reads ~/.claude/cost-ledger.jsonl (entries with app == APP) for today's
local date; logging goes through the cost-tracker skill's log.js. Never prints the token."""
import datetime, json, os, subprocess, sys, time, urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
_SITE_OVERRIDE = os.environ.get("P24_SITE_DIR")
SITE = _SITE_OVERRIDE or os.path.dirname(HERE)  # override only for tests
# TK-12243: with P24_SITE_DIR set (tests / review-server against a copy), the queue and the
# generated review.html live inside THAT copy too — so approve.py never touches the real queue.
DC = os.path.join(_SITE_OVERRIDE, "daily-cartoons") if _SITE_OVERRIDE else HERE
QUEUE = os.environ.get("P24_CARTOON_QUEUE_DIR") or os.path.join(DC, "queue")
APP = "p24-daily-cartoons"
LEDGER = os.path.expanduser("~/.claude/cost-ledger.jsonl")
LOGJS = os.path.expanduser("~/.claude/skills/cost-tracker/scripts/log.js")
# per-unit estimates (mirror cost-tracker pricing.json; WAN rate is a conservative assumption)
EST = {"flux": 0.003, "wan": 0.10}
API_KEYS = {"flux": ("replicate_flux_schnell", "image"), "wan": ("replicate_wan22_i2v_fast", "video")}

DATE_RE = r"^\d{4}-\d{2}-\d{2}$"

def queue_dates():
    """Sorted YYYY-MM-DD day dirs in the queue — skips queue/_trash (review-server deletes, TK-12243)."""
    import re
    if not os.path.isdir(QUEUE): return []
    return sorted(d for d in os.listdir(QUEUE) if re.match(DATE_RE, d) and os.path.isdir(os.path.join(QUEUE, d)))

class CapExceeded(Exception):
    pass

def cap_usd():
    return float(os.environ.get("P24_CARTOON_CAP_USD", "1.00"))

def spent_today():
    today = datetime.date.today()
    total = 0.0
    try:
        with open(LEDGER) as f:
            for line in f:
                try:
                    e = json.loads(line)
                except Exception:
                    continue
                if e.get("app") != APP:
                    continue
                ts = e.get("ts", "")
                try:
                    d = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().date()
                except Exception:
                    continue
                if d == today:
                    total += float(e.get("cost_usd") or 0)
    except FileNotFoundError:
        pass
    return round(total, 6)

def guard(kind, n=1):
    """Raise CapExceeded unless today's spend + this call's estimate fits under the cap."""
    est = EST[kind] * n
    spent, cap = spent_today(), cap_usd()
    if spent + est > cap + 1e-9:
        raise CapExceeded(f"cap ${cap:.2f}: spent today ${spent:.3f} + est ${est:.3f} for {kind} would exceed it")
    print(f"  [cost] est ${est:.3f} for {kind} (spent today ${spent:.3f} / cap ${cap:.2f})")
    return est

def log_cost(kind, note, n=1):
    api, unit = API_KEYS[kind]
    out = subprocess.run(["node", LOGJS, "--api", api, "--units", f"{n}:{unit}", "--app", APP, "--note", note],
                         capture_output=True, text=True)
    if out.returncode != 0:
        print("  [cost] WARNING ledger log failed:", out.stderr.strip()[:200], file=sys.stderr)
    return EST[kind] * n

def token():
    for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if l.startswith("REPLICATE_API_TOKEN="):
            return l.split("=", 1)[1].strip().strip("\"'")
    raise SystemExit("REPLICATE_API_TOKEN missing from secrets-manager/.env")

def api(url, data=None, prefer_wait=False):
    h = {"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}
    if prefer_wait:
        h["Prefer"] = "wait"
    r = urllib.request.Request(url, data=json.dumps(data).encode() if data else None, headers=h)
    return json.load(urllib.request.urlopen(r, timeout=300))

def poll(pr, interval, deadline_s):
    # Replicate bills at create time: never lose the id, retry transient poll errors, bound the wait.
    end = time.time() + deadline_s
    while pr["status"] not in ("succeeded", "failed", "canceled"):
        if time.time() > end:
            raise RuntimeError(f"TIMEOUT after {deadline_s}s — recover later: GET {pr['urls']['get']}")
        time.sleep(interval)
        for a in range(5):
            try:
                pr = api(pr["urls"]["get"]); break
            except Exception as e:
                print(f"  poll error ({a+1}/5): {e}"); time.sleep(interval * (a + 1))
        else:
            raise RuntimeError(f"poll failed 5x — recover later: GET {pr['urls']['get']}")
    return pr