← 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