"""Full-backlog driver: Ep 1 -> latest. One pass, fully resumable, verbose. Per episode: 1. skip if transcript + verified analysis already exist 2. download mp3 (skipped if present) 3. transcribe (re-does partial transcripts, e.g. old smoke tests) 4. analyze via opencode (fresh session, WITH HARD TIMEOUT) + dossier fact-check 5. every 10 episodes: rebuild FTS index + master JSON Run: python pipeline_all.py [--eps 84 87 89 ...] [--start-ep N] [--dry-run] Log: stdout (use with redirection) """ import argparse import json import os import sys import time import traceback import subprocess try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from config import TRANSCRIPT_DIR, KEEP_AUDIO from feed import load_episodes from download import audio_path, slug as ep_slug, download_episode from preprocess import prepare_audio, prepared_path from transcribe import transcript_path from lexicon import build_lexicon, tags_for_text from export_txt import export_txt # seconds between opencode calls - provider rate limits are real ANALYSIS_PAUSE = int(os.environ.get("DS_ANALYZE_PAUSE", "45")) # hard timeout for entire analysis (seconds) - opencode can hang ANALYSIS_TIMEOUT = int(os.environ.get("DS_ANALYZE_TIMEOUT", "1800")) # 30 minutes # memory guard threshold (bytes) - pause if available RAM drops below this MIN_FREE_RAM = int(os.environ.get("DS_MIN_FREE_RAM", str(1024 * 1024 * 1024))) # 1 GB # encoding validation interval (episodes) ENCODING_CHECK_INTERVAL = int(os.environ.get("DS_ENCODING_CHECK", "50")) def has_partial(transcript_file: str) -> bool: try: with open(transcript_file, encoding="utf-8") as f: return json.load(f).get("partial_minutes") is not None except (OSError, json.JSONDecodeError): return False def verify_utf8_encoding(root: Path = Path('.')) -> tuple[int, int]: """Verify all JSON files are valid UTF-8. Returns (errors, files_checked).""" errors = 0 checked = 0 for path in Path('.').rglob("*.json"): if any(part.startswith('.') for part in path.parts): continue if path.name.startswith('.'): continue try: with open(path, 'r', encoding='utf-8') as f: json.load(path) except (UnicodeDecodeError, json.JSONDecodeError) as e: print(f" [ENCODING ERROR] {path}: {e}", flush=True) errors += 1 checked += 1 return errors, checked def has_partial(transcript_file: str) -> bool: try: with open(transcript_file, encoding="utf-8") as f: return json.load(f).get("partial_minutes") is not None except (OSError, json.JSONDecodeError): return False def check_memory() -> bool: """Check if we have enough free RAM. Return True if OK, False if low.""" try: import psutil avail = psutil.virtual_memory().available if avail < MIN_FREE_RAM: return False except ImportError: pass # psutil not installed, skip check except Exception: pass return True def wait_for_memory() -> None: """Block until enough RAM is available.""" print(f" [MEM] Low memory (< {MIN_FREE_RAM/1e9:.1f} GB free), waiting...", flush=True) while not check_memory(): time.sleep(30) print(f" [MEM] Memory recovered, continuing...", flush=True) def do_transcribe(model, ep: dict) -> None: from transcribe import transcribe_episode from preprocess import load_pcm src = audio_path(ep) if not os.path.exists(src): print(f" [{time.strftime('%H:%M:%S')}] downloading {ep['audio_url'][-40:]} ...", flush=True) download_episode(ep) prep = prepared_path(ep) ok = False t0 = time.time() try: # Check memory before loading episode wait_for_memory() print(f" [{time.strftime('%H:%M:%S')}] transcribing Ep {ep['ep_num']} ({ep['title'][:50]})", flush=True) result = transcribe_episode(model, ep, None) outp = transcript_path(ep) with open(outp + ".tmp", "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) os.replace(outp + ".tmp", outp) dur = result["duration_sec"] or 1 rate = dur / max(time.time() - t0, 0.001) lex = build_lexicon() tags = set() for seg in result["segments"]: tags.update(tags_for_text(seg["text"], lex)) print(f" [{time.strftime('%H:%M:%S')}] TRANSCRIBED: {len(result['segments'])} segments, " f"{dur/60:.0f} min, {rate:.1f}x realtime", flush=True) print(f" TAGS: {' '.join(sorted('#' + t for t in tags)) or '(none)'}", flush=True) ok = True finally: if os.path.exists(prep): try: os.remove(prep) except OSError: pass if not KEEP_AUDIO and ok and os.path.exists(src): try: os.remove(src) except OSError: pass def run_analysis_with_timeout(ep: dict) -> tuple: """ Run analysis + verification using opencode subprocess with hard timeout. Returns (success: bool, result: dict | None, error: str | None) """ from analyze import ANALYSIS_DIR, OPENCODE, build_prompt, strip_to_json, validate from verify import verify_episode from download import slug as ep_slug from export_txt import txt_path from config import BASE_DIR label = f"Ep {ep['ep_num']}" apath = os.path.join(ANALYSIS_DIR, f"{ep_slug(ep)}.json") # Check if already analyzed if os.path.exists(apath): with open(apath, encoding="utf-8") as f: existing = json.load(f) return (True, existing, None) # Build prompt prompt = build_prompt() attach = export_txt(ep) # Run analysis with timeout + rate-limit retries cmd = [OPENCODE, "run", "--auto", "-f", attach, "--dir", BASE_DIR, prompt] # Rate limit retry waits (seconds) rate_limit_waits = [10, 60, 180, 420, 600] for attempt, wait_time in enumerate(rate_limit_waits, 1): try: print(f" [{time.strftime('%H:%M:%S')}] running analysis (attempt {attempt}, timeout {ANALYSIS_TIMEOUT}s)...", flush=True) proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=BASE_DIR, timeout=ANALYSIS_TIMEOUT) out = proc.stdout or "" err = proc.stderr or "" combined = (out + err).lower() if "rate limit" in combined or "429" in combined: raise RuntimeError("provider rate limit exceeded") if not out.strip() and err: raise RuntimeError(f"opencode produced no stdout: {err[-400:]}") # Parse result from analyze import _repair obj = json.loads(_repair(out), strict=False) validate(obj) # Add metadata obj["ep_num"] = ep["ep_num"] if ep["ep_num"] else obj.get("ep_num") obj["title"] = obj.get("title") or ep["title"] obj["pub_date"] = ep["pub_date"] obj["_analyzed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) obj["_source_transcript"] = os.path.basename(transcript_path(ep)) # Save analysis from analyze import ANALYSIS_DIR as ANALYSIS_DIR2 from download import slug as ep_slug apath = os.path.join(ANALYSIS_DIR2, f"{ep_slug(ep)}.json") with open(apath + ".tmp", "w", encoding="utf-8") as f: json.dump(obj, f, ensure_ascii=False, indent=1) os.replace(apath + ".tmp", apath) # Now run verification print(f" [{time.strftime('%H:%M:%S')}] fact-checking...", flush=True) from verify import verify_episode verify_episode(ep) return (True, obj, None) except RuntimeError as e: if "rate limit" in str(e).lower(): if attempt < len(rate_limit_waits): wait = rate_limit_waits[attempt] print(f" [{time.strftime('%H:%M:%S')}] rate limited (attempt {attempt}), sleeping {wait}s...", flush=True) time.sleep(wait) continue else: return (False, None, f"Rate limit exceeded after {attempt} attempts") else: return (False, None, str(e)) except Exception as e: return (False, None, str(e)) return (False, None, "Max retries exceeded") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--eps", nargs="*", type=int, default=None, help="Specific episode numbers to process (default: all from start-ep)") ap.add_argument("--start-ep", type=int, default=0) ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() from faster_whisper import WhisperModel from config import WHISPER_MODEL, DEVICE, COMPUTE_TYPE from analyze import analyze_episode, OPENCODE from verify import verify_episode eps = load_episodes() if args.eps: eps = [e for e in eps if e["ep_num"] in args.eps] else: eps = [e for e in eps if (e["ep_num"] or 0) >= args.start_ep] print(f"[{time.strftime('%H:%M:%S')}] pipeline over {len(eps)} episodes " f"(model={WHISPER_MODEL}/{DEVICE}, analyzer={os.path.basename(OPENCODE)})", flush=True) if args.dry_run: for e in eps[:20]: tp = transcript_path(e) t_exists = os.path.exists(tp) and not has_partial(tp) a_exists = os.path.exists(os.path.join( os.path.dirname(TRANSCRIPT_DIR), "analysis", f"{e['ep_num']:04d}.json")) if e["ep_num"] else False print(f" ep {e['ep_num']}: transcript={'Y' if t_exists else 'N'} analysis={'Y' if a_exists else 'N'}", flush=True) return model = None done_t = done_a = 0 t_start = time.time() for i, ep in enumerate(eps, 1): num = ep["ep_num"] if ep["ep_num"] else ep["title"][:30] tp = transcript_path(ep) need_transcript = (not os.path.exists(tp)) or has_partial(tp) if need_transcript and model is None: print(f"[{time.strftime('%H:%M:%S')}] loading whisper model '{WHISPER_MODEL}'...", flush=True) model = WhisperModel(WHISPER_MODEL, device=DEVICE, compute_type=COMPUTE_TYPE) if has_partial(tp): print(f"[{time.strftime('%H:%M:%S')}] [{i}/{len(eps)}] Ep {num}: redo partial transcript", flush=True) os.remove(tp) elif need_transcript: print(f"[{time.strftime('%H:%M:%S')}] [{i}/{len(eps)}] Ep {num}: transcribing", flush=True) if need_transcript: try: do_transcribe(model, ep) done_t += 1 # keep the searchable index fresh-ish if done_t % 10 == 0: import index as index_mod print(f" [{time.strftime('%H:%M:%S')}] rebuilding search index...", flush=True) index_mod.index_transcripts() except Exception as ex: print(f" [{time.strftime('%H:%M:%S')}] TRANSCRIBE FAILED, skipping episode: {ex}", flush=True) traceback.print_exc() continue # analysis + fact-check (with hard timeout) label = f"Ep {num}" print(f" [{time.strftime('%H:%M:%S')}] analyzing {label} ...", flush=True) try: success, result, error = run_analysis_with_timeout(ep) if success: done_a += 1 print(f" [{time.strftime('%H:%M:%S')}] analyzed {label} (TI={result['transgression']['overall_ti']}, " f"{len(result['talking_points'])} points, {len(result['notable_quotes'])} quotes)", flush=True) print(f" [{time.strftime('%H:%M:%S')}] verified {label}", flush=True) else: print(f" [{time.strftime('%H:%M:%S')}] ANALYSIS FAILED {label}: {error}", flush=True) except Exception as ex: print(f" [{time.strftime('%H:%M:%S')}] ANALYZE FAILED {label}: {ex}", flush=True) traceback.print_exc() # pacing: keep the provider happy print(f" [{time.strftime('%H:%M:%S')}] pacing pause {ANALYSIS_PAUSE}s ...", flush=True) time.sleep(ANALYSIS_PAUSE) if i % 10 == 0: print(f" [{time.strftime('%H:%M:%S')}] rebuilding search index (periodic)...", flush=True) import index as index_mod index_mod.index_transcripts() from aggregate import aggregate as _agg _agg() # Periodic encoding verification if i % ENCODING_CHECK_INTERVAL == 0: print(f" [{time.strftime('%H:%M:%S')}] verifying UTF-8 encoding...", flush=True) errors, checked = verify_utf8_encoding() if errors: print(f" [WARNING] Found {errors} files with encoding issues!", flush=True) else: print(f" [OK] UTF-8 encoding verified for {checked} files", flush=True) hrs = (time.time() - t_start) / 3600 rate_eps = i / hrs remaining = (len(eps) - i) / max(rate_eps, 0.01) print(f"[{time.strftime('%H:%M:%S')}] == progress: {i}/{len(eps)} eps " f"({rate_eps:.1f} eps/hr, ~{remaining:.0f} hr left) ==", flush=True) print(f"[{time.strftime('%H:%M:%S')}] final index rebuild...", flush=True) import index as index_mod index_mod.index_transcripts() from aggregate import aggregate as _agg _agg() print(f"[{time.strftime('%H:%M:%S')}] PIPELINE COMPLETE: transcribed={done_t} analyzed={done_a}", flush=True) if __name__ == "__main__": main()