borodache's picture
Create The first initial project
b528a5d verified
Raw
History Blame Contribute Delete
23.9 kB
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
# Set NEBIUS_API_KEY in environment for LLM summaries
# NEBIUS_API_KEY = os.environ.get("NEBIUS_API_KEY")
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()
# ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
# print(f"ANTHROPIC_API_KEY: {ANTHROPIC_API_KEY}")
# # Optional: GITHUB_TOKEN raises GitHub API rate limit (5000/hr vs 60/hr unauthenticated)
# GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
# print(f"GITHUB_TOKEN: {GITHUB_TOKEN}")
_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 __init__(self, repo_source: str):
# self.repo_source = repo_source
# @field_validator("repo_source")
# @classmethod
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)
# print("I am in the SummarizeGithubRequest class, repo_source is: ", SummarizeRequest.repo_source)
# @classmethod
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)
# @classmethod
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 https://github.com/owner/repo or https://github.com/owner/repo/
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))
# Prefer README and main.py, then largest remaining supported files
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")
# This method prints the exception and the stack to the server terminal, while exception handling done in the putput curl
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:
# Exponential backoff: 2^attempt seconds (2, 4, 8 seconds)
# For rate limits, use longer wait (65 seconds)
wait_time = 65 if is_rate_limit else (2 ** attempt)
await asyncio.sleep(wait_time)
continue
# Determine appropriate HTTP status code
if is_rate_limit or is_overloaded:
status_code = 503 # Service Unavailable
else:
status_code = 502 # Bad Gateway
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:
# """Read README, main.py and 10 longest code files context; return result['summary']."""
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:
# exts_sorted = sorted(extensions_in_project)
# print(f"In async def llm_technologies_from_requirements - File extensions found in this project (from FILE_EXTENSIONS): {', '.join(extensions_in_project)}.")
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)
# print(f"Init File extensions found in this project (from FILE_EXTENSIONS): {', '.join(extensions_in_project)}.")
# Sequential LLM calls to avoid bursting Anthropic rate limit (30k input tokens/min)
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)
# Build JSON with technologies on one line
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)