CoolFace
Apppublic

shovo896/codedocumentation

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes
ingest.py323 linesDownload Raw Back to root
1import hashlib2import os3import re4import shutil5import sys6import tempfile7from pathlib import Path8 9from dotenv import load_dotenv10from langchain_core.documents import Document11from langchain_openai import OpenAIEmbeddings12from langchain_pinecone import PineconeVectorStore13from langchain_text_splitters import RecursiveCharacterTextSplitter14from pinecone import Pinecone, ServerlessSpec15 16load_dotenv()17 18 19def get_required_env(name: str) -> str:20    value = os.getenv(name, "").strip().strip('"').strip("'")21    if not value:22        raise RuntimeError(f"Missing required environment variable: {name}")23    return value24 25 26PINECONE_API_KEY = get_required_env("PINECONE_API_KEY")27INDEX_NAME = "code-doc-search-openai"28EMBED_MODEL = "text-embedding-3-small"29EMBED_DIM = 153630MAX_FILE_SIZE_KB = 150031CHUNK_SIZE = 100032CHUNK_OVERLAP = 20033 34ALLOWED_EXTENSIONS = {35    ".py",36    ".md",37    ".rst",38    ".txt",39    ".js",40    ".jsx",41    ".ts",42    ".tsx",43    ".json",44    ".html",45    ".css",46    ".scss",47    ".sass",48    ".less",49    ".vue",50    ".svelte",51    ".java",52    ".go",53    ".rs",54    ".php",55    ".rb",56    ".cs",57    ".cpp",58    ".c",59    ".h",60    ".yml",61    ".yaml",62    ".toml",63}64SKIP_DIRS = {"node_modules", "__pycache__", ".git", "dist", "build", "venv", ".venv"}65SKIP_FILES = {66    "package-lock.json",67    "yarn.lock",68    "pnpm-lock.yaml",69    "bun.lockb",70    "poetry.lock",71    "pipfile.lock",72}73 74 75def normalize_repo_url(repo_url: str) -> str:76    """Return a normalized public GitHub repository URL."""77    repo_url = repo_url.strip().removesuffix("/")78    repo_url = repo_url.removesuffix(".git")79 80    match = re.fullmatch(r"https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)", repo_url)81    if not match:82        raise ValueError("Please provide a public GitHub repository URL like https://github.com/owner/repo")83 84    owner, repo = match.groups()85    return f"https://github.com/{owner}/{repo}"86 87 88def namespace_for_repo(repo_url: str, branch: str) -> str:89    """Create a stable Pinecone namespace for a repository and branch."""90    normalized = normalize_repo_url(repo_url)91    branch = branch.strip()92    digest = hashlib.sha1(f"{normalized}:{branch}".encode("utf-8")).hexdigest()[:16]93    return f"repo-{digest}"94 95 96def should_load(file_path: str) -> bool:97    """Load only small, relevant source and documentation files."""98    p = Path(file_path)99 100    for part in p.parts:101        if part in SKIP_DIRS:102            return False103 104    if p.suffix.lower() not in ALLOWED_EXTENSIONS:105        return False106 107    name = p.name.lower()108    if name in SKIP_FILES:109        return False110 111    if "test" in name or "spec" in name:112        return False113 114    return True115 116 117def clone_and_load(repo_url: str, branch: str = ""):118    """Clone the GitHub repository and load relevant files as documents."""119    tmp_dir = tempfile.mkdtemp()120    print(f"Temp directory: {tmp_dir}")121 122    try:123        from git import Blob, Repo124 125        repo = Repo.clone_from(repo_url, tmp_dir)126        requested_branch = (branch or "").strip()127        if requested_branch:128            repo.git.checkout(requested_branch)129 130        try:131            resolved_branch = repo.active_branch.name132        except TypeError:133            resolved_branch = repo.git.rev_parse("--abbrev-ref", "HEAD")134 135        docs = []136        for item in repo.tree().traverse():137            if not isinstance(item, Blob):138                continue139 140            file_path = os.path.join(tmp_dir, item.path)141            if repo.ignored([file_path]):142                continue143 144            if not should_load(file_path):145                continue146 147            try:148                with open(file_path, "rb") as f:149                    content = f.read()150                    text_content = content.decode("utf-8")151            except UnicodeDecodeError:152                continue153 154            file_type = os.path.splitext(item.name)[1]155            docs.append(156                Document(157                    page_content=text_content,158                    metadata={159                        "source": item.path,160                        "file_path": item.path,161                        "file_name": item.name,162                        "file_type": file_type,163                    },164                )165            )166 167        filtered = []168        skipped = 0169        for doc in docs:170            size_kb = len(doc.page_content.encode("utf-8")) / 1024171            if size_kb > MAX_FILE_SIZE_KB:172                skipped += 1173                continue174 175            source = doc.metadata.get("source", "")176            try:177                source_path = Path(source).resolve().relative_to(Path(tmp_dir).resolve())178                doc.metadata["source"] = source_path.as_posix()179            except (OSError, ValueError):180                doc.metadata["source"] = str(source).replace("\\", "/")181 182            filtered.append(doc)183 184        print(f"Loaded files: {len(filtered)} | Skipped large files: {skipped}")185        print(f"Resolved branch: {resolved_branch}")186        return filtered, resolved_branch187 188    except Exception as e:189        raise RuntimeError(190            f"Clone failed: {e}. Check that the repository is public and the branch exists."191        ) from e192 193    finally:194        shutil.rmtree(tmp_dir, ignore_errors=True)195 196 197def chunk_documents(docs):198    """Split loaded documents into searchable chunks."""199    splitter = RecursiveCharacterTextSplitter(200        chunk_size=CHUNK_SIZE,201        chunk_overlap=CHUNK_OVERLAP,202        separators=["\nclass ", "\ndef ", "\n\n", "\n", " ", ""],203    )204    chunks = splitter.split_documents(docs)205    print(f"Total chunks: {len(chunks)}")206    return chunks207 208 209def setup_pinecone():210    """Create the Pinecone index if it does not already exist."""211    pc = Pinecone(api_key=PINECONE_API_KEY)212    existing = [i.name for i in pc.list_indexes()]213 214    if INDEX_NAME not in existing:215        print(f"Creating Pinecone index '{INDEX_NAME}'...")216        pc.create_index(217            name=INDEX_NAME,218            dimension=EMBED_DIM,219            metric="cosine",220            spec=ServerlessSpec(cloud="aws", region="us-east-1"),221        )222        print("Index created.")223    else:224        print(f"Index '{INDEX_NAME}' already exists.")225 226    return pc227 228 229def clear_namespace(pc: Pinecone, namespace: str) -> None:230    """Clear existing vectors for a repo namespace before re-ingesting it."""231    try:232        index = pc.Index(INDEX_NAME)233        index.delete(delete_all=True, namespace=namespace)234        print(f"Cleared namespace '{namespace}'.")235    except Exception as e:236        if "Namespace not found" in str(e):237            print(f"No existing namespace found for '{namespace}'.")238        else:239            print(f"Namespace clear skipped: {e}")240 241 242def store_in_pinecone(chunks, namespace: str):243    """Embed chunks with OpenAI and store them in Pinecone."""244    print("Loading OpenAI embedding model...")245    embeddings = OpenAIEmbeddings(246        model=EMBED_MODEL,247        dimensions=EMBED_DIM,248        api_key=get_required_env("OPENAI_API_KEY"),249    )250 251    pc = setup_pinecone()252    clear_namespace(pc, namespace)253 254    print(f"Storing {len(chunks)} chunks in Pinecone namespace '{namespace}'.")255    PineconeVectorStore.from_documents(256        chunks,257        embeddings,258        index_name=INDEX_NAME,259        namespace=namespace,260    )261    print("Ingestion complete. Data has been stored in Pinecone.")262 263 264def ingest_repository(repo_url: str, branch: str = ""):265    """Ingest any public GitHub repository and return the namespace metadata."""266    repo_url = normalize_repo_url(repo_url)267    requested_branch = (branch or "").strip()268 269    docs, resolved_branch = clone_and_load(repo_url, requested_branch)270    if not docs:271        raise ValueError("No files were loaded. Check the repository URL or branch name.")272 273    for doc in docs:274        doc.metadata["repo_url"] = repo_url275        doc.metadata["branch"] = resolved_branch276 277    chunks = chunk_documents(docs)278    namespace = namespace_for_repo(repo_url, resolved_branch)279    store_in_pinecone(chunks, namespace)280 281    return {282        "repo_url": repo_url,283        "branch": resolved_branch,284        "namespace": namespace,285        "files": len(docs),286        "chunks": len(chunks),287    }288 289 290def main():291    print("=" * 50)292    print("   Code Documentation Search - Ingestion")293    print("=" * 50)294 295    if len(sys.argv) > 1:296        repo_url = sys.argv[1].strip()297    else:298        repo_url = input("\nGitHub repo URL:\n(e.g. https://github.com/tiangolo/fastapi): ").strip()299 300    if len(sys.argv) > 2:301        branch = sys.argv[2].strip()302    else:303        branch = input("Branch name (press Enter to use the default branch): ").strip()304 305    print(f"\nStarting ingestion for: {repo_url} [{branch}]\n")306 307    try:308        result = ingest_repository(repo_url, branch)309    except Exception as e:310        print(f"Error: {e}")311        sys.exit(1)312 313    print(314        "Ingested {files} files into {chunks} chunks for {repo_url} [{branch}].".format(315            **result316        )317    )318    print(f"Namespace: {result['namespace']}")319 320 321if __name__ == "__main__":322    main()323