Ctrl K
ComunidadGuías…

scripts/weekly-summary.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Weekly Kindling digest emailer.

Regenerates this week's summary via the always-on prod API (so all the SQL +
local-model narration logic lives in one place, not duplicated here), then
either prints it (default, dry-run) or emails it via Resend (--send).

Secrets: the Resend API key is read from `pass api-keys/resend` at run time,
never from a file. Recipient/sender come from env so nothing personal is
hard-coded:
    PLAID_DIGEST_TO     recipient address (required for --send)
    PLAID_DIGEST_FROM   verified Resend sender (required for --send)
    PLAID_API           base URL of the running app (default http://localhost:3408)

Usage:
    ./scripts/weekly-summary.py            # dry-run: regenerate + print subject/body
    ./scripts/weekly-summary.py --send     # regenerate + email via Resend
"""
import json
import os
import subprocess

def _pass_cmd(entry):
    """launchd-safe secret read: loopback-gpg passx (no gpg-agent hang) -> pass fallback."""
    import os
    _px = os.path.expanduser("~/.local/bin/passx")
    return [_px, entry] if os.path.exists(_px) else ["pass", entry]
import sys
import urllib.request

API = os.environ.get("PLAID_API", "http://localhost:3408")
# Mirror the app's gateway/model defaults (lib/config.ts) so the warmup below
# targets the exact model /api/summary will narrate with.
GATEWAY = os.environ.get("LITELLM_BASE_URL", "http://localhost:4000/v1")
MODEL = os.environ.get("FINANCE_LLM_MODEL", "deepseek-v4-flash")


def warm_model() -> None:
    """Boot the on-demand NVFP4 model before generating.

    The model is reaped after ~15 min idle, so an unattended run almost always
    hits it cold. /api/summary aborts its LLM call at 90s (lib/llm.ts), but a
    cold NVFP4 boot can exceed that and 502 the whole digest. We can't retry
    from inside the agent, so we absorb the boot here first: one cheap call with
    a generous timeout that blocks until vLLM is ready. After it returns, the
    summary narration runs warm in ~2s. Warmup failures are non-fatal — fall
    through and let fetch_summary surface its own error.
    """
    key = subprocess.run(
        _pass_cmd("api-keys/litellm-vk-finance"), capture_output=True, text=True
    ).stdout.strip().splitlines()
    if not key or not key[0]:
        print("warm: no vk-finance key in pass — skipping warmup", file=sys.stderr)
        return
    payload = json.dumps({
        "model": MODEL,
        "messages": [{"role": "user", "content": "ok"}],
        "max_tokens": 1,
        "temperature": 0,
    }).encode()
    req = urllib.request.Request(
        f"{GATEWAY}/chat/completions",
        data=payload,
        headers={"Authorization": f"Bearer {key[0]}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        # Generous: a cold model boot can run well past the app's 90s LLM cap.
        with urllib.request.urlopen(req, timeout=int(os.environ.get("WARM_TIMEOUT", "300"))) as r:
            r.read()
        print(f"warm: {MODEL} ready", file=sys.stderr)
    except Exception as e:  # noqa: BLE001 — warmup is best-effort, never fatal
        print(f"warm: {MODEL} warmup failed ({e}) — continuing", file=sys.stderr)


def fetch_summary() -> dict:
    req = urllib.request.Request(
        f"{API}/api/summary",
        data=b"{}",
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=150) as r:
        data = json.load(r)
    if "error" in data:
        sys.exit(f"summary generation failed: {data['error']}")
    return data


def subject(summary: dict) -> str:
    st = summary["stats"]
    spend = st["spend"]["thisWeek"]
    return f"Kindling — week of {summary['weekStart']}: ${spend:,.2f} spent"


def send_via_resend(to: str, frm: str, subj: str, html: str) -> None:
    key = subprocess.run(
        _pass_cmd("api-keys/resend"), capture_output=True, text=True
    ).stdout.strip().splitlines()
    if not key or not key[0]:
        sys.exit("no Resend key in `pass api-keys/resend`")
    payload = json.dumps({"from": frm, "to": [to], "subject": subj, "html": html}).encode()
    req = urllib.request.Request(
        "https://api.resend.com/emails",
        data=payload,
        headers={
            "Authorization": f"Bearer {key[0]}",
            "Content-Type": "application/json",
            # Resend sits behind Cloudflare, which 403s (error 1010) on urllib's
            # default User-Agent. A named UA passes.
            "User-Agent": "kindling-weekly/1.0",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            res = json.load(r)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        sys.exit(f"Resend {e.code}: {body}")
    print(f"sent: id={res.get('id')}")


def main() -> None:
    send = "--send" in sys.argv[1:]
    warm_model()          # absorb a cold model boot before the bounded summary call
    data = fetch_summary()
    summary = data["summary"]
    html = data.get("emailHtml", "")
    subj = subject(summary)

    if not send:
        print(f"[dry-run] subject: {subj}")
        print(f"[dry-run] week:    {summary['weekStart']} -> {summary['weekEnd']}")
        print(f"[dry-run] model:   {summary.get('model')}")
        print("[dry-run] narrative:\n" + summary["narrative"])
        print(f"[dry-run] html:    {len(html)} bytes (not sent — pass --send to email)")
        return

    to = os.environ.get("PLAID_DIGEST_TO", "")
    frm = os.environ.get("PLAID_DIGEST_FROM", "")
    if not to or not frm:
        sys.exit("set PLAID_DIGEST_TO and PLAID_DIGEST_FROM to send")
    send_via_resend(to, frm, subj, html)


if __name__ == "__main__":
    main()