Skip to content

Commit 9c78626

Browse files
Merge pull request #150 from ContextLab/fix-hf-daily-papers
fix(hf-daily-papers): default to yesterday + retry-with-fallback + earlier cron
2 parents cc6945b + c12de8a commit 9c78626

4 files changed

Lines changed: 218 additions & 19 deletions

File tree

.github/workflows/hf-daily-papers.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: HF Daily Papers
22

3-
# Daily @ 23:59 UTC: submit the top-5 most-upvoted Hugging Face daily-papers
3+
# Daily @ 08:00 UTC: submit the top-5 most-upvoted Hugging Face daily-papers
44
# (https://huggingface.co/papers/date/YYYY-MM-DD) as `human-submission` /
55
# `new-paper` GitHub issues. The hourly `submission-intake.yml` workflow
66
# then routes each one through the same code path the website's
@@ -10,13 +10,19 @@ name: HF Daily Papers
1010
# excluded from the contributor leaderboard by `_is_bot_submitter` in
1111
# `src/llmxive/web_data.py` (the paper's *authors* still get credited).
1212
#
13+
# Timing: the implementation defaults to (today_utc - 1 day) because HF's
14+
# daily-papers buckets are populated in the late afternoon UTC by HF's
15+
# editorial pipeline. Running at 08:00 UTC fetches yesterday's bucket,
16+
# which is guaranteed-published by then. The fallback chain in
17+
# _fetch_daily_json walks further back if even that bucket is empty.
18+
#
1319
# Security: workflow_dispatch inputs are untrusted — they're surfaced to
1420
# the shell via env: vars (never interpolated directly into run: steps)
1521
# and validated by Python's argparse before reaching `gh`.
1622

1723
on:
1824
schedule:
19-
- cron: "59 23 * * *" # 23:59 UTC daily
25+
- cron: "0 8 * * *" # 08:00 UTC daily
2026
workflow_dispatch:
2127
inputs:
2228
date:

.omc/project-memory.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@
211211
"lastAccessed": 1778845312910,
212212
"type": "file"
213213
},
214+
{
215+
"path": "src/llmxive/hf_daily_papers.py",
216+
"accessCount": 10,
217+
"lastAccessed": 1778852271916,
218+
"type": "file"
219+
},
214220
{
215221
"path": "src/llmxive/agents/personality.py",
216222
"accessCount": 7,
@@ -259,6 +265,12 @@
259265
"lastAccessed": 1778844885482,
260266
"type": "file"
261267
},
268+
{
269+
"path": "tests/unit/test_hf_daily_papers.py",
270+
"accessCount": 4,
271+
"lastAccessed": 1778857293016,
272+
"type": "file"
273+
},
262274
{
263275
"path": "specs/005-librarian-agent/spec.md",
264276
"accessCount": 3,

src/llmxive/hf_daily_papers.py

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ class SubmissionResult:
7373
# HF daily-papers fetch
7474
# ────────────────────────────────────────────────────────────────────────────
7575

76-
def _fetch_daily_json(date: str, *, timeout: float = 30.0) -> list[dict[str, Any]]:
77-
"""Hit the public HF daily-papers endpoint for a given YYYY-MM-DD.
76+
def _fetch_daily_json_one(date: str, *, timeout: float = 30.0) -> list[dict[str, Any]]:
77+
"""Hit the public HF daily-papers endpoint for a given YYYY-MM-DD (one attempt).
7878
7979
Returns the raw JSON list. Raises urllib.error.HTTPError on non-2xx.
8080
"""
@@ -88,6 +88,47 @@ def _fetch_daily_json(date: str, *, timeout: float = 30.0) -> list[dict[str, Any
8888
return data
8989

9090

91+
def _fetch_daily_json(date: str, *, timeout: float = 30.0,
92+
fallback_days: int = 1) -> tuple[str, list[dict[str, Any]]]:
93+
"""Hit HF daily-papers for `date`; on 400 (date bucket not yet populated)
94+
fall back to the previous UTC day up to `fallback_days` times.
95+
96+
Returns (effective_date, payload). Raises urllib.error.HTTPError on
97+
persistent failure after the fallback chain.
98+
99+
Rationale: GitHub Actions cron jobs scheduled at ~23:59 UTC routinely
100+
fire 5-30 minutes late. Once we cross midnight UTC, `_today_utc()` returns
101+
the *new* day whose HF bucket isn't published yet, and the endpoint
102+
returns HTTP 400. We must retry against the previous day so the cron
103+
isn't a roulette wheel keyed on Actions queue depth.
104+
"""
105+
from datetime import datetime, timedelta
106+
107+
try:
108+
ts = datetime.strptime(date, "%Y-%m-%d")
109+
except ValueError:
110+
# Date didn't parse — try a single fetch and let urlopen raise.
111+
return date, _fetch_daily_json_one(date, timeout=timeout)
112+
113+
attempts: list[str] = [date]
114+
for i in range(1, fallback_days + 1):
115+
attempts.append((ts - timedelta(days=i)).strftime("%Y-%m-%d"))
116+
117+
last_err: urllib.error.HTTPError | None = None
118+
for d in attempts:
119+
try:
120+
return d, _fetch_daily_json_one(d, timeout=timeout)
121+
except urllib.error.HTTPError as exc:
122+
if exc.code in (400, 404):
123+
# Date bucket not yet populated — try the previous day.
124+
last_err = exc
125+
continue
126+
raise
127+
# All attempts failed with 400/404 — re-raise the last one.
128+
assert last_err is not None
129+
raise last_err
130+
131+
91132
def _parse_paper(entry: dict[str, Any]) -> Paper | None:
92133
"""Pull the fields we need from one HF entry; None if it's malformed."""
93134
paper = entry.get("paper") if isinstance(entry, dict) else None
@@ -105,19 +146,33 @@ def _parse_paper(entry: dict[str, Any]) -> Paper | None:
105146
return Paper(arxiv_id=arxiv_id, title=title, summary=summary, upvotes=upvotes)
106147

107148

108-
def fetch_top_papers(date: str, *, limit: int = 5, raw_json: list[dict[str, Any]] | None = None) -> list[Paper]:
109-
"""Return the top-N HF daily papers for ``date`` (YYYY-MM-DD), sorted by
110-
upvotes desc. ``raw_json`` lets tests inject without monkey-patching
111-
urllib.
149+
def fetch_top_papers(
150+
date: str,
151+
*,
152+
limit: int = 5,
153+
raw_json: list[dict[str, Any]] | None = None,
154+
) -> tuple[str, list[Paper]]:
155+
"""Return (effective_date, top-N HF daily papers) sorted by upvotes desc.
156+
157+
The effective_date may differ from the requested `date` when the HF
158+
endpoint returns 400/404 for `date` (bucket not yet populated) and the
159+
fallback chain in `_fetch_daily_json` succeeds against an earlier day.
160+
161+
``raw_json`` lets tests inject without monkey-patching urllib. When
162+
raw_json is supplied, the effective_date equals the requested date.
112163
"""
113-
data = raw_json if raw_json is not None else _fetch_daily_json(date)
164+
if raw_json is not None:
165+
effective = date
166+
data = raw_json
167+
else:
168+
effective, data = _fetch_daily_json(date)
114169
parsed: list[Paper] = []
115170
for entry in data:
116171
p = _parse_paper(entry)
117172
if p is not None:
118173
parsed.append(p)
119174
parsed.sort(key=lambda p: (-p.upvotes, p.arxiv_id))
120-
return parsed[: max(0, int(limit))]
175+
return effective, parsed[: max(0, int(limit))]
121176

122177

123178
# ────────────────────────────────────────────────────────────────────────────
@@ -231,7 +286,14 @@ def submit_top_papers(
231286
when ``dry_run``). Idempotent across same-day retries: an arXiv URL
232287
already present in any open or closed `new-paper` issue is skipped.
233288
"""
234-
papers = fetch_top_papers(date, limit=limit, raw_json=raw_json)
289+
effective_date, papers = fetch_top_papers(date, limit=limit, raw_json=raw_json)
290+
if effective_date != date:
291+
print(
292+
f"[hf-daily-papers] HF bucket for {date} not yet populated; "
293+
f"falling back to {effective_date}",
294+
file=sys.stderr,
295+
)
296+
date = effective_date # record the date that actually returned data
235297
filed: list[dict[str, Any]] = []
236298
skipped: list[dict[str, Any]] = []
237299
seen: set[str] = set() if dry_run else _list_recent_paper_issue_urls(repo)
@@ -268,9 +330,22 @@ def submit_top_papers(
268330

269331

270332
def _today_utc() -> str:
271-
"""YYYY-MM-DD in UTC — the HF daily-papers feed is UTC-bucketed."""
272-
from datetime import datetime, timezone
273-
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
333+
"""Default date for the HF daily-papers fetch — YESTERDAY in UTC.
334+
335+
HF's daily-papers feed is bucketed by *publication* day (UTC). A bucket
336+
is populated when HF's editorial pipeline finishes that day's roundup.
337+
Empirically that happens in the late afternoon UTC; running before that
338+
against today's date returns HTTP 400.
339+
340+
We default to (today_utc - 1 day) so the cron is robust against
341+
schedule drift (Actions can fire 5-30 minutes late past midnight UTC)
342+
AND against HF publishing slowly. The fallback in `_fetch_daily_json`
343+
will walk further back if even yesterday's bucket is unpopulated.
344+
345+
Tests / manual runs can still pass an explicit `--date YYYY-MM-DD`.
346+
"""
347+
from datetime import datetime, timedelta, timezone
348+
return (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
274349

275350

276351
def cli_main(argv: Sequence[str] | None = None) -> int:

tests/unit/test_hf_daily_papers.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,20 @@ class TestFetchTopPapers:
3939
def test_sorts_by_upvotes_desc(self) -> None:
4040
raw = [_entry("2605.01000", "low", 1), _entry("2605.02000", "high", 100),
4141
_entry("2605.03000", "mid", 50)]
42-
out = fetch_top_papers("2026-05-13", limit=3, raw_json=raw)
42+
effective_date, out = fetch_top_papers("2026-05-13", limit=3, raw_json=raw)
43+
assert effective_date == "2026-05-13" # raw_json passed → no fallback
4344
assert [p.upvotes for p in out] == [100, 50, 1]
4445
assert out[0].arxiv_id == "2605.02000"
4546

4647
def test_ties_break_by_arxiv_id(self) -> None:
4748
# Deterministic tie-break so reruns + tests are stable.
4849
raw = [_entry("2605.09000", "B", 10), _entry("2605.01000", "A", 10)]
49-
out = fetch_top_papers("2026-05-13", limit=2, raw_json=raw)
50+
_, out = fetch_top_papers("2026-05-13", limit=2, raw_json=raw)
5051
assert [p.arxiv_id for p in out] == ["2605.01000", "2605.09000"]
5152

5253
def test_limit_truncates(self) -> None:
5354
raw = [_entry(f"2605.{i:05d}", f"t{i}", i) for i in range(20)]
54-
out = fetch_top_papers("2026-05-13", limit=5, raw_json=raw)
55+
_, out = fetch_top_papers("2026-05-13", limit=5, raw_json=raw)
5556
assert len(out) == 5
5657
# newest upvotes first
5758
assert out[0].upvotes == 19
@@ -65,12 +66,12 @@ def test_malformed_entries_skipped(self) -> None:
6566
{"paper": {"id": "2605.02000", "title": "", "upvotes": 99}}, # missing title
6667
"not a dict", # type error
6768
]
68-
out = fetch_top_papers("2026-05-13", limit=10, raw_json=raw)
69+
_, out = fetch_top_papers("2026-05-13", limit=10, raw_json=raw)
6970
assert [p.arxiv_id for p in out] == ["2605.01000"]
7071

7172
def test_non_int_upvotes_coerced_to_zero(self) -> None:
7273
raw = [{"paper": {"id": "2605.0", "title": "weird", "upvotes": "many"}}]
73-
out = fetch_top_papers("2026-05-13", limit=1, raw_json=raw)
74+
_, out = fetch_top_papers("2026-05-13", limit=1, raw_json=raw)
7475
assert out[0].upvotes == 0
7576

7677

@@ -131,3 +132,108 @@ def test_no_papers_to_file_yields_empty_result(self) -> None:
131132
assert result.fetched == 0
132133
assert result.filed == []
133134
assert result.skipped == []
135+
136+
137+
class TestDateFallback:
138+
"""Tests for the date-bucket fallback chain added in the post-spec-010 fix.
139+
140+
HF daily-papers buckets are populated by HF's editorial pipeline in the
141+
late afternoon UTC. Running at 23:59 UTC + cron schedule drift can put
142+
the request past midnight (the new day's bucket is empty → HTTP 400).
143+
"""
144+
145+
def test_today_utc_defaults_to_yesterday(self) -> None:
146+
from datetime import datetime, timedelta, timezone
147+
148+
from llmxive.hf_daily_papers import _today_utc
149+
150+
expected = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
151+
assert _today_utc() == expected
152+
153+
def test_fetch_falls_back_on_400(self, monkeypatch) -> None:
154+
"""When the requested date returns 400, walk back one day and retry."""
155+
import urllib.error
156+
157+
from llmxive.hf_daily_papers import _fetch_daily_json
158+
159+
calls: list[str] = []
160+
161+
def fake_one(date: str, *, timeout: float = 30.0):
162+
calls.append(date)
163+
if date == "2026-05-15":
164+
raise urllib.error.HTTPError(
165+
url="x", code=400, msg="Bad Request", hdrs=None, fp=None,
166+
)
167+
return [{"paper": {"id": "2605.fallback", "title": "ok", "upvotes": 1}}]
168+
169+
monkeypatch.setattr(
170+
"llmxive.hf_daily_papers._fetch_daily_json_one", fake_one
171+
)
172+
effective, data = _fetch_daily_json("2026-05-15", fallback_days=1)
173+
assert effective == "2026-05-14"
174+
assert calls == ["2026-05-15", "2026-05-14"]
175+
assert data[0]["paper"]["id"] == "2605.fallback"
176+
177+
def test_fetch_falls_back_on_404(self, monkeypatch) -> None:
178+
"""404 (alternative HF response shape) also triggers fallback."""
179+
import urllib.error
180+
181+
from llmxive.hf_daily_papers import _fetch_daily_json
182+
183+
def fake_one(date: str, *, timeout: float = 30.0):
184+
if date == "2026-05-15":
185+
raise urllib.error.HTTPError(
186+
url="x", code=404, msg="Not Found", hdrs=None, fp=None,
187+
)
188+
return []
189+
190+
monkeypatch.setattr(
191+
"llmxive.hf_daily_papers._fetch_daily_json_one", fake_one
192+
)
193+
effective, data = _fetch_daily_json("2026-05-15", fallback_days=1)
194+
assert effective == "2026-05-14"
195+
assert data == []
196+
197+
def test_fetch_does_not_swallow_5xx(self, monkeypatch) -> None:
198+
"""5xx errors should propagate — they indicate transient HF issues,
199+
not a date-bucket problem."""
200+
import urllib.error
201+
202+
import pytest
203+
204+
from llmxive.hf_daily_papers import _fetch_daily_json
205+
206+
def fake_one(date: str, *, timeout: float = 30.0):
207+
raise urllib.error.HTTPError(
208+
url="x", code=503, msg="Service Unavailable", hdrs=None, fp=None,
209+
)
210+
211+
monkeypatch.setattr(
212+
"llmxive.hf_daily_papers._fetch_daily_json_one", fake_one
213+
)
214+
with pytest.raises(urllib.error.HTTPError) as excinfo:
215+
_fetch_daily_json("2026-05-15", fallback_days=1)
216+
assert excinfo.value.code == 503
217+
218+
def test_fetch_raises_when_all_fallback_attempts_400(
219+
self, monkeypatch
220+
) -> None:
221+
"""If every date in the fallback chain returns 400, raise the last
222+
HTTPError so the caller can surface a meaningful failure."""
223+
import urllib.error
224+
225+
import pytest
226+
227+
from llmxive.hf_daily_papers import _fetch_daily_json
228+
229+
def fake_one(date: str, *, timeout: float = 30.0):
230+
raise urllib.error.HTTPError(
231+
url="x", code=400, msg="Bad Request", hdrs=None, fp=None,
232+
)
233+
234+
monkeypatch.setattr(
235+
"llmxive.hf_daily_papers._fetch_daily_json_one", fake_one
236+
)
237+
with pytest.raises(urllib.error.HTTPError) as excinfo:
238+
_fetch_daily_json("2026-05-15", fallback_days=2)
239+
assert excinfo.value.code == 400

0 commit comments

Comments
 (0)