Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| #!/usr/bin/env python3 | |
| """ | |
| Persist batch job progress for priority-state pipelines (captions, analyze, catalog). | |
| Writes JSON under ``data/cache/batch_jobs/`` and syncs to | |
| ``bronze.youtube_batch_job_runs`` (migration 073) for the live API dashboard. | |
| Used by ``run_priority_states_last_n.sh`` and ``backfill_jurisdiction_transcripts.py``. | |
| View in React: ``/data-explorer/batch-jobs`` (``GET /api/batch-jobs/stream`` SSE). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import functools | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from dataclasses import asdict, dataclass, field | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| _REPO_ROOT = Path(__file__).resolve().parents[3] | |
| if str(_REPO_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_REPO_ROOT)) | |
| _DEFAULT_JOBS_DIR = _REPO_ROOT / "data" / "cache" / "batch_jobs" | |
| _INDEX_NAME = "index.json" | |
| _SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]+") | |
| def _utc_now_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def batch_inactivity_seconds() -> float: | |
| """Mark ``running`` batches cancelled after this many seconds without activity.""" | |
| raw = os.getenv("BATCH_JOB_INACTIVITY_SECONDS", "3600") | |
| try: | |
| return max(60.0, float(raw)) | |
| except (TypeError, ValueError): | |
| return 3600.0 | |
| def config_state_codes(cfg: Optional[Dict[str, Any]]) -> List[str]: | |
| raw = (cfg or {}).get("states") or [] | |
| if isinstance(raw, str): | |
| return [s.strip().upper() for s in raw.split(",") if s.strip()] | |
| if isinstance(raw, list): | |
| return [str(s).strip().upper() for s in raw if str(s).strip()] | |
| return [] | |
| def _parse_utc_iso(iso: str) -> Optional[datetime]: | |
| text = (iso or "").strip() | |
| if not text: | |
| return None | |
| try: | |
| return datetime.fromisoformat(text.replace("Z", "+00:00")) | |
| except ValueError: | |
| return None | |
| def last_batch_activity_at(job: BatchJob) -> datetime: | |
| """ | |
| Latest timestamp that indicates real pipeline work (jurisdiction / video progress). | |
| Does not use ``job.updated_at`` (dashboard sync metadata). When nothing is recorded, | |
| falls back to ``started_at`` so idle ``running`` rows can stale-cancel instead of | |
| looking falsely active. | |
| """ | |
| best: Optional[datetime] = None | |
| for j in job.jurisdictions: | |
| for iso in ( | |
| j.updated_at, | |
| j.current_video_started_at, | |
| j.finished_at, | |
| j.started_at, | |
| ): | |
| dt = _parse_utc_iso(iso or "") | |
| if dt and (best is None or dt > best): | |
| best = dt | |
| for v in j.videos or []: | |
| dt = _parse_utc_iso(v.finished_at or "") | |
| if dt and (best is None or dt > best): | |
| best = dt | |
| if best is not None: | |
| return best | |
| dt = _parse_utc_iso(job.started_at or "") | |
| if dt: | |
| return dt | |
| return datetime.fromtimestamp(0, tz=timezone.utc) | |
| def latest_dashboard_activity_at(jobs: List[BatchJob]) -> str: | |
| """Latest jurisdiction/video progress across batches (not API ``generated_at``).""" | |
| best: Optional[datetime] = None | |
| for job in jobs: | |
| try: | |
| dt = last_batch_activity_at(job) | |
| if dt.year > 1971 and (best is None or dt > best): | |
| best = dt | |
| except Exception: | |
| pass | |
| return best.isoformat() if best is not None and best.year > 1971 else "" | |
| def state_progress_for_job(job: BatchJob) -> Dict[str, Any]: | |
| """ | |
| Per-batch state coverage from jurisdiction rows. | |
| * started — at least one jurisdiction in that state is running or finished | |
| * completed — every jurisdiction row for that state is finished (none pending/running) | |
| """ | |
| planned = config_state_codes(job.config) | |
| by_state: Dict[str, Dict[str, int]] = {} | |
| for j in job.jurisdictions: | |
| st = (j.state_code or "").strip().upper() | |
| if not st: | |
| continue | |
| bucket = by_state.setdefault(st, {"pending": 0, "running": 0, "done": 0}) | |
| if j.status == "pending": | |
| bucket["pending"] += 1 | |
| elif j.status == "running": | |
| bucket["running"] += 1 | |
| else: | |
| bucket["done"] += 1 | |
| universe = sorted(set(planned) | set(by_state)) | |
| started_codes = [ | |
| st | |
| for st in universe | |
| if by_state.get(st, {}).get("running", 0) + by_state.get(st, {}).get("done", 0) > 0 | |
| ] | |
| completed_codes = [ | |
| st | |
| for st in universe | |
| if by_state.get(st) | |
| and by_state[st].get("pending", 0) == 0 | |
| and by_state[st].get("running", 0) == 0 | |
| and by_state[st].get("done", 0) > 0 | |
| ] | |
| return { | |
| "states_planned": len(universe), | |
| "states_started": len(started_codes), | |
| "states_completed": len(completed_codes), | |
| "states_started_codes": started_codes, | |
| "states_completed_codes": completed_codes, | |
| } | |
| def _maybe_stale_cancel_batch(job: BatchJob) -> bool: | |
| """Cancel ``running`` batches with no activity for ``batch_inactivity_seconds()``.""" | |
| if (job.status or "").lower() != "running": | |
| return False | |
| inactive = batch_inactivity_seconds() | |
| now = datetime.now(timezone.utc) | |
| if (now - last_batch_activity_at(job)).total_seconds() <= inactive: | |
| return False | |
| finished = _utc_now_iso() | |
| job.status = "cancelled" | |
| job.finished_at = finished | |
| cfg = dict(job.config or {}) | |
| cfg["stale_cancel_reason"] = f"no activity for {int(inactive)}s" | |
| job.config = cfg | |
| for j in job.jurisdictions: | |
| if j.status == "running": | |
| j.status = "failed" | |
| j.exit_code = -1 | |
| j.finished_at = finished | |
| j.updated_at = finished | |
| j.current_video_id = "" | |
| j.current_video_title = "" | |
| j.current_video_started_at = "" | |
| return True | |
| def persist_batch_job(job: BatchJob) -> None: | |
| """Write lifecycle changes to Postgres and the JSON cache file when present.""" | |
| job.updated_at = _utc_now_iso() | |
| _recompute_summary(job) | |
| try: | |
| from api.batch_jobs.batch_job_db import sync_batch_job_to_db | |
| sync_batch_job_to_db(job) | |
| except Exception: | |
| pass | |
| try: | |
| store = BatchJobStore(job.batch_id) | |
| if store.path.is_file(): | |
| tmp = store.path.with_suffix(".json.tmp") | |
| tmp.write_text( | |
| json.dumps(job.to_dict(), indent=2, ensure_ascii=False) + "\n", | |
| encoding="utf-8", | |
| ) | |
| tmp.replace(store.path) | |
| _update_index(store.root, job) | |
| except Exception: | |
| pass | |
| def duration_seconds_from_catalog_minutes(duration_minutes: object) -> Optional[float]: | |
| """Convert ``bronze_event_youtube.duration_minutes`` to seconds for batch UI.""" | |
| if duration_minutes is None: | |
| return None | |
| try: | |
| sec = float(duration_minutes) * 60.0 | |
| except (TypeError, ValueError): | |
| return None | |
| if sec <= 0 or sec > 86400 * 48: # cap absurd values (~48h) | |
| return None | |
| return round(sec, 1) | |
| def _slug(s: str, max_len: int = 48) -> str: | |
| out = _SLUG_RE.sub("-", (s or "").strip()).strip("-") | |
| return (out[:max_len] if out else "batch") | |
| def new_batch_id(step: str) -> str: | |
| ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| return f"{_slug(step, 24)}-{ts}" | |
| def jobs_dir() -> Path: | |
| raw = (os.getenv("BATCH_JOBS_DIR") or "").strip() | |
| return Path(raw).resolve() if raw else _DEFAULT_JOBS_DIR | |
| def _batch_database_url() -> str: | |
| return ( | |
| os.getenv("NEON_DATABASE_URL_DEV", "").strip() | |
| or os.getenv("NEON_DATABASE_URL", "").strip() | |
| or os.getenv("DATABASE_URL", "").strip() | |
| ) | |
| def _batch_plan_cache_key(states: List[str], *, round_robin: bool) -> str: | |
| st_list = [s.strip().upper() for s in states if (s or "").strip()] | |
| return f"{','.join(st_list)}|{int(bool(round_robin))}" | |
| def fetch_batch_plan_jurisdictions( | |
| states: List[str], | |
| *, | |
| round_robin: bool = True, | |
| database_url: Optional[str] = None, | |
| ) -> List[JurisdictionRun]: | |
| """ | |
| Canonical jurisdictions for the batch plan (``pending``), aligned with | |
| ``intermediate.int_jurisdictions`` — not legacy ``c-AL-*`` ids on bronze video rows. | |
| """ | |
| from core_lib.jurisdictions.jurisdiction_id import ( | |
| _PREFIXED_USPS_GEOID_RE, | |
| _SLUG_GEOID_RE, | |
| _TYPED_JURISDICTION_ID_RE, | |
| ensure_canonical_jurisdiction_id, | |
| ) | |
| st_list = [s.strip().upper() for s in states if (s or "").strip()] | |
| if not st_list: | |
| return [] | |
| url = (database_url or "").strip() or _batch_database_url() | |
| if not url: | |
| return [] | |
| try: | |
| import psycopg2 | |
| from psycopg2.extras import RealDictCursor | |
| except ImportError: | |
| return [] | |
| rows: list = [] | |
| try: | |
| with psycopg2.connect(url) as conn, conn.cursor( | |
| cursor_factory=RealDictCursor | |
| ) as cur: | |
| cur.execute( | |
| """ | |
| SELECT | |
| j.state_code, | |
| j.jurisdiction_id, | |
| j.name AS jurisdiction_name, | |
| j.jurisdiction_type::text AS jurisdiction_type | |
| FROM intermediate.int_jurisdictions j | |
| WHERE j.state_code = ANY(%s) | |
| AND j.jurisdiction_id IS NOT NULL | |
| AND BTRIM(j.jurisdiction_id) <> '' | |
| AND EXISTS ( | |
| SELECT 1 | |
| FROM bronze.bronze_event_youtube y | |
| WHERE y.state_code = j.state_code | |
| AND ( | |
| y.jurisdiction_id = j.jurisdiction_id | |
| OR y.jurisdiction_id = 'c-' || j.state_code || '-' || j.geoid | |
| OR y.jurisdiction_id = 'municipality_' || j.geoid | |
| OR y.jurisdiction_id LIKE '%\\_' || j.geoid ESCAPE '\\' | |
| ) | |
| ) | |
| ORDER BY j.state_code, j.name, j.jurisdiction_id | |
| """, | |
| (st_list,), | |
| ) | |
| rows = list(cur.fetchall()) | |
| except Exception: | |
| rows = [] | |
| if not rows: | |
| try: | |
| with psycopg2.connect(url) as conn, conn.cursor( | |
| cursor_factory=RealDictCursor | |
| ) as cur: | |
| cur.execute( | |
| """ | |
| SELECT DISTINCT ON (state_code, jurisdiction_id) | |
| state_code, | |
| jurisdiction_id, | |
| jurisdiction_name, | |
| NULL::text AS jurisdiction_type | |
| FROM bronze.bronze_event_youtube | |
| WHERE state_code = ANY(%s) | |
| AND jurisdiction_id IS NOT NULL | |
| AND BTRIM(jurisdiction_id) <> '' | |
| ORDER BY state_code, jurisdiction_id, jurisdiction_name | |
| """, | |
| (st_list,), | |
| ) | |
| rows = list(cur.fetchall()) | |
| except Exception: | |
| return [] | |
| if round_robin: | |
| from collections import defaultdict | |
| buckets: Dict[str, list] = defaultdict(list) | |
| for r in rows: | |
| buckets[str(r["state_code"]).upper()].append(r) | |
| active = [s for s in st_list if buckets.get(s)] | |
| max_len = max((len(buckets[s]) for s in active), default=0) | |
| ordered: list = [] | |
| for i in range(max_len): | |
| for s in active: | |
| if i < len(buckets[s]): | |
| ordered.append(buckets[s][i]) | |
| rows = ordered | |
| out: List[JurisdictionRun] = [] | |
| seen_keys: set[str] = set() | |
| for r in rows: | |
| raw_id = str(r["jurisdiction_id"]).strip() | |
| name = str(r.get("jurisdiction_name") or "").strip() | |
| if not name or name == raw_id or _looks_like_jurisdiction_id_label(name): | |
| name = "" | |
| jtype = str(r.get("jurisdiction_type") or "").strip() | |
| jid = ensure_canonical_jurisdiction_id( | |
| raw_id, | |
| name=name or None, | |
| jurisdiction_type=jtype or None, | |
| database_url=url, | |
| ) or raw_id | |
| if _PREFIXED_USPS_GEOID_RE.match(jid): | |
| continue | |
| if not ( | |
| (_SLUG_GEOID_RE.match(jid) and not _TYPED_JURISDICTION_ID_RE.match(jid)) | |
| or _TYPED_JURISDICTION_ID_RE.match(jid) | |
| ): | |
| continue | |
| key = _plan_jurisdiction_key(jid) | |
| if not key or key in seen_keys: | |
| continue | |
| seen_keys.add(key) | |
| row = JurisdictionRun( | |
| state_code=str(r["state_code"]).upper(), | |
| jurisdiction_id=jid, | |
| jurisdiction_name=name, | |
| jurisdiction_type=jtype, | |
| status="pending", | |
| ) | |
| normalize_jurisdiction_run(row, database_url=url) | |
| out.append(row) | |
| return out | |
| def _fetch_batch_plan_cached(cache_key: str, database_url: str) -> tuple: | |
| """Cacheable wrapper; returns tuple of row dicts for ``lru_cache``.""" | |
| parts = cache_key.rsplit("|", 1) | |
| states = [s for s in parts[0].split(",") if s] | |
| round_robin = bool(int(parts[1])) | |
| runs = fetch_batch_plan_jurisdictions( | |
| states, round_robin=round_robin, database_url=database_url or None | |
| ) | |
| return tuple( | |
| ( | |
| r.state_code, | |
| r.jurisdiction_id, | |
| r.jurisdiction_name, | |
| r.jurisdiction_type, | |
| r.status, | |
| ) | |
| for r in runs | |
| ) | |
| def fetch_batch_plan_jurisdictions_cached( | |
| states: List[str], | |
| *, | |
| round_robin: bool = True, | |
| database_url: Optional[str] = None, | |
| ) -> List[JurisdictionRun]: | |
| """Same as ``fetch_batch_plan_jurisdictions`` but deduped per process (dashboard load).""" | |
| url = (database_url or "").strip() or _batch_database_url() | |
| key = _batch_plan_cache_key(states, round_robin=round_robin) | |
| if not key or key == "|0": | |
| return [] | |
| rows = _fetch_batch_plan_cached(key, url or "") | |
| return [ | |
| JurisdictionRun( | |
| state_code=row[0], | |
| jurisdiction_id=row[1], | |
| jurisdiction_name=row[2], | |
| jurisdiction_type=row[3], | |
| status=row[4], | |
| ) | |
| for row in rows | |
| ] | |
| _STATUS_RANK = { | |
| "completed": 5, | |
| "failed": 4, | |
| "running": 3, | |
| "cancelled": 2, | |
| "pending": 1, | |
| } | |
| def _plan_jurisdiction_key(jurisdiction_id: str) -> str: | |
| """Match plan rows to batch rows even when id format differs (``municipality_*`` vs ``slug_geoid``).""" | |
| from core_lib.jurisdictions.jurisdiction_id import ( | |
| parse_jurisdiction_id, | |
| resolve_canonical_jurisdiction_id, | |
| ) | |
| jid = (jurisdiction_id or "").strip() | |
| if not jid: | |
| return "" | |
| canonical = resolve_canonical_jurisdiction_id(jid).strip().lower() | |
| _jt, geoid, _slug = parse_jurisdiction_id(canonical or jid) | |
| if geoid: | |
| return f"geoid:{geoid}" | |
| return canonical or jid.lower() | |
| def _looks_like_jurisdiction_id_label(text: str) -> bool: | |
| """True when ``text`` is an id string, not a place name (e.g. ``c-AL-01021``).""" | |
| from core_lib.jurisdictions.jurisdiction_id import ( | |
| _PREFIXED_USPS_GEOID_RE, | |
| _SLUG_GEOID_RE, | |
| _TYPED_JURISDICTION_ID_RE, | |
| ) | |
| t = (text or "").strip() | |
| if not t: | |
| return False | |
| if _PREFIXED_USPS_GEOID_RE.match(t): | |
| return True | |
| if _TYPED_JURISDICTION_ID_RE.match(t): | |
| return True | |
| if _SLUG_GEOID_RE.match(t) and "_" in t and not re.search(r"\s", t): | |
| return True | |
| return False | |
| def _lookup_jurisdiction_name_from_db( | |
| jurisdiction_id: str, | |
| *, | |
| database_url: Optional[str] = None, | |
| ) -> str: | |
| """Place name from ``int_jurisdictions`` for a canonical or legacy id.""" | |
| from core_lib.jurisdictions.jurisdiction_id import parse_jurisdiction_id | |
| jid = (jurisdiction_id or "").strip() | |
| if not jid: | |
| return "" | |
| url = (database_url or "").strip() or _batch_database_url() | |
| if not url: | |
| return "" | |
| _jt, geoid, _slug = parse_jurisdiction_id(jid) | |
| try: | |
| import psycopg2 | |
| except ImportError: | |
| return "" | |
| try: | |
| with psycopg2.connect(url) as conn, conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| SELECT name FROM intermediate.int_jurisdictions | |
| WHERE jurisdiction_id = %s | |
| LIMIT 1 | |
| """, | |
| (jid,), | |
| ) | |
| row = cur.fetchone() | |
| if row and row[0]: | |
| return str(row[0]).strip() | |
| if geoid: | |
| cur.execute( | |
| """ | |
| SELECT name FROM intermediate.int_jurisdictions | |
| WHERE geoid = %s | |
| ORDER BY jurisdiction_id | |
| LIMIT 1 | |
| """, | |
| (geoid,), | |
| ) | |
| row = cur.fetchone() | |
| if row and row[0]: | |
| return str(row[0]).strip() | |
| except Exception: | |
| return "" | |
| return "" | |
| def normalize_jurisdiction_run( | |
| j: JurisdictionRun, | |
| *, | |
| database_url: Optional[str] = None, | |
| ) -> JurisdictionRun: | |
| """Canonical ``jurisdiction_id`` and human ``jurisdiction_name`` for dashboard rows.""" | |
| from core_lib.jurisdictions.jurisdiction_id import ( | |
| _PREFIXED_USPS_GEOID_RE, | |
| _SLUG_GEOID_RE, | |
| _TYPED_JURISDICTION_ID_RE, | |
| ensure_canonical_jurisdiction_id, | |
| ) | |
| raw_id = (j.jurisdiction_id or "").strip() | |
| if not raw_id: | |
| return j | |
| name = (j.jurisdiction_name or "").strip() | |
| if ( | |
| _SLUG_GEOID_RE.match(raw_id) | |
| and not _TYPED_JURISDICTION_ID_RE.match(raw_id) | |
| and name | |
| and not _looks_like_jurisdiction_id_label(name) | |
| and name != raw_id | |
| ): | |
| return j | |
| if not name or name == raw_id or _looks_like_jurisdiction_id_label(name): | |
| name = "" | |
| jid = ensure_canonical_jurisdiction_id( | |
| raw_id, | |
| name=name or None, | |
| jurisdiction_type=(j.jurisdiction_type or None), | |
| database_url=database_url, | |
| ) or raw_id | |
| if _PREFIXED_USPS_GEOID_RE.match(jid): | |
| jid = ensure_canonical_jurisdiction_id( | |
| raw_id, | |
| name=None, | |
| jurisdiction_type=(j.jurisdiction_type or "county"), | |
| database_url=database_url, | |
| ) or jid | |
| j.jurisdiction_id = jid | |
| if not name or _looks_like_jurisdiction_id_label(name): | |
| name = _lookup_jurisdiction_name_from_db(jid, database_url=database_url) | |
| if name and not _looks_like_jurisdiction_id_label(name): | |
| j.jurisdiction_name = name | |
| return j | |
| def _upgrade_jurisdiction_from_plan( | |
| existing: JurisdictionRun, placeholder: JurisdictionRun | |
| ) -> None: | |
| """Apply canonical id and display name from the fresh plan row.""" | |
| from core_lib.jurisdictions.jurisdiction_id import ( | |
| _SLUG_GEOID_RE, | |
| _TYPED_JURISDICTION_ID_RE, | |
| ) | |
| def _is_canonical_slug(jid: str) -> bool: | |
| return bool(_SLUG_GEOID_RE.match(jid) and not _TYPED_JURISDICTION_ID_RE.match(jid)) | |
| pid = (placeholder.jurisdiction_id or "").strip() | |
| eid = (existing.jurisdiction_id or "").strip() | |
| if pid and (_is_canonical_slug(pid) or not _is_canonical_slug(eid)): | |
| existing.jurisdiction_id = pid | |
| pname = (placeholder.jurisdiction_name or "").strip() | |
| if pname and not _looks_like_jurisdiction_id_label(pname): | |
| existing.jurisdiction_name = pname | |
| if placeholder.jurisdiction_type and not (existing.jurisdiction_type or "").strip(): | |
| existing.jurisdiction_type = placeholder.jurisdiction_type | |
| def normalize_batch_job_jurisdictions( | |
| job: BatchJob, | |
| *, | |
| database_url: Optional[str] = None, | |
| ) -> bool: | |
| """Normalize all jurisdiction rows in place. True when ids/names need a DB persist.""" | |
| from core_lib.jurisdictions.jurisdiction_id import _PREFIXED_USPS_GEOID_RE | |
| changed = False | |
| url = database_url | |
| for j in job.jurisdictions: | |
| before_id = (j.jurisdiction_id or "").strip() | |
| before_name = (j.jurisdiction_name or "").strip() | |
| normalize_jurisdiction_run(j, database_url=url) | |
| after_id = (j.jurisdiction_id or "").strip() | |
| after_name = (j.jurisdiction_name or "").strip() | |
| if before_id != after_id: | |
| changed = True | |
| elif _PREFIXED_USPS_GEOID_RE.match(before_id) and not _PREFIXED_USPS_GEOID_RE.match( | |
| after_id | |
| ): | |
| changed = True | |
| elif _looks_like_jurisdiction_id_label(before_name) and not _looks_like_jurisdiction_id_label( | |
| after_name | |
| ): | |
| changed = True | |
| return changed | |
| def _prefer_jurisdiction_run( | |
| current: JurisdictionRun, other: JurisdictionRun | |
| ) -> JurisdictionRun: | |
| """Keep the row with the most advanced status (never downgrade completed → pending).""" | |
| cur_rank = _STATUS_RANK.get((current.status or "").lower(), 0) | |
| other_rank = _STATUS_RANK.get((other.status or "").lower(), 0) | |
| if other_rank > cur_rank: | |
| return other | |
| if other_rank < cur_rank: | |
| return current | |
| if (other.videos or other.stats) and not (current.videos or current.stats): | |
| return other | |
| return current | |
| def needs_plan_expand(job: BatchJob) -> bool: | |
| """ | |
| True when the dashboard should merge the full state plan into this batch. | |
| Skip for finished batches that already have their jurisdiction rows (avoids | |
| re-merging hundreds of pending placeholders on every API read). | |
| """ | |
| cfg = job.config or {} | |
| if cfg.get("seed_plan") is False: | |
| return False | |
| if not cfg.get("states"): | |
| return False | |
| status = (job.status or "").lower() | |
| if status == "running": | |
| return True | |
| total = int(cfg.get("total_jurisdictions") or 0) or len(job.jurisdictions) | |
| if status in ("completed", "cancelled", "failed") and job.jurisdictions: | |
| if total > 0 and len(job.jurisdictions) >= total: | |
| return False | |
| if status in ("completed", "cancelled") and len(job.jurisdictions) > 0: | |
| return False | |
| return True | |
| def expand_batch_job_plan( | |
| job: BatchJob, | |
| plan: Optional[List[JurisdictionRun]] = None, | |
| ) -> None: | |
| """Fill in ``pending`` rows for jurisdictions not started yet in this batch.""" | |
| cfg = job.config or {} | |
| if cfg.get("seed_plan") is False: | |
| return | |
| states = cfg.get("states") or [] | |
| if not states: | |
| return | |
| rr = cfg.get("round_robin") | |
| if rr is None: | |
| rr = True | |
| if plan is None: | |
| plan = fetch_batch_plan_jurisdictions_cached(states, round_robin=bool(rr)) | |
| if not plan: | |
| return | |
| by_key: Dict[str, JurisdictionRun] = {} | |
| for j in job.jurisdictions: | |
| key = _plan_jurisdiction_key(j.jurisdiction_id) | |
| if not key: | |
| continue | |
| if key in by_key: | |
| by_key[key] = _prefer_jurisdiction_run(by_key[key], j) | |
| else: | |
| by_key[key] = j | |
| merged: List[JurisdictionRun] = [] | |
| matched: set[str] = set() | |
| for placeholder in plan: | |
| key = _plan_jurisdiction_key(placeholder.jurisdiction_id) | |
| existing = by_key.get(key) if key else None | |
| if existing is not None: | |
| _upgrade_jurisdiction_from_plan(existing, placeholder) | |
| normalize_jurisdiction_run(existing) | |
| merged.append(existing) | |
| matched.add(key) | |
| else: | |
| normalize_jurisdiction_run(placeholder) | |
| merged.append(placeholder) | |
| if key: | |
| matched.add(key) | |
| for key, j in by_key.items(): | |
| if key in matched: | |
| continue | |
| from core_lib.jurisdictions.jurisdiction_id import _PREFIXED_USPS_GEOID_RE | |
| if _PREFIXED_USPS_GEOID_RE.match((j.jurisdiction_id or "").strip()): | |
| continue | |
| normalize_jurisdiction_run(j) | |
| merged.append(j) | |
| job.jurisdictions = merged | |
| if not int(cfg.get("total_jurisdictions") or 0): | |
| job.config["total_jurisdictions"] = len(merged) | |
| class VideoResult: | |
| video_id: str | |
| title: str = "" | |
| status: str = "pending" # ok | fail | tombstoned | empty | rate_limit | skipped | |
| error: str = "" | |
| transcript_source: str = "" | |
| finished_at: str = "" | |
| duration_seconds: Optional[float] = None | |
| class JurisdictionRun: | |
| state_code: str | |
| jurisdiction_id: str | |
| jurisdiction_name: str = "" | |
| jurisdiction_type: str = "" | |
| status: str = "pending" # pending | running | completed | failed | |
| started_at: str = "" | |
| updated_at: str = "" | |
| finished_at: str = "" | |
| elapsed_seconds: float = 0.0 | |
| exit_code: int = 0 | |
| stats: Dict[str, int] = field(default_factory=dict) | |
| videos: List[VideoResult] = field(default_factory=list) | |
| file_counts: Dict[str, int] = field( | |
| default_factory=lambda: {"transcripts": 0, "analysis": 0, "reports": 0} | |
| ) | |
| current_video_id: str = "" | |
| current_video_title: str = "" | |
| current_video_started_at: str = "" | |
| class BatchJob: | |
| batch_id: str | |
| step: str | |
| status: str = "running" # running | completed | failed | cancelled | |
| started_at: str = "" | |
| updated_at: str = "" | |
| finished_at: str = "" | |
| config: Dict[str, Any] = field(default_factory=dict) | |
| summary: Dict[str, Any] = field(default_factory=dict) | |
| jurisdictions: List[JurisdictionRun] = field(default_factory=list) | |
| def to_dict(self) -> Dict[str, Any]: | |
| return asdict(self) | |
| def from_dict(cls, data: Dict[str, Any]) -> BatchJob: | |
| jurs = [] | |
| for j in data.get("jurisdictions") or []: | |
| videos = [] | |
| for v in j.get("videos") or []: | |
| vd = dict(v) | |
| if "duration_seconds" not in vd: | |
| vd["duration_seconds"] = None | |
| elif vd["duration_seconds"] is not None: | |
| try: | |
| vd["duration_seconds"] = float(vd["duration_seconds"]) | |
| except (TypeError, ValueError): | |
| vd["duration_seconds"] = None | |
| videos.append(VideoResult(**vd)) | |
| jurs.append( | |
| JurisdictionRun( | |
| state_code=j.get("state_code", ""), | |
| jurisdiction_id=j.get("jurisdiction_id", ""), | |
| jurisdiction_name=j.get("jurisdiction_name", ""), | |
| jurisdiction_type=str(j.get("jurisdiction_type") or ""), | |
| status=j.get("status", "pending"), | |
| started_at=j.get("started_at", ""), | |
| updated_at=j.get("updated_at", ""), | |
| finished_at=j.get("finished_at", ""), | |
| elapsed_seconds=float(j.get("elapsed_seconds") or 0), | |
| exit_code=int(j.get("exit_code") or 0), | |
| stats=dict(j.get("stats") or {}), | |
| videos=videos, | |
| file_counts=dict( | |
| j.get("file_counts") | |
| or {"transcripts": 0, "analysis": 0, "reports": 0} | |
| ), | |
| current_video_id=j.get("current_video_id", ""), | |
| current_video_title=j.get("current_video_title", ""), | |
| current_video_started_at=j.get("current_video_started_at", ""), | |
| ) | |
| ) | |
| return cls( | |
| batch_id=data["batch_id"], | |
| step=data.get("step", ""), | |
| status=data.get("status", "running"), | |
| started_at=data.get("started_at", ""), | |
| updated_at=data.get("updated_at", ""), | |
| finished_at=data.get("finished_at", ""), | |
| config=dict(data.get("config") or {}), | |
| summary=dict(data.get("summary") or {}), | |
| jurisdictions=jurs, | |
| ) | |
| _VIDEO_COUNT_KEYS = ("ok", "fail", "tombstoned", "empty", "rate_limit") | |
| _STATUS_TO_STAT_KEY: Dict[str, str] = { | |
| "ok": "ok", | |
| "fail": "fail", | |
| "tombstoned": "tombstoned", | |
| "empty": "empty", | |
| "rate_limit": "rate_limit", | |
| } | |
| def _disk_file_count(fc: Dict[str, int], disk_key: str, legacy_key: str) -> int: | |
| """Prefer explicit ``*_disk`` keys; fall back to legacy ``transcripts`` / ``analysis`` keys.""" | |
| if disk_key in fc: | |
| return int(fc.get(disk_key) or 0) | |
| return int(fc.get(legacy_key) or 0) | |
| def transcript_seconds_from_job_videos(job: BatchJob) -> float: | |
| """Sum ``duration_seconds`` for batch video rows with status ``ok``.""" | |
| total = 0.0 | |
| for j in job.jurisdictions: | |
| for v in j.videos or []: | |
| if (v.status or "").strip().lower() != "ok": | |
| continue | |
| if v.duration_seconds is None: | |
| continue | |
| total += float(v.duration_seconds) | |
| return total | |
| def _jurisdiction_video_counts(j: JurisdictionRun) -> Dict[str, int]: | |
| """Count finished videos for summary totals. | |
| While a jurisdiction is running, ``record_video`` appends to ``j.videos`` but | |
| ``jurisdiction_finish`` has not written final ``j.stats`` yet — prefer the video | |
| list so the dashboard does not lag behind jurisdiction success counts. | |
| """ | |
| if j.videos: | |
| counts = {k: 0 for k in _VIDEO_COUNT_KEYS} | |
| for v in j.videos: | |
| key = _STATUS_TO_STAT_KEY.get((v.status or "").strip().lower()) | |
| if key: | |
| counts[key] += 1 | |
| return counts | |
| return {k: int((j.stats or {}).get(k) or 0) for k in _VIDEO_COUNT_KEYS} | |
| def _running_file_clock( | |
| j: JurisdictionRun, now: datetime | |
| ) -> tuple[Optional[float], str, str, str]: | |
| """ | |
| Seconds on the in-flight video for a running jurisdiction. | |
| Uses explicit ``current_video_started_at`` when set; otherwise time since the | |
| last completed video in this run, or since the jurisdiction started. | |
| """ | |
| if j.status != "running": | |
| return None, "", "", "" | |
| if j.current_video_started_at: | |
| try: | |
| start = datetime.fromisoformat( | |
| j.current_video_started_at.replace("Z", "+00:00") | |
| ) | |
| secs = max(0.0, (now - start).total_seconds()) | |
| return ( | |
| secs, | |
| j.current_video_id or "", | |
| (j.current_video_title or "")[:120], | |
| j.current_video_started_at, | |
| ) | |
| except ValueError: | |
| pass | |
| start_iso = "" | |
| video_id = "" | |
| title = "" | |
| if j.videos: | |
| last = j.videos[-1] | |
| if last.finished_at: | |
| start_iso = last.finished_at | |
| video_id = last.video_id | |
| title = (last.title or "")[:120] | |
| if not start_iso and j.started_at: | |
| start_iso = j.started_at | |
| if not start_iso: | |
| return None, video_id, title, "" | |
| try: | |
| start = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) | |
| secs = max(0.0, (now - start).total_seconds()) | |
| return secs, video_id, title, start_iso | |
| except ValueError: | |
| return None, video_id, title, start_iso | |
| def apply_batch_lifecycle(job: BatchJob) -> bool: | |
| """Stale-cancel inactive runs, then auto-finish when the plan is done. Returns True if status changed.""" | |
| before = (job.status or "").lower() | |
| _maybe_stale_cancel_batch(job) | |
| _maybe_auto_finish_batch(job) | |
| return before != (job.status or "").lower() | |
| def _maybe_auto_finish_batch(job: BatchJob) -> None: | |
| """ | |
| Mark the batch completed when every target jurisdiction is done. | |
| Progress cards use jurisdiction counts; ``job.status`` only flipped on ``finish_batch`` | |
| before. Single-jurisdiction tests (or a killed shell after the last backfill) left | |
| ``running`` at 100%. | |
| """ | |
| if job.status != "running": | |
| return | |
| total_j = int(job.config.get("total_jurisdictions") or 0) or len(job.jurisdictions) | |
| if total_j <= 0: | |
| return | |
| if any(j.status == "running" for j in job.jurisdictions): | |
| return | |
| processed = sum(1 for j in job.jurisdictions if j.status in ("completed", "failed")) | |
| if processed < total_j: | |
| return | |
| job.status = "completed" | |
| if not job.finished_at: | |
| job.finished_at = _utc_now_iso() | |
| def _recompute_summary(job: BatchJob) -> None: | |
| if (job.status or "").lower() == "running": | |
| _maybe_stale_cancel_batch(job) | |
| total_j = int(job.config.get("total_jurisdictions") or 0) or len(job.jurisdictions) | |
| processed = sum(1 for j in job.jurisdictions if j.status in ("completed", "failed")) | |
| success = sum(1 for j in job.jurisdictions if j.status == "completed" and j.exit_code == 0) | |
| failed = sum(1 for j in job.jurisdictions if j.status == "failed" or j.exit_code != 0) | |
| remaining = max(0, total_j - processed) | |
| v_ok = v_fail = v_tomb = v_empty = v_rl = 0 | |
| for j in job.jurisdictions: | |
| counts = _jurisdiction_video_counts(j) | |
| v_ok += counts["ok"] | |
| v_fail += counts["fail"] | |
| v_tomb += counts["tombstoned"] | |
| v_empty += counts["empty"] | |
| v_rl += counts["rate_limit"] | |
| elapsed = 0.0 | |
| if job.started_at: | |
| try: | |
| start = datetime.fromisoformat(job.started_at.replace("Z", "+00:00")) | |
| end = ( | |
| datetime.fromisoformat(job.finished_at.replace("Z", "+00:00")) | |
| if job.finished_at | |
| else datetime.now(timezone.utc) | |
| ) | |
| elapsed = max(0.0, (end - start).total_seconds()) | |
| except ValueError: | |
| elapsed = 0.0 | |
| eta_seconds: Optional[float] = None | |
| if processed > 0 and remaining > 0 and elapsed > 0: | |
| eta_seconds = (elapsed / processed) * remaining | |
| disk = {"transcripts": 0, "analysis": 0, "reports": 0} | |
| recent = {"analysis": 0, "reports": 0} | |
| bronze_download_rows = 0 | |
| for j in job.jurisdictions: | |
| fc = j.file_counts or {} | |
| disk["transcripts"] += _disk_file_count( | |
| fc, "transcripts_disk", "transcripts" | |
| ) | |
| disk["analysis"] += _disk_file_count(fc, "analysis_disk", "analysis") | |
| disk["reports"] += _disk_file_count(fc, "reports_disk", "reports") | |
| recent["analysis"] += int(fc.get("analysis_recent") or 0) | |
| recent["reports"] += int(fc.get("reports_recent") or 0) | |
| bronze_download_rows += int(fc.get("bronze_download_rows") or 0) | |
| files_processed = v_ok + v_fail + v_tomb + v_empty + v_rl | |
| videos_attempted = files_processed | |
| avg_seconds_per_file: Optional[float] = None | |
| if files_processed > 0 and elapsed > 0: | |
| avg_seconds_per_file = round(elapsed / files_processed, 1) | |
| current_file_seconds: Optional[float] = None | |
| current_video_id = "" | |
| current_video_title = "" | |
| current_jurisdiction_id = "" | |
| current_video_started_at = "" | |
| now = datetime.now(timezone.utc) | |
| for j in job.jurisdictions: | |
| secs, vid, title, started_iso = _running_file_clock(j, now) | |
| if secs is None: | |
| continue | |
| if current_file_seconds is None or secs >= current_file_seconds: | |
| current_file_seconds = secs | |
| current_video_id = vid | |
| current_video_title = title | |
| current_jurisdiction_id = j.jurisdiction_id | |
| current_video_started_at = started_iso | |
| job.summary = { | |
| "total_jurisdictions": total_j, | |
| "processed_jurisdictions": processed, | |
| "success_jurisdictions": success, | |
| "failed_jurisdictions": failed, | |
| "remaining_jurisdictions": remaining, | |
| "elapsed_seconds": round(elapsed, 1), | |
| "eta_seconds": round(eta_seconds, 1) if eta_seconds is not None else None, | |
| "videos_ok": v_ok, | |
| "videos_fail": v_fail, | |
| "videos_tombstoned": v_tomb, | |
| "videos_empty": v_empty, | |
| "videos_rate_limit": v_rl, | |
| # Batch-scoped caption outcomes (same source as Videos OK / fail cards). | |
| "files_transcripts": v_ok, | |
| "files_processed": files_processed, | |
| "videos_attempted": videos_attempted, | |
| # Policy cache on disk (all JSON under each jurisdiction folder, not this run only). | |
| "files_transcripts_disk": disk["transcripts"], | |
| "files_analysis": disk["analysis"], | |
| "files_reports": disk["reports"], | |
| # Rolling 24h throughput: analyses summarised / reports generated whose | |
| # files were last written within the past day (mtime-based). | |
| "files_analysis_recent": recent["analysis"], | |
| "files_reports_recent": recent["reports"], | |
| # Bronze rows with transcript_download_at (lifetime per jurisdiction). | |
| "bronze_download_rows": bronze_download_rows, | |
| "transcript_seconds": round(transcript_seconds_from_job_videos(job), 1), | |
| "avg_seconds_per_file": avg_seconds_per_file, | |
| "current_file_seconds": ( | |
| round(current_file_seconds, 1) | |
| if current_file_seconds is not None | |
| else None | |
| ), | |
| "current_video_id": current_video_id, | |
| "current_video_title": current_video_title, | |
| "current_jurisdiction_id": current_jurisdiction_id, | |
| "current_video_started_at": current_video_started_at, | |
| **state_progress_for_job(job), | |
| } | |
| _maybe_auto_finish_batch(job) | |
| class BatchJobStore: | |
| def __init__(self, batch_id: str, *, jobs_root: Optional[Path] = None) -> None: | |
| self.batch_id = batch_id | |
| self.root = jobs_root or jobs_dir() | |
| self.root.mkdir(parents=True, exist_ok=True) | |
| self.path = self.root / f"{batch_id}.json" | |
| def load(self) -> BatchJob: | |
| if not self.path.is_file(): | |
| raise FileNotFoundError(self.path) | |
| data = json.loads(self.path.read_text(encoding="utf-8")) | |
| return BatchJob.from_dict(data) | |
| def save(self, job: BatchJob) -> None: | |
| job.updated_at = _utc_now_iso() | |
| _recompute_summary(job) | |
| tmp = self.path.with_suffix(".json.tmp") | |
| tmp.write_text( | |
| json.dumps(job.to_dict(), indent=2, ensure_ascii=False) + "\n", | |
| encoding="utf-8", | |
| ) | |
| tmp.replace(self.path) | |
| _update_index(self.root, job) | |
| try: | |
| from api.batch_jobs.batch_job_db import sync_batch_job_to_db | |
| sync_batch_job_to_db(job) | |
| except Exception: | |
| pass | |
| def start_batch( | |
| self, | |
| *, | |
| step: str, | |
| config: Optional[Dict[str, Any]] = None, | |
| ) -> BatchJob: | |
| now = _utc_now_iso() | |
| cfg = dict(config or {}) | |
| job = BatchJob( | |
| batch_id=self.batch_id, | |
| step=step, | |
| status="running", | |
| started_at=now, | |
| updated_at=now, | |
| config=cfg, | |
| ) | |
| states = cfg.get("states") or [] | |
| if states and cfg.get("seed_plan") is not False: | |
| plan = fetch_batch_plan_jurisdictions( | |
| states, | |
| round_robin=bool(cfg.get("round_robin", True)), | |
| ) | |
| if plan: | |
| job.jurisdictions = plan | |
| if not int(cfg.get("total_jurisdictions") or 0): | |
| cfg["total_jurisdictions"] = len(plan) | |
| job.config = cfg | |
| self.save(job) | |
| return job | |
| def finish_batch(self, *, status: str = "completed") -> BatchJob: | |
| job = self.load() | |
| job.status = status | |
| job.finished_at = _utc_now_iso() | |
| self.save(job) | |
| return job | |
| def jurisdiction_start( | |
| self, | |
| *, | |
| state_code: str, | |
| jurisdiction_id: str, | |
| jurisdiction_name: str = "", | |
| pending_videos: int = 0, | |
| ) -> JurisdictionRun: | |
| from core_lib.jurisdictions.jurisdiction_id import resolve_canonical_jurisdiction_id | |
| jurisdiction_id = resolve_canonical_jurisdiction_id( | |
| jurisdiction_id.strip(), | |
| name=(jurisdiction_name or "").strip() or None, | |
| ) | |
| job = self.load() | |
| now = _utc_now_iso() | |
| run = JurisdictionRun( | |
| state_code=state_code.upper(), | |
| jurisdiction_id=jurisdiction_id, | |
| jurisdiction_name=jurisdiction_name, | |
| status="running", | |
| started_at=now, | |
| updated_at=now, | |
| ) | |
| if pending_videos: | |
| run.stats["pending"] = pending_videos | |
| start_key = _plan_jurisdiction_key(jurisdiction_id) | |
| job.jurisdictions = [ | |
| j | |
| for j in job.jurisdictions | |
| if _plan_jurisdiction_key(j.jurisdiction_id) != start_key | |
| ] | |
| job.jurisdictions.append(run) | |
| self.save(job) | |
| return run | |
| def _find_jurisdiction(self, job: BatchJob, jurisdiction_id: str) -> JurisdictionRun: | |
| from core_lib.jurisdictions.jurisdiction_id import resolve_canonical_jurisdiction_id | |
| jid = resolve_canonical_jurisdiction_id((jurisdiction_id or "").strip()) or ( | |
| jurisdiction_id or "" | |
| ).strip() | |
| key = _plan_jurisdiction_key(jid) | |
| for j in job.jurisdictions: | |
| if j.jurisdiction_id == jid: | |
| return j | |
| if key and _plan_jurisdiction_key(j.jurisdiction_id) == key: | |
| return j | |
| raise KeyError(jurisdiction_id) | |
| def video_start( | |
| self, | |
| *, | |
| jurisdiction_id: str, | |
| video_id: str, | |
| title: str = "", | |
| ) -> None: | |
| job = self.load() | |
| j = self._find_jurisdiction(job, jurisdiction_id) | |
| j.current_video_id = video_id | |
| j.current_video_title = (title or "")[:200] | |
| j.current_video_started_at = _utc_now_iso() | |
| j.updated_at = j.current_video_started_at | |
| self.save(job) | |
| def record_video( | |
| self, | |
| *, | |
| jurisdiction_id: str, | |
| video_id: str, | |
| status: str, | |
| title: str = "", | |
| error: str = "", | |
| transcript_source: str = "", | |
| duration_seconds: Optional[float] = None, | |
| ) -> None: | |
| job = self.load() | |
| j = self._find_jurisdiction(job, jurisdiction_id) | |
| if j.current_video_id == video_id: | |
| j.current_video_id = "" | |
| j.current_video_title = "" | |
| j.current_video_started_at = "" | |
| prior_status = "" | |
| for v in j.videos: | |
| if v.video_id == video_id: | |
| prior_status = (v.status or "").strip().lower() | |
| break | |
| j.videos = [v for v in j.videos if v.video_id != video_id] | |
| if prior_status: | |
| old_key = _STATUS_TO_STAT_KEY.get(prior_status) | |
| if old_key: | |
| j.stats[old_key] = max(0, int(j.stats.get(old_key) or 0) - 1) | |
| now = _utc_now_iso() | |
| j.videos.append( | |
| VideoResult( | |
| video_id=video_id, | |
| title=title[:200], | |
| status=status, | |
| error=(error or "")[:500], | |
| transcript_source=transcript_source, | |
| finished_at=now, | |
| duration_seconds=duration_seconds, | |
| ) | |
| ) | |
| new_key = _STATUS_TO_STAT_KEY.get((status or "").strip().lower()) | |
| if new_key: | |
| j.stats[new_key] = int(j.stats.get(new_key) or 0) + 1 | |
| j.updated_at = now | |
| self.save(job) | |
| def jurisdiction_finish( | |
| self, | |
| *, | |
| jurisdiction_id: str, | |
| exit_code: int, | |
| stats: Optional[Dict[str, int]] = None, | |
| file_counts: Optional[Dict[str, int]] = None, | |
| ) -> None: | |
| job = self.load() | |
| j = self._find_jurisdiction(job, jurisdiction_id) | |
| j.status = "completed" if exit_code == 0 else "failed" | |
| j.exit_code = exit_code | |
| j.finished_at = _utc_now_iso() | |
| j.updated_at = j.finished_at | |
| j.current_video_id = "" | |
| j.current_video_title = "" | |
| j.current_video_started_at = "" | |
| if stats: | |
| j.stats = {k: int(v) for k, v in stats.items()} | |
| if file_counts: | |
| j.file_counts = {k: int(v) for k, v in file_counts.items()} | |
| if j.started_at: | |
| try: | |
| start = datetime.fromisoformat(j.started_at.replace("Z", "+00:00")) | |
| end = datetime.fromisoformat(j.finished_at.replace("Z", "+00:00")) | |
| j.elapsed_seconds = max(0.0, (end - start).total_seconds()) | |
| except ValueError: | |
| j.elapsed_seconds = 0.0 | |
| self.save(job) | |
| def shell_jurisdiction_done( | |
| self, | |
| *, | |
| state_code: str, | |
| jurisdiction_id: str, | |
| jurisdiction_name: str, | |
| exit_code: int, | |
| ) -> None: | |
| """Mark jurisdiction when backfill did not write status (crash / missing --batch-id).""" | |
| try: | |
| job = self.load() | |
| self._find_jurisdiction(job, jurisdiction_id) | |
| except (FileNotFoundError, KeyError): | |
| self.jurisdiction_start( | |
| state_code=state_code, | |
| jurisdiction_id=jurisdiction_id, | |
| jurisdiction_name=jurisdiction_name, | |
| ) | |
| self.jurisdiction_finish( | |
| jurisdiction_id=jurisdiction_id, | |
| exit_code=exit_code, | |
| stats={"shell_exit": exit_code}, | |
| ) | |
| def _update_index(root: Path, job: BatchJob) -> None: | |
| index_path = root / _INDEX_NAME | |
| entries: List[Dict[str, Any]] = [] | |
| if index_path.is_file(): | |
| try: | |
| entries = json.loads(index_path.read_text(encoding="utf-8")) | |
| except json.JSONDecodeError: | |
| entries = [] | |
| entries = [e for e in entries if e.get("batch_id") != job.batch_id] | |
| entries.insert( | |
| 0, | |
| { | |
| "batch_id": job.batch_id, | |
| "step": job.step, | |
| "status": job.status, | |
| "started_at": job.started_at, | |
| "updated_at": job.updated_at, | |
| "summary": job.summary, | |
| }, | |
| ) | |
| entries = entries[:200] | |
| index_path.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") | |
| def policy_disk_file_counts(scanned: Dict[str, int]) -> Dict[str, int]: | |
| """Map ``count_policy_files_for_jurisdiction`` keys to explicit on-disk counters.""" | |
| return { | |
| "transcripts_disk": int(scanned.get("transcripts") or 0), | |
| "analysis_disk": int(scanned.get("analysis") or 0), | |
| "reports_disk": int(scanned.get("reports") or 0), | |
| # Rolling 24h throughput (mtime within the last day); 0 when the scanner | |
| # couldn't attribute per-jurisdiction stats (legacy fallback path). | |
| "analysis_recent": int(scanned.get("analysis_recent") or 0), | |
| "reports_recent": int(scanned.get("reports_recent") or 0), | |
| } | |
| def count_policy_files_for_jurisdiction( | |
| cache_root: Path, | |
| *, | |
| state_code: str, | |
| jurisdiction_id: str, | |
| ) -> Dict[str, int]: | |
| """Count transcript/analysis/report files under policy cache for one jurisdiction.""" | |
| from llm.gemini.policy_processing_status_report import ( | |
| _DIR_ANALYSIS, | |
| _DIR_REPORTS, | |
| _DIR_TRANSCRIPTS, | |
| scan_jurisdiction_cache, | |
| ) | |
| counts = { | |
| "transcripts": 0, | |
| "analysis": 0, | |
| "reports": 0, | |
| "analysis_recent": 0, | |
| "reports_recent": 0, | |
| } | |
| if not cache_root.is_dir(): | |
| return counts | |
| by_jid = scan_jurisdiction_cache(cache_root) | |
| st = by_jid.get(jurisdiction_id) | |
| if st: | |
| counts["transcripts"] = st.transcripts | |
| counts["analysis"] = st.analysis_json | |
| counts["reports"] = st.reports_md | |
| counts["analysis_recent"] = st.analysis_recent | |
| counts["reports_recent"] = st.reports_recent | |
| return counts | |
| state = (state_code or "").upper() | |
| jid = jurisdiction_id.lower() | |
| for state_dir in cache_root.iterdir(): | |
| if not state_dir.is_dir() or state_dir.name.upper() != state: | |
| continue | |
| for type_dir in state_dir.iterdir(): | |
| if not type_dir.is_dir(): | |
| continue | |
| for jdir in type_dir.iterdir(): | |
| if jdir.name.lower() != jid and jid not in jdir.name.lower(): | |
| continue | |
| for ch in jdir.iterdir(): | |
| if not ch.is_dir(): | |
| continue | |
| tx = ch / _DIR_TRANSCRIPTS | |
| if tx.is_dir(): | |
| counts["transcripts"] += sum( | |
| 1 for p in tx.glob("*.json") if p.is_file() | |
| ) | |
| an = ch / _DIR_ANALYSIS | |
| if an.is_dir(): | |
| counts["analysis"] += sum( | |
| 1 | |
| for p in an.glob("*.json") | |
| if p.is_file() and not p.name.startswith("_") | |
| ) | |
| rp = ch / _DIR_REPORTS | |
| if rp.is_dir(): | |
| counts["reports"] += sum( | |
| 1 for p in rp.glob("*.md") if p.is_file() | |
| ) | |
| return counts | |
| def list_batches(*, limit: int = 50) -> List[BatchJob]: | |
| root = jobs_dir() | |
| jobs: List[BatchJob] = [] | |
| index_path = root / _INDEX_NAME | |
| ids: List[str] = [] | |
| if index_path.is_file(): | |
| try: | |
| for row in json.loads(index_path.read_text(encoding="utf-8")): | |
| bid = row.get("batch_id") | |
| if bid: | |
| ids.append(bid) | |
| except json.JSONDecodeError: | |
| pass | |
| if not ids: | |
| ids = sorted( | |
| (p.stem for p in root.glob("*.json") if p.name != _INDEX_NAME), | |
| reverse=True, | |
| ) | |
| for bid in ids[:limit]: | |
| path = root / f"{bid}.json" | |
| if path.is_file(): | |
| try: | |
| jobs.append(BatchJob.from_dict(json.loads(path.read_text(encoding="utf-8")))) | |
| except (json.JSONDecodeError, KeyError): | |
| continue | |
| return jobs | |
| def _cli_start(args: argparse.Namespace) -> int: | |
| batch_id = (args.batch_id or "").strip() or new_batch_id(args.step) | |
| store = BatchJobStore(batch_id) | |
| config = { | |
| "states": [s.strip().upper() for s in (args.states or "").split(",") if s.strip()], | |
| "n": args.n, | |
| "delay": args.delay, | |
| "total_jurisdictions": args.total_jurisdictions, | |
| "transcript_source": args.transcript_source or "", | |
| "max_jurisdictions": args.max_jurisdictions or 0, | |
| "round_robin": bool(getattr(args, "round_robin", 1)), | |
| "seed_plan": not getattr(args, "no_seed_plan", False), | |
| } | |
| store.start_batch(step=args.step, config=config) | |
| print(batch_id) | |
| return 0 | |
| def _cli_jurisdiction_start(args: argparse.Namespace) -> int: | |
| store = BatchJobStore(args.batch_id) | |
| store.jurisdiction_start( | |
| state_code=args.state, | |
| jurisdiction_id=args.jurisdiction_id, | |
| jurisdiction_name=args.jurisdiction_name or "", | |
| pending_videos=args.pending_videos, | |
| ) | |
| return 0 | |
| def _cli_jurisdiction_finish(args: argparse.Namespace) -> int: | |
| store = BatchJobStore(args.batch_id) | |
| stats = json.loads(args.stats) if args.stats else {} | |
| store.jurisdiction_finish( | |
| jurisdiction_id=args.jurisdiction_id, | |
| exit_code=args.exit_code, | |
| stats=stats, | |
| ) | |
| return 0 | |
| def _cli_finish(args: argparse.Namespace) -> int: | |
| store = BatchJobStore(args.batch_id) | |
| store.finish_batch(status=args.status) | |
| return 0 | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| sub = parser.add_subparsers(dest="command", required=True) | |
| p_start = sub.add_parser("start", help="Create a new batch job file") | |
| p_start.add_argument("--step", required=True) | |
| p_start.add_argument("--batch-id", default="") | |
| p_start.add_argument("--states", default="") | |
| p_start.add_argument("--n", type=int, default=0) | |
| p_start.add_argument("--delay", type=float, default=0) | |
| p_start.add_argument("--total-jurisdictions", type=int, default=0) | |
| p_start.add_argument("--transcript-source", default="") | |
| p_start.add_argument("--max-jurisdictions", type=int, default=0) | |
| p_start.add_argument( | |
| "--round-robin", | |
| type=int, | |
| default=1, | |
| help="1 = interleave states in plan order (default), 0 = state-by-state", | |
| ) | |
| p_start.add_argument( | |
| "--no-seed-plan", | |
| action="store_true", | |
| help="Do not pre-load all jurisdictions as pending rows", | |
| ) | |
| p_start.set_defaults(func=_cli_start) | |
| p_js = sub.add_parser("jurisdiction-start") | |
| p_js.add_argument("--batch-id", required=True) | |
| p_js.add_argument("--state", required=True) | |
| p_js.add_argument("--jurisdiction-id", required=True) | |
| p_js.add_argument("--jurisdiction-name", default="") | |
| p_js.add_argument("--pending-videos", type=int, default=0) | |
| p_js.set_defaults(func=_cli_jurisdiction_start) | |
| p_jf = sub.add_parser("jurisdiction-finish") | |
| p_jf.add_argument("--batch-id", required=True) | |
| p_jf.add_argument("--jurisdiction-id", required=True) | |
| p_jf.add_argument("--exit-code", type=int, default=0) | |
| p_jf.add_argument("--stats", default="") | |
| p_jf.set_defaults(func=_cli_jurisdiction_finish) | |
| p_fin = sub.add_parser("finish") | |
| p_fin.add_argument("--batch-id", required=True) | |
| p_fin.add_argument("--status", default="completed") | |
| p_fin.set_defaults(func=_cli_finish) | |
| args = parser.parse_args() | |
| return int(args.func(args)) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |