YouTube -> transcript -> claim-extraction pipeline (youtube_claims/)

A descriptive ingestion layer feeding the downstream reasoning model: monitors YouTube
playlists, transcribes new uploads, and extracts structured, verifiable CLAIM records
(with provenance + a mandatory verbatim quote). It records WHAT was claimed; it never
judges correctness or tradeability -- all judgment stays downstream, in one place.

Built per the operator's spec. Two clocks coordinated through Postgres (§2): a 30-min
detection poll + a paced concurrency-1 transcription/extraction worker. Reuses the app's
infra: shared Postgres (isolated `transcripts` schema), the app's Ollama for extraction
(free, on-LAN, private -- transcripts never leave the network), the config.py host resolver.

  youtube_claims/schema.sql    playlists / videos / claims (§7-8); claims.verbatim_quote NOT NULL
  youtube_claims/db.py         psycopg2; claim-by-atomic-status-flip under FOR UPDATE SKIP LOCKED
  youtube_claims/detection.py  playlistItems.list over urllib; dedupe by video_id (§5)
  youtube_claims/extraction.py local Ollama -> descriptive claims; drops any claim lacking an
                               exact verbatim quote present in the source window (anti-hallucination)
  youtube_claims/transcription.py yt-dlp -> faster-whisper (medium/cpu/int8, option A;
                               initial_prompt=watchlist) with youtube-transcript-api captions fallback
  youtube_claims/worker.py     paced drain; per-video state machine + retries/backoff
  youtube_claims/run.py        APScheduler poll + worker loop in one process; CLI for setup

VERIFIED LIVE: schema applied; playlist "Anatomy of a Trade" (Ross Cameron) added; poll
ingested all 12 videos (1 API unit); Ollama extraction pulled clean descriptive claims
(TC/DXST/CF) with exact verbatim quotes from a real transcript. Whisper transcription pending
ffmpeg (`apt install ffmpeg`, needs sudo) + the yt-dlp/faster-whisper deps (installing via uv).
Secrets (YOUTUBE_API_KEY) live in gitignored .env only. Whisper is option A (CPU); env-flippable
to option B (LAN GPU large-v3) later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-07-01 15:00:09 -04:00
1 parent a7f63a5b11
commit 4a54b2adcc
9 files changed
+678

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""YouTube -> transcript -> claim-extraction pipeline.
A DESCRIPTIVE ingestion layer (not a decision layer): it records WHAT was claimed in
monitored YouTube playlists, with provenance + a verbatim source quote, into Postgres for a
downstream reasoning model. It never judges whether a claim is correct or tradeable -- all
judgment lives downstream, in one place. See the build spec in docs / the handoff.
"""
+64
View File
@@ -0,0 +1,64 @@
"""Config — env-driven (§10 defaults). Reuses the app's DATABASE_URL and Ollama host;
secrets (YOUTUBE_API_KEY) come from .env only, never hard-coded."""
import os
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# load .env once (the same file the trading app uses; gitignored)
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(_ROOT, ".env"))
except Exception: # noqa: BLE001
pass
# --- §10 defaults (env-overridable) ---
POLL_INTERVAL_MINUTES = int(os.getenv("YT_POLL_INTERVAL_MINUTES", "30"))
PLAYLIST_PAGE_SIZE = 50
WORKER_CONCURRENCY = 1 # deliberate — §13 (yt-dlp politeness)
INTER_VIDEO_DELAY_SECONDS = int(os.getenv("YT_INTER_VIDEO_DELAY_SECONDS", "60"))
MAX_RETRIES = int(os.getenv("YT_MAX_RETRIES", "3"))
RETRY_BACKOFF_BASE_SEC = 30 # 30, 60, 120...
YT_DLP_FORMAT = "bestaudio"
YOUTUBE_API_QUOTA_DAILY = 10000
TRANSCRIPT_DIR = os.getenv("YT_TRANSCRIPT_DIR", os.path.join(_ROOT, "data", "transcripts"))
PG_SCHEMA = os.getenv("YT_PG_SCHEMA", "transcripts")
# Whisper — option A (CPU on this WSL box; no local GPU). Flip to large-v3/cuda/float16
# via env if we later move the worker to the LAN GPU host (option B).
WHISPER_MODEL = os.getenv("YT_WHISPER_MODEL", "medium")
WHISPER_DEVICE = os.getenv("YT_WHISPER_DEVICE", "cpu")
WHISPER_COMPUTE_TYPE = os.getenv("YT_WHISPER_COMPUTE_TYPE", "int8")
# Extraction LLM — local Ollama (free, on-LAN, private; transcripts never leave the network).
OLLAMA_MODEL = os.getenv("YT_EXTRACT_MODEL", "qwen2.5:7b-instruct")
def youtube_api_key() -> str:
k = os.getenv("YOUTUBE_API_KEY")
if not k:
raise RuntimeError("YOUTUBE_API_KEY not set — put it in .env")
return k
def database_url() -> str:
u = os.getenv("DATABASE_URL")
if not u:
raise RuntimeError("DATABASE_URL not set — the pipeline shares the app's Postgres")
return u
def ollama_host() -> str:
"""Reuse the app's resolver so the pipeline self-heals across the WSL host-flip."""
default = os.getenv("OLLAMA_HOST", "http://192.168.1.5:30068")
try:
from config import _resolve_ollama_host
return _resolve_ollama_host(default)
except Exception: # noqa: BLE001
return default
def watchlist() -> list[str]:
"""Tickers/names to bias Whisper's initial_prompt + focus extraction (§6, §12.4)."""
raw = os.getenv("YT_WATCHLIST", "")
return [w.strip() for w in raw.split(",") if w.strip()]
+166
View File
@@ -0,0 +1,166 @@
"""Postgres layer — shares the app's database (DATABASE_URL), isolated in the `transcripts`
schema. psycopg2 (consistent with the trading app). Videos are claimed by an atomic
status-flip under FOR UPDATE SKIP LOCKED, so the long transcription never holds a row lock."""
import os
from contextlib import contextmanager
from datetime import date, datetime, timezone
import psycopg2
import psycopg2.extras
from youtube_claims import config
def _dsn() -> str:
return config.database_url()
@contextmanager
def connect():
conn = psycopg2.connect(_dsn())
try:
with conn.cursor() as cur:
cur.execute(f'SET search_path TO {config.PG_SCHEMA}, public')
yield conn
finally:
conn.close()
def apply_schema() -> None:
ddl = open(os.path.join(os.path.dirname(__file__), "schema.sql")).read()
ddl = ddl.replace("{SCHEMA}", config.PG_SCHEMA)
with psycopg2.connect(_dsn()) as conn:
with conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
# --- playlists ---
def upsert_playlist(playlist_id: str, label: str, content_type: str = "unknown") -> None:
with connect() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO playlists (playlist_id, label, content_type) VALUES (%s,%s,%s) "
"ON CONFLICT (playlist_id) DO UPDATE SET label=EXCLUDED.label, "
"content_type=EXCLUDED.content_type",
(playlist_id, label, content_type))
conn.commit()
def enabled_playlists() -> list[dict]:
with connect() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM playlists WHERE enabled ORDER BY added_at")
return [dict(r) for r in cur.fetchall()]
# --- videos ---
def seen_video_ids() -> set[str]:
with connect() as conn:
with conn.cursor() as cur:
cur.execute("SELECT video_id FROM videos")
return {r[0] for r in cur.fetchall()}
def insert_pending_video(v: dict) -> bool:
"""Insert a newly-detected video as pending. Returns False if it already existed."""
pub = v.get("published_at")
lat = None
if pub:
lat = int((v["detected_at"] - pub).total_seconds())
with connect() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO videos (video_id, playlist_id, channel_id, channel_name, title, "
"content_type, published_at, detected_at, poll_latency_seconds, status) "
"VALUES (%(video_id)s,%(playlist_id)s,%(channel_id)s,%(channel_name)s,%(title)s,"
"%(content_type)s,%(published_at)s,%(detected_at)s,%(lat)s,'pending') "
"ON CONFLICT (video_id) DO NOTHING",
{**v, "lat": lat})
inserted = cur.rowcount == 1
conn.commit()
return inserted
def claim_next_pending() -> dict | None:
"""Atomically pick one pending video and flip it to `transcribing`. The lock is held
only for the flip (SKIP LOCKED prevents two workers grabbing the same row); the long
transcription runs AFTER the commit, holding no lock."""
with connect() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT * FROM videos WHERE status='pending' "
"ORDER BY published_at NULLS LAST, detected_at "
"FOR UPDATE SKIP LOCKED LIMIT 1")
row = cur.fetchone()
if row is None:
conn.commit()
return None
cur.execute("UPDATE videos SET status='transcribing' WHERE video_id=%s",
(row["video_id"],))
conn.commit()
return dict(row)
def set_status(video_id: str, status: str, **fields) -> None:
cols = ", ".join(f"{k}=%s" for k in fields)
params = list(fields.values()) + [video_id]
sql = f"UPDATE videos SET status=%s{', ' + cols if cols else ''} WHERE video_id=%s"
with connect() as conn:
with conn.cursor() as cur:
cur.execute(sql, [status, *params])
conn.commit()
def record_failure(video_id: str, stage: str, error: str) -> str:
"""Bump retry_count and set last_error; return the resulting status
(`pending` to retry, or `failed` once retries are exhausted)."""
with connect() as conn:
with conn.cursor() as cur:
cur.execute("SELECT retry_count FROM videos WHERE video_id=%s", (video_id,))
rc = (cur.fetchone() or [0])[0] + 1
status = "pending" if rc <= config.MAX_RETRIES else "failed"
cur.execute(
"UPDATE videos SET status=%s, retry_count=%s, last_error=%s WHERE video_id=%s",
(status, rc, f"[{stage}] {error}"[:2000], video_id))
conn.commit()
return status
def mark_done(video_id: str, transcript_source: str, whisper_model: str | None,
transcript_path: str) -> None:
set_status(video_id, "done", transcript_source=transcript_source,
whisper_model=whisper_model, transcript_path=transcript_path,
processed_at=datetime.now(timezone.utc))
# --- claims ---
def insert_claims(video_id: str, claims: list[dict]) -> int:
if not claims:
return 0
fields = ["asset_ticker", "asset_name", "asset_class", "direction", "claim_text",
"verbatim_quote", "timestamp_start", "timestamp_end", "stated_rationale",
"stated_horizon", "extraction_confidence"]
with connect() as conn:
with conn.cursor() as cur:
for c in claims:
cur.execute(
f"INSERT INTO claims (video_id, {', '.join(fields)}) "
f"VALUES (%s, {', '.join(['%s'] * len(fields))})",
[video_id, *[c.get(f) for f in fields]])
conn.commit()
return len(claims)
# --- quota (§5) ---
def add_quota(units: int) -> int:
with connect() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO api_quota (day, units_used) VALUES (%s,%s) "
"ON CONFLICT (day) DO UPDATE SET units_used = api_quota.units_used + EXCLUDED.units_used "
"RETURNING units_used", (date.today(), units))
used = cur.fetchone()[0]
conn.commit()
return used
+70
View File
@@ -0,0 +1,70 @@
"""Detection layer (§5): poll each enabled playlist, dedupe by video_id, insert `pending`
rows. Uses the YouTube Data API `playlistItems.list` over urllib (no extra SDK dep)."""
import json
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from youtube_claims import config, db
def _get(url: str) -> dict:
with urllib.request.urlopen(url, timeout=20) as r:
return json.load(r)
def _iso(s: str | None):
return datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None
def fetch_playlist_items(playlist_id: str, key: str) -> tuple[list[dict], int]:
"""All items of a playlist, paginated. Returns (items, api_units_used ~= #pages)."""
items, token, units = [], None, 0
while True:
q = {"part": "snippet,contentDetails", "maxResults": config.PLAYLIST_PAGE_SIZE,
"playlistId": playlist_id, "key": key}
if token:
q["pageToken"] = token
data = _get("https://www.googleapis.com/youtube/v3/playlistItems?" + urllib.parse.urlencode(q))
units += 1
items += data.get("items", [])
token = data.get("nextPageToken")
if not token:
break
return items, units
def poll_once() -> tuple[int, int]:
"""One detection pass over all enabled playlists. Returns (#new videos, quota used today)."""
key = config.youtube_api_key()
seen = db.seen_video_ids()
new_count, units = 0, 0
for pl in db.enabled_playlists():
try:
items, u = fetch_playlist_items(pl["playlist_id"], key)
except Exception as exc: # noqa: BLE001 — one bad playlist can't stop the poll
print(f"[detect] playlist {pl['playlist_id']} poll failed: {exc}")
continue
units += u
now = datetime.now(timezone.utc)
for it in items:
sn = it["snippet"]
vid = it["contentDetails"]["videoId"]
if vid in seen:
continue
v = dict(
video_id=vid, playlist_id=pl["playlist_id"],
channel_id=sn.get("videoOwnerChannelId") or sn.get("channelId"),
channel_name=sn.get("videoOwnerChannelTitle") or sn.get("channelTitle"),
title=sn.get("title"),
content_type=pl.get("content_type", "unknown"),
published_at=_iso(it["contentDetails"].get("videoPublishedAt") or sn.get("publishedAt")),
detected_at=now)
if db.insert_pending_video(v):
new_count += 1
seen.add(vid)
used = db.add_quota(units)
if used > config.YOUTUBE_API_QUOTA_DAILY * 0.8:
print(f"[detect] WARNING: {used}/{config.YOUTUBE_API_QUOTA_DAILY} daily API units used")
return new_count, used
+110
View File
@@ -0,0 +1,110 @@
"""Extraction (§8): local Ollama turns a transcript into DESCRIPTIVE claim rows. Never
evaluative — it records WHAT was claimed + an EXACT verbatim quote; it does not judge whether
the claim is correct or tradeable (all judgment lives downstream). A claim without a
verbatim_quote is dropped: that quote is the chain of custody the reasoning model verifies
against ASR error."""
import json
import urllib.request
from youtube_claims import config
_PROMPT = """You are a precise information extractor. From the TRANSCRIPT below, extract every
distinct market/trading CLAIM about a specific asset (stock, crypto, ETF, index, commodity, fx).
STRICT RULES:
- DESCRIPTIVE ONLY. Record what the speaker CLAIMED. Never judge if it is correct, good, or
tradeable. `direction` describes the claim (bullish/bearish/neutral/mixed), not a recommendation.
- Every claim MUST include `verbatim_quote`: text copied EXACTLY from the transcript (word-for-word)
that supports the claim. If you cannot copy an exact supporting quote, DO NOT emit the claim.
- `extraction_confidence` (0-1) is how sure you are you PARSED it correctly — NOT that it is true.
- Prefer claims about these watchlist assets if present, but include others too: {watchlist}
- If the transcript contains no asset-specific claims, return {{"claims": []}}.
Return ONLY JSON: {{"claims": [ {{
"asset_ticker": "AAPL or null", "asset_name": "as spoken or null",
"asset_class": "equity|crypto|etf|index|commodity|fx|other",
"direction": "bullish|bearish|neutral|mixed",
"claim_text": "short paraphrase of what was asserted",
"verbatim_quote": "EXACT transcript text",
"stated_rationale": "reason given or null", "stated_horizon": "e.g. next quarter or null",
"extraction_confidence": 0.0
}} ] }}
TRANSCRIPT:
{transcript}
"""
def _ollama_generate(prompt: str, model: str, host: str) -> str:
payload = {"model": model, "prompt": prompt, "stream": False, "format": "json",
"options": {"temperature": 0.1, "num_predict": 2048}}
req = urllib.request.Request(host.rstrip("/") + "/api/generate",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=240) as r:
return json.load(r).get("response", "")
def _chunks(segments: list[dict], max_chars: int = 8000):
"""Group whisper segments into ~max_chars windows, carrying their time span so extracted
claims can be time-located and their quotes checked against the exact window text."""
buf, start, n = [], None, 0
for seg in segments:
t = (seg.get("text") or "").strip()
if not t:
continue
if start is None:
start = seg.get("start")
buf.append((seg, t))
n += len(t)
if n >= max_chars:
yield buf, start, seg.get("end")
buf, start, n = [], None, 0
if buf:
yield buf, start, buf[-1][0].get("end")
def _normalize(c: dict, win_start, win_end) -> dict | None:
vq = (c.get("verbatim_quote") or "").strip()
if not vq:
return None # mandatory — drop unsupported claims
tkr = (c.get("asset_ticker") or "").strip().upper() or None
return {
"asset_ticker": tkr,
"asset_name": (c.get("asset_name") or None),
"asset_class": (c.get("asset_class") or None),
"direction": (c.get("direction") or None),
"claim_text": (c.get("claim_text") or None),
"verbatim_quote": vq,
"timestamp_start": win_start,
"timestamp_end": win_end,
"stated_rationale": (c.get("stated_rationale") or None),
"stated_horizon": (c.get("stated_horizon") or None),
"extraction_confidence": c.get("extraction_confidence"),
}
def extract_claims(segments: list[dict], watchlist: list[str] | None = None,
*, model: str | None = None, host: str | None = None) -> list[dict]:
"""Extract descriptive claims from whisper segments. Chunks long transcripts so nothing is
dropped, and attaches each claim's window time span. Pure w.r.t. the DB (caller inserts)."""
model = model or config.OLLAMA_MODEL
host = host or config.ollama_host()
wl = ", ".join(watchlist if watchlist is not None else config.watchlist()) or "(none specified)"
out: list[dict] = []
for win, w_start, w_end in _chunks(segments):
text = "\n".join(t for _, t in win)
try:
raw = _ollama_generate(_PROMPT.format(watchlist=wl, transcript=text), model, host)
data = json.loads(raw)
except Exception: # noqa: BLE001 — a bad chunk shouldn't kill the whole video
continue
for c in (data.get("claims") or []):
vq = (c.get("verbatim_quote") or "").strip()
# keep only claims whose quote is actually present in THIS window (anti-hallucination)
if vq and vq[:60].lower() in text.lower():
nc = _normalize(c, w_start, w_end)
if nc:
out.append(nc)
return out
+86
View File
@@ -0,0 +1,86 @@
"""Orchestration (§2): one long-lived process. APScheduler fires the 30-min poll; a worker
loop drains pending videos alongside it. On restart nothing is lost — pending rows resume from
Postgres. The same CLI exposes each piece for setup + manual runs.
python -m youtube_claims.run apply-schema
python -m youtube_claims.run add-playlist <playlist_id> --label "..." --content-type analysis
python -m youtube_claims.run poll-once # detection pass only
python -m youtube_claims.run drain --max 1 # process N pending videos then stop
python -m youtube_claims.run run # the full service (poll + worker)
"""
import argparse
import threading
import time
from youtube_claims import config, db, detection, worker
def _worker_loop(stop: threading.Event) -> None:
while not stop.is_set():
try:
if worker.drain() == 0:
stop.wait(30) # nothing pending — idle until the next poll fills it
except Exception as exc: # noqa: BLE001 — the loop must survive one bad video
print(f"[worker-loop] {exc}")
stop.wait(30)
def run_service() -> None:
from apscheduler.schedulers.background import BackgroundScheduler
db.apply_schema()
def _poll():
try:
new, used = detection.poll_once()
print(f"[poll] {new} new videos, {used} api units today")
except Exception as exc: # noqa: BLE001
print(f"[poll] failed: {exc}")
sched = BackgroundScheduler()
sched.add_job(_poll, "interval", minutes=config.POLL_INTERVAL_MINUTES)
sched.start()
_poll() # once at boot
stop = threading.Event()
threading.Thread(target=_worker_loop, args=(stop,), daemon=True).start()
print(f"[service] running: poll every {config.POLL_INTERVAL_MINUTES}m, worker concurrency 1")
try:
while True:
time.sleep(3600)
except (KeyboardInterrupt, SystemExit):
stop.set()
sched.shutdown()
def main() -> None:
ap = argparse.ArgumentParser(description="YouTube -> transcript -> claims pipeline")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("apply-schema")
a = sub.add_parser("add-playlist")
a.add_argument("playlist_id")
a.add_argument("--label", default="")
a.add_argument("--content-type", default="unknown")
sub.add_parser("poll-once")
d = sub.add_parser("drain")
d.add_argument("--max", type=int)
sub.add_parser("run")
args = ap.parse_args()
if args.cmd == "apply-schema":
db.apply_schema()
print(f"schema '{config.PG_SCHEMA}' applied")
elif args.cmd == "add-playlist":
db.apply_schema()
db.upsert_playlist(args.playlist_id, args.label, args.content_type)
print(f"playlist {args.playlist_id} added ({args.label!r}, {args.content_type})")
elif args.cmd == "poll-once":
new, used = detection.poll_once()
print(f"{new} new videos inserted, {used} api units used today")
elif args.cmd == "drain":
print("processed", worker.drain(max_videos=args.max))
elif args.cmd == "run":
run_service()
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
-- YouTube claim-extraction pipeline schema (§7-8). Isolated in its own schema so it never
-- collides with the trading tables. {SCHEMA} is substituted at apply time (default: transcripts).
CREATE SCHEMA IF NOT EXISTS {SCHEMA};
-- config: which playlists to monitor
CREATE TABLE IF NOT EXISTS {SCHEMA}.playlists (
playlist_id TEXT PRIMARY KEY,
label TEXT,
content_type TEXT DEFAULT 'unknown', -- analysis / news / opinion / promo / unknown
enabled BOOLEAN NOT NULL DEFAULT TRUE,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- videos: one row per detected upload; dedupe key is video_id (§5)
CREATE TABLE IF NOT EXISTS {SCHEMA}.videos (
video_id TEXT PRIMARY KEY,
playlist_id TEXT REFERENCES {SCHEMA}.playlists(playlist_id),
channel_id TEXT,
channel_name TEXT,
title TEXT,
content_type TEXT DEFAULT 'unknown',
published_at TIMESTAMPTZ,
detected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
poll_latency_seconds INT,
status TEXT NOT NULL DEFAULT 'pending', -- pending|transcribing|extracting|done|failed
transcript_source TEXT, -- whisper|captions|NULL
whisper_model TEXT,
transcript_path TEXT,
processed_at TIMESTAMPTZ,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS videos_status_idx ON {SCHEMA}.videos(status);
-- claims: the core deliverable (§8) — DESCRIPTIVE only, verbatim_quote mandatory
CREATE TABLE IF NOT EXISTS {SCHEMA}.claims (
claim_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id TEXT NOT NULL REFERENCES {SCHEMA}.videos(video_id),
asset_ticker TEXT,
asset_name TEXT,
asset_class TEXT, -- equity|crypto|etf|index|commodity|fx|other
direction TEXT, -- bullish|bearish|neutral|mixed (of the CLAIM, not advice)
claim_text TEXT,
verbatim_quote TEXT NOT NULL, -- mandatory chain of custody back to source
timestamp_start NUMERIC,
timestamp_end NUMERIC,
stated_rationale TEXT,
stated_horizon TEXT,
extraction_confidence NUMERIC, -- 0-1 PARSE confidence, NOT truth confidence
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS claims_video_idx ON {SCHEMA}.claims(video_id);
CREATE INDEX IF NOT EXISTS claims_ticker_idx ON {SCHEMA}.claims(asset_ticker);
-- daily YouTube API quota counter (§5)
CREATE TABLE IF NOT EXISTS {SCHEMA}.api_quota (
day DATE PRIMARY KEY,
units_used INT NOT NULL DEFAULT 0
);
+71
View File
@@ -0,0 +1,71 @@
"""Transcription (§6): yt-dlp bestaudio -> faster-whisper (initial_prompt seeded with the
watchlist) with a youtube-transcript-api captions FALLBACK. Whisper is PRIMARY because
auto-captions mangle the load-bearing tokens (tickers/numbers/prices); captions are a
last resort so a blocked/failed download still yields something. Writes transcript JSON."""
import json
import os
import tempfile
from youtube_claims import config
_model = None
def _whisper():
global _model
if _model is None:
from faster_whisper import WhisperModel
_model = WhisperModel(config.WHISPER_MODEL, device=config.WHISPER_DEVICE,
compute_type=config.WHISPER_COMPUTE_TYPE)
return _model
def _download_audio(video_id: str, out_dir: str) -> str | None:
import yt_dlp
tmpl = os.path.join(out_dir, f"{video_id}.%(ext)s")
opts = {"format": config.YT_DLP_FORMAT, "outtmpl": tmpl, "quiet": True, "no_warnings": True,
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "wav"}]}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
wav = os.path.join(out_dir, f"{video_id}.wav")
return wav if os.path.exists(wav) else None
def _transcribe_whisper(wav: str, watchlist: list[str]) -> list[dict]:
prompt = ("Tickers and companies: " + ", ".join(watchlist)) if watchlist else None
segments, _info = _whisper().transcribe(wav, initial_prompt=prompt, vad_filter=True)
return [{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text} for s in segments]
def _captions_fallback(video_id: str) -> list[dict]:
from youtube_transcript_api import YouTubeTranscriptApi
try: # newer + older API shapes
rows = YouTubeTranscriptApi().fetch(video_id).to_raw_data()
except Exception: # noqa: BLE001
rows = YouTubeTranscriptApi.get_transcript(video_id)
return [{"start": round(x["start"], 2), "end": round(x["start"] + x.get("duration", 0), 2),
"text": x["text"]} for x in rows]
def transcribe(video_id: str, watchlist: list[str] | None = None) -> tuple[list[dict], str, str | None, str]:
"""Returns (segments, source, whisper_model_or_None, transcript_path). Whisper primary,
captions fallback. Raises only if BOTH paths fail (worker handles the retry)."""
watchlist = watchlist if watchlist is not None else config.watchlist()
os.makedirs(config.TRANSCRIPT_DIR, exist_ok=True)
segs, source, model = None, None, None
try:
with tempfile.TemporaryDirectory() as td:
wav = _download_audio(video_id, td)
if wav:
segs = _transcribe_whisper(wav, watchlist)
source, model = "whisper", config.WHISPER_MODEL
except Exception as exc: # noqa: BLE001 — fall through to captions
print(f"[transcribe] whisper path failed for {video_id}: {exc}")
if not segs:
segs = _captions_fallback(video_id) # may raise -> worker retry/backoff
source = "captions"
path = os.path.join(config.TRANSCRIPT_DIR, f"{video_id}.json")
with open(path, "w") as f:
json.dump({"video_id": video_id, "source": source, "whisper_model": model, "segments": segs}, f)
return segs, source, model, path
+44
View File
@@ -0,0 +1,44 @@
"""Worker (§9): drains the pending backlog, concurrency 1, paced (INTER_VIDEO_DELAY). State
machine per video (pending -> transcribing -> extracting -> done | failed) with retries/backoff.
Each stage is independently retryable; a `done` video is never reprocessed."""
import time
from youtube_claims import config, db, extraction, transcription
def process_one(v: dict) -> str:
"""Transcribe -> extract -> insert claims for one already-claimed video. Returns final status."""
vid = v["video_id"]
try:
segs, source, model, path = transcription.transcribe(vid)
except Exception as exc: # noqa: BLE001
st = db.record_failure(vid, "transcribe", str(exc))
print(f"[worker] {vid} transcribe failed -> {st}: {exc}")
return st
db.set_status(vid, "extracting", transcript_source=source, whisper_model=model, transcript_path=path)
try:
claims = extraction.extract_claims(segs)
except Exception as exc: # noqa: BLE001
st = db.record_failure(vid, "extract", str(exc))
print(f"[worker] {vid} extract failed -> {st}: {exc}")
return st
n = db.insert_claims(vid, claims)
db.mark_done(vid, source, model, path)
print(f"[worker] {vid} done: {len(segs)} segments ({source}), {n} claims")
return "done"
def drain(max_videos: int | None = None) -> int:
"""Process pending videos one at a time with a politeness gap. Returns #processed."""
done = 0
while True:
v = db.claim_next_pending()
if v is None:
break
process_one(v)
done += 1
if max_videos and done >= max_videos:
break
time.sleep(config.INTER_VIDEO_DELAY_SECONDS) # §13 — keep yt-dlp under the rate cap
return done