torchdocs-agent / ingest /chunk_docs.py
Claude
Fix synopsis extraction: read the Sphinx definition line, not the signature head
f1d3985 unverified
Raw
History Blame Contribute Delete
10.1 kB
"""Chunk snapshot pages by heading into OKF units.
A chunk = one coherent doc section: its heading path, its prose, and any code
blocks that live under it. API pages also carry their [source] GitHub link as
metadata. Units are emitted as OKF files (YAML frontmatter + markdown body) β€”
the human/agent-readable knowledge snapshot β€” which the embed step then loads
into Neon.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
GITHUB_SOURCE_RE = re.compile(r"https://github\.com/pytorch/[\w.-]+/blob/\S+")
# Sphinx headerlinks survive HTML→markdown as e.g. [¢](#sgd "Permalink...") —
# they carry the TRUE anchor, and must not leak into titles/heading paths
HEADERLINK_RE = re.compile(r"\[[^\]]*\]\(#([^)\s\"]+)[^)]*\)")
# A section bigger than this is cut into parts at natural seams. Aligned with
# index/embed.MAX_EMBED_CHARS: anything past it was invisible to the dense
# vector anyway (embedded blind, silently). Each part inherits the section's
# heading_path, so indexed_text() prepends the same symbol+heading "synopsis"
# to every part β€” the serialized-story recap β€” and all parts share the
# section's URL+anchor, so citations stay exact.
CHUNK_TARGET_CHARS = 2000
_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
@dataclass
class Section:
heading_path: list[str]
title: str
text: str
anchor: str = ""
source_link: str = field(default="")
def slugify(title: str) -> str:
"""Sphinx-style anchor slug: lowercase, alphanumerics, hyphens."""
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
def clean_heading(raw_title: str) -> tuple[str, str]:
"""(clean title, anchor): take the real Sphinx anchor from the headerlink
when present, strip the link markup from the title, unescape markdown."""
match = HEADERLINK_RE.search(raw_title)
title = HEADERLINK_RE.sub("", raw_title).replace("\\_", "_").strip()
anchor = match.group(1) if match else slugify(title)
return title, anchor
def split_by_heading(markdown: str) -> list[Section]:
"""Split a page into sections at every heading; preamble becomes section 0."""
matches = list(HEADING_RE.finditer(markdown))
sections: list[Section] = []
stack: list[tuple[int, str]] = [] # (level, title) breadcrumbs
preamble = markdown[: matches[0].start()].strip() if matches else markdown.strip()
if preamble:
sections.append(Section(heading_path=[], title="", text=preamble))
for i, match in enumerate(matches):
level = len(match.group(1))
title, anchor = clean_heading(match.group(2).strip())
end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
text = markdown[match.end() : end].strip()
while stack and stack[-1][0] >= level:
stack.pop()
stack.append((level, title))
source = GITHUB_SOURCE_RE.search(text)
sections.append(
Section(
heading_path=[t for _, t in stack],
title=title,
text=text,
anchor=anchor,
source_link=source.group(0) if source else "",
)
)
return sections
def _atoms(text: str) -> list[str]:
"""Paragraphs of the text, with fenced code blocks kept whole.
A naive split on blank lines would cut inside ``` fences (code often has
blank lines); half a code block is noise to embed. Fences are atoms.
"""
atoms: list[str] = []
last = 0
for match in _FENCE_RE.finditer(text):
atoms += [p for p in text[last : match.start()].split("\n\n") if p.strip()]
atoms.append(match.group(0))
last = match.end()
atoms += [p for p in text[last:].split("\n\n") if p.strip()]
return atoms
def _hard_split(atom: str, limit: int) -> list[str]:
"""Last resort for one atom bigger than the whole budget: cut at line ends."""
parts: list[str] = []
current = ""
for line in atom.splitlines(keepends=True):
while len(line) > limit: # a single enormous line β€” slice it
parts.append(line[:limit])
line = line[limit:]
if current and len(current) + len(line) > limit:
parts.append(current)
current = line
else:
current += line
if current.strip():
parts.append(current)
return [p.strip("\n") for p in parts if p.strip()]
def split_oversized(text: str, limit: int | None = None) -> list[str]:
"""Cut oversized section text into parts at natural seams.
Greedy paragraph packing: whole paragraphs (and whole code fences) are
accumulated until the next one would overflow the limit; only an atom
that alone exceeds the limit is cut inside (at line boundaries).
"""
if limit is None:
limit = CHUNK_TARGET_CHARS
if len(text) <= limit:
return [text]
pieces: list[str] = []
for atom in _atoms(text):
pieces.extend(_hard_split(atom, limit) if len(atom) > limit else [atom])
parts: list[str] = []
current = ""
for piece in pieces:
if current and len(current) + 2 + len(piece) > limit:
parts.append(current)
current = piece
else:
current = f"{current}\n\n{piece}" if current else piece
if current:
parts.append(current)
return parts
def page_kind(url: str) -> str:
if "/tutorials/" in url:
return "tutorial"
if "/generated/" in url or "/docs/" in url:
return "api"
return "guide"
# a signature-shaped paragraph: `class torch.nn.Linear(...)`, `torch.add(...)`
_SIGNATURE_START_RE = re.compile(r"^(class\s+|@)?[\w.]+\(")
_SENTENCE_RE = re.compile(r"(.+?[.!?])(?:\s|$)")
_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)") # [text](url) β†’ text
SYNOPSIS_MAX_CHARS = 240
def _plain(text: str) -> str:
"""Markdown β†’ comparable prose: links keep their text, emphasis dropped."""
return " ".join(_MD_LINK_RE.sub(r"\1", text).replace("*", "").replace("\\_", "_").split())
def page_synopsis(body: str, limit: int = SYNOPSIS_MAX_CHARS) -> str:
"""The page's own first prose sentence β€” the docstring summary a human wrote.
Reference pages are signature/parameter-shaped, so their embedding sits far
from descriptive questions even though the description sentence ("This
criterion computes the cross entropy loss between input logits and target")
usually exists β€” buried. Extracting it deterministically (no LLM, no quota,
no hallucination) lets indexed_text() lead every chunk of the page with it.
Shape gotcha this handles: markdownify renders Sphinx's signature <dl> as
ONE paragraph β€” `*class* torch.nn.X(...)[[source]](github)` and then the
description on a `: ...` definition line. The description after the `:`
marker is the synopsis; naively taking the paragraph head yields the
signature + a github URL, which merely duplicates the chunk's own opening.
"""
text = _FENCE_RE.sub("", body)
for para in text.split("\n\n"):
# a definition-list description line (`: prose`) beats the paragraph
# head β€” that's where Sphinx puts the docstring summary
desc_lines = [ln.lstrip()[1:] for ln in para.splitlines() if ln.lstrip().startswith(": ")]
flat = _plain(" ".join(desc_lines) if desc_lines else para)
if not flat or flat.startswith("#"):
continue
if _SIGNATURE_START_RE.match(flat):
continue
if len(flat.split()) < 5: # crumbs like "Parameters" or a bare type
continue
match = _SENTENCE_RE.match(flat)
return (match.group(1) if match else flat)[:limit]
return ""
def chunk_page(meta: dict, body: str) -> list[dict]:
"""One snapshot page β†’ list of OKF-unit dicts (frontmatter fields + content).
Oversized sections are cut into parts (split_oversized); every part keeps
the section's heading_path (its embedded "synopsis") and URL+anchor (its
citation), and carries a `part` ordinal that makes its identity unique.
"""
units = []
kind = page_kind(meta["url"])
# api pages only: tutorials/guides already retrieve well, and their first
# sentence is prose anyway β€” the synopsis is the terse pages' rescue line
synopsis = page_synopsis(body) if kind == "api" else ""
for section in split_by_heading(body):
if not section.text:
continue
for part, content in enumerate(split_oversized(section.text)):
units.append(
{
"url": meta["url"],
"anchor": section.anchor,
"page_title": meta.get("title", ""),
"heading_path": section.heading_path,
"library": meta.get("library", ""),
"kind": kind,
"source_link": section.source_link,
"content_hash": meta.get("content_hash", ""),
"part": part,
"synopsis": synopsis,
"content": content,
}
)
return units
def write_units(units: list[dict], out_dir: Path) -> list[Path]:
"""Write each unit as an OKF file: YAML frontmatter over the section body."""
out_dir.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for i, unit in enumerate(units):
frontmatter = {k: v for k, v in unit.items() if k != "content"}
# always suffix with the enumerate index: two sections on one page can
# slugify to the same anchor (e.g. two "Parameters" headings) and would
# otherwise clobber each other on disk, silently losing a section
stem = slugify(f"{Path(unit['url']).stem}-{unit['anchor'] or 'sec'}-{i}")
path = out_dir / f"{stem}.md"
frontmatter_yaml = yaml.safe_dump(frontmatter, sort_keys=True)
path.write_text(f"---\n{frontmatter_yaml}---\n\n{unit['content']}\n", encoding="utf-8")
paths.append(path)
return paths