Files
adk-python/contributing/dev/utils/build_llms_txt.py
T

339 lines
11 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
build_llms_txt.py produce llms.txt and llms-full.txt
skips ```java``` blocks
README can be next to docs/ or inside docs/
includes Python API reference from HTML files
includes adk-python repository README
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
import textwrap
from typing import List
from typing import Tuple
import urllib.error
import urllib.request
RE_JAVA = re.compile(r"```java[ \t\r\n][\s\S]*?```", re.I | re.M)
RE_SNIPPET = re.compile(r"^(\s*)--8<--\s+\"([^\"]+?)(?::([^\"]+))?\"$", re.M)
def fetch_adk_python_readme() -> str:
"""Fetch README content from adk-python repository"""
try:
url = "https://raw.githubusercontent.com/google/adk-python/main/README.md"
with urllib.request.urlopen(url) as response:
return response.read().decode("utf-8")
except (urllib.error.URLError, urllib.error.HTTPError) as e:
print(f"Warning: Could not fetch adk-python README: {e}")
return ""
def strip_java(md: str) -> str:
return RE_JAVA.sub("", md)
def first_heading(md: str) -> str | None:
for line in md.splitlines():
if line.startswith("#"):
return line.lstrip("#").strip()
return None
def md_to_text(md: str) -> str:
import bs4
import markdown
html = markdown.markdown(
md, extensions=["fenced_code", "tables", "attr_list"]
)
return bs4.BeautifulSoup(html, "html.parser").get_text("\n")
def html_to_text(html_file: Path) -> str:
"""Extract text content from HTML files (for Python API reference)"""
import bs4
try:
html_content = html_file.read_text(encoding="utf-8")
soup = bs4.BeautifulSoup(html_content, "html.parser")
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
# Get text and clean it up
text = soup.get_text()
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
text = "\n".join(chunk for chunk in chunks if chunk)
return text
except Exception as e:
print(f"Warning: Could not process {html_file}: {e}")
return ""
def count_tokens(text: str, model: str = "cl100k_base") -> int:
try:
import tiktoken
return len(tiktoken.get_encoding(model).encode(text))
except Exception:
return len(text.split())
def expand_code_snippets(content: str, project_root: Path) -> str:
"""
Expands code snippets marked with --8<-- "path/to/file.py" or
--8<-- "path/to/file.py:section_name" into the content.
"""
def replace_snippet(match):
indent = match.group(1) # Capture leading spaces
snippet_path_str = match.group(
2
) # Capture the file path (e.g., "examples/python/snippets/file.py")
section_name = match.group(
3
) # Capture the section name if present (e.g., "init")
snippet_full_path = (
project_root / snippet_path_str
) # Changed from base_path to project_root
# If not found in project root, try adk-docs directory
if not snippet_full_path.exists():
script_dir = Path(__file__).resolve().parent
adk_docs_path = script_dir / "adk-docs" / snippet_path_str
if adk_docs_path.exists():
snippet_full_path = adk_docs_path
if snippet_full_path.exists():
try:
file_content = snippet_full_path.read_text(encoding="utf-8")
if section_name:
# Extract content based on section markers
# Handle both single and double hash markers with optional spacing
start_marker_patterns = [
f"# --8<-- [start:{section_name.strip()}]",
f"## --8<-- [start:{section_name.strip()}]",
]
end_marker_patterns = [
f"# --8<-- [end:{section_name.strip()}]",
f"## --8<-- [end:{section_name.strip()}]",
f"## --8<-- [end:{section_name.strip()}]", # Handle extra space
]
start_index = -1
end_index = -1
# Find start marker
for pattern in start_marker_patterns:
start_index = file_content.find(pattern)
if start_index != -1:
start_marker = pattern
break
# Find end marker
for pattern in end_marker_patterns:
end_index = file_content.find(pattern)
if end_index != -1:
break
if start_index != -1 and end_index != -1 and start_index < end_index:
# Adjust start_index to begin immediately after the start_marker
start_of_code = start_index + len(start_marker)
temp_content = file_content[start_of_code:end_index]
lines = temp_content.splitlines(keepends=True)
extracted_lines = []
for line in lines:
if (
not line.strip().startswith("# --8<--")
and not line.strip().startswith("## --8<--")
and line.strip() != ""
):
extracted_lines.append(line)
extracted_content = "".join(extracted_lines).strip("\n")
return textwrap.indent(extracted_content, indent)
else:
print(
f"Warning: Section '{section_name}' not found or markers"
f" malformed in {snippet_full_path}"
)
return match.group(0)
else:
# Read entire file if no section name
return textwrap.indent(file_content, indent)
except Exception as e:
print(f"Warning: Could not read snippet file {snippet_full_path}: {e}")
return match.group(0)
else:
print(f"Warning: Snippet file not found: {snippet_full_path}")
return match.group(0)
expanded_content = RE_SNIPPET.sub(replace_snippet, content)
return expanded_content
# ---------- index (llms.txt) ----------
def build_index(docs: Path) -> str:
# Locate README
for cand in (docs / "README.md", docs.parent / "README.md"):
if cand.exists():
readme = cand.read_text(encoding="utf-8")
break
else:
sys.exit("README.md not found in docs/ or its parent")
title = first_heading(readme) or "Documentation"
summary = md_to_text(readme).split("\n\n")[0]
lines = [f"# {title}", "", f"> {summary}", ""]
# Add adk-python repository README content
adk_readme = fetch_adk_python_readme()
if adk_readme:
lines.append("## ADK Python Repository")
lines.append("")
# Include the full README content, properly formatted
adk_text = md_to_text(strip_java(adk_readme))
lines.append(adk_text)
lines.append("")
lines.append(
f"**Source:** [adk-python"
f" repository](https://github.com/google/adk-python)"
)
lines.append("")
primary: List[Tuple[str, str]] = []
secondary: List[Tuple[str, str]] = []
# Process Markdown files
for md in sorted(docs.rglob("*.md")):
# Skip Java API reference files
if "api-reference" in md.parts and "java" in md.parts:
continue
rel = md.relative_to(docs)
# Construct the correct GitHub URL for the Markdown file
url = f"https://github.com/google/adk-docs/blob/main/docs/{rel}".replace(
" ", "%20"
)
h = first_heading(strip_java(md.read_text(encoding="utf-8"))) or rel.stem
(
secondary
if "sample" in rel.parts or "tutorial" in rel.parts
else primary
).append((h, url))
# Add Python API reference
python_api_dir = docs / "api-reference" / "python"
if python_api_dir.exists():
primary.append((
"Python API Reference",
"https://github.com/google/adk-docs/blob/main/docs/api-reference/python/",
))
def emit(name: str, items: List[Tuple[str, str]]):
nonlocal lines
if items:
lines.append(f"## {name}")
lines += [f"- [{h}]({u})" for h, u in items]
lines.append("")
emit("Documentation", primary)
emit("Optional", secondary)
return "\n".join(lines)
# ---------- full corpus ----------
def build_full(docs: Path) -> str:
out = []
script_dir = Path(__file__).resolve().parent
project_root = script_dir.parents[2] # Correct project root
print(f"DEBUG: Project Root: {project_root}")
print(f"DEBUG: Docs Dir: {docs}")
# Add adk-python repository README content at the beginning
adk_readme = fetch_adk_python_readme()
if adk_readme:
# Expand snippets in README if any
expanded_adk_readme = expand_code_snippets(
strip_java(adk_readme), project_root
) # Pass project_root
out.append("# ADK Python Repository")
out.append("")
out.append(expanded_adk_readme) # Use expanded content
out.append("")
out.append("---")
out.append("")
# Process Markdown files
for md in sorted(docs.rglob("*.md")):
# Skip Java API reference files
if "api-reference" in md.parts and "java" in md.parts:
continue
md_content = md.read_text(encoding="utf-8")
print(f"DEBUG: Processing markdown file: {md.relative_to(docs)}")
expanded_md_content = expand_code_snippets(
strip_java(md_content), project_root
) # Changed back to project_root
out.append(expanded_md_content) # Use expanded content
# Process Python API reference HTML files
python_api_dir = docs / "api-reference" / "python"
if python_api_dir.exists():
# Add a separator and header for Python API reference
out.append("\n\n# Python API Reference\n")
# Process main HTML files (skip static assets and generated files)
html_files = [
python_api_dir / "index.html",
python_api_dir / "google-adk.html",
python_api_dir / "genindex.html",
python_api_dir / "py-modindex.html",
]
for html_file in html_files:
if html_file.exists():
text = html_to_text(html_file)
if text.strip():
out.append(f"\n## {html_file.stem}\n")
out.append(text)
return "\n\n".join(out)
def main() -> None:
ap = argparse.ArgumentParser(
description="Generate llms.txt / llms-full.txt",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--docs-dir", required=True, type=Path)
ap.add_argument("--out-root", default=Path("."), type=Path)
ap.add_argument("--index-limit", type=int, default=50_000)
ap.add_argument("--full-limit", type=int, default=500_000)
args = ap.parse_args()
idx, full = build_index(args.docs_dir), build_full(args.docs_dir)
if (tok := count_tokens(idx)) > args.index_limit:
sys.exit(f"Index too big: {tok:,}")
if (tok := count_tokens(full)) > args.full_limit:
sys.exit(f"Full text too big: {tok:,}")
(args.out_root / "llms.txt").write_text(idx, encoding="utf-8")
(args.out_root / "llms-full.txt").write_text(full, encoding="utf-8")
print("✅ Generated llms.txt and llms-full.txt successfully")
print(f"llms.txt tokens: {count_tokens(idx)}")
print(f"llms-full.txt tokens: {count_tokens(full)}")
if __name__ == "__main__":
main()