Spaces:
Sleeping
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 +1 -1
- .github/workflows/generate-glosses.yml +1 -1
- agent/tools.py +11 -0
- index/hydrate.py +4 -2
- ingest/crawl.py +4 -2
- ingest/discover.py +51 -0
- tests/agent/test_tools.py +12 -0
- tests/ingest/test_discover.py +66 -0
|
@@ -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
|
| 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: |
|
|
@@ -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
|
| 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: |
|
|
@@ -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"}
|
|
@@ -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
|
| 33 |
|
| 34 |
try:
|
| 35 |
-
|
|
|
|
|
|
|
| 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
|
|
@@ -102,7 +102,7 @@ def crawl(
|
|
| 102 |
import os
|
| 103 |
import time
|
| 104 |
|
| 105 |
-
from ingest.discover import
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
|
@@ -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 |
|
|
@@ -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)
|