97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
315 lines
10 KiB
Python
Executable File
315 lines
10 KiB
Python
Executable File
import os
|
|
import asyncio
|
|
import yaml
|
|
from typing import Optional, Any
|
|
from collections.abc import Generator
|
|
from openai import AsyncOpenAI
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.progress import Progress
|
|
import hashlib
|
|
from asyncio import as_completed
|
|
import tenacity
|
|
import re
|
|
|
|
console = Console()
|
|
|
|
|
|
def traverse_docs(
|
|
root_dir: str = "docs",
|
|
) -> Generator[tuple[str, str, str], None, None]:
|
|
"""
|
|
Recursively traverse the docs folder and yield the path, content, and content hash of each file.
|
|
|
|
Args:
|
|
root_dir (str): The root directory to start traversing from. Defaults to 'docs'.
|
|
|
|
Yields:
|
|
Tuple[str, str, str]: A tuple containing the relative path from 'docs', the file content, and the content hash.
|
|
"""
|
|
for root, _, files in os.walk(root_dir):
|
|
for file in files:
|
|
if file.endswith(".md"): # Assuming we're only interested in Markdown files
|
|
file_path = os.path.join(root, file)
|
|
relative_path = os.path.relpath(file_path, root_dir)
|
|
|
|
with open(file_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
yield relative_path, content, content_hash
|
|
|
|
|
|
def extract_markdown_links(content: str) -> list[str]:
|
|
"""
|
|
Extract all markdown links from the content.
|
|
|
|
Args:
|
|
content (str): The markdown content to analyze
|
|
|
|
Returns:
|
|
List[str]: List of extracted link paths
|
|
"""
|
|
# Match markdown links [text](path)
|
|
link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
|
matches = re.findall(link_pattern, content)
|
|
|
|
links = []
|
|
for _, link_path in matches:
|
|
# Filter out external links and anchors
|
|
if not link_path.startswith(("http://", "https://", "#", "mailto:")):
|
|
# Clean up relative paths
|
|
link_path = link_path.strip("/")
|
|
if link_path.endswith(".md"):
|
|
links.append(link_path)
|
|
elif "." not in link_path:
|
|
# Assume it's a directory reference, add index.md
|
|
links.append(f"{link_path}/index.md")
|
|
|
|
return links
|
|
|
|
|
|
def normalize_path(path: str, current_path: str) -> str:
|
|
"""
|
|
Normalize a relative path based on the current file's location.
|
|
|
|
Args:
|
|
path (str): The path to normalize
|
|
current_path (str): The current file's path
|
|
|
|
Returns:
|
|
str: The normalized path
|
|
"""
|
|
if path.startswith("/"):
|
|
# Absolute path from docs root
|
|
return path.strip("/")
|
|
|
|
# Relative path
|
|
current_dir = os.path.dirname(current_path)
|
|
if current_dir:
|
|
normalized = os.path.normpath(os.path.join(current_dir, path))
|
|
# Remove any leading '../' that go outside docs/
|
|
while normalized.startswith("../"):
|
|
normalized = normalized[3:]
|
|
return normalized
|
|
|
|
return path
|
|
|
|
|
|
@tenacity.retry(
|
|
stop=tenacity.stop_after_attempt(3),
|
|
wait=tenacity.wait_exponential(multiplier=1, min=4, max=10),
|
|
retry=tenacity.retry_if_exception_type(Exception),
|
|
before_sleep=lambda retry_state: console.print(
|
|
f"[yellow]Retrying analysis... (Attempt {retry_state.attempt_number})[/yellow]"
|
|
),
|
|
)
|
|
async def analyze_content(
|
|
client: AsyncOpenAI, path: str, content: str
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Analyze the content of a file to extract summary, keywords, topics, and references.
|
|
|
|
Args:
|
|
client (AsyncOpenAI): The AsyncOpenAI client.
|
|
path (str): The path of the file.
|
|
content (str): The content of the file.
|
|
|
|
Returns:
|
|
Dict[str, Any]: Analysis results including summary, keywords, topics, and references.
|
|
|
|
Raises:
|
|
Exception: If all retry attempts fail.
|
|
"""
|
|
try:
|
|
response = await client.chat.completions.create(
|
|
model="gpt-4o-mini",
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": """You are a documentation analyzer. Extract and return the following information in a structured format:
|
|
1. A concise summary (2-3 sentences) for SEO
|
|
2. A list of important keywords (5-10 words/phrases)
|
|
3. Main topics/concepts covered (3-5 topics)
|
|
4. Any references to other documentation pages mentioned in the text
|
|
|
|
Return the response in this exact format:
|
|
SUMMARY: [Your summary here]
|
|
KEYWORDS: [keyword1, keyword2, keyword3, ...]
|
|
TOPICS: [topic1, topic2, topic3, ...]
|
|
REFERENCES: [referenced_page1.md, referenced_page2.md, ...]
|
|
|
|
If no references are found, write: REFERENCES: none""",
|
|
},
|
|
{"role": "user", "content": content},
|
|
],
|
|
max_tokens=4000,
|
|
)
|
|
|
|
result_text = response.choices[0].message.content
|
|
|
|
# Parse the structured response
|
|
summary = ""
|
|
keywords = []
|
|
topics = []
|
|
references = []
|
|
|
|
if result_text:
|
|
for line in result_text.split("\n"):
|
|
line = line.strip()
|
|
if line.startswith("SUMMARY:"):
|
|
summary = line[8:].strip()
|
|
elif line.startswith("KEYWORDS:"):
|
|
keywords_text = line[9:].strip()
|
|
if keywords_text and keywords_text != "none":
|
|
keywords = [k.strip() for k in keywords_text.split(",")]
|
|
elif line.startswith("TOPICS:"):
|
|
topics_text = line[7:].strip()
|
|
if topics_text and topics_text != "none":
|
|
topics = [t.strip() for t in topics_text.split(",")]
|
|
elif line.startswith("REFERENCES:"):
|
|
refs_text = line[11:].strip()
|
|
if refs_text and refs_text != "none":
|
|
references = [r.strip() for r in refs_text.split(",")]
|
|
|
|
return {
|
|
"summary": summary,
|
|
"keywords": keywords,
|
|
"topics": topics,
|
|
"ai_references": references,
|
|
}
|
|
|
|
except Exception as e:
|
|
console.print(f"[bold red]Error analyzing {path}: {str(e)}[/bold red]")
|
|
raise
|
|
|
|
|
|
async def generate_sitemap(
|
|
root_dir: str,
|
|
output_file: str,
|
|
api_key: Optional[str] = None,
|
|
max_concurrency: int = 5,
|
|
) -> None:
|
|
"""
|
|
Generate a sitemap from the given root directory.
|
|
|
|
Args:
|
|
root_dir (str): The root directory to start traversing from.
|
|
output_file (str): The output file to save the sitemap.
|
|
api_key (Optional[str]): The OpenAI API key. If not provided, it will be read from the OPENAI_API_KEY environment variable.
|
|
max_concurrency (int): The maximum number of concurrent tasks. Defaults to 5.
|
|
"""
|
|
client = AsyncOpenAI(api_key=api_key)
|
|
|
|
# Load existing sitemap if it exists
|
|
existing_sitemap: dict[str, dict[str, Any]] = {}
|
|
if os.path.exists(output_file):
|
|
with open(output_file, encoding="utf-8") as sitemap_file:
|
|
existing_sitemap = yaml.safe_load(sitemap_file) or {}
|
|
|
|
sitemap_data: dict[str, dict[str, Any]] = {}
|
|
|
|
async def process_file(
|
|
path: str, content: str, content_hash: str
|
|
) -> tuple[str, dict[str, Any]]:
|
|
# Check if we can reuse existing data
|
|
if (
|
|
path in existing_sitemap
|
|
and existing_sitemap[path].get("hash") == content_hash
|
|
):
|
|
# Extract markdown links even for cached content
|
|
links = extract_markdown_links(content)
|
|
normalized_links = []
|
|
for link in links:
|
|
normalized = normalize_path(link, path)
|
|
if normalized:
|
|
normalized_links.append(normalized)
|
|
|
|
existing_data = existing_sitemap[path].copy()
|
|
existing_data["references"] = normalized_links
|
|
return path, existing_data
|
|
|
|
try:
|
|
# Extract markdown links
|
|
links = extract_markdown_links(content)
|
|
normalized_links = []
|
|
for link in links:
|
|
normalized = normalize_path(link, path)
|
|
if normalized:
|
|
normalized_links.append(normalized)
|
|
|
|
# Get AI analysis
|
|
analysis = await analyze_content(client, path, content)
|
|
|
|
return path, {
|
|
"summary": analysis["summary"],
|
|
"keywords": analysis["keywords"],
|
|
"topics": analysis["topics"],
|
|
"references": normalized_links,
|
|
"ai_references": analysis["ai_references"],
|
|
"hash": content_hash,
|
|
}
|
|
except Exception as e:
|
|
console.print(
|
|
f"[bold red]Failed to analyze {path} after multiple attempts: {str(e)}[/bold red]"
|
|
)
|
|
return path, {
|
|
"summary": "Failed to generate summary",
|
|
"keywords": [],
|
|
"topics": [],
|
|
"references": normalized_links,
|
|
"ai_references": [],
|
|
"hash": content_hash,
|
|
}
|
|
|
|
files_to_process: list[tuple[str, str, str]] = list(traverse_docs(root_dir))
|
|
total_files = len(files_to_process)
|
|
|
|
with Progress() as progress:
|
|
task = progress.add_task("[green]Processing files...", total=total_files)
|
|
|
|
semaphore = asyncio.Semaphore(max_concurrency)
|
|
|
|
async def bounded_process_file(*args):
|
|
async with semaphore:
|
|
return await process_file(*args)
|
|
|
|
tasks = [
|
|
bounded_process_file(path, content, content_hash)
|
|
for path, content, content_hash in files_to_process
|
|
]
|
|
|
|
for completed_task in as_completed(tasks):
|
|
path, result = await completed_task
|
|
sitemap_data[path] = result
|
|
progress.update(task, advance=1)
|
|
|
|
# Save final results
|
|
with open(output_file, "w", encoding="utf-8") as sitemap_file:
|
|
yaml.dump(sitemap_data, sitemap_file, default_flow_style=False, sort_keys=True)
|
|
|
|
console.print(
|
|
f"[bold green]Sitemap has been generated and saved to {output_file}[/bold green]"
|
|
)
|
|
console.print(f"[green]Processed {total_files} files[/green]")
|
|
|
|
|
|
app = typer.Typer()
|
|
|
|
|
|
@app.command()
|
|
def main(
|
|
root_dir: str = typer.Option("docs", help="Root directory to traverse"),
|
|
output_file: str = typer.Option("sitemap.yaml", help="Output file for the sitemap"),
|
|
api_key: Optional[str] = typer.Option(None, help="OpenAI API key"),
|
|
max_concurrency: int = typer.Option(5, help="Maximum number of concurrent tasks"),
|
|
):
|
|
"""
|
|
Generate a sitemap with keywords, topics, and reference analysis.
|
|
"""
|
|
asyncio.run(generate_sitemap(root_dir, output_file, api_key, max_concurrency))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|