torchdocs-agent / scripts /generate_questions.py
eliezer avihail
enrichment: paid hy3, batch 50, and per-batch checkpoint push (#93)
a36cd22 unverified
Raw
History Blame Contribute Delete
6.74 kB
"""Generate hypothetical questions for API reference pages (QuOTE-style).
Why: the reranker measurement (2026-07-09) left a residue of genuinely-buried
pages — Linear, Conv2d, random_split, WeightedRandomSampler, einsum — where a
descriptive question ("what's the standard fully-connected layer?") matches
NOTHING the page carries, not even its one-sentence gloss (Linear still sat at
true dense rank ~3,400 post-gloss). The literature's index-side answer
(QuOTE, arXiv:2502.10976; HyPE) is to index the QUESTIONS a page answers,
turning question→document matching into question→question matching — paid
once at index time, not per query like HyDE.
What: for every api-kind page in the corpus snapshot, ask an LLM for a few
short questions a user would ask that this page answers — phrased in everyday
task vocabulary, mostly WITHOUT naming the symbol (the vocabulary bridge is
the whole point; the symbol token is already in the index). The questions are
folded into indexed_text() by index/embed.py — feeding both the page's vector
and its tsvector, the exact channel pair that flipped CrossEntropyLoss.
Output: index/questions.jsonl — {"url", "questions": [...]} per line,
committed. Same shape as the gloss pipeline: batched, flushed per batch,
resumable (already-covered URLs are skipped) — rate-limit deaths just mean
"run it again".
Usage: python scripts/generate_questions.py [--limit N] [--batch N]
(needs an LLM key; corpus snapshot must exist — run the crawl first)
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
from scripts.generate_glosses import api_pages, existing_urls_of, git_checkpoint
QUESTIONS_PATH = Path(__file__).parent.parent / "index" / "questions.jsonl"
QUESTIONS_PER_PAGE = 5
QUESTION_MAX_CHARS = 160
SYSTEM = (
"You write search-bridging questions for PyTorch documentation reference "
f"pages. For each numbered page (symbol/title + excerpt) write "
f"{QUESTIONS_PER_PAGE} distinct short questions (8-20 words) a PyTorch "
"user would ask that THIS page answers. Phrase them the way users talk "
"about the TASK, in everyday ML vocabulary; at most one question may name "
"the symbol itself — the rest must describe what it does or when you need "
"it (e.g. for Linear: 'What's the standard fully-connected layer that "
"applies a weight matrix and bias?'). Reply with a JSON array only, one "
'item per page, no other text: [{"i": 0, "questions": ["...", ...]}, ...]'
)
def batch_prompt(batch: list[dict]) -> str:
from index.embed import symbol_from_url
blocks = []
for i, page in enumerate(batch):
symbol = symbol_from_url(page["url"]) or page["title"]
blocks.append(f"### {i}\nsymbol: {symbol}\nexcerpt: {page['excerpt']}")
return "\n\n".join(blocks) + f"\n\nJSON array with {len(batch)} question sets:"
def parse_questions(raw: str, n: int) -> dict[int, list[str]]:
"""{index: [questions]} from the model's reply; malformed items are dropped."""
start, end = raw.find("["), raw.rfind("]")
if start == -1 or end == -1:
return {}
try:
items = json.loads(raw[start : end + 1])
except json.JSONDecodeError:
return {}
out: dict[int, list[str]] = {}
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
i, qs = item.get("i"), item.get("questions")
if not (isinstance(i, int) and 0 <= i < n and isinstance(qs, list)):
continue
clean = [q.strip()[:QUESTION_MAX_CHARS] for q in qs if isinstance(q, str) and q.strip()]
if clean:
out[i] = clean[:QUESTIONS_PER_PAGE]
return out
def main() -> int:
load_dotenv()
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=0, help="cover at most N pages (0 = all)")
parser.add_argument("--batch", type=int, default=50, help="pages per LLM call")
parser.add_argument("--sleep", type=float, default=0.0, help="pause between calls (s)")
parser.add_argument(
"--push",
action="store_true",
help="commit+push the jsonl after every batch (CI runs; keeps progress "
"if the job is cancelled/timed out). Off by default so local runs don't commit.",
)
args = parser.parse_args()
from agent.llm import GenerationError, _raw_completion
from ingest.crawl import CORPUS_DIR
if not CORPUS_DIR.exists() or not any(CORPUS_DIR.rglob("*.md")):
print("corpus snapshot is empty — run the crawl (Build Index) first", flush=True)
return 1
done = existing_urls_of(QUESTIONS_PATH)
todo = [p for p in api_pages(CORPUS_DIR) if p["url"] not in done]
if args.limit:
todo = todo[: args.limit]
print(f"[questions] {len(done)} pages already covered, {len(todo)} to go", flush=True)
if not todo:
return 0
written = failed_batches = 0
with QUESTIONS_PATH.open("a") as out:
for at in range(0, len(todo), args.batch):
batch = todo[at : at + args.batch]
try:
raw = _raw_completion(batch_prompt(batch), system=SYSTEM, timeout=180.0)
except GenerationError as exc:
print(f"[questions] batch at {at} failed: {exc}", flush=True)
failed_batches += 1
if failed_batches >= 5:
print("[questions] 5 failed batches — provider looks down, stopping",
flush=True)
break
continue
sets = parse_questions(raw, len(batch))
if not sets:
print(f"[questions] batch at {at}: unparseable reply, skipped", flush=True)
failed_batches += 1
continue
for i, qs in sorted(sets.items()):
out.write(json.dumps({"url": batch[i]["url"], "questions": qs},
ensure_ascii=False) + "\n")
out.flush() # checkpoint: kill/rate-limit here loses nothing
written += len(sets)
print(f"[questions] {at + len(batch)}/{len(todo)} pages seen, "
f"{written} question sets written", flush=True)
if args.push:
git_checkpoint(QUESTIONS_PATH, "questions")
time.sleep(args.sleep)
print(f"[questions] done: {written} new question sets → {QUESTIONS_PATH}", flush=True)
# partial success is success (resumable); total failure is loud
return 0 if written else 1
if __name__ == "__main__":
sys.exit(main())