project_codebase / main.py
muhammad7456's picture
staging deployment completed
944f820
Raw
History Blame Contribute Delete
15.3 kB
"""
main.py
-------
FastAPI backend for the Codebase Oracle system.
This is the HTTP layer β€” thin wrapper around inference.py.
Endpoints:
POST /index β€” ingest + embed a codebase from given path
POST /query β€” run a query (macro / micro / cross_module)
GET /status β€” check if a codebase is indexed and ready
GET /tree β€” return parsed codebase tree for UI sidebar
GET /health β€” simple health check
Run:
uvicorn main:app --reload --port 8000
Depends on:
- inference.py
- embedder.py
- call_graph.py
- ast_parser.py
- vector_store.py
- fastapi, uvicorn, pydantic, python-dotenv
"""
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
import tempfile
import zipfile
import shutil
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from rich.console import Console
from inference.inference import get_engine, InferenceRequest
from ingest.embed import embed_codebase
from store.call_graph import build_and_save, get_call_graph, CALL_GRAPH_PATH
from ingest.parse_ast import parse_codebase
from store.vector_store import get_vector_store
load_dotenv()
console = Console()
# ── App Lifespan ──────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize shared resources on startup."""
console.rule("[bold cyan]Codebase Oracle β€” Starting[/bold cyan]")
# Pre-warm the inference engine (loads embedding model once)
get_engine()
console.print("[green]βœ”[/green] Server ready.\n")
yield
console.print("[dim]Server shutting down.[/dim]")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="Codebase Oracle",
description="AI-powered monolithic codebase comprehension system.",
version="1.0.0",
lifespan=lifespan,
)
# Allow UI (served from same origin or localhost dev)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Serve UI static files
UI_DIR = os.path.join(os.path.dirname(__file__), "ui")
if os.path.exists(UI_DIR):
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
app.mount("/static", StaticFiles(directory=os.path.join(UI_DIR, "static")), name="static")
# ── Pydantic Request / Response Models ────────────────────────────────────────
class IndexRequest(BaseModel):
"""Request body for POST /index"""
root_path: str = Field(
...,
description="Absolute path to the monolithic codebase root directory.",
example="/home/user/projects/my-django-app"
)
class QueryRequest(BaseModel):
"""Request body for POST /query"""
query_type: str = Field(
...,
description="One of: 'macro', 'micro', 'cross_module'",
example="micro"
)
query: str = Field(
...,
description="Natural language developer query.",
example="What does process_payment do and how do I use it?"
)
subtype: str = Field(
default="",
description="Macro subtype: 'overall_architecture' | 'module_responsibility' | 'data_flow'",
example="overall_architecture"
)
function_name: str = Field(
default="",
description="Target function/method name for micro and cross_module queries.",
example="process_payment"
)
class_name: str = Field(
default="",
description="Target class name if function is a method.",
example="PaymentProcessor"
)
module_name: str = Field(
default="",
description="Target module name for macro module_responsibility queries.",
example="payments"
)
followup: bool = Field(
default=False,
description="True if this is a follow-up to a previous response."
)
previous_response: str = Field(
default="",
description="Previous LLM response for follow-up context."
)
class IndexResponse(BaseModel):
success: bool
message: str
class_chunks: int = 0
function_chunks: int = 0
total_chunks: int = 0
graph_nodes: int = 0
graph_edges: int = 0
class QueryResponse(BaseModel):
success: bool
content: str
error: str = ""
metadata: dict = {}
class StatusResponse(BaseModel):
indexed: bool
class_chunks: int
function_chunks: int
total_chunks: int
graph_loaded: bool
graph_nodes: int
class TreeNode(BaseModel):
name: str
type: str # "module" | "file" | "class" | "function"
children: list["TreeNode"] = []
TreeNode.model_rebuild()
class TreeResponse(BaseModel):
success: bool
tree: list[TreeNode] = []
error: str = ""
# ── Endpoints ─────────────────────────────────────────────────────────────────
@app.post("/upload-index", response_model=IndexResponse)
async def upload_index(file: UploadFile = File(...)):
"""
Accept a ZIP file, extract it to a temp directory, and index it.
Allows deployment without requiring local filesystem access.
"""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip files are accepted.")
tmp_dir = tempfile.mkdtemp()
try:
zip_path = os.path.join(tmp_dir, file.filename)
with open(zip_path, "wb") as f:
shutil.copyfileobj(file.file, f)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmp_dir)
os.remove(zip_path)
# Find the extracted root β€” skip __MACOSX and similar artifacts
candidates = [
os.path.join(tmp_dir, d)
for d in os.listdir(tmp_dir)
if os.path.isdir(os.path.join(tmp_dir, d)) and not d.startswith("__")
]
root = candidates[0] if candidates else tmp_dir
console.rule(f"[bold cyan]Indexing ZIP: {file.filename}[/bold cyan]")
embed_codebase(root)
graph = build_and_save(root)
graph_stats = graph.stats()
store = get_vector_store()
vstats = store.stats()
console.print("[bold green]βœ” ZIP Indexing complete.[/bold green]\n")
return IndexResponse(
success=True,
message=f"ZIP indexed successfully: {file.filename}",
class_chunks=vstats["class_chunks"],
function_chunks=vstats["function_chunks"],
total_chunks=vstats["total"],
graph_nodes=graph_stats["total_nodes"],
graph_edges=graph_stats["total_edges"],
)
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file.")
except Exception as e:
console.print(f"[red]❌ ZIP indexing failed: {e}[/red]")
raise HTTPException(status_code=500, detail=f"ZIP indexing failed: {str(e)}")
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
@app.get("/health")
async def health():
"""Simple health check."""
return {"status": "ok", "service": "Codebase Oracle"}
@app.get("/", response_class=FileResponse)
async def serve_ui():
"""Serve the UI index.html at root."""
ui_path = os.path.join(UI_DIR, "index.html")
if not os.path.exists(ui_path):
raise HTTPException(
status_code=404,
detail="UI not found. Place index.html in the ui/ directory."
)
return FileResponse(ui_path)
@app.post("/index", response_model=IndexResponse)
async def index_codebase(req: IndexRequest):
"""
Ingest, parse, embed, and index a monolithic codebase.
Builds both ChromaDB vector index and call_graph.json.
This is the first endpoint to call before any queries.
"""
root = req.root_path.strip()
if not os.path.exists(root):
raise HTTPException(
status_code=400,
detail=f"Path does not exist: {root}"
)
if not os.path.isdir(root):
raise HTTPException(
status_code=400,
detail=f"Path is not a directory: {root}"
)
try:
console.rule(f"[bold cyan]Indexing: {root}[/bold cyan]")
# Step 1 β€” Embed codebase into ChromaDB
embed_codebase(root)
# Step 2 β€” Build and save call graph
graph = build_and_save(root)
graph_stats = graph.stats()
# Step 3 β€” Fetch vector store stats
store = get_vector_store()
vstats = store.stats()
console.print("[bold green]βœ” Indexing complete.[/bold green]\n")
return IndexResponse(
success=True,
message=f"Codebase indexed successfully: {root}",
class_chunks=vstats["class_chunks"],
function_chunks=vstats["function_chunks"],
total_chunks=vstats["total"],
graph_nodes=graph_stats["total_nodes"],
graph_edges=graph_stats["total_edges"],
)
except Exception as e:
console.print(f"[red]❌ Indexing failed: {e}[/red]")
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
@app.post("/query", response_model=QueryResponse)
async def query(req: QueryRequest):
"""
Run a macro, micro, or cross-module query against the indexed codebase.
Returns a markdown-formatted response string.
"""
store = get_vector_store()
if not store.is_indexed():
raise HTTPException(
status_code=400,
detail="Codebase is not indexed yet. Call POST /index first."
)
engine = get_engine()
inference_req = InferenceRequest(
query_type=req.query_type,
query=req.query,
subtype=req.subtype,
function_name=req.function_name,
class_name=req.class_name,
module_name=req.module_name,
followup=req.followup,
previous_response=req.previous_response,
)
resp = engine.infer(inference_req)
return QueryResponse(
success=resp.success,
content=resp.content,
error=resp.error,
metadata=resp.metadata,
)
@app.get("/status", response_model=StatusResponse)
async def status():
"""
Check whether the codebase is indexed and the system is ready for queries.
"""
store = get_vector_store()
vstats = store.stats()
graph_loaded = False
graph_nodes = 0
if os.path.exists(CALL_GRAPH_PATH):
try:
graph = get_call_graph()
graph_loaded = graph.is_loaded()
graph_nodes = graph.stats()["total_nodes"]
except Exception:
pass
return StatusResponse(
indexed=store.is_indexed(),
class_chunks=vstats["class_chunks"],
function_chunks=vstats["function_chunks"],
total_chunks=vstats["total"],
graph_loaded=graph_loaded,
graph_nodes=graph_nodes,
)
@app.get("/tree", response_model=TreeResponse)
async def get_tree():
"""
Return the parsed codebase structure as a nested tree.
Used by the UI sidebar to render the codebase explorer.
"""
store = get_vector_store()
if not store.is_indexed():
return TreeResponse(
success=False,
error="Codebase not indexed yet. Call POST /index first."
)
try:
# Fetch both class and function chunks to reconstruct tree
class_results = store.get_all("class_chunks", limit=500)
func_results = store.get_all("function_chunks", limit=500)
# Group by module β†’ file β†’ classes/functions
modules: dict[str, dict[str, dict[str, set]]] = {}
# --- classes ---
for chunk in class_results:
mod = chunk.module
file = chunk.file
modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
modules[mod][file]["classes"].add(chunk.name)
# --- functions (top-level only) ---
for chunk in func_results:
if not chunk.class_name:
mod = chunk.module
file = chunk.file
modules.setdefault(mod, {}).setdefault(file, {"classes": set(), "functions": set()})
modules[mod][file]["functions"].add(chunk.name)
# Also fetch function chunks for top-level functions
func_results = store.get_all("function_chunks", limit=500)
func_by_file: dict[str, list[str]] = {}
for chunk in func_results:
if not chunk.class_name: # top-level only
func_by_file.setdefault(chunk.file, []).append(chunk.name)
# Build tree structure
all_files = set()
for files in modules.values():
for file_path in files:
all_files.add(file_path)
# Derive root directory name from common first path component
first_parts = [f.split("/")[0] for f in all_files if "/" in f]
root_name = first_parts[0] if first_parts else "codebase"
root_node = TreeNode(name=root_name, type="module")
for module_name, files in sorted(modules.items()):
module_node = TreeNode(name=module_name, type="module")
for file_path, content in sorted(files.items()):
file_node = TreeNode(
name=os.path.basename(file_path),
type="file"
)
for cls_name in sorted(content["classes"]):
file_node.children.append(
TreeNode(name=cls_name, type="class")
)
for fn_name in sorted(content["functions"]):
file_node.children.append(
TreeNode(name=fn_name, type="function")
)
module_node.children.append(file_node)
root_node.children.append(module_node)
return TreeResponse(success=True, tree=[root_node])
except Exception as e:
console.print(f"[red]❌ Tree build failed: {e}[/red]")
return TreeResponse(success=False, error=str(e))
# ── Entry Point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
port=8000,
reload=True,
)