| import asyncio
|
| import json
|
| import os
|
| import re
|
| import sys
|
| import base64
|
| import httpx
|
| from pathlib import Path
|
| from fastapi import FastAPI, HTTPException, Request
|
| from fastapi.exceptions import RequestValidationError
|
| from fastapi.responses import FileResponse, JSONResponse, Response
|
| from pydantic import BaseModel, field_validator
|
| from dotenv import load_dotenv
|
| import os
|
|
|
| try:
|
| from anthropic import Anthropic
|
| except ImportError:
|
| print("Error: anthropic package not installed. Run: pip install anthropic")
|
| sys.exit(1)
|
|
|
| from constants import FILE_EXTENSIONS
|
| from logger import log_entry
|
|
|
|
|
|
|
|
|
| load_dotenv(override=True)
|
|
|
| ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
| GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
|
|
|
|
| def _httpx_verify() -> bool | str:
|
| """CA bundle path for httpx/Anthropic. Fixes common Windows `[SSL: CERTIFICATE_VERIFY_FAILED]` failures.
|
|
|
| Env:
|
| - ``HTTPX_VERIFY=0|false`` — disable TLS verification (insecure; last resort).
|
| - ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` / ``SSL_CA_BUNDLE`` — path to a PEM bundle (e.g. corporate proxy CA).
|
| """
|
| if os.getenv("HTTPX_VERIFY", "").strip().lower() in {"0", "false", "no", "off"}:
|
| return False
|
| bundle = (
|
| os.getenv("SSL_CERT_FILE")
|
| or os.getenv("REQUESTS_CA_BUNDLE")
|
| or os.getenv("SSL_CA_BUNDLE")
|
| )
|
| if bundle and Path(bundle).is_file():
|
| return bundle
|
| import certifi
|
|
|
| return certifi.where()
|
|
|
|
|
| HTTPX_VERIFY = _httpx_verify()
|
|
|
|
|
|
|
|
|
|
|
|
|
| _INDEX_HTML = Path(__file__).resolve().parent / "index.html"
|
|
|
| app = FastAPI(title="Repo Summarizer")
|
|
|
|
|
| def _error_message(detail: str | list | dict) -> str:
|
| """Convert exception detail to a single string for error response."""
|
| if isinstance(detail, str):
|
| return detail
|
| if isinstance(detail, list):
|
| parts = []
|
| for e in detail:
|
| if isinstance(e, dict) and "msg" in e:
|
| loc = e.get("loc", [])
|
| parts.append(f"{'.'.join(str(x) for x in loc)}: {e['msg']}")
|
| else:
|
| parts.append(str(e))
|
| return "; ".join(parts) if parts else str(detail)
|
| return str(detail)
|
|
|
|
|
| def _error_response(status_code: int, message: str) -> JSONResponse:
|
| """Return a JSON error response as a JSON file (Content-Disposition: attachment)."""
|
| return Response(
|
| content=json.dumps({"status": "error", "message": message}, indent=2, ensure_ascii=False),
|
| media_type="application/json",
|
| )
|
|
|
|
|
| @app.exception_handler(HTTPException)
|
| async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
| return _error_response(exc.status_code, _error_message(exc.detail))
|
|
|
|
|
| @app.exception_handler(RequestValidationError)
|
| async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
| return _error_response(422, _error_message(exc.errors()))
|
|
|
|
|
| @app.exception_handler(Exception)
|
| async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
| return _error_response(500, str(exc) or "Internal server error")
|
|
|
| class SummarizeRequest(BaseModel):
|
| repo_source: str
|
|
|
|
|
|
|
|
|
|
|
| def check_repo_source(self) -> str:
|
| log_entry()
|
| print("I am in the SummarizeRequest class, repo_source is: ", self.repo_source)
|
| value = self.repo_source.strip()
|
| if not value:
|
| raise ValueError("repo_source is required")
|
| return value
|
|
|
|
|
| class SummarizeGithubRequest(SummarizeRequest):
|
| def __init__(self, repo_source: str):
|
| super().__init__(repo_source=repo_source)
|
| print("I am in the SummarizeGithubRequest class, repo_source is: ", self.repo_source)
|
|
|
|
|
| def check_github_url(self) -> str:
|
| log_entry()
|
| if not self.repo_source or "github.com" not in self.repo_source:
|
| raise ValueError("Must be a valid GitHub repository URL")
|
| return self.repo_source.strip()
|
|
|
|
|
| class SummarizeLocalRequest(SummarizeRequest):
|
| def __init__(self, repo_source: str):
|
| super().__init__(repo_source=repo_source)
|
| print("I am in the SummarizeLocalRequest class, repo_source is: ", self.repo_source)
|
|
|
|
|
| def check_repo_path(self) -> str:
|
| log_entry()
|
| value = self.repo_source.strip()
|
| if not value:
|
| raise ValueError("repo_path is required")
|
|
|
| path = Path(value).expanduser().resolve()
|
| if not path.exists():
|
| raise ValueError("Local repository path does not exist")
|
| if not path.is_dir():
|
| raise ValueError("repo_path must point to a directory")
|
|
|
| return str(path)
|
|
|
|
|
| def parse_github_url(url: str) -> tuple[str, str]:
|
| """Extract owner and repo from GitHub URL."""
|
| log_entry()
|
| url = url.strip().rstrip("/")
|
|
|
| match = re.match(r"https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
|
| if not match:
|
| raise ValueError("Invalid GitHub repository URL")
|
| owner, repo = match.groups()
|
| return owner, repo
|
|
|
|
|
| def _path_is_readme(path: str) -> bool:
|
| log_entry()
|
| name = path.split("/")[-1] if "/" in path else path
|
| return name.lower() == "readme.md"
|
|
|
|
|
| def _path_is_main_py(path: str) -> bool:
|
| log_entry()
|
| return path.endswith("/main.py") or path == "main.py"
|
|
|
|
|
| def _build_directory_tree(nodes: list[dict]) -> str:
|
| """Build a sorted path list from tree nodes for structure summary."""
|
| log_entry()
|
| paths = [n.get("path", "") for n in nodes if n.get("path")]
|
| return "\n".join(sorted(paths))
|
|
|
|
|
| def _extensions_in_tree(directory_tree: str) -> set[str]:
|
| """Return file extensions found in directory_tree that are in FILE_EXTENSIONS (unique)."""
|
| exts: set[str] = set()
|
| for line in directory_tree.strip().splitlines():
|
| path = line.strip()
|
| if "." in path and "/" in path:
|
| ext = "." + path.rsplit("/", 1)[-1].split(".")[-1].lower()
|
| elif "." in path:
|
| ext = "." + path.split(".")[-1].lower()
|
| else:
|
| continue
|
| if ext in FILE_EXTENSIONS:
|
| exts.add(ext)
|
| return exts
|
|
|
|
|
| async def fetch_repo_data_lcoaldisk(repo_path: str) -> tuple[str, str, str, int]:
|
| """Fetch local repo data: (code_context, requirements_content, directory_tree, file_count)."""
|
| log_entry()
|
|
|
| root = Path(repo_path).expanduser().resolve()
|
| if not root.exists() or not root.is_dir():
|
| raise HTTPException(status_code=404, detail="Local repository path not found")
|
|
|
| readme_path: Path | None = None
|
| main_py_path: Path | None = None
|
| requirements_path: Path | None = None
|
| code_files: list[tuple[Path, int]] = []
|
| tree_nodes: list[dict] = []
|
|
|
| for path in root.rglob("*"):
|
| if not path.is_file():
|
| continue
|
| if any(part in {".git", ".venv", "venv", "__pycache__", "node_modules"} for part in path.parts):
|
| continue
|
|
|
| rel = path.relative_to(root).as_posix()
|
| size = path.stat().st_size
|
| tree_nodes.append({"path": rel, "type": "blob", "size": size})
|
|
|
| lower_name = path.name.lower()
|
| lower_rel = rel.lower()
|
|
|
| if lower_name == "readme.md":
|
| readme_path = path
|
| elif lower_rel == "main.py" or lower_rel.endswith("/main.py"):
|
| main_py_path = path
|
| elif lower_name.startswith("requirements"):
|
| requirements_path = path
|
|
|
| ext = path.suffix.lower()
|
| if ext in FILE_EXTENSIONS:
|
| code_files.append((path, size))
|
|
|
|
|
| code_files.sort(key=lambda x: -x[1])
|
| selected: list[Path] = []
|
| seen: set[Path] = set()
|
|
|
| for preferred in (readme_path, main_py_path):
|
| if preferred and preferred not in seen:
|
| selected.append(preferred)
|
| seen.add(preferred)
|
|
|
| for path, _ in code_files:
|
| if path not in seen:
|
| selected.append(path)
|
| seen.add(path)
|
| if len(selected) >= 12:
|
| break
|
|
|
| max_file_chars = 100_000
|
| parts = [f"# Local Repository: {root.name}\n"]
|
| file_count = 0
|
|
|
| for path in selected:
|
| try:
|
| raw = path.read_text(encoding="utf-8", errors="replace")
|
| except Exception:
|
| continue
|
|
|
| if len(raw) > max_file_chars:
|
| raw = raw[:max_file_chars] + "\n... (truncated)"
|
|
|
| rel = path.relative_to(root).as_posix()
|
| parts.append(f"\n## File: {rel}\n```\n{raw}\n```")
|
| file_count += 1
|
|
|
| requirements_content = ""
|
| if requirements_path:
|
| try:
|
| requirements_content = requirements_path.read_text(encoding="utf-8", errors="replace")
|
| except Exception:
|
| pass
|
|
|
| directory_tree = _build_directory_tree(tree_nodes)
|
| code_context = "\n".join(parts)
|
| return code_context, requirements_content, directory_tree, file_count
|
|
|
|
|
| async def fetch_repo_data_github(owner: str, repo: str) -> tuple[str, str, str, int]:
|
| """Fetch repo: (code_context for summary, requirements_content, directory_tree, file_count)."""
|
| log_entry()
|
| headers = {"Accept": "application/vnd.github.v3+json"}
|
| print("I am in the fetch_repo_data_github function, GITHUB_TOKEN is: ", GITHUB_TOKEN)
|
| if GITHUB_TOKEN:
|
| headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
|
|
|
| async with httpx.AsyncClient(timeout=30.0, verify=HTTPX_VERIFY) as client:
|
| r = await client.get(
|
| f"https://api.github.com/repos/{owner}/{repo}",
|
| headers=headers,
|
| )
|
| if r.status_code == 404:
|
| raise HTTPException(status_code=404, detail="Repository not found")
|
|
|
|
|
| r.raise_for_status()
|
| default_branch = r.json().get("default_branch", "main")
|
|
|
| tree_r = await client.get(
|
| f"https://api.github.com/repos/{owner}/{repo}/git/trees/{default_branch}",
|
| params={"recursive": "1"},
|
| headers=headers,
|
| )
|
| if tree_r.status_code != 200:
|
| raise HTTPException(
|
| status_code=tree_r.status_code,
|
| detail="Failed to fetch repository tree",
|
| )
|
| tree = tree_r.json()
|
| nodes = tree.get("tree", [])
|
|
|
| readme_path: str | None = None
|
| main_py_path: str | None = None
|
| py_files: list[tuple[str, int]] = []
|
| requirements_path: str | None = None
|
|
|
| for node in nodes:
|
| if node.get("type") != "blob":
|
| continue
|
| path = node.get("path", "")
|
| size = node.get("size") or 0
|
|
|
| if _path_is_readme(path):
|
| readme_path = path
|
| elif _path_is_main_py(path):
|
| main_py_path = path
|
| elif path.lower().endswith(".py"):
|
| py_files.append((path, size))
|
| elif path.lower().startswith("requirements"):
|
| requirements_path = path
|
|
|
| py_files.sort(key=lambda x: -x[1])
|
| top_10_other_py = [path for path, _ in py_files[:10]]
|
|
|
| paths_to_fetch: list[str] = []
|
| if readme_path:
|
| paths_to_fetch.append(readme_path)
|
| if main_py_path:
|
| paths_to_fetch.append(main_py_path)
|
| paths_to_fetch.extend(top_10_other_py)
|
|
|
| max_file_chars = 100_000
|
| parts = [f"# Repository: {owner}/{repo} (branch: {default_branch})\n"]
|
| file_count = 0
|
|
|
| for path in paths_to_fetch:
|
| content_r = await client.get(
|
| f"https://api.github.com/repos/{owner}/{repo}/contents/{path}",
|
| params={"ref": default_branch},
|
| headers=headers,
|
| )
|
| if content_r.status_code != 200:
|
| continue
|
| data = content_r.json()
|
| if data.get("encoding") != "base64":
|
| continue
|
| try:
|
| raw = base64.b64decode(data.get("content", "")).decode("utf-8", errors="replace")
|
| except Exception:
|
| continue
|
| if len(raw) > max_file_chars:
|
| raw = raw[:max_file_chars] + "\n... (truncated)"
|
| parts.append(f"\n## File: {path}\n```\n{raw}\n```")
|
| file_count += 1
|
|
|
| code_context = "\n".join(parts)
|
|
|
| requirements_content = ""
|
| if requirements_path:
|
| req_r = await client.get(
|
| f"https://api.github.com/repos/{owner}/{repo}/contents/{requirements_path}",
|
| params={"ref": default_branch},
|
| headers=headers,
|
| )
|
| if req_r.status_code == 200:
|
| data = req_r.json()
|
| if data.get("encoding") == "base64":
|
| try:
|
| requirements_content = base64.b64decode(data.get("content", "")).decode("utf-8", errors="replace")
|
| except Exception:
|
| pass
|
|
|
| directory_tree = _build_directory_tree(nodes)
|
| return code_context, requirements_content, directory_tree, file_count
|
|
|
|
|
| async def _call_llm(system: str, user_content: str, parse_json: bool = False, model="claude-sonnet-4-6"):
|
| """Call Anthropic API; returns raw content or parsed JSON. Retries on 429 rate limit and 529 overloaded errors."""
|
| log_entry()
|
| print("I am in _call_llm")
|
| client = Anthropic(api_key=ANTHROPIC_API_KEY, http_client=httpx.Client(verify=HTTPX_VERIFY))
|
| last_error: Exception | None = None
|
| max_retries = 3
|
| for attempt in range(max_retries):
|
| try:
|
| print("I am in _call_llm, trying to call the LLM, model =", model)
|
| response = client.messages.create(
|
| model=model,
|
| max_tokens=4096,
|
| system=system,
|
| messages=[{"role": "user", "content": user_content}],
|
| )
|
| raw = response.content[0].text.strip()
|
| if raw.startswith("```"):
|
| raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
| if not parse_json:
|
| return raw
|
| try:
|
| return json.loads(raw)
|
| except json.JSONDecodeError:
|
| return raw
|
| except Exception as e:
|
| last_error = e
|
| err_str = str(e).lower()
|
| is_rate_limit = "429" in err_str or "rate_limit" in err_str
|
| is_overloaded = "529" in err_str or "overloaded" in err_str
|
| is_retryable = is_rate_limit or is_overloaded
|
|
|
| if is_retryable and attempt < max_retries - 1:
|
|
|
|
|
| wait_time = 65 if is_rate_limit else (2 ** attempt)
|
| await asyncio.sleep(wait_time)
|
| continue
|
|
|
|
|
| if is_rate_limit or is_overloaded:
|
| status_code = 503
|
| else:
|
| status_code = 502
|
|
|
| raise HTTPException(
|
| status_code=status_code,
|
| detail=f"LLM request failed: {str(e)}",
|
| )
|
| if last_error:
|
| raise HTTPException(status_code=503, detail=f"LLM request failed: {str(last_error)}")
|
| raise HTTPException(status_code=503, detail="LLM request failed")
|
|
|
|
|
| async def llm_summary_from_code(code_context: str) -> str:
|
|
|
| log_entry()
|
| prompt = (
|
| "Based on the following repository file contents (README, main.py, and key code files), "
|
| "write a concise 1-3 paragraph summary of the project: purpose, what it does, and how developers might use it. "
|
| "Use markdown (e.g. **ProjectName** for the project name) if helpful. Return only the summary text, no JSON."
|
| )
|
| return await _call_llm("You summarize code repositories clearly and concisely.",
|
| f"{prompt}\n\n{code_context}",
|
| parse_json=False,
|
| model="claude-opus-4-6")
|
|
|
|
|
| async def llm_technologies_from_requirements(
|
| requirements_content: str,
|
| extensions_in_project: set[str] | None = None,
|
| ) -> list[str]:
|
| """Read requirements*.txt and summarize into result['technologies']."""
|
| log_entry()
|
| if not requirements_content.strip():
|
| prompt_parts = []
|
| else:
|
| prompt_parts = [
|
| "Use the content of a requirements.txt (or similar) file. "
|
| ]
|
|
|
|
|
|
|
|
|
| if extensions_in_project:
|
| prompt_parts.append(f"File extensions found in this project (from FILE_EXTENSIONS): {', '.join(extensions_in_project)}.")
|
| prompt_parts.append(f"Respond with a JSON array of strings listing the main technologies, languages, and key dependencies ")
|
| prompt_parts.append(f'(e.g. ["Python", "urllib3", "certifi"])')
|
| prompt_parts.append(f"Return only the JSON array, no other text or markdown.")
|
| prompt = "\n".join(prompt_parts)
|
| out = await _call_llm("You respond only with a valid JSON array of strings.",
|
| f"{prompt}\n\n{requirements_content}",
|
| parse_json=True,
|
| model="claude-haiku-4-5")
|
| if isinstance(out, list):
|
| return [str(x) for x in out]
|
| if isinstance(out, str):
|
| try:
|
| arr = json.loads(out)
|
| return [str(x) for x in arr] if isinstance(arr, list) else []
|
| except json.JSONDecodeError:
|
| pass
|
| return []
|
|
|
|
|
| async def llm_structure_from_tree(directory_tree: str) -> str:
|
| """Look at directory tree and summarize into result['structure']."""
|
| log_entry()
|
| if not directory_tree.strip():
|
| return ""
|
| prompt = (
|
| "Below is the directory/file tree of a project. Write one short paragraph describing the project layout: "
|
| "where the main code lives, where tests are, docs, config files, etc. Use backticks for paths and folder names "
|
| '(e.g. main code in `src/`, tests in `tests/`). Return only that paragraph, no JSON or extra formatting.'
|
| )
|
| return await _call_llm("You describe project structure clearly and concisely.",
|
| f"{prompt}\n\n{directory_tree}",
|
| parse_json=False, model="claude-sonnet-4-6")
|
|
|
|
|
| @app.get("/health")
|
| async def health():
|
| """Health check endpoint."""
|
| log_entry()
|
| return {"status": "ok"}
|
|
|
|
|
| @app.get("/", response_class=FileResponse)
|
| async def index():
|
| """Serve the HTML page for submitting a GitHub URL or a Directory Path to POST /summarize."""
|
| log_entry()
|
| if not _INDEX_HTML.exists():
|
| raise HTTPException(status_code=404, detail="index.html not found")
|
| return FileResponse(_INDEX_HTML, media_type="text/html")
|
|
|
| @app.post("/summarize")
|
| async def summarize(request: SummarizeRequest):
|
| """Accept either a GitHub URL or a local repository path."""
|
| log_entry()
|
|
|
| repo_source = request.repo_source.strip()
|
|
|
| if "github.com" in repo_source:
|
| return await summarize_github(
|
| SummarizeGithubRequest(repo_source=repo_source)
|
| )
|
|
|
| path = Path(repo_source).expanduser().resolve()
|
| if path.exists() and path.is_dir():
|
| return await summarize_local(
|
| SummarizeLocalRequest(repo_source=str(path))
|
| )
|
|
|
| raise HTTPException(
|
| status_code=422,
|
| detail="repo_source must be either a valid GitHub URL or an existing local directory path",
|
| )
|
|
|
|
|
| @app.post("/summarize_github")
|
| async def summarize_github(request: SummarizeGithubRequest):
|
| """Accept a GitHub repository URL, fetch contents, and return an LLM-generated summary."""
|
| log_entry()
|
| try:
|
| owner, repo = parse_github_url(request.repo_source)
|
| except ValueError as e:
|
| raise HTTPException(status_code=422, detail=str(e))
|
|
|
| code_context, requirements_content, directory_tree, files_included = await fetch_repo_data_github(owner, repo)
|
| if files_included == 0:
|
| raise HTTPException(
|
| status_code=422,
|
| detail="No readable text files found in the repository.",
|
| )
|
|
|
| extensions_in_project = _extensions_in_tree(directory_tree)
|
|
|
|
|
| summary_text = await llm_summary_from_code(code_context)
|
| technologies_list = await llm_technologies_from_requirements(requirements_content, extensions_in_project)
|
| structure_text = await llm_structure_from_tree(directory_tree)
|
|
|
|
|
| body = (
|
| "{\n"
|
| ' "summary": ' + json.dumps(summary_text, ensure_ascii=False) + ",\n"
|
| ' "technologies": ' + json.dumps(technologies_list, ensure_ascii=False) + ",\n"
|
| ' "structure": ' + json.dumps(structure_text, ensure_ascii=False) + "\n"
|
| "}"
|
| )
|
| return Response(
|
| content=body,
|
| media_type="application/json",
|
| )
|
|
|
| @app.post("/summarize_local")
|
| async def summarize_local(request: SummarizeLocalRequest):
|
| """Accept a local repository path, fetch contents, and return an LLM-generated summary."""
|
| log_entry()
|
|
|
| code_context, requirements_content, directory_tree, files_included = await fetch_repo_data_lcoaldisk(
|
| request.repo_source
|
| )
|
| if files_included == 0:
|
| raise HTTPException(
|
| status_code=422,
|
| detail="No readable text files found in the repository.",
|
| )
|
|
|
| extensions_in_project = _extensions_in_tree(directory_tree)
|
| summary_text = await llm_summary_from_code(code_context)
|
| technologies_list = await llm_technologies_from_requirements(
|
| requirements_content, extensions_in_project
|
| )
|
| structure_text = await llm_structure_from_tree(directory_tree)
|
|
|
| body = (
|
| "{\n"
|
| ' "summary": ' + json.dumps(summary_text, ensure_ascii=False) + ",\n"
|
| ' "technologies": ' + json.dumps(technologies_list, ensure_ascii=False) + ",\n"
|
| ' "structure": ' + json.dumps(structure_text, ensure_ascii=False) + "\n"
|
| "}"
|
| )
|
| return Response(content=body, media_type="application/json")
|
|
|
|
|
| if __name__ == "__main__":
|
| import uvicorn
|
| uvicorn.run(app, host="127.0.0.1", port=8000)
|
|
|