Islam Mamedov
Day 2: AST-aware chunkers for code, docs, issues
98aa8c9
Raw
History Blame Contribute Delete
9.05 kB
"""Chunk ingested content into retrieval units.
Reads data/manifest.json + files produced by ingest.py.
Writes data/chunks.jsonl — one JSON chunk per line.
Chunk schema (same for all source types):
id stable unique id
text what gets embedded (includes a context header)
source_type code | doc | issue
path file path in the repo (or issues/N.json)
symbol function/class name, doc heading, or issue title
url link to the exact GitHub location
start_line / end_line (code and docs only)
Usage:
python src/chunk.py
"""
import json
import subprocess
from pathlib import Path
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
DATA_DIR = Path("data")
REPO_DIR = DATA_DIR / "repo"
MAX_CLASS_LINES = 150 # classes bigger than this get split into methods
MIN_CHUNK_CHARS = 50 # drop trivial fragments
MAX_ISSUE_BODY = 3000
MAX_COMMENT = 1000
PY_LANGUAGE = Language(tspython.language())
PARSER = Parser(PY_LANGUAGE)
# ---------------------------------------------------------------- helpers
def default_branch() -> str:
out = subprocess.run(["git", "-C", str(REPO_DIR), "rev-parse",
"--abbrev-ref", "HEAD"],
capture_output=True, text=True)
return out.stdout.strip() or "master"
def github_url(repo: str, branch: str, path: str,
start: int | None = None, end: int | None = None) -> str:
url = f"https://github.com/{repo}/blob/{branch}/{path}"
if start is not None:
url += f"#L{start}-L{end}"
return url
# ---------------------------------------------------------------- code
def chunk_code_file(source: bytes, rel_path: str, repo: str,
branch: str) -> list[dict]:
"""AST-aware chunking: one chunk per top-level function/class.
Large classes are split into per-method chunks. Module-level leftovers
(imports, constants) become one '<module>' chunk.
"""
tree = PARSER.parse(source)
text_lines = source.decode(errors="replace").splitlines()
chunks: list[dict] = []
covered: set[int] = set() # 1-based line numbers already in a chunk
def node_text(node) -> str:
return source[node.start_byte:node.end_byte].decode(errors="replace")
def emit(node, symbol: str, context: str = "") -> None:
start, end = node.start_point[0] + 1, node.end_point[0] + 1
header = f"# File: {rel_path}"
if context:
header += f" | {context}"
header += f" | Symbol: {symbol}\n"
body = node_text(node)
if len(body) < MIN_CHUNK_CHARS:
covered.update(range(start, end + 1))
return
chunks.append({
"id": f"{rel_path}::{symbol}::L{start}",
"text": header + body,
"source_type": "code",
"path": rel_path,
"symbol": symbol,
"url": github_url(repo, branch, rel_path, start, end),
"start_line": start,
"end_line": end,
})
covered.update(range(start, end + 1))
def unwrap(node):
"""decorated_definition wraps the real def; we want the decorator
text included but the inner node's name."""
if node.type == "decorated_definition":
for child in node.children:
if child.type in ("function_definition", "class_definition"):
return child, node
return node, node
def symbol_name(def_node) -> str:
name = def_node.child_by_field_name("name")
return name.text.decode() if name else "?"
for node in tree.root_node.children:
inner, outer = unwrap(node)
if inner.type == "function_definition":
emit(outer, symbol_name(inner))
elif inner.type == "class_definition":
cls_name = symbol_name(inner)
n_lines = inner.end_point[0] - inner.start_point[0] + 1
if n_lines <= MAX_CLASS_LINES:
emit(outer, cls_name)
else:
# Big class: one chunk per method
body = inner.child_by_field_name("body")
for child in (body.children if body else []):
m_inner, m_outer = unwrap(child)
if m_inner.type == "function_definition":
emit(m_outer, f"{cls_name}.{symbol_name(m_inner)}",
context=f"Class: {cls_name}")
# Module-level leftovers: imports, constants, top-level statements
leftover = [i for i in range(1, len(text_lines) + 1)
if i not in covered and text_lines[i - 1].strip()]
if leftover:
body = "\n".join(text_lines[i - 1] for i in leftover)
if len(body) >= MIN_CHUNK_CHARS:
start, end = leftover[0], leftover[-1]
chunks.append({
"id": f"{rel_path}::<module>",
"text": f"# File: {rel_path} | Symbol: <module>\n{body}",
"source_type": "code",
"path": rel_path,
"symbol": "<module>",
"url": github_url(repo, branch, rel_path, start, end),
"start_line": start,
"end_line": end,
})
return chunks
# ---------------------------------------------------------------- docs
def chunk_markdown_file(text: str, rel_path: str, repo: str,
branch: str) -> list[dict]:
"""Split on '## ' headings; each section keeps the page title as context."""
lines = text.splitlines()
page_title = next((l.lstrip("# ").strip() for l in lines
if l.startswith("# ")), rel_path)
sections: list[tuple[str, int]] = [] # (heading, start_line 1-based)
for i, line in enumerate(lines):
if line.startswith("## "):
sections.append((line.lstrip("# ").strip(), i + 1))
if not sections or sections[0][1] > 1:
sections.insert(0, (page_title, 1))
chunks = []
for idx, (heading, start) in enumerate(sections):
end = sections[idx + 1][1] - 1 if idx + 1 < len(sections) else len(lines)
body = "\n".join(lines[start - 1:end]).strip()
if len(body) < MIN_CHUNK_CHARS:
continue
header = f"# Doc: {rel_path} | Page: {page_title} | Section: {heading}\n"
chunks.append({
"id": f"{rel_path}::{heading}::L{start}",
"text": header + body,
"source_type": "doc",
"path": rel_path,
"symbol": heading,
"url": github_url(repo, branch, rel_path, start, end),
"start_line": start,
"end_line": end,
})
return chunks
# ---------------------------------------------------------------- issues
def chunk_issue(record: dict) -> dict | None:
"""One chunk per issue: title + body + top comments."""
parts = [f"# Issue #{record['number']}: {record['title']}"]
if record.get("labels"):
parts.append(f"Labels: {', '.join(record['labels'])}")
if record.get("body"):
parts.append(record["body"][:MAX_ISSUE_BODY])
for c in record.get("comments", []):
parts.append(f"---\nComment: {c[:MAX_COMMENT]}")
text = "\n".join(parts)
if len(text) < MIN_CHUNK_CHARS:
return None
return {
"id": f"issue::{record['number']}",
"text": text,
"source_type": "issue",
"path": f"issues/{record['number']}.json",
"symbol": record["title"],
"url": record["url"],
"start_line": None,
"end_line": None,
}
# ---------------------------------------------------------------- main
def main() -> None:
manifest = json.loads((DATA_DIR / "manifest.json").read_text())
repo = manifest["repo"]
branch = default_branch()
all_chunks: list[dict] = []
for rec in manifest["files"]:
path = REPO_DIR / rec["path"]
if rec["source_type"] == "code":
all_chunks += chunk_code_file(path.read_bytes(), rec["path"],
repo, branch)
elif rec["source_type"] == "doc":
all_chunks += chunk_markdown_file(
path.read_text(errors="replace"), rec["path"], repo, branch)
for issue_file in sorted((DATA_DIR / "issues").glob("*.json")):
chunk = chunk_issue(json.loads(issue_file.read_text()))
if chunk:
all_chunks.append(chunk)
out = DATA_DIR / "chunks.jsonl"
with out.open("w", encoding="utf-8") as f:
for c in all_chunks:
f.write(json.dumps(c, ensure_ascii=False) + "\n")
counts: dict[str, int] = {}
sizes = []
for c in all_chunks:
counts[c["source_type"]] = counts.get(c["source_type"], 0) + 1
sizes.append(len(c["text"]))
print(f"[done] {len(all_chunks)} chunks -> {out}")
print(f"[done] by type: {counts}")
print(f"[done] chars/chunk: min={min(sizes)} "
f"avg={sum(sizes)//len(sizes)} max={max(sizes)}")
if __name__ == "__main__":
main()