Spaces:
Running
docs: per-package reference + showcase site (GitHub Pages) (#107)
Browse files* docs: per-package reference under docs/ (agent, index, ingest, eval, app, scripts)
Adds one reference doc per code package under docs/<package>/README.md,
plus a docs/README.md index that ties them to the existing cross-cutting
design docs. Each package doc follows the same shape β boundary, flow,
design decisions & rationale (with rejected alternatives), tool choices,
and a file-by-file map β grounded in the code as written, not the plan.
Also points the root README at docs/ for discoverability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b
* site: showcase landing page + GitHub Pages deploy
A self-contained, responsive landing page (site/index.html) β dark/light
themes, the PyTorch flame palette, and a hero that plays the product's live
reasoning trace and resolves it into a grounded, cited answer. Describes
what the agent is, how the pipeline works, and how to use it (live Space or
self-host), linking into the per-package docs.
Publishes via GitHub Pages using the Actions deploy (actions/upload-pages-
artifact + deploy-pages), serving site/ as-is so it never touches docs/.
Requires a one-time repo setting: Pages β Source: GitHub Actions.
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/pages.yml +44 -0
- README.md +4 -1
- docs/README.md +83 -0
- docs/agent/README.md +98 -0
- docs/app/README.md +156 -0
- docs/eval/README.md +100 -0
- docs/index/README.md +277 -0
- docs/ingest/README.md +89 -0
- docs/scripts/README.md +248 -0
- site/index.html +530 -0
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy showcase site to GitHub Pages
|
| 2 |
+
|
| 3 |
+
# Publishes the static landing page in site/ to GitHub Pages via the modern
|
| 4 |
+
# Actions deploy (no gh-pages branch, no Jekyll). It serves site/ as-is, so it
|
| 5 |
+
# never touches the markdown under docs/.
|
| 6 |
+
#
|
| 7 |
+
# ONE-TIME MANUAL STEP: in the repo, Settings β Pages β Build and deployment β
|
| 8 |
+
# Source: "GitHub Actions". Until that is set, this workflow's deploy step has
|
| 9 |
+
# nowhere to publish to. (This environment cannot flip that setting for you.)
|
| 10 |
+
on:
|
| 11 |
+
push:
|
| 12 |
+
branches: [main]
|
| 13 |
+
paths:
|
| 14 |
+
- "site/**"
|
| 15 |
+
- ".github/workflows/pages.yml"
|
| 16 |
+
workflow_dispatch:
|
| 17 |
+
|
| 18 |
+
# least privilege + exactly what Pages deploy needs (OIDC token to publish)
|
| 19 |
+
permissions:
|
| 20 |
+
contents: read
|
| 21 |
+
pages: write
|
| 22 |
+
id-token: write
|
| 23 |
+
|
| 24 |
+
# one live deploy at a time; don't cancel a publish mid-flight
|
| 25 |
+
concurrency:
|
| 26 |
+
group: pages
|
| 27 |
+
cancel-in-progress: false
|
| 28 |
+
|
| 29 |
+
jobs:
|
| 30 |
+
deploy:
|
| 31 |
+
runs-on: ubuntu-latest
|
| 32 |
+
environment:
|
| 33 |
+
name: github-pages
|
| 34 |
+
url: ${{ steps.deployment.outputs.page_url }}
|
| 35 |
+
steps:
|
| 36 |
+
- uses: actions/checkout@v4
|
| 37 |
+
- uses: actions/configure-pages@v5
|
| 38 |
+
- name: Upload the site/ folder as the Pages artifact
|
| 39 |
+
uses: actions/upload-pages-artifact@v3
|
| 40 |
+
with:
|
| 41 |
+
path: site
|
| 42 |
+
- name: Deploy to GitHub Pages
|
| 43 |
+
id: deployment
|
| 44 |
+
uses: actions/deploy-pages@v4
|
|
@@ -65,7 +65,10 @@ If the primary provider is unreachable or a free model is rate-limited, the app
|
|
| 65 |
- When a question goes beyond the docs, say so honestly and point to where to look (source links, GitHub search) instead of guessing.
|
| 66 |
- Stay easy to run locally with minimal setup.
|
| 67 |
|
| 68 |
-
See [PLAN.md](PLAN.md) for the current roadmap and TODO list
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
## Building the index
|
| 71 |
|
|
|
|
| 65 |
- When a question goes beyond the docs, say so honestly and point to where to look (source links, GitHub search) instead of guessing.
|
| 66 |
- Stay easy to run locally with minimal setup.
|
| 67 |
|
| 68 |
+
See [PLAN.md](PLAN.md) for the current roadmap and TODO list, and
|
| 69 |
+
[docs/](docs/README.md) for the design rationale and a per-package reference
|
| 70 |
+
(one doc per code package: `agent/`, `index/`, `ingest/`, `eval/`, `app/`,
|
| 71 |
+
`scripts/`).
|
| 72 |
|
| 73 |
## Building the index
|
| 74 |
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "TorchDocsAgent β documentation index"
|
| 3 |
+
kind: index
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# TorchDocsAgent β documentation
|
| 7 |
+
|
| 8 |
+
This folder is the reasoning behind the code: **why** each part exists, the
|
| 9 |
+
logic it follows, and the tool choices it rests on. It has two layers.
|
| 10 |
+
|
| 11 |
+
- **Cross-cutting design docs** explain the system as a whole β the product
|
| 12 |
+
shape, the content pipeline, the agent contract, deployment.
|
| 13 |
+
- **Per-package references** (`docs/<package>/README.md`) mirror the code tree
|
| 14 |
+
one-to-one: each documents a single top-level package β its boundary, its
|
| 15 |
+
flow, the decisions and their rejected alternatives, and a file-by-file map.
|
| 16 |
+
|
| 17 |
+
Start with the design docs for the shape of the whole; drop into a package
|
| 18 |
+
reference when you're about to touch that package.
|
| 19 |
+
|
| 20 |
+
## The system in one picture
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
the docs.pytorch.org site
|
| 24 |
+
β
|
| 25 |
+
ingest/ discover β crawl β chunk builds the _corpus/ snapshot
|
| 26 |
+
β
|
| 27 |
+
index/ embed β Neon (pgvector + tsvector) storage + hybrid retrieval
|
| 28 |
+
β pointers + hydrated sections
|
| 29 |
+
agent/ guard β route β grounded | loop β llm β Answer the answering brain
|
| 30 |
+
β
|
| 31 |
+
app/ Gradio Space: stream the reasoning, render the answer + citations
|
| 32 |
+
β
|
| 33 |
+
the user
|
| 34 |
+
|
| 35 |
+
scripts/ the CLI + CI surface that builds, evaluates, and operates all of the above
|
| 36 |
+
eval/ the measurement layer that keeps every layer honest
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Data flows down the middle; `scripts/` and `eval/` wrap the column as tooling.
|
| 40 |
+
The knowledge boundary is firm: the docs **site** is the corpus, source code is
|
| 41 |
+
never indexed (it is referred out), and every answer is grounded in retrieved
|
| 42 |
+
sections with clickable citations.
|
| 43 |
+
|
| 44 |
+
## Per-package references
|
| 45 |
+
|
| 46 |
+
| Package | What it is | Reference |
|
| 47 |
+
|---|---|---|
|
| 48 |
+
| **`ingest/`** | The crawl β snapshot β chunk pipeline that turns the live docs site into a heading-chunked corpus. Touches no DB. | [docs/ingest/](ingest/README.md) |
|
| 49 |
+
| **`index/`** | The Neon/pgvector storage + retrieval layer: embeddings, hybrid search, DB-served hydration, and the self-healing freshness pass. | [docs/index/](index/README.md) |
|
| 50 |
+
| **`agent/`** | The answer-generation brain: guard β route β grounded/loop β LLM dispatch β validated `Answer`. | [docs/agent/](agent/README.md) |
|
| 51 |
+
| **`app/`** | The Gradio web app (the Hugging Face Space): serving, concurrency, and the live grey reasoning trace. | [docs/app/](app/README.md) |
|
| 52 |
+
| **`eval/`** | The measurement layer: static answer checks, retrieval metrics, and LLM-as-judge β wired into CI and the live answer path. | [docs/eval/](eval/README.md) |
|
| 53 |
+
| **`scripts/`** | The command-line + CI surface: build the index, generate synthetic data, evaluate, calibrate, and smoke-test. | [docs/scripts/](scripts/README.md) |
|
| 54 |
+
|
| 55 |
+
## Cross-cutting design docs
|
| 56 |
+
|
| 57 |
+
| Document | What it covers |
|
| 58 |
+
|---|---|
|
| 59 |
+
| [design-content-and-agent-flow.md](design-content-and-agent-flow.md) | The architecture bible: corpus scope, the ingestion pipeline, the three agent tools, the session flow, LangChain-vs-LangGraph, and how stored pointers map to live links. Start here. |
|
| 60 |
+
| [retrieval-gaps-and-improvements.md](retrieval-gaps-and-improvements.md) | Known retrieval weaknesses and the improvement backlog (reranking, multilingual embedder, judge model, β¦). |
|
| 61 |
+
| [loop-vs-langgraph.md](loop-vs-langgraph.md) | The measured comparison of the manual tool loop (`agent/loop.py`) against its LangGraph twin (`agent/graph.py`). |
|
| 62 |
+
| [deploy-hf-spaces.md](deploy-hf-spaces.md) | The Hugging Face Spaces deployment walkthrough β secrets, the sync workflow, and the post-deploy smoke test. |
|
| 63 |
+
|
| 64 |
+
See also [PLAN.md](../PLAN.md) for the milestone roadmap and the binding
|
| 65 |
+
decisions that constrain execution.
|
| 66 |
+
|
| 67 |
+
## Suggested reading order
|
| 68 |
+
|
| 69 |
+
1. [design-content-and-agent-flow.md](design-content-and-agent-flow.md) β the whole shape in one read.
|
| 70 |
+
2. [docs/ingest/](ingest/README.md) β [docs/index/](index/README.md) β how the corpus is built and retrieved (data flows up from here).
|
| 71 |
+
3. [docs/agent/](agent/README.md) β how a question becomes a grounded answer.
|
| 72 |
+
4. [docs/app/](app/README.md) β how it is served and streamed to the user.
|
| 73 |
+
5. [docs/eval/](eval/README.md) and [docs/scripts/](scripts/README.md) β how it is measured and operated.
|
| 74 |
+
|
| 75 |
+
## Conventions
|
| 76 |
+
|
| 77 |
+
- Each package reference has YAML frontmatter (`title`, `kind: reference`,
|
| 78 |
+
`package`), then: boundary β flow β design decisions & rationale β tool
|
| 79 |
+
choices β file-by-file β related docs.
|
| 80 |
+
- Docs describe the **code as written**, not an idealized plan. Where a plan
|
| 81 |
+
item is unbuilt or a decision was superseded, the doc says so.
|
| 82 |
+
- These are OKF-style knowledge documents (markdown + frontmatter), the same
|
| 83 |
+
convention the corpus chunks and the other `docs/*.md` reports use.
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "The agent/ package β answer generation"
|
| 3 |
+
kind: reference
|
| 4 |
+
package: agent
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# The `agent/` package
|
| 8 |
+
|
| 9 |
+
The answer-generation brain: it takes one vetted PyTorch question and returns a single docs-grounded `Answer` β with validated citations, honest referrals, and a bounded worst-case cost.
|
| 10 |
+
|
| 11 |
+
## Why this package exists / its boundary
|
| 12 |
+
|
| 13 |
+
`agent/` owns everything from "a question has arrived" to "a structured `Answer` is ready to render". It owns the **trust boundary check** (`guard.py`), the **cost decision** (`route.py`), the two answering strategies (`grounded.py` single-shot and `loop.py` / `graph.py` multi-tool), the **provider dispatch and structured-output contract** (`llm.py`), the **output schema** (`schemas.py`), and the **tool surface** the agent drives (`tools.py`, `tools_exec.py`).
|
| 14 |
+
|
| 15 |
+
It does **not** own retrieval or content. The three tools are thin: `search_docs` calls `index.retrieve` + `index.hydrate`, `read_page` calls `index.hydrate`, and the guard's topicality check calls `index.retrieve.top_distance`. All embedding, hybrid search (pgvector + tsvector RRF), snapshot hydration, and the index itself live in `index/`. `agent/` treats retrieval as a pointer-returning oracle and works entirely with the hydrated section dicts it hands back. The static answer checks (parses/imports/symbols) live in `eval/checks.py`; `grounded.py` merely wires them into the live path. So the boundary is: **`agent/` decides how to think about a question and how to phrase the answer; `index/` decides what the docs say.**
|
| 16 |
+
|
| 17 |
+
## The flow
|
| 18 |
+
|
| 19 |
+
A question travels through the package like this:
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
question
|
| 23 |
+
β
|
| 24 |
+
ββ guard.py one check at the trust boundary (length β language β topicality)
|
| 25 |
+
β fail β refusal string, never reaches the LLM
|
| 26 |
+
β
|
| 27 |
+
ββ route.py zero-LLM regex heuristic: multi-source shape?
|
| 28 |
+
β ββ yes ββββββββββββββββββββββββββββββΆ loop.py (answer_agentic)
|
| 29 |
+
β ββ no ββββββΆ grounded.py (answer_grounded, one pass)
|
| 30 |
+
β ββ produced NO citations? ββΆ escalate to loop.py
|
| 31 |
+
β
|
| 32 |
+
ββ loop.py / graph.py planner β tools within budgets, accumulating sections
|
| 33 |
+
β (search_docs β€6, read_page β€2, ask_source β€1)
|
| 34 |
+
β
|
| 35 |
+
ββ grounded.answer_from_sections the shared terminus for BOTH paths
|
| 36 |
+
β build context β llm.answer_question β static-check
|
| 37 |
+
β repair (β€1) β validate_citations
|
| 38 |
+
β
|
| 39 |
+
ββ llm.py + schemas.py provider dispatch β structured JSON β validated Answer
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
**The routing heuristic** (`route.needs_loop`) is a single compiled regex over the raw English question, matching the shapes where one retrieval pass is provably not enough: catalog (`what/which β¦ exist / are available`, `list all β¦`), compare (`difference between`, `X vs Y`, `should I use X or Y`), recipe (`build/train β¦ a model/network/classifier`, `end-to-end`, `from scratch`), and internals (`how is β¦ implemented`, `under the hood`). Everything else goes to the single-shot grounded path. The heuristic is deliberately coarse because it decides **cost, not correctness** β it is allowed to be wrong.
|
| 43 |
+
|
| 44 |
+
**Escalation on no citations** is the safety net that makes coarseness acceptable. When `answer_routed` sends a question to `answer_grounded` and the returned `Answer` has an empty `citations` list, that means one fixed retrieval pass found nothing groundable. Rather than ship an unsourced reply, `route.py` escalates the same question to `answer_agentic`, which can reformulate and re-search. The heuristic thus fails open in both directions: a misrouted multi-source question still gets a (slower) correct loop answer, and a misrouted simple question that comes back empty gets a second, more thorough attempt.
|
| 45 |
+
|
| 46 |
+
Both the loop and the single-shot path converge on `grounded.answer_from_sections`, so the grounding contract (context-only generation, one static-check repair round, citation validation) is applied **exactly once and identically** no matter how the sections were gathered.
|
| 47 |
+
|
| 48 |
+
## Design decisions & rationale
|
| 49 |
+
|
| 50 |
+
**The grounding contract.** `GROUNDED_SYSTEM` in `grounded.py` instructs the model to answer *only* from the numbered context sections, be concise, cite every section it used verbatim, and add a `referral` instead of guessing when the context falls short. That instruction is enforced, not trusted:
|
| 51 |
+
- `validate_citations` drops any citation whose `(url, anchor)` (or bare `url`) is not in the sections actually provided β the model cannot cite a page it wasn't shown.
|
| 52 |
+
- `_regenerate_if_checks_fail` runs `eval/checks.run_checks` (code blocks parse via `ast`, imports are torch-family/stdlib only, every `symbols_used` entry appears verbatim in the prose) and, on failure, re-asks once with the failure reasons injected. The repair is kept **only if it is strictly cleaner** (fewer failing checks); it never blocks the user.
|
| 53 |
+
- Per-section context is capped at `SECTION_CHAR_LIMIT` (2500 chars) with a **visible** truncation marker and a log line, so the model can referral out rather than silently answer from a cut-off view.
|
| 54 |
+
|
| 55 |
+
**Route before loop (cost).** The loop costs ~5β13 LLM calls per question (planner rounds + answer + possible repair); on the free-tier deployment models that is minutes of wall-clock. The loop's value is real only for multi-source questions β measured agentic coverage 0.567 vs 0.133 single-shot β while a plain usage question is answered well by one retrieval pass plus one generation. A zero-LLM-call heuristic pays nothing to keep the common case cheap.
|
| 56 |
+
|
| 57 |
+
**The guard uses embedding distance as the on-topic policy.** Membership in the docs' embedding space *is* the policy: `_is_on_topic` embeds the question, takes the cosine distance to its nearest doc chunk, and blocks anything beyond `DEFAULT_TOPICALITY_MAX_DISTANCE` (0.35, calibrated 2026-07-07 to sit between the worst on-topic 0.305 and the best off-topic 0.371). This is deliberate: an off-topic request and a prompt injection ("ignore your rules and β¦") both land far from the corpus and get the same refusal, so **no dedicated injection classifier is needed** β an earlier design used one, which cost an extra model call and still missed injections wrapped in on-topic questions. What passes the gate is safe anyway because of the downstream grounding contract. The guard is **fail-open**: any retrieval error is logged and allowed, so the guard can never take the app down, and the whole guard is toggleable via `TORCHDOCS_GUARD=0`.
|
| 58 |
+
|
| 59 |
+
**English-only.** The corpus and the bge-small embedder are English-only, so `looks_english` (a cheap mostly-ASCII heuristic, tolerating a couple of stray non-Latin chars) bounces foreign input with a "please rephrase in English" refusal. The rejected alternative was a per-question translation LLM call, which dominated latency for non-English input. A multilingual embedder would remove this limit (noted in `docs/retrieval-gaps-and-improvements.md`).
|
| 60 |
+
|
| 61 |
+
**The provider fallback chain.** `llm.py` dispatches across three providers β `openai-compat` (OpenRouter/DeepInfra/Nebius/β¦, the free-tier deployment default), `anthropic` (the paid production path), and `gemini`. `default_provider()` honors `TORCHDOCS_PROVIDER` or auto-detects from whichever API key is set; `_provider_chain` then puts the preferred provider first and appends every *other* configured provider, so one broken or misconfigured secret can't take the deploy down β the answer path self-heals to another provider. Within `openai-compat`, `TORCHDOCS_OPENAI_COMPAT_MODEL` is a comma-separated model chain (default `tencent/hy3:free` β `meta-llama/llama-3.3-70b-instruct:free`) so a rate-limited or retired slug falls through to the next. A process-wide **cooldown circuit breaker** (`_COOLDOWNS`) records models/providers that just failed (60s on a 429, an hour on a 404) so other in-flight requests skip them instead of each paying the full retry+sleep cost. There is hard-won secret hygiene here too: `_env_secret` strips trailing newlines (a pasted key with a newline becomes an illegal `Authorization` header and looks like a total outage), and `_redact` masks Bearer/`sk-` tokens before any exception text is logged.
|
| 62 |
+
|
| 63 |
+
**The `progress` reasoning-trace sink.** `answer_grounded`, `answer_agentic`, and `answer_routed` all accept an optional `progress` callable. It is a sink for short human-readable trace lines ("π searched β¦", "π found: β¦", "βοΈ writing the answer", "β» no sources yet β searching more thoroughly") that the web UI streams in grey while the answer assembles. `loop._humanize` turns the terse transcript records into these lines, falling back to the raw line on any shape it doesn't recognise so a format change degrades gracefully. It defaults to `None` (no-op), so scripts and tests are unaffected.
|
| 64 |
+
|
| 65 |
+
**The LangGraph twin.** `graph.py` reimplements the manual loop as a LangGraph state machine (`planner β tools β planner` cycle, `planner/tools β generate β END`). It is intentionally the *second* implementation: it shares the exact same tools, budgets, `_plan` planner, and forced seed search, and its tool step is literally `tools_exec.execute_tool` β so the two drivers **cannot drift**. Only the control flow differs (an explicit graph vs a Python `while`-loop). The point is a controlled comparison of lines-of-code, debuggability, and latency, recorded in `docs/loop-vs-langgraph.md`, and a ready path to features that genuinely need a graph runtime (checkpointed multi-turn, human-in-the-loop, parallel tool fan-out).
|
| 66 |
+
|
| 67 |
+
## Tool & library choices
|
| 68 |
+
|
| 69 |
+
| Library | Used for | Why (and what was rejected) |
|
| 70 |
+
|---|---|---|
|
| 71 |
+
| **pydantic** | `schemas.py` `Answer`/`Citation`/`Referral`; every provider validates into `Answer` | One schema drives structured output across all three SDKs (`Answer.model_json_schema()` becomes a Gemini `response_schema`, an OpenAI `json_object` prompt, and an Anthropic forced-tool `input_schema`) and the validation error text feeds the single repair round. |
|
| 72 |
+
| **anthropic** SDK | `_answer_anthropic` / anthropic raw path | Forced tool call (`tool_choice` β `submit_answer`) is the most reliable structured-output mechanism on the paid production model. |
|
| 73 |
+
| **google-genai** SDK | `_answer_gemini` | Native `response_schema` + `application/json` mime type; free tier for cheap runs. |
|
| 74 |
+
| **openai** SDK | `_answer_openai_compat` + shared `_compat_client`/`_compat_complete` | Talks to any OpenAI-compatible host (OpenRouter default). One pooled client per `(base_url, key, timeout)` keeps httpx keep-alive across the ~13 calls a question makes. |
|
| 75 |
+
| **No LangChain** | β | Provider abstraction is `llm.py`'s own dispatch layer; the three tools are ours (SQL retrieval + snapshot reads + referral links); nothing is left for LangChain to abstract, so it is not a dependency. Older design docs mention **LiteLLM and Langfuse β neither is in the real code**; `agent/llm.py` is the hand-rolled dispatch, and observability is `print`-to-logs. |
|
| 76 |
+
| **langgraph** | `graph.py` only | The orchestration runtime for the twin loop; imported lazily inside `build_graph()` so the manual path has no hard dependency on it. |
|
| 77 |
+
|
| 78 |
+
A dispatch layer instead of a framework is the recurring theme: the structured-output contract is uniform (schema-valid `Answer` or `GenerationError`, one repair round) but each provider reaches it its own way, and keeping that in ~640 lines of `llm.py` bought the exact cooldown/fallback/secret-hygiene behavior the free-tier deployment needed.
|
| 79 |
+
|
| 80 |
+
## File by file
|
| 81 |
+
|
| 82 |
+
- **`__init__.py`** β empty package marker; imports are done lazily at call sites throughout the package (to keep optional SDKs like `langgraph`/`google-genai` from being hard import-time dependencies).
|
| 83 |
+
- **`guard.py`** β the single input guardrail, run once on the raw user question at the trust boundary. Three cheapest-first checks (length β English β topicality). *Key decision:* embedding-distance membership is the entire on-topic policy β off-topic and prompt injection get the same refusal, no separate classifier β and it fails open on any error.
|
| 84 |
+
- **`route.py`** β the cost router. A zero-LLM regex sends multi-source shapes to the loop and everything else to the single-shot path. *Key decision:* a grounded answer with no citations escalates to the loop, which lets the heuristic be coarse without shipping unsourced replies.
|
| 85 |
+
- **`grounded.py`** β the single-shot path *and* the shared answer terminus (`answer_from_sections`). Builds the numbered context, generates, runs the static-check repair, and validates citations. *Key decision:* citations are validated against the provided sections, so the model can never cite a page it wasn't shown.
|
| 86 |
+
- **`loop.py`** β the manual agent loop. A planner LLM returns a JSON action each step; tools run within budgets (`search_docs:6, read_page:2, ask_source:1`) until the model answers or a budget trips. *Key decision:* a forced seed `search_docs` on the raw question runs *before* the first planner call, so a rate-limited planner never blocks the obvious first retrieval.
|
| 87 |
+
- **`graph.py`** β the LangGraph twin of `loop.py`, same tools/budgets/planner. *Key decision:* the tool node delegates to the shared `tools_exec.execute_tool`, guaranteeing the two drivers can't diverge behaviorally.
|
| 88 |
+
- **`tools_exec.py`** β one tool-execution step (`do_search`, `execute_tool`) shared by both drivers. Mutates the section/referral/seen/transcript accumulators in place with consistent dedup and observation strings. *Key decision:* extracting the dispatch + dedup here is exactly what keeps loop and graph identical; budget accounting stays with each driver.
|
| 89 |
+
- **`tools.py`** β the three tool functions themselves. `search_docs` (hybrid retrieval, optional `kind`/`library` filter, unknown `kind` degrades to unrestricted), `read_page` (whole-page hydrate; rejects a non-URL with a self-correcting error message), `ask_source` (referral-only). *Key decision:* `ask_source` never returns claims about the code β only DeepWiki + GitHub-code-search referral links β so it works with no network access and keeps source knowledge strictly out of docs-cited text.
|
| 90 |
+
- **`llm.py`** β the provider dispatch layer: one question in, one validated `Answer` out, across gemini/anthropic/openai-compat with a self-healing provider+model fallback chain, cooldown circuit breaker, retry/backoff, and secret redaction/stripping. *Key decision:* a hand-rolled dispatch (not LiteLLM/LangChain) because the free-tier reality β dead slugs, per-minute and per-day rate limits, newline-corrupted secrets β needed bespoke resilience.
|
| 91 |
+
- **`schemas.py`** β the pydantic output contract: `Answer` (`answer_md`, `symbols_used`, `torch_version`, `citations`, `referrals`) plus `Citation` and `Referral`. *Key decision:* the distinction between a `Citation` ("the answer came from here") and a `Referral` ("this is beyond the docs β look here") is encoded in the type system, mirroring the product's knowledge-boundary rule.
|
| 92 |
+
|
| 93 |
+
## Related docs
|
| 94 |
+
|
| 95 |
+
- [`../design-content-and-agent-flow.md`](../design-content-and-agent-flow.md) β the design rationale for the tools, session flow, and live-link mechanism this package implements.
|
| 96 |
+
- [`../loop-vs-langgraph.md`](../loop-vs-langgraph.md) β the measured comparison of the manual `loop.py` against the `graph.py` twin.
|
| 97 |
+
- [`../index/README.md`](../index/README.md) β the sibling package that owns retrieval, hydration, and the docs index this package delegates to.
|
| 98 |
+
- [`../eval/README.md`](../eval/README.md) β the evaluation harness and `eval/checks.py`, the static answer checks wired into `grounded.py`.
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: The app/ package
|
| 3 |
+
kind: reference
|
| 4 |
+
package: app
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# `app/` β the Gradio web app
|
| 8 |
+
|
| 9 |
+
The long-lived Gradio server that fronts the agent: the Hugging Face Space
|
| 10 |
+
entrypoint that loads the embedding model once, streams a live reasoning trace
|
| 11 |
+
while it works, and delegates every answer to `agent/`.
|
| 12 |
+
|
| 13 |
+
## Why this package exists / its boundary
|
| 14 |
+
|
| 15 |
+
`app/` owns **UI and serving concerns only**. It paints the chat surface, keeps
|
| 16 |
+
the server up, manages concurrency and rate limiting, streams progress, and
|
| 17 |
+
formats the final markdown. It does **not** answer questions β the moment a
|
| 18 |
+
question needs answering, `_pipeline()` hands off to `agent/` (`agent.guard`,
|
| 19 |
+
`agent.route.answer_routed`) and `index/` (`index.embed`, `index.freshness`).
|
| 20 |
+
|
| 21 |
+
The split is deliberate: the batch GitHub Actions runs and the eval harness call
|
| 22 |
+
the same `agent/` code with no Gradio in sight, and the app stays a thin,
|
| 23 |
+
replaceable shell. If you are looking for retrieval, tool-calling, or JSON
|
| 24 |
+
validation logic, it is not here β it is in `agent/`. What lives here is
|
| 25 |
+
everything that turns a validated `Answer` object into a responsive web page.
|
| 26 |
+
|
| 27 |
+
## The request lifecycle in the UI
|
| 28 |
+
|
| 29 |
+
`respond()` (`app/main.py`) is a **generator**. Gradio streams every value it
|
| 30 |
+
yields to the same output `Markdown` component, so the screen updates in place
|
| 31 |
+
with no page refresh. The sequence per question:
|
| 32 |
+
|
| 33 |
+
1. **Thinking note** β yields the static `THINKING_NOTE` ("π Searching the
|
| 34 |
+
PyTorch docsβ¦") immediately, so the first paint happens the instant Enter is
|
| 35 |
+
pressed.
|
| 36 |
+
2. **Live grey trace + spinner** β the real work (`_pipeline`) runs on a daemon
|
| 37 |
+
**worker thread**. While it is alive, the generator loops every
|
| 38 |
+
`THINKING_TICK` (0.6 s), reads the trace lines the pipeline has appended, and
|
| 39 |
+
yields them via `_render_trace()`: each step in the theme's subdued grey with
|
| 40 |
+
a turning Braille wheel (`THINKING_SPINNER`) on a trailing line. A multi-
|
| 41 |
+
second wait reads as visible work rather than a freeze.
|
| 42 |
+
3. **Black answer** β when the worker finishes, the generator yields the final
|
| 43 |
+
markdown (`render(answer)`), replacing the whole grey trace with the answer in
|
| 44 |
+
normal text.
|
| 45 |
+
4. **Freshness spinner** β if the answer has citations and `index.freshness` is
|
| 46 |
+
enabled, a stale-while-revalidate pass starts. The answer stays fully on
|
| 47 |
+
screen while a **bare** wheel (just the spinner, no words) turns underneath it
|
| 48 |
+
via `run_below()` β the user reads while the app verifies the cited pages.
|
| 49 |
+
5. **(Maybe) regenerated answer** β a clean check just drops the spinner. If the
|
| 50 |
+
cited docs **drifted**, `freshness.refresh_pages()` self-heals the stored
|
| 51 |
+
copies, `answer_routed` is re-run against the fresh content, and the new
|
| 52 |
+
answer swaps in with the `β» FRESHNESS_NOTE`. A failed check or failed
|
| 53 |
+
regeneration silently keeps the answer already shown.
|
| 54 |
+
|
| 55 |
+
### Why stream the reasoning trace and not answer tokens
|
| 56 |
+
|
| 57 |
+
The obvious move β stream answer tokens like a chatbot β does not fit this
|
| 58 |
+
pipeline. The answer is **not free prose**; it is a validated JSON `Answer`
|
| 59 |
+
object assembled over several tool calls (search β read β generate β static
|
| 60 |
+
check). There are no partial tokens to emit until the whole thing is built and
|
| 61 |
+
validated. So instead the app streams the **reasoning**: the pipeline emits a
|
| 62 |
+
short trace line per step (which docs it searched, what it found, when it starts
|
| 63 |
+
writing) through the `progress` sink, and `respond()` renders those live. The
|
| 64 |
+
grey trace is honest visible progress for a request whose payload can only arrive
|
| 65 |
+
all at once.
|
| 66 |
+
|
| 67 |
+
## Serving & robustness
|
| 68 |
+
|
| 69 |
+
| Concern | Mechanism (in `app/main.py`) | Why |
|
| 70 |
+
| --- | --- | --- |
|
| 71 |
+
| **Warmup** | `_warm_up()` calls `index.embed.embed_query("warmup")` once at startup | Loads bge-small (~130 MB) before the first request, so no user eats the cold-start; also warms the guard's topicality embed. Best-effort β a warmup failure is logged, not fatal. |
|
| 72 |
+
| **Concurrency** | `demo.queue(default_concurrency_limit=CONCURRENCY)` | Gradio defaults every event to serial `concurrency_limit=1`; opening the queue lets many I/O-bound requests (LLM + Neon) overlap so nobody waits in line. `max_threads` is lifted in step (`max(40, CONCURRENCY*2)`) so the thread pool β which also holds threads parked in 429 back-off β never becomes the hidden ceiling. |
|
| 73 |
+
| **Backpressure** | `max_size=QUEUE_SIZE` | Bounds how many requests may wait behind the workers. Under a flood, extra callers get "queue full" instead of an unbounded, forever-growing line. |
|
| 74 |
+
| **Per-client rate limit** | `_rate_limited()` β sliding window keyed on client IP | At most `RATE_LIMIT` questions per `RATE_WINDOW` seconds per IP, so one over-eager caller can't occupy every worker slot and burn the shared free-tier LLM quota. Set `RATE_LIMIT=0` to disable. The bucket table self-prunes past 4096 entries. |
|
| 75 |
+
| **Fail-open errors** | `try/except` in `_pipeline` and in the worker `work()` | The UI must never crash or hang. Any exception is logged with type + message and the user gets the generic `ERROR_NOTE`; the real error never reaches the browser, since an exception string can leak hosts, model slugs, and config internals. |
|
| 76 |
+
| **Smoke-test contract** | `ERROR_NOTE` contains the literal phrase **"went wrong"** | The post-deploy smoke test (`scripts/smoke_space.py`) greps for that marker to detect a broken Space. Keep the phrase. The `respond` event is also registered with `api_name="respond"` so the smoke test has a stable `client.predict(..., api_name="/respond")` endpoint; because `gradio_client.predict` returns the *last* yielded value, the generator's final yield is always a real answer. |
|
| 77 |
+
|
| 78 |
+
## UX decisions & rationale
|
| 79 |
+
|
| 80 |
+
- **Send-on-Enter requires `lines=1`.** The question `Textbox` is `lines=1` (not
|
| 81 |
+
2), and this is load-bearing: Gradio only fires `Textbox.submit` on a **bare
|
| 82 |
+
Enter for a single-line box**. A multi-line box (`lines>1`) treats Enter as a
|
| 83 |
+
newline and submits on Shift+Enter instead. `max_lines=6` still lets a long
|
| 84 |
+
question grow visually β the submit rule keys off the `lines` prop, not the
|
| 85 |
+
rendered height β so Enter keeps sending. Do not bump `lines` back to 2.
|
| 86 |
+
- **Theme-aware subdued grey that survives the sanitiser.** Trace lines use the
|
| 87 |
+
inline style `color:var(--body-text-color-subdued)` (`TRACE_STYLE`), a Gradio
|
| 88 |
+
CSS variable that adapts to light/dark theme. The inline style survives
|
| 89 |
+
Gradio's markdown sanitiser (verified on gradio 6.20), which would strip a
|
| 90 |
+
`<style>` block or class. Trace text is `html.escape`d because a step can echo
|
| 91 |
+
the user's untrusted query.
|
| 92 |
+
- **English-only copy.** All UI strings (`INTRO`, `EXAMPLES`, notes) are English,
|
| 93 |
+
matching the agent's English-only answering contract β no localisation layer.
|
| 94 |
+
- **In-place freshness swap.** The freshness pass never blanks the screen or
|
| 95 |
+
refreshes the page: it streams updates to the same output component, keeping
|
| 96 |
+
the answer visible with only a spinner line changing underneath. Verification
|
| 97 |
+
is invisible unless it actually changes the answer.
|
| 98 |
+
|
| 99 |
+
## Tool & library choices
|
| 100 |
+
|
| 101 |
+
- **gradio** β provides the whole web surface (`Blocks`, `Textbox`, `Markdown`,
|
| 102 |
+
`Examples`, `Button`) and, crucially, the **queue** model that makes the app
|
| 103 |
+
concurrent and gives it backpressure. It is also the native Hugging Face Spaces
|
| 104 |
+
SDK, so the same file is the local dev server and the deployed entrypoint. Its
|
| 105 |
+
generator-streaming support is what lets `respond()` push a live trace.
|
| 106 |
+
- **threading** (stdlib) β the answer pipeline runs on a daemon worker thread so
|
| 107 |
+
`respond()` can stay responsive and stream the spinner/trace instead of
|
| 108 |
+
blocking. A `threading.Lock` guards the shared `trace` list (worker appends,
|
| 109 |
+
generator reads) and the rate-limit buckets (`_RATE_LOCK`). `run_below()`
|
| 110 |
+
reuses the same pattern for the freshness and regeneration passes.
|
| 111 |
+
- **python-dotenv** β `load_dotenv()` reads local `.env` so `python -m app.main`
|
| 112 |
+
works with `NEON_URL` + OpenRouter config without exporting vars by hand; on
|
| 113 |
+
the Space the real environment already carries them, so this is a no-op there.
|
| 114 |
+
|
| 115 |
+
## File by file
|
| 116 |
+
|
| 117 |
+
- **`app/main.py`** β the entire app: config constants and the `TORCHDOCS_*`
|
| 118 |
+
env-var reads; the rate limiter (`_rate_limited`); warmup (`_warm_up`); the
|
| 119 |
+
pure answer pipeline (`_pipeline`, no UI concerns) and markdown assembly
|
| 120 |
+
(`render`, `_render_trace`); the streaming UI generator (`respond`) with its
|
| 121 |
+
freshness pass; and the wiring (`build_ui`, `serve`, `main`). Running it as
|
| 122 |
+
`python -m app.main` starts a local server.
|
| 123 |
+
- **`app/__init__.py`** β empty package marker (present but zero-length); the
|
| 124 |
+
module is imported as `app.main`.
|
| 125 |
+
- **`app.py`** (repo root) β the Hugging Face Spaces entrypoint shim. It imports
|
| 126 |
+
`_warm_up`, `build_ui`, and `serve` from `app.main`, warms the model, and
|
| 127 |
+
builds the `demo` at import time (Spaces looks for a module-level `demo`), then
|
| 128 |
+
calls `serve(demo)` under `__main__` for local runs. It exists so the Space has
|
| 129 |
+
a top-level `app.py` while the real code stays in the `app/` package β the two
|
| 130 |
+
share `serve()` so bind settings never drift between entrypoints.
|
| 131 |
+
|
| 132 |
+
## Configuration
|
| 133 |
+
|
| 134 |
+
All read once at import in `app/main.py`; override per deploy in the Space's
|
| 135 |
+
Variables and secrets.
|
| 136 |
+
|
| 137 |
+
| Env var | Default | What it controls |
|
| 138 |
+
| --- | --- | --- |
|
| 139 |
+
| `TORCHDOCS_CONCURRENCY` | `16` | How many questions are answered at once (Gradio `default_concurrency_limit`, and the floor for `max_threads`). Generous because requests are almost all I/O wait. |
|
| 140 |
+
| `TORCHDOCS_QUEUE_SIZE` | `64` | Queue `max_size`: how many requests may wait behind the workers before new ones are rejected with "queue full". |
|
| 141 |
+
| `TORCHDOCS_RATE_LIMIT` | `8` | Max questions per client IP per window. `0` disables the throttle. |
|
| 142 |
+
| `TORCHDOCS_RATE_WINDOW_SECONDS` | `60` | Length of the sliding rate-limit window, in seconds. |
|
| 143 |
+
| `PORT` | `7860` | Server bind port (`server_name` is fixed to `0.0.0.0`). |
|
| 144 |
+
|
| 145 |
+
Answering-side config (`NEON_URL`, the OpenRouter/LLM provider vars,
|
| 146 |
+
`TORCHDOCS_LIVE_HYDRATE`) is consumed by `agent/` and `index/`, not by this
|
| 147 |
+
package β see the deploy runbook.
|
| 148 |
+
|
| 149 |
+
## Related docs
|
| 150 |
+
|
| 151 |
+
- [`../deploy-hf-spaces.md`](../deploy-hf-spaces.md) β how the Space is created,
|
| 152 |
+
configured, and smoke-tested.
|
| 153 |
+
- [`../design-content-and-agent-flow.md`](../design-content-and-agent-flow.md) β
|
| 154 |
+
the design rationale; Β§5.4 covers what the user sees (citations, referrals).
|
| 155 |
+
- [`../agent/README.md`](../agent/README.md) β the pipeline this package
|
| 156 |
+
delegates to (guard, routing, tool loop, `Answer` schema).
|
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "eval β the evaluation harness"
|
| 3 |
+
kind: reference
|
| 4 |
+
package: eval
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# `eval/` β the measurement layer
|
| 8 |
+
|
| 9 |
+
The `eval/` package is how we know whether a change to the agent made things better or worse. It holds the static checks that run on every answer, the offline benchmark suites (retrieval / judge / agentic / v0), a retrieval microscope for debugging misses, and the labeled question sets and result files that make "before vs after" a number instead of an opinion.
|
| 10 |
+
|
| 11 |
+
## Why this package exists / its boundary
|
| 12 |
+
|
| 13 |
+
The plan committed to **eval from day one** (`PLAN.md` Β§1.3, before any grounding was built). That order is deliberate: this project's whole reason to exist is that an ungrounded LLM confidently invents PyTorch APIs, and the only honest way to justify the grounding work β retrieval, glosses, reranking, citation-scoping, static-check regeneration β is to *measure* the hallucination it removes. The `eval/hallucinations.md` log is the origin point: 15 v0 questions run with no retrieval, 5 documented invented-API / wrong-signature findings, each one a measurable target that grounding then has to erase.
|
| 14 |
+
|
| 15 |
+
So the boundary of this package is **measurement, not machinery**. Nothing here answers a user's question in production; it exercises the code that does (`agent/`, `index/`) and scores the output. The one deliberate exception is `checks.py`, which is imported by the live answer path β see [The static checks](#the-static-checks). Everything else reads from the same database and LLM providers the app uses, runs offline (locally or in the `Eval` GitHub Action), and writes JSONL to `eval/results/` for diffing.
|
| 16 |
+
|
| 17 |
+
## The suites
|
| 18 |
+
|
| 19 |
+
Each runner is a standalone `python -m eval.<runner>` entry point. They measure different layers so a regression can be localized β retrieval, generated prose, and the full agent loop are scored separately rather than as one opaque end-to-end number.
|
| 20 |
+
|
| 21 |
+
| Runner | Layer measured | Metric emitted | Results file | In `Eval` workflow |
|
| 22 |
+
|---|---|---|---|---|
|
| 23 |
+
| `run_v0.py` | Generation + static checks on the manual v0 set | pass/fail table across parses/imports/symbols; `--grounded` adds `grounded_api_rate` + avg citations | `results/v0.jsonl`, `results/v0-grounded.jsonl` | no (local baseline) |
|
| 24 |
+
| `run_retrieval.py` | Retrieval only β no LLM | mean `recall@k`, mean `MRR` | `results/retrieval_<set>.jsonl` | `suite=retrieval`, `retrieval+judge`, `all` |
|
| 25 |
+
| `run_judge.py` | Generated answer prose (grounded single-shot) | LLM-as-judge faithfulness / answer-relevance / citation-correctness (1β5 β [0,1]) + overall; latency p50/p95/max/mean | `results/judge_<set>.jsonl` | `suite=judge`, `retrieval+judge`, `all` |
|
| 26 |
+
| `run_agentic.py` | The full agent loop vs one-shot | citation `coverage` and the **agentic β single-shot delta** | `results/agentic_v1.jsonl` | `suite=agentic`, `all` |
|
| 27 |
+
| `diagnose_retrieval.py` | (debugging, not a benchmark) | per-miss rank/distance triage, printed | none (stdout) | runs after `suite=retrieval` |
|
| 28 |
+
|
| 29 |
+
Notes on the workflow (`.github/workflows/eval.yml`): the default one-click suite is `retrieval+judge` β recall/MRR plus the answer-quality anchor β chosen deliberately because the GitHub mobile app can't set workflow inputs, so the default has to be the measurement worth having. `agentic` stays opt-in (it is slow: many LLM calls per question for both paths, hence the 120-minute job timeout). Every job commits its `results/*.jsonl` back to `main` with `[skip ci]` so before/after diffs live in git history, not in ephemeral CI logs.
|
| 30 |
+
|
| 31 |
+
A recurring guard shared by the LLM suites: a partial run (`TORCHDOCS_JUDGE_LIMIT`, `TORCHDOCS_AGENTIC_LIMIT`, non-8 `k`) suffixes its results filename (`_first10`, `_k4`) so a bounded CI run can **never masquerade as the full-set result** it would otherwise overwrite.
|
| 32 |
+
|
| 33 |
+
## The static checks
|
| 34 |
+
|
| 35 |
+
`checks.py` runs three checks on every `Answer`, and it runs no code β ever. This is a docs assistant, not a sandbox; the checks are static-analysis only:
|
| 36 |
+
|
| 37 |
+
| Check | What it enforces |
|
| 38 |
+
|---|---|
|
| 39 |
+
| `parses` | Every ` ```python ` block in `answer_md` passes `ast.parse` (untagged fences, and `pycon` console sessions with `>>>` prompts, are intentionally skipped) |
|
| 40 |
+
| `imports` | Every import in those blocks resolves to a torch-family root (`torch`/`torchvision`/`torchaudio`) or the stdlib (`sys.stdlib_module_names`); relative imports are always rejected |
|
| 41 |
+
| `symbols` | Every entry in the answer's `symbols_used` actually appears in the prose, tolerant of conventional spelling (`nn.Linear` for `torch.nn.Linear`, `F.relu`, `.add_`) with word-boundary matching so `torch.relu` isn't "found" inside `prelu` |
|
| 42 |
+
|
| 43 |
+
**The important fact: these checks run in the *live* answer path, not just offline.** `agent/grounded.py` imports `run_checks` and calls it inside `_regenerate_if_checks_fail`: when a generated answer fails a check (unparseable snippet, off-family import, a symbol listed but missing from the prose), it re-asks the model *once* with the specific failure reasons injected, and keeps the repair only if it is strictly cleaner β never blocking the user on a failed check. So the same code that scores answers in the benchmark is a real-time quality gate on production answers. That dual use is why `checks.py` is pure functions over an `Answer` with no I/O: it has to be cheap and side-effect-free enough to sit in the request path.
|
| 44 |
+
|
| 45 |
+
## LLM-as-judge
|
| 46 |
+
|
| 47 |
+
`run_judge.py` closes the gap the other suites leave open: retrieval eval scores whether the right *pages* were found and the static checks score whether *code* parses, but neither scores the prose the user actually reads. The judge does, on three dimensions:
|
| 48 |
+
|
| 49 |
+
- **faithfulness** β is every claim supported by the provided context, or invented beyond it? (an honest "not in the docs" referral counts as faithful, not a failure). This is the hallucination axis the grounding contract exists to hold.
|
| 50 |
+
- **answer_relevance** β does the answer address the question actually asked?
|
| 51 |
+
- **citation_correctness** β do the cited sections genuinely support the claims, and is every load-bearing claim cited?
|
| 52 |
+
|
| 53 |
+
Each is scored 1β5 and normalized to `[0,1]` so it shares a scale with the retrieval metrics; the mean is the `overall` before/after number. The judge sees the **same numbered context the answer saw** (`build_context`), so faithfulness is checked against the real inputs rather than a re-retrieval.
|
| 54 |
+
|
| 55 |
+
Two caveats are baked into the docstring, honestly:
|
| 56 |
+
|
| 57 |
+
1. **Same-family bias.** On free-tier keys the judge may be the *same model* that wrote the answer, which biases toward leniency β so the score is a **relative gauge for regressions, not an absolute grade**. Pointing `TORCHDOCS_*` at a stronger, independent judge model is an open `PLAN.md` M4 item ("Pick a dedicated judge model"); until it lands, read the numbers as a trend, not a grade.
|
| 58 |
+
2. **Trust boundary.** The judge reads model-written answers and doc text, so its system prompt is hardened to *score* those as data, never to *follow* embedded instructions β but a suspiciously perfect run still deserves skepticism.
|
| 59 |
+
|
| 60 |
+
The judge run also captures **latency**, and deliberately times only what the user waits on β retrieval + answer generation, with the eval-only judge call excluded β then reports p50/p95/max/mean. That is the core UX number: question in β answer out.
|
| 61 |
+
|
| 62 |
+
## Design decisions & rationale
|
| 63 |
+
|
| 64 |
+
- **Static before semantic.** The checks in `checks.py` (deterministic, free, in-path) run before any LLM judgment. A parse error or a fabricated import is a hard defect that never needs a model to adjudicate, and catching it deterministically keeps the expensive, noisier judge focused on prose quality. The regeneration loop reflects the same ordering: cheap static repair first, LLM judgment reserved for offline scoring.
|
| 65 |
+
- **Before/after comparability via `index_version`.** The corpus is always-latest (a PyTorch release just shows up as a hash-diff on the next recrawl), so "recall went up" is only meaningful against a fixed index. `index_version` is an internal crawl-build id kept precisely for eval comparability (and cache invalidation), decoupled from PyTorch version numbers β it's what lets a retrieval delta be attributed to a retrieval change rather than a corpus change underneath it.
|
| 66 |
+
- **A labeled retrieval set.** `run_retrieval.py` measures recall/MRR against questions carrying *expected* source groups (each group a list of alternative URL/title substrings, any alternative counting as a hit). This costs authoring effort β 100 questions written against the verified docs inventory β but it's what makes retrieval measurable without an LLM in the loop, which is what makes it fast, deterministic, and cheap enough to run on every retrieval-affecting change. The glossesΓreranker recall jump from 0.430 β 0.840 (documented in `docs/retrieval-gaps-and-improvements.md`) is only a claim because this labeled set exists.
|
| 67 |
+
- **Coverage delta, not absolute, for the agent loop.** `run_agentic.py`'s headline is `agentic_coverage β single_shot_coverage`: catalog/compare/recipe answers are spread across pages, so the honest question isn't "is the agentic answer good" but "did the loop assemble *more* of the answer than one search would." A negative delta is reported as an honest negative result, not hidden.
|
| 68 |
+
|
| 69 |
+
## Tool & library choices
|
| 70 |
+
|
| 71 |
+
- **`ast` for the static checks.** Parsing candidate code with the standard-library `ast` module (not regex, not execution) is both safe β no code runs β and precise: `ast.walk` finds every `Import`/`ImportFrom` node including relative imports, which a regex would miss. `textwrap.dedent` first, because models routinely indent whole blocks inside markdown lists.
|
| 72 |
+
- **The app's own LLM dispatch, reused for judging.** `run_judge.py` calls `agent.llm._raw_completion` with the shared provider/fallback chain rather than a bespoke client. Same dispatch, same fallback behavior, one place to configure providers β and the judge reply is validated through Pydantic models (`JudgeScores`), with `_extract_json` tolerating the fences and stray prose models emit despite an "only JSON" instruction.
|
| 73 |
+
- **JSONL result files.** Every runner writes one JSON object per line to `eval/results/`. It's append-friendly, diffs cleanly in git (which is where before/after lives), and is trivial to load back for aggregation. Runners that spend scarce free-tier quota (`run_v0.py`, and the LLM suites) flush after every question and, in `run_v0.py`, resume from prior results unless `--fresh` β so a crash or rate-limit mid-run never discards answers already paid for.
|
| 74 |
+
|
| 75 |
+
## File by file
|
| 76 |
+
|
| 77 |
+
- **`__init__.py`** β empty; marks `eval/` as a package so the runners are importable as `eval.<name>`.
|
| 78 |
+
- **`checks.py`** β the three static checks (`check_code_parses`, `check_imports_allowed`, `check_symbols_present`), the `CHECKS` registry, `run_checks`, and `format_table` for the pass/fail grid. Pure, no I/O; imported by both the runners and the live `agent/grounded.py` regeneration path.
|
| 79 |
+
- **`run_v0.py`** β runs the 15-question manual v0 set through `answer_question` (ungrounded) or, with `--grounded`, through `answer_grounded`, applies `run_checks`, and prints the pass table. `--grounded` additionally computes `grounded_api_rate` (share of `symbols_used` that actually exist in the docs index, via a tsvector probe) and average citations. Resumable; flushes per question.
|
| 80 |
+
- **`run_retrieval.py`** β the retrieval-only benchmark. Loads the selected set (`v1` inline expectations, or `v0` with a `retrieval_v0.jsonl` sidecar), runs `index.retrieve.retrieve` at `k`, and computes per-question `recall@k` and `MRR`, then aggregates. No LLM. The reference before/after suite for any retrieval change.
|
| 81 |
+
- **`run_judge.py`** β the LLM-as-judge suite (above): generates a grounded single-shot answer, scores it on the three dimensions with a hardened judge prompt, normalizes to `[0,1]`, aggregates, and reports UX latency percentiles. Pure parts (`_normalize`, `_extract_json`, `parse_judge_reply`, `aggregate`) are unit-tested.
|
| 82 |
+
- **`run_agentic.py`** β the agent-loop benchmark for multi-page (catalog/compare/recipe) questions in `agentic_v1.jsonl`. Runs each through both `answer_agentic` and `answer_grounded`, scores citation `coverage` against `expected_any` (objective URL/anchor/title substring match β no judge), and reports the loop-vs-single-shot delta.
|
| 83 |
+
- **`diagnose_retrieval.py`** β a debugging microscope, not a benchmark. For a handful of known descriptive misses it prints, per kind-pool, the nearest candidates with cosine distances and locates the *expected* page in the raw dense/keyword candidates β triaging whether a miss was never a candidate, out-ranked within its pool, or dropped by the relevance-gap filter, and whether the expected page's own nearest chunk is a *crowding* problem (deeper pool helps) or an *embedding* problem (only doc-side enrichment helps). Runs in Actions after the retrieval suite; intended to be deleted once the fix it points at lands.
|
| 84 |
+
|
| 85 |
+
### Data files under `eval/`
|
| 86 |
+
|
| 87 |
+
- **`questions_v0.jsonl`** (15) β the manual day-one set spanning the five question types (usage / catalog / recipe / source / edge); driven by `run_v0.py`.
|
| 88 |
+
- **`questions_v1.jsonl`** (100) β the main labeled set, authored against the verified external docs inventory with expected sources inline; driven by `run_retrieval.py` and `run_judge.py`.
|
| 89 |
+
- **`retrieval_v0.jsonl`** β expected-source sidecar for the v0 set (v1 keeps expectations inline).
|
| 90 |
+
- **`agentic_v1.jsonl`** (20) β the multi-page catalog/compare/recipe set with `expected_any` source groups; driven by `run_agentic.py`.
|
| 91 |
+
- **`invalid_v1.jsonl`** (100) β out-of-scope questions (e.g. React/`useState`) for refusal / negative-case calibration, consumed by `scripts/` (e.g. `calibrate_guard.py`), not the core runners.
|
| 92 |
+
- **`docs_inventory.jsonl`** / **`index_manifest.jsonl`** β the verified external docs inventory (symbol β URL) and the live index manifest; produced by `scripts/dump_*` and used to author expectations and diff coverage (`scripts/coverage_diff.py`).
|
| 93 |
+
- **`hallucinations.md`** β the v0 ungrounded-baseline hallucination log (OKF-style, per-finding frontmatter): the measurable target grounding had to erase.
|
| 94 |
+
- **`results/`** β committed JSONL outputs of the suites (`retrieval_*`, `judge_*`, `agentic_*`, `v0*`), the substrate for before/after diffs.
|
| 95 |
+
|
| 96 |
+
## Related docs
|
| 97 |
+
|
| 98 |
+
- [`../design-content-and-agent-flow.md`](../design-content-and-agent-flow.md) β the product boundary (site + `main` only, no sandbox, tool-calling loop) the suites are written to measure.
|
| 99 |
+
- [`../retrieval-gaps-and-improvements.md`](../retrieval-gaps-and-improvements.md) β the RAG maturity review whose recall/MRR numbers come straight out of `run_retrieval.py` and `diagnose_retrieval.py`.
|
| 100 |
+
- [`../agent/README.md`](../agent/README.md) β the sibling package these suites exercise (`answer_grounded`, `answer_agentic`, and the `checks.py` regeneration hook).
|
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "The index/ package β retrieval + storage layer"
|
| 3 |
+
kind: reference
|
| 4 |
+
package: index
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# The `index/` package
|
| 8 |
+
|
| 9 |
+
The Neon/pgvector storage and retrieval layer: it owns the `chunks` table, embeds
|
| 10 |
+
corpus chunks with a local bge-small model, serves hybrid (dense + keyword) search
|
| 11 |
+
as ranked pointers, hydrates section/page content, and self-heals drifted chunks
|
| 12 |
+
against the live docs after an answer goes out.
|
| 13 |
+
|
| 14 |
+
## Why this package exists / its boundary
|
| 15 |
+
|
| 16 |
+
Three packages split the RAG core along a clean seam, and `index/` is the middle one:
|
| 17 |
+
|
| 18 |
+
| Package | Owns | Hands off |
|
| 19 |
+
|---|---|---|
|
| 20 |
+
| `ingest/` | Crawls the docs site, builds the on-disk `_corpus/` snapshot, chunks pages by heading | Chunk *units* (`{url, anchor, heading_path, content, content_hash, kind, library, β¦}`) |
|
| 21 |
+
| **`index/`** | **Neon schema, embeddings, the `chunks` table, hybrid retrieval, hydration, freshness** | **Ranked *pointers* with content already attached** |
|
| 22 |
+
| `agent/` | The tool-calling loop, grounding, generation, guards | Answers with citations |
|
| 23 |
+
|
| 24 |
+
`ingest/` decides *what the corpus is*; `index/` decides *how it is stored and found*;
|
| 25 |
+
`agent/` decides *what to say*. The boundary is deliberate: `index/` never crawls
|
| 26 |
+
(that is `ingest/`'s job β see `_live_units` and `hydrate` importing `ingest.crawl`
|
| 27 |
+
rather than re-implementing it) and never reasons about answers (it returns pointers,
|
| 28 |
+
not prose). The one place this package reaches back into `ingest/` is to re-use the
|
| 29 |
+
*exact* chunking and HTMLβmarkdown code, so a chunk healed at answer time is
|
| 30 |
+
byte-identical to one the batch build would have written β see the freshness section.
|
| 31 |
+
|
| 32 |
+
The snapshot under `_corpus/` remains the crawl-time source of truth; the `content`
|
| 33 |
+
the DB now stores is a *served copy* of it (see the data-model note below).
|
| 34 |
+
|
| 35 |
+
## The data model
|
| 36 |
+
|
| 37 |
+
Everything lives in one wide table, `chunks` (defined in `index/db.py`'s `SCHEMA`).
|
| 38 |
+
One row = one heading-granular section (or one size-split *part* of an oversized
|
| 39 |
+
section).
|
| 40 |
+
|
| 41 |
+
| Column | Purpose |
|
| 42 |
+
|---|---|
|
| 43 |
+
| `id` | `bigserial` surrogate key |
|
| 44 |
+
| `chunk_key` | `sha256(url#anchor#heading_path[#partN])`, **unique** β the identity used for upsert, skip-unchanged, purge, and heal |
|
| 45 |
+
| `url`, `anchor`, `page_title`, `heading_path`, `source_link` | The pointer β enough to render a live citation and a `[source]` referral without a lookup |
|
| 46 |
+
| `library` | `core` / `vision` / `audio` / β¦ β lets retrieval filter or route per question |
|
| 47 |
+
| `kind` | `api` / `tutorial` / `guide` β the per-kind retrieval pools key off this |
|
| 48 |
+
| `content_hash` | Page/section content hash; drives the incremental skip and the freshness compare |
|
| 49 |
+
| `index_version` | Which build wrote the row (answers are stamped with it) |
|
| 50 |
+
| `part` | Ordinal within a size-split section; `0` keeps the legacy key format so older rows stay valid |
|
| 51 |
+
| `embedding` | `vector(384)` β the bge-small dense vector (cosine) |
|
| 52 |
+
| `tsv` | `tsvector` β Postgres full-text index of the same `indexed_text` |
|
| 53 |
+
| `content` | Raw section text, **served at answer time** so hydration needs no live fetch |
|
| 54 |
+
|
| 55 |
+
A second tiny table, `index_meta` (key/value), stores the live `embed_recipe` so the
|
| 56 |
+
build can detect a recipe change and force a full re-embed.
|
| 57 |
+
|
| 58 |
+
**Indexes.** `chunks_embedding_idx` is HNSW with `vector_cosine_ops` (approximate
|
| 59 |
+
nearest-neighbour for the dense channel); `chunks_tsv_idx` is a GIN index over the
|
| 60 |
+
`tsvector` (the keyword channel); `chunks_url_idx` supports the freshness/hydrate
|
| 61 |
+
lookups that fetch all rows for one page. Uniqueness is on `chunk_key` β the whole
|
| 62 |
+
upsert/skip/heal machinery hangs off it.
|
| 63 |
+
|
| 64 |
+
**Width can't drift.** `EMBED_DIMS` is derived from the configured model
|
| 65 |
+
(`_MODEL_DIMS` table in `db.py`), reading the *same* env var (`TORCHDOCS_EMBED_MODEL`)
|
| 66 |
+
that `embed.py` reads, so the column width and the vectors written into it can never
|
| 67 |
+
disagree. An unknown model with no `TORCHDOCS_EMBED_DIMS` override is a loud config
|
| 68 |
+
error, not a silently wrong-width table. If the dimension *does* change (a model swap),
|
| 69 |
+
`ensure_schema` reads `atttypmod` off `pg_attribute`, sees the mismatch, and drops +
|
| 70 |
+
recreates the table β the index is treated as a rebuildable cache, not precious state.
|
| 71 |
+
|
| 72 |
+
**Pool + runtime-migration-at-pool-open.** There are two ways into Neon, for two
|
| 73 |
+
workloads:
|
| 74 |
+
|
| 75 |
+
- `connect()` β one dedicated connection for the batch build (a single long-running
|
| 76 |
+
writer that commits in checkpoints).
|
| 77 |
+
- `get_pool()` β a process-wide `ConnectionPool` (cached with `functools.cache`) for
|
| 78 |
+
the web app, where many concurrent questions each borrow a connection for a couple
|
| 79 |
+
of quick reads. Reconnecting per read costs a TLS handshake (~100β300 ms) that would
|
| 80 |
+
dominate answer time under load and risk exhausting Neon's free-tier connection cap.
|
| 81 |
+
`max_size` (`TORCHDOCS_DB_POOL`, default 8) is kept at or under the plan limit;
|
| 82 |
+
`check=check_connection` validates a connection on checkout so a Neon-side idle
|
| 83 |
+
timeout surfaces as a fresh connection rather than a mid-request query error.
|
| 84 |
+
|
| 85 |
+
The subtle bit: **`RUNTIME_MIGRATIONS` are applied when the pool opens, not only at
|
| 86 |
+
build time.** `create table if not exists` won't touch an existing table, so a column
|
| 87 |
+
added after the table first shipped (`part`, `content`) needs an idempotent
|
| 88 |
+
`alter table β¦ add column if not exists`. The app SELECTs those columns, and a fresh
|
| 89 |
+
deploy can go live *before* the next index build runs `ensure_schema` β so `get_pool()`
|
| 90 |
+
runs the same migrations itself, or every search would 500 until the next build. Both
|
| 91 |
+
writers (`ensure_schema`) and the reader (`get_pool`) apply the identical idempotent
|
| 92 |
+
list.
|
| 93 |
+
|
| 94 |
+
## The retrieval flow
|
| 95 |
+
|
| 96 |
+
`retrieve()` in `index/retrieve.py` is the engine behind the agent's `search_docs`
|
| 97 |
+
tool. For each content **kind** (`api`, `tutorial`, `guide`) it runs two channels and
|
| 98 |
+
fuses them *within the kind*:
|
| 99 |
+
|
| 100 |
+
- **Dense** (`DENSE_SQL`): `embedding <=> query_vector` cosine order over the HNSW
|
| 101 |
+
index, `pool` candidates.
|
| 102 |
+
- **Keyword** (`KEYWORD_SQL`): `tsv @@ plainto_tsquery(...)` ranked by `ts_rank` β this
|
| 103 |
+
rescues exact symbol names (`scaled_dot_product_attention`) that dense similarity
|
| 104 |
+
misses.
|
| 105 |
+
|
| 106 |
+
The two rankings are merged with **Reciprocal Rank Fusion** (`rrf_merge`,
|
| 107 |
+
`score = Ξ£ 1/(k+rank)`). A per-pool relevance gate (`RELEVANCE_GAP = 0.15`) drops
|
| 108 |
+
candidates far from *that kind's own* best hit β gated per pool, never against a global
|
| 109 |
+
best, so a close tutorial can't set a threshold that filters the entire `api` pool out.
|
| 110 |
+
Pools are then interleaved round-robin, strongest pool first (`_interleave_pools`), and
|
| 111 |
+
the first `k` win. When the query contains a dotted/underscored identifier a third
|
| 112 |
+
**symbol channel** (`SYMBOL_SQL`) runs, matching the token in url/title/heading and
|
| 113 |
+
preferring `kind='api'`; it leads the interleave, and an *exact* API-reference hit is
|
| 114 |
+
pinned to position 0 β the docs-search behaviour users expect.
|
| 115 |
+
|
| 116 |
+
Two hard-won details live here as comments:
|
| 117 |
+
|
| 118 |
+
- **`SET hnsw.ef_search = 150`** on every pool query. HNSW is approximate *and*
|
| 119 |
+
pgvector applies the `WHERE kind=β¦` filter *after* the index scan; at the default
|
| 120 |
+
`ef_search=40` a `kind='api'` query first collects the ~40 globally-nearest chunks
|
| 121 |
+
(mostly tutorials for a descriptive question) and only then filters β the api page
|
| 122 |
+
can be discarded before the filter sees it. Widening the candidate scan (~2 ms extra
|
| 123 |
+
on a 7K-chunk index) rescues pages whose true in-pool rank is good.
|
| 124 |
+
- **The cross-encoder reranker is gone.** A rerank stage sat between fusion and the
|
| 125 |
+
final order until the 2026-07-10 ablation on real content measured it at
|
| 126 |
+
+0.02 recall / β0.005 MRR β no earned keep for a ~90 MB model and per-query cost.
|
| 127 |
+
The fused RRF order is the final order. (Global-RRF, a reference channel, and reserved
|
| 128 |
+
seats were also tried and rejected; see the module docstring.)
|
| 129 |
+
|
| 130 |
+
**What a "pointer" is.** A pointer is a dict of `POINTER_COLUMNS` β `chunk_key, url,
|
| 131 |
+
anchor, page_title, heading_path, library, kind, source_link, part` β **plus `content`**.
|
| 132 |
+
That last column is the latency story: `content` rides along in the *same* retrieval
|
| 133 |
+
query, so the answer path hydrates each section straight from it with zero extra
|
| 134 |
+
round-trips. `hydrate_section` in `index/hydrate.py` takes a fast path when
|
| 135 |
+
`pointer["content"]` is present (just returns it); only an empty/un-backfilled row falls
|
| 136 |
+
through to a snapshot read or live fetch. Previously each of *k* sections was a separate
|
| 137 |
+
live page fetch β the dominant answer latency (measured p50 β 12 s, one outlier 69 s).
|
| 138 |
+
`hydrate_sections` also runs the remaining fetch-path work concurrently in a thread pool
|
| 139 |
+
(preserving retrieval order, dropping gone sections), collapsing *k* round-trips into
|
| 140 |
+
roughly one. `hydrate_page` serves a whole page for `read_page`, returning the heading
|
| 141 |
+
outline instead when a page exceeds `PAGE_CHAR_LIMIT` (30 k chars).
|
| 142 |
+
|
| 143 |
+
`top_distance()` is a small sibling of retrieve: it returns the single smallest cosine
|
| 144 |
+
distance for a query (a topicality signal the agent's input guard uses to tell on-topic
|
| 145 |
+
from off-topic), using the same pooled access.
|
| 146 |
+
|
| 147 |
+
## Freshness (stale-while-revalidate)
|
| 148 |
+
|
| 149 |
+
`index/freshness.py` implements the standard stale-while-revalidate pattern for the
|
| 150 |
+
docs index: **serve the stored copy instantly, revalidate right after.** The design
|
| 151 |
+
model that drove it: the product promises answers grounded in the docs *as the site
|
| 152 |
+
serves them today*, but hydrating from the DB `content` column means an answer can be
|
| 153 |
+
served from a chunk that drifted since the last weekly crawl. So *after* an answer goes
|
| 154 |
+
out, `refresh_pages(cited_urls)` re-checks exactly the pages that answer cited and heals
|
| 155 |
+
any drift, telling the caller which urls changed so it can regenerate the just-shown
|
| 156 |
+
answer.
|
| 157 |
+
|
| 158 |
+
The pass, step by step:
|
| 159 |
+
|
| 160 |
+
1. **TTL gate** (`_due`, `TORCHDOCS_FRESHNESS_TTL_SECONDS`, default 3600 s). A
|
| 161 |
+
thread-safe check-and-set so one hot page isn't re-fetched on every question; the
|
| 162 |
+
URL table is bounded (evicted past 4096 entries) for a long-lived process. Docs move
|
| 163 |
+
slowly and the weekly crawl is the backstop β an hour bounds both fetch traffic and
|
| 164 |
+
the staleness window.
|
| 165 |
+
2. **Live compare.** `_live_units` fetches the page *right now* and chunks it with the
|
| 166 |
+
exact `ingest` code path (`fetch_html` β `extract_main_html` β `to_markdown` β
|
| 167 |
+
`chunk_page`), computing the page-level `content_hash` the same way the crawl does.
|
| 168 |
+
Each live chunk is matched by `chunk_key` to its stored row and compared by `content`.
|
| 169 |
+
3. **In-place heal** (`_HEAL`). A drifted row is fixed *completely*: `content`,
|
| 170 |
+
`content_hash`, a freshly computed `embedding`, and the `tsv` β all in one UPDATE. The
|
| 171 |
+
embedding model is already hot in this process (it embeds every incoming query), so
|
| 172 |
+
re-embedding a handful of chunks costs milliseconds and leaves the row exactly as a
|
| 173 |
+
fresh build would have written it. Because the stored hash now matches the re-embedded
|
| 174 |
+
text, the next weekly crawl correctly *skips* the healed rows instead of redoing them.
|
| 175 |
+
4. **Scope limit.** The re-embed re-uses the page's *existing* gloss and hypothetical
|
| 176 |
+
questions (via `indexed_text`) β they describe what the symbol is *for*, which small
|
| 177 |
+
edits don't change. Sections that appeared or vanished on the live page are skipped;
|
| 178 |
+
structural change (new/deleted pages, restructured sections, drift drastic enough to
|
| 179 |
+
need fresh enrichment) stays the job of the periodic Build Index crawl.
|
| 180 |
+
5. **Fail-open + kill switch.** Every failure β a dead page, a bad fetch, a DB error β is
|
| 181 |
+
logged and skipped; the outer `try/except` guarantees a freshness error can never
|
| 182 |
+
break an already-shown answer. `TORCHDOCS_FRESHNESS=0` disables the pass entirely.
|
| 183 |
+
|
| 184 |
+
## Design decisions & rationale
|
| 185 |
+
|
| 186 |
+
- **Local bge-small on CPU (no API, no quota, no cost).** Gemini's free embedding quota
|
| 187 |
+
(~100 items/day) would take *weeks* for the corpus; a 130 MB open model has no key, no
|
| 188 |
+
quota, and no per-item cost β the whole corpus embeds in minutes on a CI runner's CPU,
|
| 189 |
+
and the *same* model embeds queries at answer time (which is what makes millisecond
|
| 190 |
+
in-process healing possible). The tradeoff is accepted and bounded: bge-small is
|
| 191 |
+
English-only, and a controlled A/B against bge-base (768d, full re-embed + benchmark,
|
| 192 |
+
2026-07-08) showed **identical** recall (0.846) and marginally worse MRR at 4Γ the
|
| 193 |
+
model size and 2Γ the build time β no measurable gain, so the cheaper model stays. The
|
| 194 |
+
`_MODEL_DIMS` table remains so a future swap is a one-line change plus an automatic
|
| 195 |
+
rebuild.
|
| 196 |
+
- **`EMBED_RECIPE` versioning.** `indexed_text()` embeds far more than raw body: symbol
|
| 197 |
+
+ synopsis + Contextual-Retrieval gloss + QuOTE-style hypothetical questions + heading
|
| 198 |
+
+ content (see `retrieval-gaps-and-improvements.md` for *why* β descriptive questions
|
| 199 |
+
live in a different region of embedding space than terse reference pages). When that
|
| 200 |
+
*shape* changes, the row-skip check would otherwise keep stale vectors, so the recipe
|
| 201 |
+
string (`v7-<model>-g<gloss_stamp>-q<questions_stamp>`) is stored in `index_meta`; any
|
| 202 |
+
change forces a one-time full re-embed. The gloss/question **content stamps** are
|
| 203 |
+
hashes of the committed enrichment files, so editing an enrichment file (or its first
|
| 204 |
+
arrival) forces the re-embed by itself β no manual version bump to forget. Folding the
|
| 205 |
+
model tag in means even a same-dims model swap re-embeds and `index_meta` stays honest
|
| 206 |
+
about which model's vectors are live.
|
| 207 |
+
- **`content_hash` skip-unchanged.** A chunk whose `(chunk_key, content_hash)` already
|
| 208 |
+
matches the DB is skipped; every batch commits, so a build is resumable and CI-safe
|
| 209 |
+
(kill it anytime, re-run continues). This is the chat-langchain record-manager lesson.
|
| 210 |
+
Introducing size-capped `part` rows embedded *only* the new parts because `part 0`
|
| 211 |
+
keeps the legacy key format.
|
| 212 |
+
- **Why `content` moved into the DB.** Originally the DB stored *no* text β content was
|
| 213 |
+
re-read from the `_corpus/` snapshot (or fetched live) at answer time. On a deployed
|
| 214 |
+
Space with no bundled snapshot, that per-section live fetch was the dominant answer
|
| 215 |
+
latency. Storing `content` lets retrieval hand back the section text in the row it
|
| 216 |
+
already returns; the migration is safe because empty `content` falls through to the old
|
| 217 |
+
fetch path, and `_backfill_content` fills pre-existing rows once with a cheap
|
| 218 |
+
metadata-only UPDATE (no re-embed).
|
| 219 |
+
- **pgvector + tsvector over a dedicated vector DB.** Postgres already holds the pointers
|
| 220 |
+
and content; putting the vectors (HNSW) and the keyword index (GIN `tsvector`) in the
|
| 221 |
+
*same* table means a hybrid query is two SELECTs on one connection, an upsert is
|
| 222 |
+
atomic, and freshness heals content + vector + keyword index in one statement. A
|
| 223 |
+
separate vector store would add an operational component, a second consistency problem,
|
| 224 |
+
and cross-store fan-out β for a lean index where "the agent loop, not index
|
| 225 |
+
sophistication, carries retrieval quality" (see the design doc), it isn't worth it.
|
| 226 |
+
|
| 227 |
+
## Tool & library choices
|
| 228 |
+
|
| 229 |
+
| Tool | Role | Why |
|
| 230 |
+
|---|---|---|
|
| 231 |
+
| `psycopg[binary,pool]` (v3) | Neon client + `ConnectionPool` | Modern psycopg with server-side params and a first-party pool; `open=False` + explicit `open()` avoids the deprecated constructor-time open |
|
| 232 |
+
| `pgvector` (Postgres extension) | `vector` column + HNSW index + `<=>` cosine | Keeps dense search inside Postgres, next to pointers and content |
|
| 233 |
+
| Postgres `tsvector` / GIN | Keyword channel | Free lexical search co-located with the vectors; rescues exact symbol matches dense misses |
|
| 234 |
+
| `sentence-transformers` + `BAAI/bge-small-en-v1.5` | Local 384d embeddings (docs + queries) | No API/quota/cost, runs on CPU in CI, hot in-process for query embedding and healing |
|
| 235 |
+
| Neon (serverless Postgres) | Managed store | Free tier fits the corpus; connection cap is the reason for the shared pool |
|
| 236 |
+
|
| 237 |
+
The model is loaded exactly once via a double-checked lock (`_model()`), not
|
| 238 |
+
`functools.cache`, so two concurrent first queries can't both load the 130 MB model;
|
| 239 |
+
`encode()` itself is safe to call concurrently on the shared instance.
|
| 240 |
+
|
| 241 |
+
## File by file
|
| 242 |
+
|
| 243 |
+
- **`__init__.py`** β empty package marker.
|
| 244 |
+
- **`db.py`** β Neon connectivity and schema ownership: `EMBED_DIMS` derived from the
|
| 245 |
+
model, the `chunks` + `index_meta` `SCHEMA`, `connect()` (single writer) vs `get_pool()`
|
| 246 |
+
(shared pool with runtime migrations applied at open), `RUNTIME_MIGRATIONS`,
|
| 247 |
+
`ensure_schema()` (idempotent create + migrate + rebuild-on-dims-change), and the
|
| 248 |
+
`get_meta`/`set_meta` recipe helpers.
|
| 249 |
+
- **`embed.py`** β the batch index build. `indexed_text()` assembles what actually gets
|
| 250 |
+
embedded and tsvector'd (symbol + synopsis + gloss + questions + heading + body);
|
| 251 |
+
`EMBED_RECIPE` and the enrichment stamps drive full-re-embed decisions; `build_index()`
|
| 252 |
+
walks the snapshot, skips unchanged chunks, upserts new/changed ones in committing
|
| 253 |
+
batches, backfills `content`, purges stale rows, and stamps the recipe. Local model
|
| 254 |
+
loading and `embed_texts`/`embed_query` live here too.
|
| 255 |
+
- **`retrieve.py`** β hybrid retrieval: per-kind dense + keyword pools, RRF fusion, the
|
| 256 |
+
symbol channel, per-pool relevance gating and round-robin interleave, `ef_search`
|
| 257 |
+
widening, exact-API pinning. Returns pointers with `content` attached. Also
|
| 258 |
+
`top_distance()` for the input guard.
|
| 259 |
+
- **`hydrate.py`** β turns pointers into content: `hydrate_section` (fast path off the
|
| 260 |
+
stored `content`, else snapshot-then-live fallback), `hydrate_sections` (concurrent),
|
| 261 |
+
and `hydrate_page` (whole page, outline-first when oversized). `TORCHDOCS_LIVE_HYDRATE=0`
|
| 262 |
+
requires the snapshot.
|
| 263 |
+
- **`freshness.py`** β the post-answer stale-while-revalidate pass: TTL gate, live fetch +
|
| 264 |
+
chunk-by-chunk compare, full in-place heal (content + hash + embedding + tsv), fail-open,
|
| 265 |
+
and the `TORCHDOCS_FRESHNESS=0` kill switch.
|
| 266 |
+
|
| 267 |
+
## Related docs
|
| 268 |
+
|
| 269 |
+
- [`../design-content-and-agent-flow.md`](../design-content-and-agent-flow.md) β the
|
| 270 |
+
system design this package implements (corpus, tools, session flow, live links).
|
| 271 |
+
- [`../retrieval-gaps-and-improvements.md`](../retrieval-gaps-and-improvements.md) β the
|
| 272 |
+
measured retrieval-quality analysis behind glosses, hypothetical questions, the
|
| 273 |
+
`ef_search` fix, and the reranker decision.
|
| 274 |
+
- [`../ingest/README.md`](../ingest/README.md) β the sibling package that builds the
|
| 275 |
+
snapshot this one indexes.
|
| 276 |
+
- [`../agent/README.md`](../agent/README.md) β the consumer of the pointers this package
|
| 277 |
+
returns.
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "ingest/ β the crawl β snapshot β chunk pipeline"
|
| 3 |
+
kind: reference
|
| 4 |
+
package: ingest
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# `ingest/` β the crawl β snapshot β chunk pipeline
|
| 8 |
+
|
| 9 |
+
Turns the live PyTorch documentation *site* into an on-disk knowledge snapshot: enumerate every page, fetch and clean it to markdown, and split it into heading-granular OKF units that the index step embeds.
|
| 10 |
+
|
| 11 |
+
## Why this package exists / its boundary
|
| 12 |
+
|
| 13 |
+
`ingest/` produces the **source of truth** for retrieval: the on-disk `_corpus/` snapshot (one markdown file per page) and, from it, the OKF chunk units. That is the whole job β **it never touches the database.** Embedding, tsvector computation, and the upsert into Neon all live downstream in `index/embed.py`, which *consumes* what this package writes.
|
| 14 |
+
|
| 15 |
+
The split is deliberate and load-bearing for the design's pointer-based storage (see `../design-content-and-agent-flow.md` Β§1.2): the DB stores only vectors, tsvectors, and pointers β **no page text**. At query time content is re-read ("hydrated") from the snapshot. So the snapshot isn't a scratch cache you can delete after indexing; it's a runtime dependency that ships with the deploy (PLAN.md M5). Keeping ingest DB-free means the crawl can run anywhere (a laptop, a CI runner) with no credentials, and the snapshot it emits is a plain, greppable, diff-able directory of markdown β the artifact a human can inspect to see exactly what the agent will ever be able to say.
|
| 16 |
+
|
| 17 |
+
## The pipeline
|
| 18 |
+
|
| 19 |
+
Three stages, each a separate module, each handing a concrete artifact to the next:
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
discover.py β crawl.py β chunk_docs.py β (index/embed.py)
|
| 23 |
+
enumerate URLs fetch + clean + snap split by heading embed + upsert
|
| 24 |
+
[downstream, not us]
|
| 25 |
+
|
| 26 |
+
{library: _corpus/<path>.md OKF units
|
| 27 |
+
{url, url, β¦}} --- ---
|
| 28 |
+
url, title, library, url, anchor, page_title,
|
| 29 |
+
content_hash, crawled_at heading_path, library, kind,
|
| 30 |
+
--- source_link, content_hash,
|
| 31 |
+
<clean markdown body> part, synopsis
|
| 32 |
+
---
|
| 33 |
+
<section body, code attached>
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
1. **discover** (`discover.py`) β for each seed in the tiered `SEEDS` map, fetch `objects.inv` (the Sphinx inventory: every documented symbol β exact page + anchor) and `sitemap.xml` (tutorials/guides no inventory covers). Returns `{library: set(page_url)}`. Only inventory entries ending in `.html`, and only sitemap URLs under the seed base, are kept.
|
| 37 |
+
2. **crawl** (`crawl.py`) β for each discovered URL: `fetch_html` (following the meta-refresh redirect stubs `docs/stable/` serves), strip nav/chrome to the main content container, convert HTML β markdown, hash the body, and write `_corpus/<url-path>.md` with YAML frontmatter. If the `content_hash` matches what's already on disk, the page is skipped β nothing downstream re-runs.
|
| 38 |
+
3. **chunk** (`chunk_docs.py`) β read each snapshot page, split it at every heading into `Section`s (code fences stay attached to their section), cut oversized sections at natural seams, and emit OKF unit dicts / files carrying the pointer metadata + a per-page synopsis.
|
| 39 |
+
|
| 40 |
+
## Design decisions & rationale
|
| 41 |
+
|
| 42 |
+
**Heading-granular chunks, never a token window.** `split_by_heading` cuts a page at its markdown headings; a chunk is one coherent section with its full `heading_path` breadcrumb. This is the kapa.ai lesson (design doc Β§Research grounding): a fixed-size token window slices a catalog list ("What LR schedulers exist?") or a worked example in half, and half a list retrieves as noise. Sections are the unit an author already made coherent. Oversized sections are *still* split β but by `split_oversized`, which greedily packs whole paragraphs and whole code fences up to `CHUNK_TARGET_CHARS` (2000, aligned with `index/embed.MAX_EMBED_CHARS`), and only ever cuts *inside* an atom that alone exceeds the budget, at line boundaries. Each resulting part inherits the section's `heading_path` and URL+anchor, so citations stay exact and every part is prefixed with the same symbol/heading synopsis.
|
| 43 |
+
|
| 44 |
+
**Code blocks are atoms.** `_atoms` walks the fenced-code regex so that the blank lines *inside* a `β```β¦```β` block never become split points. Half a code block is noise to embed; a fence is indivisible.
|
| 45 |
+
|
| 46 |
+
**The snapshot is the source of truth.** Covered above under boundary β the DB stores no page text, so the crawl output is not disposable. `save_page` writes human-readable markdown + frontmatter precisely so the snapshot doubles as an auditable knowledge artifact, not an opaque blob.
|
| 47 |
+
|
| 48 |
+
**`content_hash` idempotency / incrementality.** `save_page` sha256's the *rendered markdown body* and short-circuits if the on-disk file already carries that hash (`return False`, "unchanged"). This is the chat-langchain record-manager lesson (design doc Β§Research grounding): a weekly recrawl over thousands of mostly-unchanged pages must be cheap. Because chunk identity is `(url, anchor)` and the hash rides through into every unit's frontmatter, an unchanged page re-chunks and re-embeds to nothing downstream. Hashing the *body* (not the raw HTML) is the right choice β it ignores chrome/timestamp churn that doesn't change meaning.
|
| 49 |
+
|
| 50 |
+
**The tiered seed list.** `SEEDS` is the v1-core tier (core, tutorials, vision, audio) from design doc Β§1.1. The whole point, restated in the code comment: adding a doc set (ExecuTorch, torchao, β¦) is *one line here and nothing else*, because every PyTorch domain library is a Sphinx site with its own `objects.inv` that the same discoverβcrawlβchunk path already handles. The `library` field is stamped on every page and chunk so retrieval can filter/route per question.
|
| 51 |
+
|
| 52 |
+
**Why the docs SITE, not the source code.** The corpus is what `docs.pytorch.org` serves, never the PyTorch source tree. This supersedes an earlier five-source-modules plan (design doc scope-history note) and is externally validated: LangChain tried indexing their own source code for retrieval and dropped it β raw code chunks retrieved worse than prose docs. Source questions are handled by *referral* instead: `chunk_docs` captures each API page's `[source]` GitHub link (`GITHUB_SOURCE_RE`) as `source_link` metadata, and the agent's `ask_source` tool points at DeepWiki. The docs are the knowledge boundary; code is a link, not a claim.
|
| 53 |
+
|
| 54 |
+
**OKF units.** Chunks are emitted as Open Knowledge Format files β YAML frontmatter over a markdown body β not raw DB rows. This makes each chunk a human/agent-readable knowledge snapshot you can open and read, consistent with the repo-wide OKF convention (PLAN.md) for hand-authored/generated knowledge documents. (Note the deliberate limit: OKF is *not* used for the DB schema itself, which is pointer-based with typed columns β the units are the on-disk representation the embed step loads.)
|
| 55 |
+
|
| 56 |
+
## Tool & library choices
|
| 57 |
+
|
| 58 |
+
| Tool | Where | Why this one |
|
| 59 |
+
|---|---|---|
|
| 60 |
+
| **requests** | `discover.fetch` | Streaming GET with a hard `MAX_PAGE_BYTES` cap enforced mid-download, retry/backoff on 5xx/429 (permanent 4xx raises immediately), and a `torchdocs-agent` UA. Streaming lets a runaway body be abandoned early instead of after a full download. |
|
| 61 |
+
| **beautifulsoup4** | `crawl.extract_main_html`, `discover.redirect_target` | Select the Sphinx content container (`article.pytorch-article` β `div[role=main]` β β¦ β `body`) and `decompose()` the chrome tags (`nav/header/footer/script/style/aside/form/iframe`); also to read the `<meta http-equiv=refresh>` / `<link rel=canonical>` redirect target. |
|
| 62 |
+
| **markdownify** | `crawl.to_markdown` | HTML fragment β markdown with ATX headings (`#`), `*` bullets. ATX headings are what `chunk_docs.HEADING_RE` splits on, so the two modules are coupled by this choice. |
|
| 63 |
+
| **PyYAML** | `crawl` + `chunk_docs` | `safe_dump`/`safe_load` the frontmatter on both snapshot pages and OKF units (`sort_keys=True` for stable diffs). |
|
| 64 |
+
| **Sphinx `objects.inv` parsing** | `discover.parse_objects_inv` | Hand-rolled on stdlib `zlib` + Sphinx's *own* inventory-line regex. The format is `"<name> <domain:role> <priority> <uri> <dispname>"` and **names may contain spaces** (`std:label "PyTorch Contribution Guide"`), so a naive split corrupts the URI β hence the exact upstream regex. Handles the `$` shorthand (uri ending in `$` expands to the entry name). |
|
| 65 |
+
| stdlib `xml.etree` | `discover.parse_sitemap` | Namespace-agnostic sitemap parsing (`_localname` strips ElementTree's `{ns}` prefix), following one level of `<sitemapindex>`, taking only the `<loc>` directly under each `<url>`/`<sitemap>` so nested `<image:loc>` is ignored. |
|
| 66 |
+
|
| 67 |
+
Two hard-won specifics worth calling out, both documented in code comments:
|
| 68 |
+
|
| 69 |
+
- **Redirect stubs.** `docs.pytorch.org/docs/stable/<β¦>` serves a "Redirectingβ¦" page whose real content is behind a `<meta refresh>` β which `requests` does *not* follow. A naive fetch captured empty stubs for 3,435 of 4,517 pages (the entire core API reference). `fetch_html` detects the refresh meta and follows it, hop-bounded and loop-protected, while the caller keeps the *original* URL as the citation key so stable URLs stay stable.
|
| 70 |
+
- **The size cap is generous on purpose.** `MAX_PAGE_BYTES` is 20 MB, not 5 β a 5 MB cap silently dropped legitimate image-heavy tutorials (dcgan_faces, hybrid_demucs) from the index on the 2026-07-08 build.
|
| 71 |
+
|
| 72 |
+
## File by file
|
| 73 |
+
|
| 74 |
+
All four modules are **implemented** (with a full test suite under `tests/ingest/` β `test_discover.py`, `test_crawl.py`, `test_chunk_docs.py`). Note PLAN.md's M2.1 checkboxes for these files were still unticked at the time of writing; the code is present and substantive, so treat the boxes as lagging the work, not the reverse.
|
| 75 |
+
|
| 76 |
+
- **`__init__.py`** β empty (0 bytes). Marks `ingest` as a package; there is no package-level API surface, callers import the modules directly (e.g. `from ingest.discover import fetch_html`).
|
| 77 |
+
|
| 78 |
+
- **`discover.py`** β page enumeration. Pure parsers (`parse_objects_inv`, `parse_sitemap`, `is_sitemap_index`, `redirect_target`) take bytes/text and return entries, so they test offline; only `fetch`/`fetch_html`/`discover` touch the network. `discover()` walks `SEEDS`, tries inventory then sitemap per seed, and *tolerates only network errors* β a parse error (PyTorch changing the inventory format) propagates and fails the run loudly rather than silently shrinking the index. Also home to the resilient `fetch` (retry/backoff/size-cap) and the meta-refresh-following `fetch_html`.
|
| 79 |
+
|
| 80 |
+
- **`crawl.py`** β fetch β clean β snapshot. `extract_main_html` selects the content container and strips chrome; `to_markdown` converts; `page_path` maps a URL to a stable on-disk path (host dropped, path preserved, `.html`β`.md`); `save_page` hashes the body and writes-or-skips; `crawl` drives the whole set with a politeness delay (`TORCHDOCS_CRAWL_DELAY`, default 0.2s) and per-page exception isolation (one bad page must not kill the crawl). Returns per-library change counts.
|
| 81 |
+
|
| 82 |
+
- **`chunk_docs.py`** β snapshot page β OKF units. `split_by_heading` builds `Section`s with a breadcrumb stack; `clean_heading` recovers the *true* Sphinx anchor from the surviving headerlink (`[ΒΆ](#sgd β¦)`) rather than re-slugifying; `split_oversized`/`_atoms`/`_hard_split` handle over-budget sections without cutting code fences; `page_kind` classifies url β `api`/`tutorial`/`guide`; `page_synopsis` deterministically extracts the page's first real description sentence (the docstring summary buried after Sphinx's `:` definition-line marker) β API pages only, since terse reference pages otherwise embed far from descriptive questions. `chunk_page` assembles the unit dicts; `write_units` serializes them, always suffixing the filename with the enumerate index so two same-named sections (e.g. two "Parameters" headings) can't clobber each other on disk.
|
| 83 |
+
|
| 84 |
+
- **`watch.py`** β **not present / not yet built.** The design doc (Β§1.3) and PLAN.md M5 specify a release watcher that polls the `pytorch/pytorch` GitHub Releases API and kicks an immediate recrawl on a new stable tag. It does not exist in `ingest/` yet; the scheduled/triggered recrawl orchestration is M5 work.
|
| 85 |
+
|
| 86 |
+
## Related docs
|
| 87 |
+
|
| 88 |
+
- `../design-content-and-agent-flow.md` β the design rationale this package implements, especially Β§1 (content extraction), Β§1.1 (the tiered corpus/seed list), and Β§1.2β1.3 (pipeline properties and recrawl cadence).
|
| 89 |
+
- `../index/README.md` β the sibling `index/` package (`embed.py`, `hydrate.py`) that consumes the snapshot: embeds the OKF units into Neon and hydrates content back at query time.
|
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "scripts/ β operator CLI entrypoints and CI jobs"
|
| 3 |
+
kind: reference
|
| 4 |
+
package: scripts
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# `scripts/` β the command-line surface
|
| 8 |
+
|
| 9 |
+
The operator/CLI layer: every way a human (or a GitHub Actions job) drives the
|
| 10 |
+
system by hand β build and refresh the index, generate synthetic index-side
|
| 11 |
+
content, evaluate, calibrate the guard, smoke-test, and dump inventories for
|
| 12 |
+
diagnostics.
|
| 13 |
+
|
| 14 |
+
## Why this package exists / its boundary
|
| 15 |
+
|
| 16 |
+
These scripts are **thin wrappers**. They own no business logic of their own β
|
| 17 |
+
they parse argv, `load_dotenv()`, fail fast on a missing env var, and then call
|
| 18 |
+
into `ingest/`, `index/`, and `agent/`. The real work lives there:
|
| 19 |
+
|
| 20 |
+
- `build_index.py` calls `ingest.discover.discover`, `ingest.crawl.crawl`, and
|
| 21 |
+
`index.embed.build_index` β it just sequences them and prints timing.
|
| 22 |
+
- `ask.py` calls `agent.guard.guard` then `agent.loop.answer_agentic`.
|
| 23 |
+
- `search.py` calls `index.retrieve.retrieve` + `index.hydrate.hydrate_section`.
|
| 24 |
+
- `calibrate_guard.py` calls `index.retrieve.top_distance`.
|
| 25 |
+
|
| 26 |
+
The one piece of genuine logic that *does* live here is the batch-job
|
| 27 |
+
scaffolding in `generate_glosses.py` (`git_checkpoint`, batching, resume) β
|
| 28 |
+
because it is an operational concern (surviving a cancelled CI run), not a
|
| 29 |
+
system concern. `generate_questions.py` imports that scaffolding rather than
|
| 30 |
+
duplicating it.
|
| 31 |
+
|
| 32 |
+
Heavy imports are deliberately done **inside `main()`**, after the env-var
|
| 33 |
+
check, so a missing `NEON_URL` prints one clean line instead of a stack trace
|
| 34 |
+
from importing a DB module at file scope.
|
| 35 |
+
|
| 36 |
+
`__init__.py` is empty β it exists only to make `scripts` a package so
|
| 37 |
+
`generate_questions.py` can `from scripts.generate_glosses import ...`.
|
| 38 |
+
|
| 39 |
+
## Script catalog
|
| 40 |
+
|
| 41 |
+
| Script | What it does | Run it | Workflow(s) that invoke it |
|
| 42 |
+
|---|---|---|---|
|
| 43 |
+
| `build_index.py` | End-to-end index build: discover β crawl β chunk+embed β Neon. Every stage resumable; every embed batch commits. | `python scripts/build_index.py [--skip-crawl] [--skip-embed] [--libraries core,vision]` | **build-index.yml** (`--skip-embed` then `--skip-crawl`), **backfill-content.yml** (`--skip-crawl`) |
|
| 44 |
+
| `ask.py` | Answer one question end-to-end from the CLI: guard, then the full agent tool loop; prints answer + citations + referrals. | `python scripts/ask.py "how do I build a CNN?"` | β (local only) |
|
| 45 |
+
| `search.py` | Retrieval-only acceptance test: run hybrid retrieve, print the ranked pointers + a snippet of the top hit. No LLM. | `python scripts/search.py "scaled_dot_product_attention" [-k 8] [--library vision]` | β (local only) |
|
| 46 |
+
| `calibrate_guard.py` | Recompute the guard's topicality cutoff against the **live** index: distances for 100 on-topic + 100 off-topic + borderline probes, plus a suggested threshold. Prints only. | `python scripts/calibrate_guard.py` | **calibrate-guard.yml** |
|
| 47 |
+
| `generate_glosses.py` | Batch LLM job: a 1-sentence Contextual-Retrieval gloss per api page β `index/glosses.jsonl`. Batched, resumable, per-batch flush, optional `--push` checkpoint. | `python scripts/generate_glosses.py [--limit N] [--batch N] [--push]` | **build-index.yml**, **generate-glosses.yml** |
|
| 48 |
+
| `generate_questions.py` | Batch LLM job: a few QuOTE-style hypothetical questions per api page β `index/questions.jsonl`. Same scaffolding as glosses. | `python scripts/generate_questions.py [--limit N] [--batch N] [--push]` | **build-index.yml**, **generate-glosses.yml** |
|
| 49 |
+
| `smoke.py` | Preflight: one Neon write/read, one Gemini call, one local bge-small embedding, one optional Anthropic call. Exits non-zero on any required failure. | `python scripts/smoke.py` | β (local preflight) |
|
| 50 |
+
| `smoke_space.py` | Post-deploy health check against the live HF Space: poll runtime until RUNNING, ask a real question via the Gradio API, fail on an LLM/transport error marker. | `python scripts/smoke_space.py` | **sync-to-hf.yml** |
|
| 51 |
+
| `dump_docs_inventory.py` | Dump external ground truth: every documented symbol/page the docs SITE publishes (`objects.inv` + sitemap) β `eval/docs_inventory.jsonl`. Needs network to docs.pytorch.org. | `python scripts/dump_docs_inventory.py` | **build-index.yml** (inventory job) |
|
| 52 |
+
| `dump_index_manifest.py` | Dump internal ground truth: every distinct page actually in the `chunks` table β `eval/index_manifest.jsonl`. Needs `NEON_URL`. | `python scripts/dump_index_manifest.py` | **build-index.yml** (inventory job) |
|
| 53 |
+
| `coverage_diff.py` | Diff the two dumps: pages the site publishes but our index is missing. A non-empty gap is a pipeline bug. | `python scripts/coverage_diff.py` | **build-index.yml** (inventory job) |
|
| 54 |
+
| `__init__.py` | Empty package marker (enables `from scripts.generate_glosses import ...`). | β | β |
|
| 55 |
+
|
| 56 |
+
Note: the **eval** jobs (`eval.yml`) run `python -m eval.run_retrieval`,
|
| 57 |
+
`eval.diagnose_retrieval`, `eval.run_agentic`, `eval.run_judge` β those live in
|
| 58 |
+
the `eval/` package, not here. `ci.yml` runs `ruff` + `pytest`, and
|
| 59 |
+
`security.yml` runs Trivy; neither invokes a script in this package.
|
| 60 |
+
|
| 61 |
+
## Operational flows
|
| 62 |
+
|
| 63 |
+
### (a) Build / refresh the index
|
| 64 |
+
|
| 65 |
+
`build_index.py` is the whole pipeline. Locally you run it plain; in CI the
|
| 66 |
+
**build-index.yml** workflow splits it so glossing can happen *between* crawl
|
| 67 |
+
and embed:
|
| 68 |
+
|
| 69 |
+
1. `build_index.py --skip-embed` β crawl only, refresh the on-disk `_corpus`
|
| 70 |
+
snapshot, never touch Neon (so the crawl-only path doesn't even require
|
| 71 |
+
`NEON_URL`).
|
| 72 |
+
2. `generate_glosses.py --limit 0 --push` then `generate_questions.py --limit 0
|
| 73 |
+
--push` β enrich only the pages that don't have a gloss/question set yet.
|
| 74 |
+
3. `build_index.py --skip-crawl` β embed the snapshot into Neon. A changed
|
| 75 |
+
`glosses.jsonl`/`questions.jsonl` bumps the embed recipe, so only the touched
|
| 76 |
+
pages re-embed.
|
| 77 |
+
|
| 78 |
+
Resumability is the design point: crawling skips unchanged pages, embedding
|
| 79 |
+
skips chunks whose hash is already in the DB, every embed batch commits. Kill it
|
| 80 |
+
and re-run β it continues. Both **build-index.yml** and **backfill-content.yml**
|
| 81 |
+
share the `concurrency: build-index` lock so two writers never race the DB.
|
| 82 |
+
`--libraries core,vision` restricts the run to a subset of the seed list.
|
| 83 |
+
|
| 84 |
+
### (b) Generate synthetic index-side content (glosses / questions)
|
| 85 |
+
|
| 86 |
+
`generate_glosses.py` and `generate_questions.py` are the two batch LLM jobs.
|
| 87 |
+
Both walk every `api`-kind page in the snapshot, batch them into LLM calls, and
|
| 88 |
+
append JSON lines to `index/{glosses,questions}.jsonl`, flushing after each
|
| 89 |
+
batch. They are **resumable**: URLs already present in the output file are
|
| 90 |
+
skipped, so a rate-limited death just means "run it again." Core-torch pages are
|
| 91 |
+
glossed first, so a partial run still covers the pages that matter most. After 5
|
| 92 |
+
failed batches they stop, assuming the provider is down. `generate_questions.py`
|
| 93 |
+
imports `api_pages`, `existing_urls_of`, and `git_checkpoint` directly from
|
| 94 |
+
`generate_glosses.py` β one pipeline shape, not two.
|
| 95 |
+
|
| 96 |
+
The `--push` flag turns on `git_checkpoint`: commit **and push** the jsonl after
|
| 97 |
+
every batch. See rationale below.
|
| 98 |
+
|
| 99 |
+
### (c) Evaluate
|
| 100 |
+
|
| 101 |
+
Retrieval acceptance from the CLI is `search.py` (pointers only, no LLM) and
|
| 102 |
+
end-to-end answering is `ask.py`. The scored benchmarks
|
| 103 |
+
(recall/MRR, agentic, judge) are the `eval/` package run via `eval.yml`, not
|
| 104 |
+
scripts here. `coverage_diff.py` is the eval-adjacent check that the corpus the
|
| 105 |
+
index holds actually matches the corpus the docs site publishes.
|
| 106 |
+
|
| 107 |
+
### (d) Calibrate the guard
|
| 108 |
+
|
| 109 |
+
`calibrate_guard.py` re-derives the topicality distance cutoff against the live
|
| 110 |
+
index. It runs three groups β 100 on-topic (must all pass), 100 off-topic
|
| 111 |
+
(should all block), and a handful of borderline/injection probes to eyeball β
|
| 112 |
+
through the guard's `top_distance` path and prints every distance plus a
|
| 113 |
+
suggested `TORCHDOCS_TOPICALITY_MAX_DISTANCE` (the midpoint between the worst
|
| 114 |
+
on-topic and best off-topic distance). It **only prints**; a threshold change is
|
| 115 |
+
a policy decision, so a human reads the log and edits the constant in
|
| 116 |
+
`agent/guard.py` by hand. Run it (via **calibrate-guard.yml**) after any corpus
|
| 117 |
+
change big enough to shift the distance distribution β a re-embed or a new doc
|
| 118 |
+
set.
|
| 119 |
+
|
| 120 |
+
### (e) Smoke-test β locally and post-deploy
|
| 121 |
+
|
| 122 |
+
Two different tests for two different moments:
|
| 123 |
+
|
| 124 |
+
- `smoke.py` β **before building anything**, verify each external connection
|
| 125 |
+
works: Neon write/read, Gemini, local embedding, optional Anthropic. A missing
|
| 126 |
+
key skips (Anthropic) or fails with a clear message rather than a traceback.
|
| 127 |
+
- `smoke_space.py` β **after deploy**, verify the live Space actually answers.
|
| 128 |
+
It runs in **sync-to-hf.yml** right after the push (GitHub Actions can reach
|
| 129 |
+
both the Space and the LLM provider; the dev sandbox can't). It polls the HF
|
| 130 |
+
runtime API until RUNNING, asks a real question through the Gradio
|
| 131 |
+
`/respond` endpoint, and fails the job if the answer contains an
|
| 132 |
+
LLM/transport error marker β so a broken deploy is a red check, not a Space
|
| 133 |
+
that silently serves errors. An empty-index answer warns but doesn't fail
|
| 134 |
+
(that's a separate subsystem).
|
| 135 |
+
|
| 136 |
+
### (f) Inventory / diagnostics
|
| 137 |
+
|
| 138 |
+
Three scripts build and compare two ground-truth files, all in the inventory job
|
| 139 |
+
of **build-index.yml**:
|
| 140 |
+
|
| 141 |
+
- `dump_docs_inventory.py` β `eval/docs_inventory.jsonl` β what the site
|
| 142 |
+
publishes (external truth; must run where docs.pytorch.org is reachable).
|
| 143 |
+
- `dump_index_manifest.py` β `eval/index_manifest.jsonl` β what our index
|
| 144 |
+
actually holds (internal truth; needs `NEON_URL`).
|
| 145 |
+
- `coverage_diff.py` β pages in the first but not the second: a page the docs
|
| 146 |
+
document that our system can never retrieve. Both dumps are committed so the
|
| 147 |
+
diff can run offline.
|
| 148 |
+
|
| 149 |
+
## Design decisions & rationale
|
| 150 |
+
|
| 151 |
+
**Batch git-checkpoint (`git_checkpoint` + `--push`).** A gloss/question pass
|
| 152 |
+
over the ~3.6K-page corpus is a multi-hour LLM job. The batches are flushed to
|
| 153 |
+
disk, but on a GitHub runner that file only reaches the repo via the workflow's
|
| 154 |
+
final commit step β so a cancel or a job timeout part-way through throws away
|
| 155 |
+
everything generated in the run. `--push` commits and pushes the jsonl every few
|
| 156 |
+
batches instead, so a killed run keeps its progress. Every git failure (unset
|
| 157 |
+
identity, a push race with a concurrent enrichment run, a rebase conflict) is
|
| 158 |
+
logged and swallowed β a missed checkpoint just defers to the final commit; it
|
| 159 |
+
must **never** kill the long run. The committer identity is injected per-command
|
| 160 |
+
(`git -c user.name=...`) so no global config or extra workflow step is needed,
|
| 161 |
+
and the commit message carries `[skip ci]` so a checkpoint push doesn't kick off
|
| 162 |
+
a CI run. It is opt-in (`--push`) so local runs never commit.
|
| 163 |
+
|
| 164 |
+
**`--push` off by default.** Local runs of the batch jobs should produce a
|
| 165 |
+
jsonl and nothing else; only CI, which needs to persist progress across a
|
| 166 |
+
possible cancellation, turns pushing on.
|
| 167 |
+
|
| 168 |
+
**Smoke tests exist to fail fast on deploy.** `smoke.py` stops you before an
|
| 169 |
+
hour of crawling if a credential is wrong; `smoke_space.py` turns a broken
|
| 170 |
+
deploy into a red check on the very run that produced it (one workflow does both
|
| 171 |
+
push and verify). Both do exactly one real round-trip per subsystem β enough to
|
| 172 |
+
prove the wire works, cheap enough to run every time.
|
| 173 |
+
|
| 174 |
+
**Guard recalibration is manual after corpus changes.** The topicality
|
| 175 |
+
threshold is a distance in embedding space; re-embedding or adding a doc set
|
| 176 |
+
shifts the whole distribution. `calibrate_guard.py` measures the new
|
| 177 |
+
distribution and *suggests* a cutoff, but a human commits the constant β where
|
| 178 |
+
to draw the on-topic/off-topic line is a policy call, and the script prints the
|
| 179 |
+
overlap so you can see when the groups aren't cleanly separable and refuse to
|
| 180 |
+
split the difference blindly.
|
| 181 |
+
|
| 182 |
+
## Tool & library choices
|
| 183 |
+
|
| 184 |
+
- **`argparse` + `main() -> int` + `sys.exit(main())`** everywhere. Exit codes
|
| 185 |
+
are load-bearing: they make each script a CI gate. `smoke*.py`,
|
| 186 |
+
`coverage_diff.py`, and the batch jobs return non-zero on failure so a
|
| 187 |
+
workflow step goes red. The batch jobs treat partial success as success
|
| 188 |
+
(resumable) and total failure as loud (`return 0 if written else 1`).
|
| 189 |
+
- **`python-dotenv`** β every script that touches a credential calls
|
| 190 |
+
`load_dotenv()` first, so a local `.env` and CI secrets are configured the
|
| 191 |
+
same way.
|
| 192 |
+
- **Reuse of the real modules, not reimplementation.** The scripts import the
|
| 193 |
+
exact code the app uses: `agent.guard`/`agent.loop` (ask), `index.retrieve`
|
| 194 |
+
(search + calibrate), `index.embed`/`ingest.*` (build), `agent.llm._raw_completion`
|
| 195 |
+
for the batch LLM calls. The batch jobs therefore ride the same provider
|
| 196 |
+
dispatch and fallback chain (OpenRouter/hy3 β Gemini) the workflows configure
|
| 197 |
+
via env β nothing is stubbed, so a CLI green means the production path is green.
|
| 198 |
+
- **`gradio_client`** in `smoke_space.py` to hit the Space through its real
|
| 199 |
+
public API, tolerating the `hf_token`β`token` kwarg rename across versions.
|
| 200 |
+
|
| 201 |
+
## File by file
|
| 202 |
+
|
| 203 |
+
- **`__init__.py`** β empty; makes `scripts` an importable package so the two
|
| 204 |
+
batch jobs can share code.
|
| 205 |
+
- **`ask.py`** β one question end-to-end from the CLI. Guards the input first
|
| 206 |
+
(bails with the guard's reason if it fails), then runs `answer_agentic` and
|
| 207 |
+
pretty-prints the answer, citations (title βΊ anchor + URL), and referrals.
|
| 208 |
+
- **`build_index.py`** β the overnight-safe full pipeline. Fails fast if
|
| 209 |
+
`NEON_URL` is missing (unless `--skip-embed`, which never touches Neon).
|
| 210 |
+
Stamps an `index_version` from the crawl timestamp. `--skip-crawl` re-embeds
|
| 211 |
+
the existing snapshot; `--skip-embed` refreshes the snapshot without embedding.
|
| 212 |
+
- **`calibrate_guard.py`** β reads the 100/100 eval question files plus inline
|
| 213 |
+
borderline probes, measures `top_distance` per question, prints sorted
|
| 214 |
+
distances + per-group stats, and suggests a threshold (or flags an overlap).
|
| 215 |
+
Print-only by design.
|
| 216 |
+
- **`coverage_diff.py`** β set-difference of two committed jsonl dumps; reports
|
| 217 |
+
site pages missing from the index, bucketed by library. Returns 1 if either
|
| 218 |
+
dump is absent. No network, no DB.
|
| 219 |
+
- **`dump_docs_inventory.py`** β reads each seed's Sphinx `objects.inv` (kept
|
| 220 |
+
roles: classes/functions/methods/attributes/data + `std:doc`) and sitemap,
|
| 221 |
+
de-dups, and writes the external ground-truth inventory. Must run where
|
| 222 |
+
docs.pytorch.org is reachable.
|
| 223 |
+
- **`dump_index_manifest.py`** β one SQL pass over the `chunks` table, rolled up
|
| 224 |
+
per page (title/kind/library + up to 40 headings + chunk count), written as
|
| 225 |
+
the internal manifest. Needs `NEON_URL`; run from Actions.
|
| 226 |
+
- **`generate_glosses.py`** β the batch-job home base: `api_pages` (snapshot β
|
| 227 |
+
api pages, core first), batched LLM calls, `parse_glosses` (tolerant JSON
|
| 228 |
+
extraction), `existing_urls_of`/resume, and `git_checkpoint`/`--push`. Writes
|
| 229 |
+
`index/glosses.jsonl`.
|
| 230 |
+
- **`generate_questions.py`** β the QuOTE-style twin. Imports the scaffolding
|
| 231 |
+
from `generate_glosses.py` and only differs in prompt, parser
|
| 232 |
+
(`parse_questions`), batch size, and output (`index/questions.jsonl`).
|
| 233 |
+
- **`search.py`** β retrieval acceptance test. `retrieve(..., debug=True)`, print
|
| 234 |
+
ranked pointers, hydrate and snippet the top hit. Returns 1 if the index is
|
| 235 |
+
empty. No LLM.
|
| 236 |
+
- **`smoke.py`** β four preflight checks (Neon, Gemini, local embedding, optional
|
| 237 |
+
Anthropic), each catching its own exception so one broken connection reports
|
| 238 |
+
instead of crashing the run. Exits non-zero if any required check fails.
|
| 239 |
+
- **`smoke_space.py`** β post-deploy health check: poll the HF runtime API to
|
| 240 |
+
RUNNING (or a failure stage), call the Gradio `/respond` endpoint, and
|
| 241 |
+
fail on error markers in the answer. Empty-index β warn, not fail.
|
| 242 |
+
|
| 243 |
+
## Related docs
|
| 244 |
+
|
| 245 |
+
- [`../design-content-and-agent-flow.md`](../design-content-and-agent-flow.md) β the system these scripts drive (pipeline, agent tools, session flow).
|
| 246 |
+
- [`../deploy-hf-spaces.md`](../deploy-hf-spaces.md) β the deploy that `smoke_space.py` verifies.
|
| 247 |
+
- [`../index/README.md`](../index/README.md) β `embed`/`retrieve`/`hydrate`, called by `build_index.py`, `search.py`, `calibrate_guard.py`.
|
| 248 |
+
- [`../agent/README.md`](../agent/README.md) β `guard`/`loop`/`llm`, called by `ask.py` and the batch jobs.
|
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>TorchDocs Agent β grounded PyTorch answers</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 10 |
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
| 11 |
+
|
| 12 |
+
/* warm, ember-biased neutrals β the greys are literally the reasoning-trace colour */
|
| 13 |
+
--bg: #17120E;
|
| 14 |
+
--bg-raised: #211a14;
|
| 15 |
+
--bg-inset: #100c09;
|
| 16 |
+
--text: #F3EDE6;
|
| 17 |
+
--muted: #A99C8D; /* the grey a trace line is drawn in */
|
| 18 |
+
--faint: #6E6255;
|
| 19 |
+
--border: rgba(243, 237, 230, 0.11);
|
| 20 |
+
--border-strong: rgba(243, 237, 230, 0.2);
|
| 21 |
+
--ember: #FF5A36;
|
| 22 |
+
--ember-2: #FF8A5B;
|
| 23 |
+
--verified: #64C296; /* semantic: grounded/cited β NOT the accent */
|
| 24 |
+
--shadow: 0 24px 60px -30px rgba(0,0,0,0.75);
|
| 25 |
+
}
|
| 26 |
+
@media (prefers-color-scheme: light) {
|
| 27 |
+
:root {
|
| 28 |
+
--bg: #FAF8F5;
|
| 29 |
+
--bg-raised: #FFFFFF;
|
| 30 |
+
--bg-inset: #F1EBE4;
|
| 31 |
+
--text: #211B16;
|
| 32 |
+
--muted: #6E6357;
|
| 33 |
+
--faint: #9B9184;
|
| 34 |
+
--border: rgba(34, 27, 22, 0.12);
|
| 35 |
+
--border-strong: rgba(34, 27, 22, 0.22);
|
| 36 |
+
--ember: #E5431F;
|
| 37 |
+
--ember-2: #FF6A3D;
|
| 38 |
+
--verified: #2E9367;
|
| 39 |
+
--shadow: 0 24px 60px -34px rgba(60, 30, 12, 0.28);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
:root[data-theme="dark"] {
|
| 43 |
+
--bg: #17120E; --bg-raised: #211a14; --bg-inset: #100c09;
|
| 44 |
+
--text: #F3EDE6; --muted: #A99C8D; --faint: #6E6255;
|
| 45 |
+
--border: rgba(243,237,230,0.11); --border-strong: rgba(243,237,230,0.2);
|
| 46 |
+
--ember: #FF5A36; --ember-2: #FF8A5B; --verified: #64C296;
|
| 47 |
+
--shadow: 0 24px 60px -30px rgba(0,0,0,0.75);
|
| 48 |
+
}
|
| 49 |
+
:root[data-theme="light"] {
|
| 50 |
+
--bg: #FAF8F5; --bg-raised: #FFFFFF; --bg-inset: #F1EBE4;
|
| 51 |
+
--text: #211B16; --muted: #6E6357; --faint: #9B9184;
|
| 52 |
+
--border: rgba(34,27,22,0.12); --border-strong: rgba(34,27,22,0.22);
|
| 53 |
+
--ember: #E5431F; --ember-2: #FF6A3D; --verified: #2E9367;
|
| 54 |
+
--shadow: 0 24px 60px -34px rgba(60,30,12,0.28);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
* { box-sizing: border-box; }
|
| 58 |
+
body {
|
| 59 |
+
margin: 0; background: var(--bg); color: var(--text);
|
| 60 |
+
font-family: var(--sans); line-height: 1.6;
|
| 61 |
+
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
| 62 |
+
}
|
| 63 |
+
.wrap { width: 100%; max-width: 1120px; margin: 0 auto; padding: 0 24px; }
|
| 64 |
+
a { color: inherit; }
|
| 65 |
+
h1, h2, h3 { text-wrap: balance; margin: 0; }
|
| 66 |
+
|
| 67 |
+
.eyebrow {
|
| 68 |
+
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.16em;
|
| 69 |
+
text-transform: uppercase; color: var(--ember); font-weight: 600;
|
| 70 |
+
}
|
| 71 |
+
.sub-eyebrow { color: var(--faint); }
|
| 72 |
+
|
| 73 |
+
/* ---- top bar ---- */
|
| 74 |
+
header.bar {
|
| 75 |
+
position: sticky; top: 0; z-index: 20;
|
| 76 |
+
backdrop-filter: blur(12px);
|
| 77 |
+
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
| 78 |
+
border-bottom: 1px solid var(--border);
|
| 79 |
+
}
|
| 80 |
+
.bar-inner { display: flex; align-items: center; gap: 24px; height: 62px; }
|
| 81 |
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.01em; }
|
| 82 |
+
.flame {
|
| 83 |
+
width: 22px; height: 22px; flex: none;
|
| 84 |
+
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--ember) 55%, transparent));
|
| 85 |
+
}
|
| 86 |
+
nav.links { display: flex; gap: 22px; margin-left: auto; align-items: center; }
|
| 87 |
+
nav.links a {
|
| 88 |
+
text-decoration: none; color: var(--muted); font-size: 0.92rem;
|
| 89 |
+
font-weight: 500; transition: color .15s;
|
| 90 |
+
}
|
| 91 |
+
nav.links a:hover { color: var(--text); }
|
| 92 |
+
.nav-hide { }
|
| 93 |
+
@media (max-width: 760px) { .nav-hide { display: none; } nav.links { gap: 14px; } }
|
| 94 |
+
|
| 95 |
+
.btn {
|
| 96 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 97 |
+
font-family: var(--sans); font-weight: 600; font-size: 0.92rem;
|
| 98 |
+
text-decoration: none; border-radius: 8px; padding: 9px 15px;
|
| 99 |
+
border: 1px solid transparent; cursor: pointer; transition: transform .12s, box-shadow .2s, background .2s;
|
| 100 |
+
}
|
| 101 |
+
.btn-primary {
|
| 102 |
+
color: #fff; background: linear-gradient(135deg, var(--ember), var(--ember-2));
|
| 103 |
+
box-shadow: 0 8px 22px -10px color-mix(in srgb, var(--ember) 70%, transparent);
|
| 104 |
+
}
|
| 105 |
+
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--ember) 75%, transparent); }
|
| 106 |
+
.btn-ghost { color: var(--text); border-color: var(--border-strong); background: transparent; }
|
| 107 |
+
.btn-ghost:hover { border-color: var(--ember); color: var(--ember); }
|
| 108 |
+
|
| 109 |
+
.toggle {
|
| 110 |
+
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
|
| 111 |
+
background: transparent; color: var(--muted); cursor: pointer; display: grid; place-items: center;
|
| 112 |
+
flex: none; transition: color .15s, border-color .15s;
|
| 113 |
+
}
|
| 114 |
+
.toggle:hover { color: var(--ember); border-color: var(--ember); }
|
| 115 |
+
|
| 116 |
+
/* ---- hero ---- */
|
| 117 |
+
.hero { padding: 74px 0 40px; }
|
| 118 |
+
.hero-grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 54px; align-items: center; }
|
| 119 |
+
@media (max-width: 900px) { .hero-grid { grid-template-columns: 1fr; gap: 40px; } .hero { padding: 46px 0 20px; } }
|
| 120 |
+
h1.head {
|
| 121 |
+
font-size: clamp(2.3rem, 5.2vw, 3.9rem); line-height: 1.05; letter-spacing: -0.03em;
|
| 122 |
+
font-weight: 800; margin: 18px 0 0;
|
| 123 |
+
}
|
| 124 |
+
h1.head .flame-word { color: var(--ember); }
|
| 125 |
+
.lede { color: var(--muted); font-size: clamp(1.02rem, 1.6vw, 1.2rem); margin: 20px 0 0; max-width: 34em; }
|
| 126 |
+
.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
|
| 127 |
+
.cta-row .btn { padding: 12px 20px; font-size: 1rem; }
|
| 128 |
+
.disclaimer { margin-top: 20px; font-size: 0.82rem; color: var(--faint); }
|
| 129 |
+
|
| 130 |
+
/* ---- the live answer card (the thesis) ---- */
|
| 131 |
+
.card {
|
| 132 |
+
background: var(--bg-inset); border: 1px solid var(--border-strong);
|
| 133 |
+
border-radius: 14px; box-shadow: var(--shadow); overflow: hidden;
|
| 134 |
+
}
|
| 135 |
+
.card-top {
|
| 136 |
+
display: flex; align-items: center; gap: 8px; padding: 12px 16px;
|
| 137 |
+
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg-raised) 60%, transparent);
|
| 138 |
+
}
|
| 139 |
+
.dot { width: 11px; height: 11px; border-radius: 50%; background: var(--border-strong); }
|
| 140 |
+
.card-title { margin-left: 8px; font-family: var(--mono); font-size: 0.76rem; color: var(--faint); letter-spacing: 0.02em; }
|
| 141 |
+
.card-body { padding: 20px 20px 22px; min-height: 292px; }
|
| 142 |
+
.ask { font-family: var(--mono); font-size: 0.86rem; color: var(--text); margin-bottom: 16px; }
|
| 143 |
+
.ask .q { color: var(--ember); }
|
| 144 |
+
.trace { font-family: var(--mono); font-size: 0.83rem; color: var(--muted); line-height: 1.85; }
|
| 145 |
+
.trace .row { opacity: 0; transform: translateY(3px); transition: opacity .35s, transform .35s; }
|
| 146 |
+
.trace .row.show { opacity: 1; transform: none; }
|
| 147 |
+
.answer {
|
| 148 |
+
margin-top: 16px; opacity: 0; transition: opacity .5s; font-size: 0.95rem; line-height: 1.6;
|
| 149 |
+
}
|
| 150 |
+
.answer.show { opacity: 1; }
|
| 151 |
+
.answer code, .code {
|
| 152 |
+
font-family: var(--mono); background: color-mix(in srgb, var(--ember) 12%, transparent);
|
| 153 |
+
color: var(--ember); padding: 1px 5px; border-radius: 4px; font-size: 0.86em;
|
| 154 |
+
}
|
| 155 |
+
.snippet {
|
| 156 |
+
margin: 12px 0 4px; padding: 11px 13px; border-radius: 8px; background: var(--bg);
|
| 157 |
+
border: 1px solid var(--border); font-family: var(--mono); font-size: 0.8rem;
|
| 158 |
+
color: var(--text); overflow-x: auto; line-height: 1.5;
|
| 159 |
+
}
|
| 160 |
+
.snippet .kw { color: var(--ember); }
|
| 161 |
+
.snippet .cm { color: var(--faint); }
|
| 162 |
+
.cite {
|
| 163 |
+
display: inline-flex; align-items: center; gap: 6px; margin-top: 14px;
|
| 164 |
+
font-family: var(--mono); font-size: 0.75rem; color: var(--verified);
|
| 165 |
+
border: 1px solid color-mix(in srgb, var(--verified) 40%, transparent);
|
| 166 |
+
border-radius: 999px; padding: 4px 11px; text-decoration: none;
|
| 167 |
+
}
|
| 168 |
+
.cite:hover { background: color-mix(in srgb, var(--verified) 12%, transparent); }
|
| 169 |
+
.caret { display: inline-block; width: 8px; height: 1.05em; vertical-align: -0.16em;
|
| 170 |
+
background: var(--ember); margin-left: 2px; animation: blink 1.05s steps(1) infinite; }
|
| 171 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 172 |
+
|
| 173 |
+
/* ---- generic section ---- */
|
| 174 |
+
section.band { padding: 64px 0; border-top: 1px solid var(--border); }
|
| 175 |
+
.band-head { max-width: 40em; margin-bottom: 40px; }
|
| 176 |
+
.band-head h2 { font-size: clamp(1.7rem, 3vw, 2.3rem); letter-spacing: -0.02em; font-weight: 800; margin-top: 12px; }
|
| 177 |
+
.band-head p { color: var(--muted); font-size: 1.06rem; margin: 14px 0 0; }
|
| 178 |
+
|
| 179 |
+
/* thesis strip */
|
| 180 |
+
.thesis { font-size: clamp(1.3rem, 2.6vw, 1.9rem); font-weight: 600; letter-spacing: -0.02em; line-height: 1.4; max-width: 20em; }
|
| 181 |
+
.thesis .hl { color: var(--ember); }
|
| 182 |
+
.thesis .em2 { color: var(--verified); }
|
| 183 |
+
|
| 184 |
+
/* feature grid */
|
| 185 |
+
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
| 186 |
+
@media (max-width: 860px) { .features { grid-template-columns: 1fr; } }
|
| 187 |
+
.feat {
|
| 188 |
+
background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px;
|
| 189 |
+
padding: 22px 22px 24px; transition: border-color .2s, transform .2s;
|
| 190 |
+
}
|
| 191 |
+
.feat:hover { border-color: var(--border-strong); transform: translateY(-2px); }
|
| 192 |
+
.feat .tag { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); }
|
| 193 |
+
.feat h3 { font-size: 1.12rem; margin: 12px 0 8px; letter-spacing: -0.01em; }
|
| 194 |
+
.feat p { color: var(--muted); font-size: 0.95rem; margin: 0; }
|
| 195 |
+
.feat .rule { width: 26px; height: 3px; border-radius: 2px; background: var(--ember); margin-bottom: 16px; }
|
| 196 |
+
|
| 197 |
+
/* pipeline (a real sequence) */
|
| 198 |
+
.pipe { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; }
|
| 199 |
+
@media (max-width: 860px) { .pipe { grid-template-columns: 1fr; } }
|
| 200 |
+
.step { position: relative; padding: 24px 22px; border: 1px solid var(--border); background: var(--bg-raised); }
|
| 201 |
+
.pipe .step:not(:last-child) { border-right: none; }
|
| 202 |
+
@media (max-width: 860px) { .pipe .step:not(:last-child) { border-right: 1px solid var(--border); border-bottom: none; } }
|
| 203 |
+
.step:first-child { border-radius: 12px 0 0 12px; }
|
| 204 |
+
.step:last-child { border-radius: 0 12px 12px 0; }
|
| 205 |
+
@media (max-width: 860px) {
|
| 206 |
+
.step:first-child { border-radius: 12px 12px 0 0; }
|
| 207 |
+
.step:last-child { border-radius: 0 0 12px 12px; }
|
| 208 |
+
}
|
| 209 |
+
.step .n { font-family: var(--mono); font-size: 0.78rem; color: var(--ember); font-weight: 600; }
|
| 210 |
+
.step h3 { font-family: var(--mono); font-size: 0.98rem; margin: 10px 0 8px; letter-spacing: 0.01em; }
|
| 211 |
+
.step p { color: var(--muted); font-size: 0.88rem; margin: 0; }
|
| 212 |
+
.step .arrow { position: absolute; right: -9px; top: 50%; transform: translateY(-50%); z-index: 2;
|
| 213 |
+
width: 18px; height: 18px; color: var(--ember); }
|
| 214 |
+
@media (max-width: 860px) { .step .arrow { display: none; } }
|
| 215 |
+
|
| 216 |
+
/* use it β two tracks */
|
| 217 |
+
.tracks { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; align-items: start; }
|
| 218 |
+
@media (max-width: 860px) { .tracks { grid-template-columns: 1fr; } }
|
| 219 |
+
.track { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; padding: 26px; }
|
| 220 |
+
.track h3 { font-size: 1.18rem; letter-spacing: -0.01em; }
|
| 221 |
+
.track .kicker { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ember); }
|
| 222 |
+
.track ol { margin: 16px 0 0; padding-left: 20px; color: var(--muted); }
|
| 223 |
+
.track ol li { margin-bottom: 9px; }
|
| 224 |
+
.track ol li::marker { color: var(--faint); font-family: var(--mono); font-size: 0.85em; }
|
| 225 |
+
pre.block {
|
| 226 |
+
margin: 16px 0 0; padding: 16px; border-radius: 10px; background: var(--bg-inset);
|
| 227 |
+
border: 1px solid var(--border); overflow-x: auto;
|
| 228 |
+
font-family: var(--mono); font-size: 0.82rem; line-height: 1.7; color: var(--text);
|
| 229 |
+
}
|
| 230 |
+
pre.block .cm { color: var(--faint); }
|
| 231 |
+
pre.block .kw { color: var(--ember); }
|
| 232 |
+
.ex { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
| 233 |
+
.ex span {
|
| 234 |
+
font-family: var(--mono); font-size: 0.78rem; color: var(--muted);
|
| 235 |
+
border: 1px solid var(--border); border-radius: 999px; padding: 5px 11px; background: var(--bg);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/* docs links */
|
| 239 |
+
.doclist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
| 240 |
+
@media (max-width: 860px) { .doclist { grid-template-columns: 1fr 1fr; } }
|
| 241 |
+
@media (max-width: 520px) { .doclist { grid-template-columns: 1fr; } }
|
| 242 |
+
.doc {
|
| 243 |
+
display: block; text-decoration: none; padding: 16px 18px; border-radius: 10px;
|
| 244 |
+
border: 1px solid var(--border); background: var(--bg-raised); transition: border-color .2s, transform .2s;
|
| 245 |
+
}
|
| 246 |
+
.doc:hover { border-color: var(--ember); transform: translateY(-2px); }
|
| 247 |
+
.doc .p { font-family: var(--mono); color: var(--ember); font-size: 0.9rem; }
|
| 248 |
+
.doc .d { color: var(--muted); font-size: 0.86rem; margin-top: 5px; }
|
| 249 |
+
|
| 250 |
+
/* footer */
|
| 251 |
+
footer { border-top: 1px solid var(--border); padding: 40px 0 56px; margin-top: 8px; }
|
| 252 |
+
.foot { display: flex; flex-wrap: wrap; gap: 18px 30px; align-items: center; color: var(--muted); font-size: 0.9rem; }
|
| 253 |
+
.foot a { color: var(--muted); text-decoration: none; }
|
| 254 |
+
.foot a:hover { color: var(--ember); }
|
| 255 |
+
.foot .sep { flex: 1; }
|
| 256 |
+
|
| 257 |
+
@media (prefers-reduced-motion: reduce) {
|
| 258 |
+
.trace .row, .answer { opacity: 1 !important; transform: none !important; transition: none !important; }
|
| 259 |
+
.caret { animation: none; }
|
| 260 |
+
.btn, .feat, .doc { transition: none; }
|
| 261 |
+
}
|
| 262 |
+
</style>
|
| 263 |
+
</head>
|
| 264 |
+
<body>
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
<header class="bar">
|
| 268 |
+
<div class="wrap bar-inner">
|
| 269 |
+
<span class="brand">
|
| 270 |
+
<svg class="flame" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" style="color:var(--ember)" d="M13.5 1.5c.4 3-1.2 4.7-2.8 6.3C9 9.6 7.2 11.3 7.2 14.2a4.8 4.8 0 0 0 9.6.3c0-1.6-.7-2.9-1.4-4-.3 1-1 1.6-1.8 1.7.8-2.3.3-5.3-2-8.4l1.9-2.3ZM10 15.4c0-1.3.8-2.2 1.6-3 .3 1 .1 2 .9 2.9.5-.3.8-.8.9-1.4.5.7.9 1.5.9 2.4a2.6 2.6 0 0 1-5.2 0c0-.5.1-.9.3-1.3.1.6.3 1 .6 1.3-.1-.9-.4-1.6 0-2.3-.1.7 0 1.1 0 1.4Z"/></svg>
|
| 271 |
+
TorchDocs Agent
|
| 272 |
+
</span>
|
| 273 |
+
<nav class="links">
|
| 274 |
+
<a class="nav-hide" href="#what">What</a>
|
| 275 |
+
<a class="nav-hide" href="#how">How it works</a>
|
| 276 |
+
<a class="nav-hide" href="#use">Use it</a>
|
| 277 |
+
<a href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs">Docs</a>
|
| 278 |
+
<button class="toggle" id="themeBtn" aria-label="Toggle colour theme" title="Toggle theme">
|
| 279 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/></svg>
|
| 280 |
+
</button>
|
| 281 |
+
</nav>
|
| 282 |
+
</div>
|
| 283 |
+
</header>
|
| 284 |
+
|
| 285 |
+
<main>
|
| 286 |
+
<!-- HERO -->
|
| 287 |
+
<section class="hero">
|
| 288 |
+
<div class="wrap hero-grid">
|
| 289 |
+
<div>
|
| 290 |
+
<div class="eyebrow">PyTorch docs Β· grounded RAG agent</div>
|
| 291 |
+
<h1 class="head">Ask PyTorch anything.<br>Get an answer <span class="flame-word">grounded in the docs</span>.</h1>
|
| 292 |
+
<p class="lede">A chat agent that answers from the official PyTorch documentation β every claim traced to the exact page it cites, and an honest referral when the question reaches past the docs. No invented APIs.</p>
|
| 293 |
+
<div class="cta-row">
|
| 294 |
+
<a class="btn btn-primary" href="https://huggingface.co/spaces/eliezeravihail/torchdocs-agent">
|
| 295 |
+
Try it live
|
| 296 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 297 |
+
</a>
|
| 298 |
+
<a class="btn btn-ghost" href="#how">See how it works</a>
|
| 299 |
+
</div>
|
| 300 |
+
<p class="disclaimer">A personal project β not affiliated with or endorsed by the PyTorch team.</p>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<!-- the live answer card: the product's signature UX -->
|
| 304 |
+
<div class="card" id="demo" aria-label="Example of the agent answering, with its reasoning shown">
|
| 305 |
+
<div class="card-top">
|
| 306 |
+
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
| 307 |
+
<span class="card-title">torchdocs-agent</span>
|
| 308 |
+
</div>
|
| 309 |
+
<div class="card-body">
|
| 310 |
+
<div class="ask">> <span class="q">How do I use SGD with momentum?</span></div>
|
| 311 |
+
<div class="trace" id="trace">
|
| 312 |
+
<div class="row" data-row>π searched βSGD momentumβ</div>
|
| 313 |
+
<div class="row" data-row>π found: torch.optim.SGD Β· Per-parameter options</div>
|
| 314 |
+
<div class="row" data-row>βοΈ writing the answer</div>
|
| 315 |
+
</div>
|
| 316 |
+
<div class="answer" id="answer">
|
| 317 |
+
Pass <code>momentum</code> to <code>torch.optim.SGD</code> β a value around <code>0.9</code> is typical:
|
| 318 |
+
<div class="snippet"><span class="kw">optimizer</span> = torch.optim.SGD(model.parameters(),
|
| 319 |
+
lr=<span class="kw">0.01</span>, momentum=<span class="kw">0.9</span>) <span class="cm"># accelerates in consistent directions</span></div>
|
| 320 |
+
<a class="cite" href="https://docs.pytorch.org/docs/stable/generated/torch.optim.SGD.html">β torch.optim.SGD β</a><span class="caret" id="caret"></span>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
</section>
|
| 326 |
+
|
| 327 |
+
<!-- THESIS -->
|
| 328 |
+
<section class="band" id="what">
|
| 329 |
+
<div class="wrap">
|
| 330 |
+
<div class="eyebrow sub-eyebrow">The point</div>
|
| 331 |
+
<p class="thesis" style="margin-top:16px">
|
| 332 |
+
A general model will happily <span class="hl">invent an API that doesnβt exist</span>.
|
| 333 |
+
This one canβt: every answer is built only from <span class="em2">retrieved documentation</span> β
|
| 334 |
+
or it tells you itβs beyond the docs and points you to the source.
|
| 335 |
+
</p>
|
| 336 |
+
</div>
|
| 337 |
+
</section>
|
| 338 |
+
|
| 339 |
+
<!-- FEATURES -->
|
| 340 |
+
<section class="band">
|
| 341 |
+
<div class="wrap">
|
| 342 |
+
<div class="band-head">
|
| 343 |
+
<div class="eyebrow">What makes it different</div>
|
| 344 |
+
<h2>Grounded, current, and honest about its limits.</h2>
|
| 345 |
+
</div>
|
| 346 |
+
<div class="features">
|
| 347 |
+
<div class="feat">
|
| 348 |
+
<div class="rule"></div>
|
| 349 |
+
<div class="tag">Citations</div>
|
| 350 |
+
<h3>Grounded, or it refers you out</h3>
|
| 351 |
+
<p>Every claim traces to a retrieved doc section; citations open the exact page and anchor. Source-code internals get a DeepWiki / GitHub referral β never a guess dressed as fact.</p>
|
| 352 |
+
</div>
|
| 353 |
+
<div class="feat">
|
| 354 |
+
<div class="rule"></div>
|
| 355 |
+
<div class="tag">Freshness</div>
|
| 356 |
+
<h3>The index heals itself</h3>
|
| 357 |
+
<p>After answering, it revalidates the cited pages against the live docs. If they drifted, it re-embeds the changed sections in place and corrects the answer β no rebuild, no stale cache.</p>
|
| 358 |
+
</div>
|
| 359 |
+
<div class="feat">
|
| 360 |
+
<div class="rule"></div>
|
| 361 |
+
<div class="tag">Transparency</div>
|
| 362 |
+
<h3>You watch it think</h3>
|
| 363 |
+
<p>The reasoning streams live in grey β what it searched, what it found β then resolves into the answer in black. Honest progress for a payload that can only arrive whole.</p>
|
| 364 |
+
</div>
|
| 365 |
+
<div class="feat">
|
| 366 |
+
<div class="rule"></div>
|
| 367 |
+
<div class="tag">Retrieval</div>
|
| 368 |
+
<h3>Hybrid search, lean stack</h3>
|
| 369 |
+
<p>Dense pgvector + keyword tsvector, RRF-merged over Neon Postgres. Embeddings run on a local 384-dim model on CPU: no embedding API, no quota, no cost.</p>
|
| 370 |
+
</div>
|
| 371 |
+
<div class="feat">
|
| 372 |
+
<div class="rule"></div>
|
| 373 |
+
<div class="tag">Resilience</div>
|
| 374 |
+
<h3>Wonβt fall over</h3>
|
| 375 |
+
<p>A provider fallback chain β OpenRouter β Gemini β Anthropic β with per-model cooldowns. One rate-limited or broken key self-heals to the next instead of taking the app down.</p>
|
| 376 |
+
</div>
|
| 377 |
+
<div class="feat">
|
| 378 |
+
<div class="rule"></div>
|
| 379 |
+
<div class="tag">Discipline</div>
|
| 380 |
+
<h3>Checked before it ships</h3>
|
| 381 |
+
<p>Every snippet is statically checked β it must parse, import only torch/stdlib, and mention only symbols that exist in the index. A failed check triggers one regeneration, not a bluff.</p>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
</section>
|
| 386 |
+
|
| 387 |
+
<!-- HOW IT WORKS -->
|
| 388 |
+
<section class="band" id="how">
|
| 389 |
+
<div class="wrap">
|
| 390 |
+
<div class="band-head">
|
| 391 |
+
<div class="eyebrow">The pipeline</div>
|
| 392 |
+
<h2>From the docs site to a cited answer, in four stages.</h2>
|
| 393 |
+
<p>Data flows one way. Each stage is a package in the repo, documented on its own.</p>
|
| 394 |
+
</div>
|
| 395 |
+
<div class="pipe">
|
| 396 |
+
<div class="step">
|
| 397 |
+
<div class="n">01</div>
|
| 398 |
+
<h3>ingest</h3>
|
| 399 |
+
<p>Discover every page (Sphinx <span class="code">objects.inv</span> + sitemaps), crawl it, and heading-chunk the docs site into an on-disk snapshot.</p>
|
| 400 |
+
<svg class="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 401 |
+
</div>
|
| 402 |
+
<div class="step">
|
| 403 |
+
<div class="n">02</div>
|
| 404 |
+
<h3>index</h3>
|
| 405 |
+
<p>Embed into Neon (pgvector + tsvector). Hybrid retrieval returns pointers; content is hydrated straight from the database.</p>
|
| 406 |
+
<svg class="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 407 |
+
</div>
|
| 408 |
+
<div class="step">
|
| 409 |
+
<div class="n">03</div>
|
| 410 |
+
<h3>agent</h3>
|
| 411 |
+
<p>Guard β route β a single grounded pass or a tool loop (<span class="code">search_docs</span> / <span class="code">read_page</span> / <span class="code">ask_source</span>) β a validated answer.</p>
|
| 412 |
+
<svg class="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 413 |
+
</div>
|
| 414 |
+
<div class="step">
|
| 415 |
+
<div class="n">04</div>
|
| 416 |
+
<h3>app</h3>
|
| 417 |
+
<p>A Gradio Space streams the reasoning trace and renders the answer with clickable citations β then quietly checks the docs are still current.</p>
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
</section>
|
| 422 |
+
|
| 423 |
+
<!-- USE IT -->
|
| 424 |
+
<section class="band" id="use">
|
| 425 |
+
<div class="wrap">
|
| 426 |
+
<div class="band-head">
|
| 427 |
+
<div class="eyebrow">Use it</div>
|
| 428 |
+
<h2>Two ways in.</h2>
|
| 429 |
+
</div>
|
| 430 |
+
<div class="tracks">
|
| 431 |
+
<div class="track">
|
| 432 |
+
<div class="kicker">No install</div>
|
| 433 |
+
<h3>Ask the live agent</h3>
|
| 434 |
+
<ol>
|
| 435 |
+
<li>Open the Space β it runs as a public web app.</li>
|
| 436 |
+
<li>Type a question <strong>in English</strong> and press Enter.</li>
|
| 437 |
+
<li>Read the answer; click a citation to land on the exact doc section.</li>
|
| 438 |
+
</ol>
|
| 439 |
+
<div class="ex">
|
| 440 |
+
<span>How do I use torch.optim.SGD with momentum?</span>
|
| 441 |
+
<span>What LR schedulers are supported?</span>
|
| 442 |
+
<span>How do I build a CNN to classify images?</span>
|
| 443 |
+
</div>
|
| 444 |
+
<div class="cta-row" style="margin-top:22px">
|
| 445 |
+
<a class="btn btn-primary" href="https://huggingface.co/spaces/eliezeravihail/torchdocs-agent">Open the Space
|
| 446 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 447 |
+
</a>
|
| 448 |
+
</div>
|
| 449 |
+
</div>
|
| 450 |
+
<div class="track">
|
| 451 |
+
<div class="kicker">Self-host</div>
|
| 452 |
+
<h3>Run or deploy your own</h3>
|
| 453 |
+
<p style="color:var(--muted); margin:14px 0 0; font-size:0.95rem;">Embeddings run locally on CPU, so building the index needs only a Neon connection string.</p>
|
| 454 |
+
<pre class="block"><span class="cm"># 1 Β· install</span>
|
| 455 |
+
git clone https://github.com/eliezeravihail/TorchDocsAgent
|
| 456 |
+
<span class="kw">cd</span> TorchDocsAgent && pip install -e .
|
| 457 |
+
|
| 458 |
+
<span class="cm"># 2 Β· build the index (crawl + embed, CPU, minutes)</span>
|
| 459 |
+
<span class="kw">export</span> NEON_URL=postgres://...
|
| 460 |
+
python scripts/build_index.py
|
| 461 |
+
|
| 462 |
+
<span class="cm"># 3 Β· run locally β or deploy to HF Spaces</span>
|
| 463 |
+
python -m app.main</pre>
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
</section>
|
| 468 |
+
|
| 469 |
+
<!-- ENGINEERING DOCS -->
|
| 470 |
+
<section class="band">
|
| 471 |
+
<div class="wrap">
|
| 472 |
+
<div class="band-head">
|
| 473 |
+
<div class="eyebrow">Built in the open</div>
|
| 474 |
+
<h2>Read the reasoning, not just the code.</h2>
|
| 475 |
+
<p>Every package is documented with its boundary, its logic, the design decisions behind it, and the tools it chose β and why.</p>
|
| 476 |
+
</div>
|
| 477 |
+
<div class="doclist">
|
| 478 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs/agent"><div class="p">agent/</div><div class="d">Guard, router, grounded & loop paths, LLM dispatch.</div></a>
|
| 479 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs/index"><div class="p">index/</div><div class="d">Neon/pgvector storage, hybrid retrieval, freshness.</div></a>
|
| 480 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs/ingest"><div class="p">ingest/</div><div class="d">Discover β crawl β chunk the docs into a snapshot.</div></a>
|
| 481 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs/eval"><div class="p">eval/</div><div class="d">Static checks, retrieval metrics, LLM-as-judge.</div></a>
|
| 482 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs/app"><div class="p">app/</div><div class="d">The Gradio Space: serving + the live trace.</div></a>
|
| 483 |
+
<a class="doc" href="https://github.com/eliezeravihail/TorchDocsAgent/blob/main/docs/design-content-and-agent-flow.md"><div class="p">design β</div><div class="d">The architecture bible β corpus, tools, flow.</div></a>
|
| 484 |
+
</div>
|
| 485 |
+
</div>
|
| 486 |
+
</section>
|
| 487 |
+
</main>
|
| 488 |
+
|
| 489 |
+
<footer>
|
| 490 |
+
<div class="wrap foot">
|
| 491 |
+
<span class="brand"><svg class="flame" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" style="color:var(--ember)" d="M13.5 1.5c.4 3-1.2 4.7-2.8 6.3C9 9.6 7.2 11.3 7.2 14.2a4.8 4.8 0 0 0 9.6.3c0-1.6-.7-2.9-1.4-4-.3 1-1 1.6-1.8 1.7.8-2.3.3-5.3-2-8.4l1.9-2.3Z"/></svg> TorchDocs Agent</span>
|
| 492 |
+
<span class="sep"></span>
|
| 493 |
+
<a href="https://github.com/eliezeravihail/TorchDocsAgent">GitHub</a>
|
| 494 |
+
<a href="https://huggingface.co/spaces/eliezeravihail/torchdocs-agent">Live Space</a>
|
| 495 |
+
<a href="https://github.com/eliezeravihail/TorchDocsAgent/tree/main/docs">Docs</a>
|
| 496 |
+
<a href="https://github.com/pytorch/pytorch/blob/main/LICENSE">Docs license Β· BSD-3</a>
|
| 497 |
+
</div>
|
| 498 |
+
</footer>
|
| 499 |
+
|
| 500 |
+
<script>
|
| 501 |
+
// theme toggle β stamps data-theme, overriding the media query in both directions
|
| 502 |
+
(function () {
|
| 503 |
+
var btn = document.getElementById("themeBtn");
|
| 504 |
+
btn && btn.addEventListener("click", function () {
|
| 505 |
+
var root = document.documentElement;
|
| 506 |
+
var dark = getComputedStyle(root).getPropertyValue("--bg").trim().indexOf("#1") === 0
|
| 507 |
+
|| matchMedia("(prefers-color-scheme: dark)").matches;
|
| 508 |
+
var cur = root.getAttribute("data-theme");
|
| 509 |
+
if (!cur) cur = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
| 510 |
+
root.setAttribute("data-theme", cur === "dark" ? "light" : "dark");
|
| 511 |
+
});
|
| 512 |
+
})();
|
| 513 |
+
|
| 514 |
+
// hero: play the reasoning trace, then reveal the answer (reduced-motion β final state)
|
| 515 |
+
(function () {
|
| 516 |
+
var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 517 |
+
var rows = Array.prototype.slice.call(document.querySelectorAll("#trace [data-row]"));
|
| 518 |
+
var answer = document.getElementById("answer");
|
| 519 |
+
if (reduce) { rows.forEach(function (r) { r.classList.add("show"); }); answer.classList.add("show"); return; }
|
| 520 |
+
var i = 0;
|
| 521 |
+
function next() {
|
| 522 |
+
if (i < rows.length) { rows[i].classList.add("show"); i++; setTimeout(next, 620); }
|
| 523 |
+
else { setTimeout(function () { answer.classList.add("show"); }, 260); }
|
| 524 |
+
}
|
| 525 |
+
setTimeout(next, 420);
|
| 526 |
+
})();
|
| 527 |
+
</script>
|
| 528 |
+
|
| 529 |
+
</body>
|
| 530 |
+
</html>
|