Something went wrong. Try again.
Something went wrong. Try again.
4.0 kB · 129 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130"""Simcluster podcast transcripts -- FastAPI app + hourly feed poller."""import asyncioimport loggingimport osfrom contextlib import asynccontextmanager
from dotenv import load_dotenvfrom fastapi import FastAPI, HTTPExceptionfrom fastapi.staticfiles import StaticFiles
load_dotenv() # must run before config reads the environment
import config # noqa: E402import db # noqa: E402import pipeline # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")log = logging.getLogger("simcluster")
_cycle_lock = asyncio.Lock()
async def _run_cycle_locked(): if _cycle_lock.locked(): return {"started": False, "reason": "cycle already running"} async with _cycle_lock: processed = await asyncio.to_thread(pipeline.run_cycle) return {"started": True, "processed": processed}
async def _poller(): while True: try: await _run_cycle_locked() except Exception: log.exception("poll cycle crashed") await asyncio.sleep(config.POLL_INTERVAL_SEC)
@asynccontextmanagerasync def lifespan(app): db.init() task = asyncio.create_task(_poller()) yield task.cancel()
app = FastAPI(title="simcluster transcripts", lifespan=lifespan)
@app.get("/health")def health(): return {"status": "ok"}
@app.get("/api/meta")def meta(): """Show branding for the frontend.""" return config.meta()
@app.get("/api/episodes")def episodes(): return {"episodes": db.list_episodes()}
@app.get("/api/episodes/{num}")def episode(num: int): ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") return ep
@app.post("/api/poll")async def poll_now(): """Manually trigger a poll cycle. Dedupe makes this cost-safe.""" return await _run_cycle_locked()
@app.post("/api/episodes/{num}/review")async def review_episode(num: int): """Run the editorial review pass on a DONE, not-yet-reviewed episode.
Idempotence gate keeps this cost-bounded: once a review is stored the endpoint refuses, so it can't be spammed into a bill. """ ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") if ep["status"] != "done" or not ep.get("transcript"): raise HTTPException(409, f"episode is '{ep['status']}', not reviewable") tr = ep["transcript"] if tr.get("review"): raise HTTPException(409, "already reviewed")
import review as review_mod edited, report = await asyncio.to_thread( review_mod.review_turns, tr["turns"], tr.get("host", config.HOST_NAME), tr.get("guest") or ep.get("guest") or "Guest", ep["title"], ep.get("description")) tr.update(raw_turns=tr["turns"], turns=edited, review=report) words = sum(len(t["text"].split()) for t in edited) db.save_transcript(ep["guid"], tr, cost_usd=ep.get("cost_usd"), word_count=words, names_resolved=bool(ep.get("names_resolved"))) return {"episode": num, "review": report}
@app.post("/api/episodes/{num}/retry")async def retry(num: int): """Queue a FAILED or backfill-SKIPPED episode. Never re-runs successes.""" ep = db.get_episode(num) if not ep: raise HTTPException(404, "no such episode") if ep["status"] not in ("error", "skipped"): raise HTTPException(409, f"episode is '{ep['status']}', not retryable") db.set_status(ep["guid"], "pending") return await _run_cycle_locked()
# Serve the built frontend when it's baked into the image (single-container# deploys). Behind a reverse proxy that serves the SPA itself, this mount is# simply never reached._dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend_dist")if os.path.isdir(_dist): app.mount("/", StaticFiles(directory=_dist, html=True), name="frontend")