eliezer avihail Claude commited on
Commit
2f0fa95
·
unverified ·
1 Parent(s): 9bc69b9

Follow client-side redirects + read_page URL guard: real API content (#92)

Browse files

* Follow client-side redirects: the core torch API reference was empty stubs

Measured from the committed index manifest: 3,435 of 4,517 crawled pages had
title "Redirecting…" with a single empty chunk — every one a
docs.pytorch.org/docs/stable/... API reference page (torch.optim.SGD,
torch.nn.Linear, CrossEntropyLoss — exactly the pages retrieval was tuned on).
docs/stable/ serves a client-side redirect stub whose only real content is a
`<meta http-equiv="refresh" content="0; url=<versioned>">`; requests follows
HTTP 3xx but not this, so the crawler snapshotted empty stubs and answers on
core API pages came back hollow ("the context only contains redirect notices").
Retrieval still scored well because it matches symbol + gloss + synopsis, not
the (empty) body — so the ceiling was hidden until an answer exposed it.

ingest/discover.py: redirect_target() reads the meta-refresh (or a differing
<link rel=canonical>) target, and fetch_html() follows it, hop-bounded and
loop-protected, returning the real page's HTML while the caller keeps its
ORIGINAL url as the pointer/citation key (stable URLs stay stable in the index
and in answers). Both the crawl (ingest/crawl.py) and live hydration
(index/hydrate.py) now go through fetch_html, so a re-crawl fills real content
and the deployed Space stops serving redirect stubs.

Also: questions pass --sleep 0 (the 429-retry reads the server's own
Retry-After — the real rate limiter — instead of a guessed proactive delay).

220 tests pass (redirect parsing: meta-refresh, canonical fallback, real-page
negative, follow-through, loop protection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

* read_page: reject a section heading passed as a URL instead of fetching it

Live Space logs showed the planner calling read_page with a section heading it
saw in a search result — "Build the Neural Network > Define the Class" — as the
url. hydrate_page → fetch then failed with "No scheme supplied", wasting a tool
call and printing a scary error. read_page now rejects a non-http url up front
and returns a corrective error ("needs the full https:// URL from a search
result's url field"), so the planner self-corrects instead of burning a step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

---------

Co-authored-by: Claude <noreply@anthropic.com>

.github/workflows/build-index.yml CHANGED
@@ -91,7 +91,7 @@ jobs:
91
  OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
92
  OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
93
  TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
94
- run: python scripts/generate_questions.py --limit 0 --batch 25 --sleep 3
95
  - name: Commit any new enrichment (also on failure — partial progress is saved)
96
  if: always()
97
  run: |
 
91
  OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
92
  OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
93
  TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
94
+ run: python scripts/generate_questions.py --limit 0 --batch 25 --sleep 0
95
  - name: Commit any new enrichment (also on failure — partial progress is saved)
96
  if: always()
97
  run: |
.github/workflows/generate-glosses.yml CHANGED
@@ -78,7 +78,7 @@ jobs:
78
  # 5 questions/page → batch 25 = ~125 short questions/reply, ~145 calls
79
  # for the whole corpus (vs ~363 at batch 10). hy3 handles the longer
80
  # reply fine; fewer calls means far less time in the free-tier queue.
81
- run: python scripts/generate_questions.py --limit "${{ inputs.limit }}" --batch 25 --sleep 3
82
  - name: Commit whatever was written (also on failure — the run is resumable)
83
  if: always()
84
  run: |
 
78
  # 5 questions/page → batch 25 = ~125 short questions/reply, ~145 calls
79
  # for the whole corpus (vs ~363 at batch 10). hy3 handles the longer
80
  # reply fine; fewer calls means far less time in the free-tier queue.
81
+ run: python scripts/generate_questions.py --limit "${{ inputs.limit }}" --batch 25 --sleep 0
82
  - name: Commit whatever was written (also on failure — the run is resumable)
83
  if: always()
84
  run: |
agent/tools.py CHANGED
@@ -80,6 +80,17 @@ def read_page(url: str) -> dict:
80
  """Whole page for a URL already surfaced by search_docs."""
81
  from index.hydrate import hydrate_page
82
 
 
 
 
 
 
 
 
 
 
 
 
83
  page = hydrate_page(url)
84
  if page is None:
85
  return {"url": url, "error": "page not in the snapshot"}
 
80
  """Whole page for a URL already surfaced by search_docs."""
81
  from index.hydrate import hydrate_page
82
 
83
+ url = (url or "").strip()
84
+ if not url.startswith(("http://", "https://")):
85
+ # the planner sometimes passes a section HEADING it saw in a search
86
+ # result (e.g. "Build the Neural Network > Define the Class") instead of
87
+ # the url. Don't fetch that (it 'No scheme supplied'-errors and wastes a
88
+ # call) — tell the model exactly what read_page needs so it self-corrects.
89
+ return {
90
+ "url": url,
91
+ "error": "read_page needs the full https:// URL from a search_docs "
92
+ "result's `url` field, not a section title.",
93
+ }
94
  page = hydrate_page(url)
95
  if page is None:
96
  return {"url": url, "error": "page not in the snapshot"}
index/hydrate.py CHANGED
@@ -29,10 +29,12 @@ def _live_page(url: str) -> tuple[dict, str] | None:
29
  if not _LIVE:
30
  return None
31
  from ingest.crawl import extract_main_html, to_markdown
32
- from ingest.discover import fetch
33
 
34
  try:
35
- html = fetch(url).decode("utf-8", errors="replace")
 
 
36
  except Exception as exc: # noqa: BLE001 — a dead link just means "no content"
37
  print(f"[hydrate] live fetch failed for {url}: {exc}", flush=True)
38
  return None
 
29
  if not _LIVE:
30
  return None
31
  from ingest.crawl import extract_main_html, to_markdown
32
+ from ingest.discover import fetch_html
33
 
34
  try:
35
+ # follow client-side redirects (docs/stable/... → versioned page); a
36
+ # plain fetch would return the "Redirecting…" stub and hollow the answer
37
+ html = fetch_html(url)
38
  except Exception as exc: # noqa: BLE001 — a dead link just means "no content"
39
  print(f"[hydrate] live fetch failed for {url}: {exc}", flush=True)
40
  return None
ingest/crawl.py CHANGED
@@ -102,7 +102,7 @@ def crawl(
102
  import os
103
  import time
104
 
105
- from ingest.discover import fetch
106
 
107
  delay = float(os.environ.get("TORCHDOCS_CRAWL_DELAY", "0.2"))
108
  changed: dict[str, int] = {}
@@ -110,7 +110,9 @@ def crawl(
110
  count = 0
111
  for url in sorted(urls):
112
  try:
113
- html = fetch(url).decode("utf-8", errors="replace")
 
 
114
  except Exception as exc: # noqa: BLE001 — one bad page must not kill the crawl
115
  print(f"[crawl] {url}: fetch failed ({exc})")
116
  continue
 
102
  import os
103
  import time
104
 
105
+ from ingest.discover import fetch_html
106
 
107
  delay = float(os.environ.get("TORCHDOCS_CRAWL_DELAY", "0.2"))
108
  changed: dict[str, int] = {}
 
110
  count = 0
111
  for url in sorted(urls):
112
  try:
113
+ # follow client-side redirects: docs/stable/... serves a
114
+ # "Redirecting…" stub whose real content is behind a meta-refresh
115
+ html = fetch_html(url)
116
  except Exception as exc: # noqa: BLE001 — one bad page must not kill the crawl
117
  print(f"[crawl] {url}: fetch failed ({exc})")
118
  continue
ingest/discover.py CHANGED
@@ -132,6 +132,57 @@ def fetch(url: str, timeout: float = 30.0, retries: int = FETCH_RETRIES) -> byte
132
  raise last_exc # type: ignore[misc] # loop ran ≥1 time, so last_exc is set
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  def _sitemap_pages(base: str, xml_text: str) -> set[str]:
136
  """Page URLs from a sitemap, following one level of <sitemapindex>."""
137
  import requests
 
132
  raise last_exc # type: ignore[misc] # loop ran ≥1 time, so last_exc is set
133
 
134
 
135
+ def redirect_target(html: str, base_url: str) -> str | None:
136
+ """The URL a client-side redirect stub points at, else None.
137
+
138
+ docs.pytorch.org/docs/stable/<...> serves a "Redirecting…" page whose only
139
+ real content is `<meta http-equiv="refresh" content="0; url=<versioned>">`.
140
+ requests follows HTTP 3xx but NOT this, so a plain fetch captured an empty
141
+ stub for the entire core API reference (measured: 3,435/4,517 crawled pages
142
+ had title "Redirecting…", every one a docs/stable API page). A real content
143
+ page never carries a refresh meta, so finding one unambiguously means "this
144
+ is a redirect — follow it". Falls back to <link rel="canonical"> when it
145
+ points elsewhere.
146
+ """
147
+ from urllib.parse import urljoin
148
+
149
+ from bs4 import BeautifulSoup
150
+
151
+ soup = BeautifulSoup(html, "html.parser")
152
+ meta = soup.find("meta", attrs={"http-equiv": re.compile(r"^refresh$", re.I)})
153
+ if meta and meta.get("content"):
154
+ m = re.search(r"url\s*=\s*(.+)$", meta["content"], re.I)
155
+ if m:
156
+ return urljoin(base_url, m.group(1).strip().strip("'\""))
157
+ canonical = soup.find("link", rel="canonical")
158
+ if canonical and canonical.get("href"):
159
+ target = urljoin(base_url, canonical["href"].strip())
160
+ if target.rstrip("/") != base_url.rstrip("/"):
161
+ return target
162
+ return None
163
+
164
+
165
+ def fetch_html(url: str, *, max_hops: int = 3, **kwargs) -> str:
166
+ """fetch() + follow client-side (meta-refresh) redirects to the real page.
167
+
168
+ Returns the final page's HTML as text. Loop-protected and hop-bounded so a
169
+ misconfigured redirect chain can't spin forever. The caller keeps its
170
+ ORIGINAL url as the pointer/citation key — only the *content* comes from the
171
+ redirect target — so stable URLs stay stable in the index and in answers.
172
+ """
173
+ seen: set[str] = set()
174
+ html = ""
175
+ for _ in range(max_hops + 1):
176
+ html = fetch(url, **kwargs).decode("utf-8", errors="replace")
177
+ seen.add(url)
178
+ target = redirect_target(html, url)
179
+ if not target or target in seen:
180
+ return html
181
+ print(f"[fetch] following redirect {url} → {target}", flush=True)
182
+ url = target
183
+ return html # hop budget exhausted → return the last page fetched
184
+
185
+
186
  def _sitemap_pages(base: str, xml_text: str) -> set[str]:
187
  """Page URLs from a sitemap, following one level of <sitemapindex>."""
188
  import requests
tests/agent/test_tools.py CHANGED
@@ -45,6 +45,18 @@ def test_read_page_missing(monkeypatch):
45
  assert "error" in read_page("https://x")
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def test_search_docs_passes_kind_to_retrieve(monkeypatch):
49
  import agent.tools as tools
50
 
 
45
  assert "error" in read_page("https://x")
46
 
47
 
48
+ def test_read_page_rejects_a_heading_instead_of_a_url(monkeypatch):
49
+ # the planner sometimes passes a section heading it saw in a search result;
50
+ # read_page must not try to fetch it (No scheme supplied) — it returns a
51
+ # corrective error WITHOUT touching hydrate_page
52
+ def must_not_fetch(url): # pragma: no cover
53
+ raise AssertionError("hydrate_page must not run on a non-URL")
54
+
55
+ monkeypatch.setattr("index.hydrate.hydrate_page", must_not_fetch)
56
+ out = read_page("Build the Neural Network > Define the Class")
57
+ assert "error" in out and "URL" in out["error"]
58
+
59
+
60
  def test_search_docs_passes_kind_to_retrieve(monkeypatch):
61
  import agent.tools as tools
62
 
tests/ingest/test_discover.py CHANGED
@@ -171,3 +171,69 @@ def test_fetch_abandons_oversized_pages_early(monkeypatch):
171
  monkeypatch.setattr("requests.get", lambda url, **kw: huge)
172
  with pytest.raises(ValueError, match="exceeds"):
173
  disc.fetch("https://docs.pytorch.org/huge.html")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  monkeypatch.setattr("requests.get", lambda url, **kw: huge)
172
  with pytest.raises(ValueError, match="exceeds"):
173
  disc.fetch("https://docs.pytorch.org/huge.html")
174
+
175
+
176
+ # --- client-side redirect following (docs/stable "Redirecting…" stubs) --------
177
+
178
+
179
+ def test_redirect_target_reads_meta_refresh():
180
+ from ingest.discover import redirect_target
181
+
182
+ stub = (
183
+ '<html><head><title>Redirecting…</title>'
184
+ '<meta http-equiv="refresh" content="0; url=../../2.13/generated/torch.optim.SGD.html">'
185
+ "</head><body>You should have been redirected.</body></html>"
186
+ )
187
+ base = "https://docs.pytorch.org/docs/stable/generated/torch.optim.SGD.html"
188
+ assert (
189
+ redirect_target(stub, base)
190
+ == "https://docs.pytorch.org/docs/2.13/generated/torch.optim.SGD.html"
191
+ )
192
+
193
+
194
+ def test_redirect_target_falls_back_to_canonical():
195
+ stub = (
196
+ '<html><head><link rel="canonical" '
197
+ 'href="https://docs.pytorch.org/docs/2.13/x.html"></head><body/></html>'
198
+ )
199
+ from ingest.discover import redirect_target
200
+
201
+ assert redirect_target(stub, "https://docs.pytorch.org/docs/stable/x.html") == (
202
+ "https://docs.pytorch.org/docs/2.13/x.html"
203
+ )
204
+
205
+
206
+ def test_redirect_target_none_for_a_real_page():
207
+ from ingest.discover import redirect_target
208
+
209
+ real = "<html><head><title>torch.optim.SGD</title></head><body><h1>SGD</h1></body></html>"
210
+ # a canonical that points at the SAME page is not a redirect
211
+ same = (
212
+ '<html><head><link rel="canonical" href="https://d/p.html"></head><body>real</body></html>'
213
+ )
214
+ assert redirect_target(real, "https://d/p.html") is None
215
+ assert redirect_target(same, "https://d/p.html") is None
216
+
217
+
218
+ def test_fetch_html_follows_the_stub_to_the_real_content(monkeypatch):
219
+ import ingest.discover as disc
220
+
221
+ real = "<html><body><h1>SGD</h1><p>momentum...</p></body></html>"
222
+ stub = (
223
+ '<meta http-equiv="refresh" content="0; url=https://d/real.html">'
224
+ )
225
+ pages = {"https://d/stable.html": stub, "https://d/real.html": real}
226
+ monkeypatch.setattr(disc, "fetch", lambda url, **kw: pages[url].encode())
227
+ assert disc.fetch_html("https://d/stable.html") == real
228
+
229
+
230
+ def test_fetch_html_is_loop_protected(monkeypatch):
231
+ import ingest.discover as disc
232
+
233
+ # two stubs pointing at each other must not spin forever
234
+ a = '<meta http-equiv="refresh" content="0; url=https://d/b.html">'
235
+ b = '<meta http-equiv="refresh" content="0; url=https://d/a.html">'
236
+ pages = {"https://d/a.html": a, "https://d/b.html": b}
237
+ monkeypatch.setattr(disc, "fetch", lambda url, **kw: pages[url].encode())
238
+ # terminates (returns the last fetched html) instead of hanging
239
+ assert disc.fetch_html("https://d/a.html", max_hops=3) in (a, b)