Skip to content

Commit b65f66c

Browse files
authored
Merge pull request #5 from palewire/claude/busy-moore-4531a6
Add podcast archive pages and IA links
2 parents ce8bc35 + 2ab2139 commit b65f66c

39 files changed

Lines changed: 23757 additions & 21724 deletions

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ name = "fakethirtyeight"
77
dynamic = ["version"]
88
requires-python = ">=3.11"
99
dependencies = [
10-
"anthropic>=0.103.1",
1110
"beautifulsoup4",
1211
"brotli",
1312
"click",

src/fakethirtyeight/caption.py

Lines changed: 131 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
Some images — especially the timestamped ``screen-shot-…`` files —
44
don't have meaningful filenames or alt text, so we can't tell from
55
metadata alone whether they're charts, chats, or game UIs. This
6-
module sends each one through Claude's vision API to get:
6+
module sends each one through an OpenAI-compatible LiteLLM vision endpoint to get:
77
88
* a content category (``chart``, ``map``, ``table``, ``chart-screenshot``,
99
``chat``, ``social-media``, ``ui-screenshot``, ``photo``, ``other``)
1010
* a one-sentence description (becomes alt text on the IA item)
1111
* a concise suggested title
12+
* any visible text extracted from the image
1213
1314
Results are written to ``data/image_captions.csv`` keyed by identifier
1415
so :mod:`ia_image_upload` can join them in and override the
1516
filename-derived category for items in scope.
1617
17-
Auth: ``ANTHROPIC_API_KEY`` env var. Resumable: identifiers already in
18-
the captions CSV are skipped.
18+
Auth: ``LITELLM_API_KEY`` + ``LITELLM_BASE_URL`` env vars. Resumable:
19+
identifiers already in the captions CSV are skipped.
1920
"""
2021

2122
from __future__ import annotations
@@ -27,11 +28,13 @@
2728
import os
2829
import re
2930
import threading
31+
from collections.abc import Mapping
3032
from dataclasses import dataclass
3133
from pathlib import Path
34+
from typing import cast
35+
from urllib.parse import urljoin
3236

33-
import anthropic
34-
from anthropic import APIStatusError
37+
import httpx
3538
from tenacity import (
3639
retry,
3740
retry_if_exception_type,
@@ -40,7 +43,8 @@
4043
)
4144
from tqdm.contrib.concurrent import thread_map
4245

43-
from fakethirtyeight.images import IMAGE_LOG, IMAGE_REFS_FILE
46+
from fakethirtyeight.http import DEFAULT_TIMEOUT, make_ssl_context
47+
from fakethirtyeight.images import IMAGE_LOG, IMAGE_REFS_FILE, _is_image_body
4448
from fakethirtyeight.paths import DATA_DIR, ensure_dirs
4549

4650
log = logging.getLogger(__name__)
@@ -52,14 +56,15 @@
5256
"ai_category",
5357
"ai_description",
5458
"ai_title",
59+
"ai_text",
5560
"model",
5661
"status",
5762
"error",
5863
)
5964

60-
#: Default model — Sonnet is the sweet spot for visual classification.
61-
#: Override with ``--model`` on the CLI.
62-
DEFAULT_MODEL = "claude-sonnet-4-5"
65+
#: Default model. Prefer ``LITELLM_MODEL`` so local config can choose
66+
#: whatever the gateway exposes. Override with ``--model`` on the CLI.
67+
DEFAULT_MODEL = os.environ.get("LITELLM_MODEL") or "gpt-4o-mini"
6368

6469
#: Categories the model is allowed to return. Constrained list keeps
6570
#: the downstream filter logic deterministic.
@@ -95,6 +100,9 @@
95100
"title": a concise display title for an archive.org item
96101
(≤80 chars; if the image is a chart, prefer the
97102
chart's own title text)
103+
"text": all legible text visible in the image, preserving the
104+
rough reading order. Use an empty string if there is
105+
no legible text. Do not summarize or invent text.
98106
99107
Return ONLY the JSON. No prose, no markdown fences.\
100108
""".format(categories=", ".join(ALLOWED_CATEGORIES))
@@ -106,13 +114,14 @@ class CaptionResult:
106114
ai_category: str = ""
107115
ai_description: str = ""
108116
ai_title: str = ""
117+
ai_text: str = ""
109118
model: str = ""
110119
status: str = "ok"
111120
error: str = ""
112121

113122

114123
def _detect_media_type(path: Path) -> str:
115-
"""Pick the IANA media type Claude expects for the image source."""
124+
"""Pick the IANA media type the vision endpoint expects."""
116125
ext = path.suffix.lower()
117126
by_ext = {
118127
".jpg": "image/jpeg",
@@ -124,6 +133,32 @@ def _detect_media_type(path: Path) -> str:
124133
return by_ext.get(ext, "image/jpeg")
125134

126135

136+
def _litellm_url() -> str:
137+
base = (os.environ.get("LITELLM_BASE_URL") or "").strip()
138+
if not base:
139+
msg = "Set LITELLM_BASE_URL first."
140+
raise RuntimeError(msg)
141+
if base.rstrip("/").endswith("/chat/completions"):
142+
return base
143+
return urljoin(base.rstrip("/") + "/", "chat/completions")
144+
145+
146+
def _litellm_headers() -> dict[str, str]:
147+
key = (os.environ.get("LITELLM_API_KEY") or "").strip()
148+
if not key:
149+
msg = "Set LITELLM_API_KEY first."
150+
raise RuntimeError(msg)
151+
user_agent = (os.environ.get("LITELLM_USER_AGENT") or "").strip()
152+
if not user_agent:
153+
msg = "Set LITELLM_USER_AGENT first."
154+
raise RuntimeError(msg)
155+
return {
156+
"Authorization": f"Bearer {key}",
157+
"Content-Type": "application/json",
158+
"User-Agent": user_agent,
159+
}
160+
161+
127162
_JSON_RX = re.compile(r"\{.*\}", re.DOTALL)
128163

129164

@@ -137,38 +172,73 @@ def _parse_response(text: str) -> dict[str, str]:
137172
return json.loads(text)
138173

139174

175+
def _content_from_response(data: dict[str, object]) -> str:
176+
"""Extract assistant text from an OpenAI-compatible response."""
177+
if data.get("error"):
178+
error = data["error"]
179+
if isinstance(error, Mapping):
180+
error_map = cast(Mapping[str, object], error)
181+
message_obj = error_map.get("message")
182+
message = str(message_obj or error_map)
183+
else:
184+
message = str(error)
185+
msg = f"LiteLLM error: {message}"
186+
raise RuntimeError(msg[:500])
187+
188+
choices = data.get("choices")
189+
if not isinstance(choices, list) or not choices:
190+
msg = f"LiteLLM response missing choices: keys={sorted(data)}"
191+
raise RuntimeError(msg)
192+
first = choices[0]
193+
if not isinstance(first, dict):
194+
msg = "LiteLLM response choice is not an object"
195+
raise RuntimeError(msg)
196+
message = first.get("message")
197+
if isinstance(message, dict) and isinstance(message.get("content"), str):
198+
return message["content"]
199+
text = first.get("text")
200+
if isinstance(text, str):
201+
return text
202+
msg = "LiteLLM response choice missing message content"
203+
raise RuntimeError(msg)
204+
205+
140206
@retry(
141-
retry=retry_if_exception_type(APIStatusError),
207+
retry=retry_if_exception_type(httpx.HTTPError),
142208
stop=stop_after_attempt(4),
143209
wait=wait_exponential(multiplier=2, min=4, max=60),
144210
reraise=True,
145211
)
146212
def _classify_one(
147-
client: anthropic.Anthropic, image_path: Path, *, model: str
213+
client: httpx.Client, image_path: Path, *, model: str
148214
) -> dict[str, str]:
149-
"""Send one image to Claude. Returns the parsed JSON response."""
215+
"""Send one image to a LiteLLM vision endpoint and parse JSON output."""
150216
data = base64.standard_b64encode(image_path.read_bytes()).decode("ascii")
151-
msg = client.messages.create(
152-
model=model,
153-
max_tokens=400,
154-
messages=[
217+
payload = {
218+
"model": model,
219+
"max_tokens": 400,
220+
"messages": [
155221
{
156222
"role": "user",
157223
"content": [
224+
{"type": "text", "text": _PROMPT},
158225
{
159-
"type": "image",
160-
"source": {
161-
"type": "base64",
162-
"media_type": _detect_media_type(image_path),
163-
"data": data,
226+
"type": "image_url",
227+
"image_url": {
228+
"url": (
229+
f"data:{_detect_media_type(image_path)};base64,{data}"
230+
)
164231
},
165232
},
166-
{"type": "text", "text": _PROMPT},
167233
],
168234
}
169235
],
170-
)
171-
raw = "".join(block.text for block in msg.content if block.type == "text")
236+
}
237+
resp = client.post(_litellm_url(), json=payload)
238+
if resp.status_code in {429, 500, 502, 503, 504}:
239+
resp.raise_for_status()
240+
resp.raise_for_status()
241+
raw = _content_from_response(resp.json())
172242
return _parse_response(raw)
173243

174244

@@ -191,6 +261,22 @@ def _load_done(path: Path) -> set[str]:
191261
return out
192262

193263

264+
def _ensure_caption_header(path: Path) -> None:
265+
"""Upgrade older caption CSVs before appending rows with new fields."""
266+
if not path.exists() or path.stat().st_size == 0:
267+
return
268+
with path.open(newline="", encoding="utf-8") as fh:
269+
reader = csv.DictReader(fh)
270+
if reader.fieldnames == list(CAPTION_FIELDS):
271+
return
272+
rows = list(reader)
273+
with path.open("w", newline="", encoding="utf-8") as fh:
274+
writer = csv.DictWriter(fh, fieldnames=CAPTION_FIELDS)
275+
writer.writeheader()
276+
for row in rows:
277+
writer.writerow({field: row.get(field, "") for field in CAPTION_FIELDS})
278+
279+
194280
def _select_targets(
195281
refs_path: Path,
196282
image_log_path: Path,
@@ -222,8 +308,11 @@ def _select_targets(
222308
if only_screenshots and cat_by_id.get(ident) != "screenshot":
223309
continue
224310
fp = DATA_DIR.parent / fp_rel
225-
if fp.exists():
226-
targets.append((ident, fp))
311+
if not fp.exists():
312+
continue
313+
with fp.open("rb") as image_fh:
314+
if _is_image_body(image_fh.read(2048)):
315+
targets.append((ident, fp))
227316
return targets
228317

229318

@@ -237,16 +326,17 @@ def caption_images(
237326
image_log_path: Path = IMAGE_LOG,
238327
out_path: Path = CAPTIONS_FILE,
239328
) -> tuple[int, int]:
240-
"""Classify a subset of downloaded images using Claude vision.
329+
"""Classify a subset of downloaded images using LiteLLM vision.
241330
242331
Returns ``(captioned, failed)``. Resumable via ``out_path``.
243332
"""
244-
if not os.environ.get("ANTHROPIC_API_KEY"):
245-
msg = "Set ANTHROPIC_API_KEY first."
246-
raise RuntimeError(msg)
333+
# Validate config before doing any target scan or log setup.
334+
headers = _litellm_headers()
335+
_litellm_url()
247336

248337
ensure_dirs()
249338
out_path.parent.mkdir(parents=True, exist_ok=True)
339+
_ensure_caption_header(out_path)
250340

251341
targets = _select_targets(
252342
refs_path, image_log_path, only_screenshots=only_screenshots
@@ -268,9 +358,14 @@ def caption_images(
268358

269359
write_header = not out_path.exists()
270360
write_lock = threading.Lock()
271-
client = anthropic.Anthropic()
272-
273-
with out_path.open("a", newline="", encoding="utf-8") as fh:
361+
with (
362+
httpx.Client(
363+
timeout=DEFAULT_TIMEOUT,
364+
headers=headers,
365+
verify=make_ssl_context(),
366+
) as client,
367+
out_path.open("a", newline="", encoding="utf-8") as fh,
368+
):
274369
writer = csv.DictWriter(fh, fieldnames=CAPTION_FIELDS)
275370
if write_header:
276371
writer.writeheader()
@@ -285,6 +380,7 @@ def _process(item: tuple[str, Path]) -> int:
285380
ai_category=_normalize_category(parsed.get("category", "")),
286381
ai_description=str(parsed.get("description", "")).strip()[:300],
287382
ai_title=str(parsed.get("title", "")).strip()[:120],
383+
ai_text=str(parsed.get("text", "")).strip()[:5000],
288384
model=model,
289385
status="ok",
290386
)
@@ -303,6 +399,7 @@ def _process(item: tuple[str, Path]) -> int:
303399
"ai_category": result.ai_category,
304400
"ai_description": result.ai_description,
305401
"ai_title": result.ai_title,
402+
"ai_text": result.ai_text,
306403
"model": result.model,
307404
"status": result.status,
308405
"error": result.error,

src/fakethirtyeight/classify.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
_MEGAPHONE_EP_ID = re.compile(r"(ESP\d+)", re.IGNORECASE)
8383

8484
_NUMERIC = re.compile(r"^\d+$")
85+
_INVISIBLE_SLUG_CHARS = str.maketrans("", "", "\ufffc\ufffd")
8586

8687

8788
@dataclass(slots=True, frozen=True)
@@ -223,7 +224,7 @@ def classify(url: str, host: str | None = None) -> Classification:
223224
# Features + DataLab era articles share slugs; roll up together.
224225
if first in {"features", "datalab"}:
225226
if len(segs) == 2:
226-
return Classification(KIND_ARTICLE, f"article:{segs[1]}")
227+
return Classification(KIND_ARTICLE, f"article:{_slug_segment(segs[1])}")
227228
# /features/<slug>/comment-page-N/ → paginated
228229
if _COMMENT_PAGE.search(path):
229230
return Classification(KIND_PAGINATED, f"paginated:{path}")
@@ -240,18 +241,18 @@ def classify(url: str, host: str | None = None) -> Classification:
240241
if first == "interactives":
241242
if len(segs) == 1:
242243
return Classification(KIND_SECTION, "section:interactives")
243-
return Classification(KIND_PROJECT, f"project:{segs[1]}")
244+
return Classification(KIND_PROJECT, f"project:{_slug_segment(segs[1])}")
244245

245246
# Videos & podcasts
246247
if first == "videos":
247248
if len(segs) == 2:
248-
return Classification(KIND_VIDEO, f"video:{segs[1]}")
249+
return Classification(KIND_VIDEO, f"video:{_slug_segment(segs[1])}")
249250
if len(segs) == 1:
250251
return Classification(KIND_SECTION, "section:videos")
251252
return Classification(KIND_OTHER, f"other:{path}")
252253
if first in {"podcasts", "podcast"}:
253254
if len(segs) == 2:
254-
return Classification(KIND_PODCAST, f"podcast:{segs[1]}")
255+
return Classification(KIND_PODCAST, f"podcast:{_slug_segment(segs[1])}")
255256
if len(segs) == 1:
256257
return Classification(KIND_SECTION, "section:podcasts")
257258
return Classification(KIND_OTHER, f"other:{path}")
@@ -261,7 +262,9 @@ def classify(url: str, host: str | None = None) -> Classification:
261262
# Wayback drilldown noise rather than a distinct doc.
262263
if first == "methodology":
263264
if len(segs) >= 2:
264-
return Classification(KIND_METHODOLOGY, f"methodology:{segs[1]}")
265+
return Classification(
266+
KIND_METHODOLOGY, f"methodology:{_slug_segment(segs[1])}"
267+
)
265268
return Classification(KIND_METHODOLOGY, "methodology:")
266269

267270
# Section landings/sub-landings.
@@ -288,3 +291,9 @@ def classify(url: str, host: str | None = None) -> Classification:
288291

289292
# ---- malformed / unrecognized hosts --------------------------------
290293
return Classification(KIND_OTHER, f"unknown-host:{h}{path}")
294+
295+
296+
def _slug_segment(segment: str) -> str:
297+
"""Normalize one URL path segment for rollup-key use."""
298+
slug = unquote(segment).translate(_INVISIBLE_SLUG_CHARS)
299+
return re.sub(r"[-\s]+", "-", slug).strip("-")

0 commit comments

Comments
 (0)