diff --git a/docs/_scripts/generate_llms_text.py b/docs/_scripts/generate_llms_text.py
index d5485f2e3..d71f28589 100644
--- a/docs/_scripts/generate_llms_text.py
+++ b/docs/_scripts/generate_llms_text.py
@@ -1,10 +1,16 @@
"""Experimental script to generate consolidated llms text from the docs."""
+import asyncio
import glob
import os
-from typing import TypedDict, List
+from typing import TypedDict, List, Optional
+import pydantic
+import re
+from pydantic import BaseModel, Field
+from langchain_core.rate_limiters import InMemoryRateLimiter
import yaml
+from langchain.chat_models import init_chat_model
from mkdocs.structure.files import File
from mkdocs.structure.pages import Page
from yaml import SafeLoader
@@ -16,7 +22,49 @@ HERE = os.path.dirname(os.path.abspath(__file__))
SOURCE_DIR = os.path.abspath(os.path.join(os.path.dirname(HERE), "docs"))
-def generate_full_llms_text(output_file: str) -> str:
+async def convert_ipynb_to_md(file_path: str) -> Optional[str]:
+ """Process a file (markdown or notebook) to markdown format.
+
+ Args:
+ file_path: Path to the file to process
+
+ Returns:
+ Processed markdown content if successful, None otherwise
+ """
+ rel_path = os.path.relpath(file_path, SOURCE_DIR)
+
+ # Create File and Page objects to match mkdocs structure
+ file_obj = File(
+ path=rel_path, src_dir=SOURCE_DIR, dest_dir="", use_directory_urls=True
+ )
+ page = Page(
+ title="",
+ file=file_obj,
+ config={},
+ )
+
+ try:
+ # Read raw content
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Convert to markdown without logic to resolve API references
+ processed_content = _on_page_markdown_with_config(
+ content, page, add_api_references=False, remove_base64_images=True
+ )
+ # Remove self-closing img tags
+ processed_content = re.sub(r"
]*/>", "", processed_content)
+ # Remove img tags with content
...
+ processed_content = re.sub(
+ r"
]*>.*?", "", processed_content, flags=re.DOTALL
+ )
+ return processed_content
+ except Exception as e:
+ print(f"Error processing file {file_path}: {e}")
+ return None
+
+
+async def generate_full_llms_text(output_file: str) -> None:
"""Generate a consolidated text file from markdown/notebook files for LLM training.
Args:
@@ -24,11 +72,9 @@ def generate_full_llms_text(output_file: str) -> str:
"""
# Collect all markdown and notebook files
all_files = glob.glob(os.path.join(SOURCE_DIR, "how-tos/*.md"), recursive=True)
-
all_files.extend(
glob.glob(os.path.join(SOURCE_DIR, "how-tos/*.ipynb"), recursive=True)
)
- # Add all concepts
all_files.extend(
glob.glob(os.path.join(SOURCE_DIR, "concepts/*.md"), recursive=True)
)
@@ -38,30 +84,14 @@ def generate_full_llms_text(output_file: str) -> str:
all_content = []
- # Process each file
- for file_path in all_files:
- print(f"Processing {file_path}")
- rel_path = os.path.relpath(file_path, SOURCE_DIR)
+ # Process files concurrently
+ tasks = [convert_ipynb_to_md(file_path) for file_path in all_files]
+ results = await asyncio.gather(*tasks)
- # Create File and Page objects to match mkdocs structure
- file_obj = File(
- path=rel_path, src_dir=SOURCE_DIR, dest_dir="", use_directory_urls=True
- )
- page = Page(
- title="",
- file=file_obj,
- config={},
- )
-
- # Read raw content
- with open(file_path, "r", encoding="utf-8") as f:
- content = f.read()
-
- # Convert to markdown without logic to resolve API references
- processed_content = _on_page_markdown_with_config(
- content, page, add_api_references=False, remove_base64_images=True
- )
+ # Combine results with file paths
+ for file_path, processed_content in zip(all_files, results):
if processed_content:
+ rel_path = os.path.relpath(file_path, SOURCE_DIR)
# Add file name
all_content.append(f"---\n{rel_path}\n---")
# Add content
@@ -86,6 +116,7 @@ class NavItem(TypedDict):
title: str
url: str
hierarchy: tuple[str, ...]
+ description: str
def _flatten_nav(
@@ -98,7 +129,14 @@ def _flatten_nav(
new_path = path + (title,)
if isinstance(node, str):
# Leaf page
- flat.append({"title": title, "url": node, "hierarchy": new_path})
+ flat.append(
+ {
+ "title": title,
+ "url": node,
+ "hierarchy": new_path,
+ "description": "",
+ }
+ )
elif isinstance(node, list):
# Dive in, carrying along the updated path
flat.extend(_flatten_nav(node, new_path))
@@ -109,14 +147,82 @@ def _flatten_nav(
elif isinstance(item, str):
# Bare string entry → use itself as title, and as URL
new_path = path + (item,)
- flat.append({"title": item, "url": item, "hierarchy": new_path})
+ flat.append(
+ {"title": item, "url": item, "hierarchy": new_path, "description": ""}
+ )
else:
raise TypeError(f"Unexpected item type {type(item)} in nav")
return flat
-def generate_nav_links_text(output_file: str, *, replace_links: bool = False) -> None:
- """Generate a text file containing navigation structure and links from mkdocs.yaml."""
+class PageInfo(BaseModel):
+ title: str = Field(description="The title of the page")
+ description: str = Field(
+ description="A short description of the page no longer than 3 sentences "
+ "explaining the kind of content that can be found in the page."
+ )
+
+
+async def process_nav_items(nav_items: list[NavItem]) -> list[NavItem]:
+ """Open the contents of each nav item and come up with a better title and description."""
+ rate_limiter = InMemoryRateLimiter(requests_per_second=10)
+ model = init_chat_model("gpt-4o-mini", temperature=0.0, rate_limiter=rate_limiter)
+ model = model.with_structured_output(PageInfo)
+
+ async def process_single_item(item: NavItem) -> NavItem:
+ path = item["url"]
+ file_path = os.path.join(SOURCE_DIR, path)
+
+ # Process the file content (handles both markdown and notebooks)
+ if path.endswith(".ipynb"):
+ content = await convert_ipynb_to_md(file_path)
+ else:
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ if not content:
+ return item
+
+ # Generate a better title and description
+ response = await model.ainvoke(
+ [
+ {
+ "role": "system",
+ "content": "You are a technical documentation writer. "
+ "You are given a markdown page of documentation. "
+ "Please come up with an appropriate title and "
+ "description for the page. The description should "
+ "be a short summary of the page content that is "
+ "no longer than 3 sentences.",
+ },
+ {
+ "role": "user",
+ "content": "The markdown page is as follows:\n\n" + content,
+ },
+ ]
+ )
+ return {
+ "title": response.title,
+ "url": item["url"],
+ "hierarchy": item["hierarchy"],
+ "description": response.description,
+ }
+
+ # Remove any items that start with http:// or https:// looking only for
+ # local file at this stages.
+ nav_items = [
+ item for item in nav_items if not item["url"].startswith(("http://", "https://"))
+ ]
+ # Process items in parallel
+ tasks = [process_single_item(item) for item in nav_items]
+ new_nav_items = await asyncio.gather(*tasks)
+ return new_nav_items
+
+
+async def generate_nav_links_text(
+ output_file: str, *, replace_links: bool = False
+) -> None:
+ """Generate llms.txt from mkdocs.yaml."""
# Get path to mkdocs.yaml relative to this script
script_dir = os.path.dirname(os.path.abspath(__file__))
mkdocs_path = os.path.join(os.path.dirname(script_dir), "mkdocs.yml")
@@ -129,15 +235,15 @@ def generate_nav_links_text(output_file: str, *, replace_links: bool = False) ->
nav = config.get("nav", [])
flattened = _flatten_nav(nav)
+ processed_nav = await process_nav_items(flattened)
+
with open(output_file, "w") as f:
current_section = None
- for item in flattened:
+ for item in processed_nav:
# Get the top-level section (first item in hierarchy)
section = item["hierarchy"][0]
- if section not in {
- "Guides", "Examples", "Resources"
- }:
+ if section not in {"Guides", "Examples", "Resources"}:
continue
# If we're starting a new section, add a heading
@@ -145,15 +251,7 @@ def generate_nav_links_text(output_file: str, *, replace_links: bool = False) ->
f.write(f"\n# {section}\n\n")
current_section = section
- # Add the item as a bullet point with title and link
- # Include full hierarchy path in title, separated by " > "
- hierarchy_path = " > ".join(item["hierarchy"][1:])
- title = (
- f"{item['title']} ({hierarchy_path})"
- if hierarchy_path
- else item["title"]
- )
-
+ title = item["title"]
# Process URL based on replace_links flag
url = item["url"]
if replace_links:
@@ -163,7 +261,7 @@ def generate_nav_links_text(output_file: str, *, replace_links: bool = False) ->
url = url.rstrip("/") + "/"
url = f"https://langchain-ai.github.io/langgraph/{url}"
- f.write(f"- [{title}]({url})\n")
+ f.write(f"- [{title}]({url}): {item['description']}\n")
if __name__ == "__main__":
@@ -188,6 +286,10 @@ if __name__ == "__main__":
args = parser.parse_args()
if args.link_only:
- generate_nav_links_text(args.output_file, replace_links=args.replace_links)
+ coro = generate_nav_links_text(
+ args.output_file, replace_links=args.replace_links
+ )
else:
- generate_full_llms_text(args.output_file)
+ coro = generate_full_llms_text(args.output_file)
+
+ asyncio.run(coro)