eliezer avihail Claude commited on
Commit
80ceec5
Β·
unverified Β·
1 Parent(s): a170f20

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 ADDED
@@ -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
README.md CHANGED
@@ -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
 
docs/README.md ADDED
@@ -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.
docs/agent/README.md ADDED
@@ -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`.
docs/app/README.md ADDED
@@ -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).
docs/eval/README.md ADDED
@@ -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).
docs/index/README.md ADDED
@@ -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.
docs/ingest/README.md ADDED
@@ -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.
docs/scripts/README.md ADDED
@@ -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.
site/index.html ADDED
@@ -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">&gt; <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 &amp; 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>