Spaces:
Runtime error
Runtime error
trakshan-mishra commited on
Commit Β·
f56e3a3
1
Parent(s): ed862c3
Phase 1 Agentic Upgrade: SQLite sessions, git clone support, semantic search, resolution, file blast
Browse files- diffcontext-service/backend/main.py +363 -204
diffcontext-service/backend/main.py
CHANGED
|
@@ -1,27 +1,42 @@
|
|
| 1 |
"""
|
| 2 |
-
DiffContext Web Service β FastAPI Backend
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import ast
|
| 7 |
import json
|
| 8 |
import os
|
|
|
|
|
|
|
|
|
|
| 9 |
import tempfile
|
|
|
|
|
|
|
| 10 |
import zipfile
|
| 11 |
import shutil
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
-
from typing import Dict, List, Optional
|
| 14 |
|
| 15 |
-
from fastapi import FastAPI, File,
|
| 16 |
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
from fastapi.responses import JSONResponse
|
| 18 |
from pydantic import BaseModel
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
| 21 |
app = FastAPI(
|
| 22 |
title="DiffContext API",
|
| 23 |
-
description="
|
| 24 |
-
version="
|
| 25 |
)
|
| 26 |
|
| 27 |
app.add_middleware(
|
|
@@ -31,29 +46,84 @@ app.add_middleware(
|
|
| 31 |
allow_headers=["*"],
|
| 32 |
)
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
# ββ Pydantic models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
class BlastRequest(BaseModel):
|
| 41 |
repo_id: str
|
| 42 |
-
symbol: str
|
| 43 |
|
| 44 |
class CompileRequest(BaseModel):
|
| 45 |
repo_id: str
|
| 46 |
symbol: str
|
| 47 |
max_tokens: int = 8000
|
| 48 |
|
| 49 |
-
class
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class InlineRequest(BaseModel):
|
| 54 |
-
"""Lets beginners paste code directly without uploading a zip."""
|
| 55 |
-
files: List[FileItem] # list of file items to make it compatible with GPT Actions
|
| 56 |
-
symbol: str # which function to analyze
|
| 57 |
|
| 58 |
class HealthResponse(BaseModel):
|
| 59 |
status: str
|
|
@@ -64,78 +134,64 @@ class RootResponse(BaseModel):
|
|
| 64 |
endpoints: List[str]
|
| 65 |
|
| 66 |
|
|
|
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
def
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 82 |
|
|
|
|
|
|
|
|
|
|
| 83 |
idx = index_repository(repo_path)
|
| 84 |
if symbol not in idx.symbols and symbol not in idx.graph:
|
| 85 |
-
# Suggest similar ones
|
| 86 |
import difflib
|
| 87 |
suggestions = difflib.get_close_matches(symbol, list(idx.symbols.keys()), n=3, cutoff=0.4)
|
| 88 |
-
return {
|
| 89 |
-
"error": f"Symbol '{symbol}' not found.",
|
| 90 |
-
"suggestions": suggestions,
|
| 91 |
-
"all_symbols": list(idx.symbols.keys()),
|
| 92 |
-
}
|
| 93 |
|
| 94 |
impact = analyze_impact(idx, [symbol])
|
| 95 |
-
|
| 96 |
-
# Build callers / callees for response
|
| 97 |
reverse: Dict[str, List[str]] = {}
|
| 98 |
for caller, callees in idx.graph.items():
|
| 99 |
for callee in callees:
|
| 100 |
reverse.setdefault(callee, []).append(caller)
|
| 101 |
|
| 102 |
-
direct_callers = reverse.get(symbol, [])
|
| 103 |
-
direct_callees = idx.graph.get(symbol, [])
|
| 104 |
-
|
| 105 |
-
# Group blast radius by file
|
| 106 |
by_file: Dict[str, List[str]] = {}
|
| 107 |
for sym in impact.blast_radius:
|
| 108 |
-
|
| 109 |
-
by_file.setdefault(
|
| 110 |
|
| 111 |
return {
|
| 112 |
"symbol": symbol,
|
| 113 |
-
"direct_callers":
|
| 114 |
-
"direct_callees":
|
| 115 |
"blast_radius_count": len(impact.blast_radius),
|
| 116 |
"blast_radius_by_file": by_file,
|
| 117 |
"all_affected": impact.blast_radius[:50],
|
| 118 |
"scores": {k: round(v, 1) for k, v in list(impact.scores.items())[:30]},
|
| 119 |
"total_symbols_in_repo": len(idx.symbols),
|
| 120 |
}
|
| 121 |
-
|
| 122 |
except ImportError:
|
| 123 |
-
# Fallback: pure AST analysis without DiffContext
|
| 124 |
return _ast_fallback(repo_path, symbol)
|
| 125 |
|
| 126 |
-
|
| 127 |
def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
| 128 |
-
|
| 129 |
-
Lightweight fallback: parse all .py files with stdlib ast,
|
| 130 |
-
build a simple call graph, return blast radius.
|
| 131 |
-
Works even without DiffContext installed.
|
| 132 |
-
"""
|
| 133 |
-
file_part, func_name = (symbol.split(":", 1) + [""])[:2]
|
| 134 |
-
if not func_name:
|
| 135 |
-
return {"error": "Invalid symbol format. Use ./file.py:function_name"}
|
| 136 |
-
|
| 137 |
-
# Collect all functions from all .py files
|
| 138 |
-
all_functions: Dict[str, str] = {} # id -> source snippet
|
| 139 |
call_graph: Dict[str, List[str]] = {}
|
| 140 |
|
| 141 |
for py_file in Path(repo_path).rglob("*.py"):
|
|
@@ -145,12 +201,10 @@ def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
|
| 145 |
tree = ast.parse(source)
|
| 146 |
except SyntaxError:
|
| 147 |
continue
|
| 148 |
-
|
| 149 |
for node in ast.walk(tree):
|
| 150 |
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 151 |
fid = f"{rel}:{node.name}"
|
| 152 |
-
all_functions[fid] =
|
| 153 |
-
# Collect calls made inside this function
|
| 154 |
calls = []
|
| 155 |
for child in ast.walk(node):
|
| 156 |
if isinstance(child, ast.Call):
|
|
@@ -160,49 +214,36 @@ def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
|
| 160 |
calls.append(child.func.attr)
|
| 161 |
call_graph[fid] = calls
|
| 162 |
|
| 163 |
-
# Resolve calls to full IDs
|
| 164 |
name_to_ids: Dict[str, List[str]] = {}
|
| 165 |
for fid in all_functions:
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
for call in raw_calls:
|
| 173 |
-
for candidate in name_to_ids.get(call, []):
|
| 174 |
-
if candidate != fid:
|
| 175 |
-
resolved.append(candidate)
|
| 176 |
-
resolved_graph[fid] = resolved
|
| 177 |
-
|
| 178 |
-
# Build reverse graph
|
| 179 |
reverse: Dict[str, List[str]] = {}
|
| 180 |
-
for caller, callees in
|
| 181 |
for callee in callees:
|
| 182 |
reverse.setdefault(callee, []).append(caller)
|
| 183 |
|
| 184 |
if symbol not in all_functions:
|
| 185 |
import difflib
|
| 186 |
suggestions = difflib.get_close_matches(symbol, list(all_functions.keys()), n=3, cutoff=0.4)
|
| 187 |
-
return {
|
| 188 |
-
|
| 189 |
-
"suggestions": suggestions,
|
| 190 |
-
"all_symbols": list(all_functions.keys()),
|
| 191 |
-
}
|
| 192 |
|
| 193 |
-
# BFS blast radius
|
| 194 |
visited = {symbol}
|
| 195 |
-
blast = []
|
| 196 |
-
frontier = [symbol]
|
| 197 |
while frontier:
|
| 198 |
-
|
| 199 |
for node in frontier:
|
| 200 |
for caller in reverse.get(node, []):
|
| 201 |
if caller not in visited:
|
| 202 |
visited.add(caller)
|
| 203 |
blast.append(caller)
|
| 204 |
-
|
| 205 |
-
frontier =
|
| 206 |
|
| 207 |
by_file: Dict[str, List[str]] = {}
|
| 208 |
for sym in blast:
|
|
@@ -212,30 +253,41 @@ def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
|
| 212 |
return {
|
| 213 |
"symbol": symbol,
|
| 214 |
"direct_callers": reverse.get(symbol, []),
|
| 215 |
-
"direct_callees":
|
| 216 |
"blast_radius_count": len(blast),
|
| 217 |
"blast_radius_by_file": by_file,
|
| 218 |
"all_affected": blast[:50],
|
| 219 |
"scores": {},
|
| 220 |
"total_symbols_in_repo": len(all_functions),
|
| 221 |
-
"note": "Lightweight AST mode (install DiffContext for full analysis)",
|
| 222 |
}
|
| 223 |
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
# ββ Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -243,15 +295,20 @@ def _list_symbols(repo_path: str) -> List[str]:
|
|
| 243 |
@app.get("/", response_model=RootResponse)
|
| 244 |
def root():
|
| 245 |
return {
|
| 246 |
-
"service": "DiffContext API",
|
| 247 |
"docs": "/docs",
|
| 248 |
"endpoints": [
|
| 249 |
-
"POST /upload
|
| 250 |
-
"POST /
|
| 251 |
-
"
|
| 252 |
-
"
|
| 253 |
-
"
|
| 254 |
-
"GET /
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
],
|
| 256 |
}
|
| 257 |
|
|
@@ -261,6 +318,68 @@ def health():
|
|
| 261 |
return {"status": "ok"}
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
@app.post("/upload")
|
| 266 |
async def upload_repo(file: UploadFile = File(...)):
|
|
@@ -271,7 +390,6 @@ async def upload_repo(file: UploadFile = File(...)):
|
|
| 271 |
if not file.filename.endswith(".zip"):
|
| 272 |
raise HTTPException(400, "Please upload a .zip file of your project folder.")
|
| 273 |
|
| 274 |
-
# Save zip to temp dir
|
| 275 |
tmp_dir = tempfile.mkdtemp(prefix="diffctx_")
|
| 276 |
zip_path = os.path.join(tmp_dir, "repo.zip")
|
| 277 |
extract_path = os.path.join(tmp_dir, "repo")
|
|
@@ -280,118 +398,179 @@ async def upload_repo(file: UploadFile = File(...)):
|
|
| 280 |
with open(zip_path, "wb") as f:
|
| 281 |
f.write(content)
|
| 282 |
|
| 283 |
-
# Extract
|
| 284 |
try:
|
| 285 |
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 286 |
zf.extractall(extract_path)
|
| 287 |
except zipfile.BadZipFile:
|
| 288 |
shutil.rmtree(tmp_dir)
|
| 289 |
-
raise HTTPException(400, "Could not open zip file.
|
| 290 |
|
| 291 |
-
# Find the actual Python root (handle nested folder inside zip)
|
| 292 |
py_files = list(Path(extract_path).rglob("*.py"))
|
| 293 |
if not py_files:
|
| 294 |
shutil.rmtree(tmp_dir)
|
| 295 |
raise HTTPException(400, "No Python (.py) files found in your zip.")
|
| 296 |
|
| 297 |
-
# Use shallowest common directory
|
| 298 |
repo_root = str(min(py_files, key=lambda p: len(p.parts)).parent)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
repo_id = str(uuid.uuid4())[:8]
|
| 303 |
-
SESSIONS[repo_id] = repo_root
|
| 304 |
-
|
| 305 |
symbols = _list_symbols(repo_root)
|
| 306 |
|
| 307 |
return {
|
| 308 |
"repo_id": repo_id,
|
|
|
|
| 309 |
"message": f"Uploaded successfully! Found {len(symbols)} functions.",
|
| 310 |
"symbol_count": len(symbols),
|
| 311 |
"sample_symbols": symbols[:10],
|
| 312 |
-
"next_step": f'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
}
|
| 314 |
|
| 315 |
|
| 316 |
@app.post("/inline")
|
| 317 |
def analyze_inline(req: InlineRequest):
|
| 318 |
"""
|
| 319 |
-
Paste code directly β no upload needed.
|
| 320 |
-
Great for quick testing.
|
| 321 |
|
| 322 |
Example body:
|
| 323 |
{
|
| 324 |
-
"files":
|
| 325 |
-
"service.py": "def create_user(name):
|
| 326 |
-
|
| 327 |
"symbol": "./service.py:onboard"
|
| 328 |
}
|
| 329 |
"""
|
| 330 |
tmp_dir = tempfile.mkdtemp(prefix="diffctx_inline_")
|
| 331 |
try:
|
| 332 |
-
files_dict = {}
|
| 333 |
for f in req.files:
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
safe_name = Path(filename).name
|
| 339 |
-
filepath = os.path.join(tmp_dir, safe_name)
|
| 340 |
-
with open(filepath, "w") as f:
|
| 341 |
-
f.write(code)
|
| 342 |
-
|
| 343 |
-
result = _run_diffcontext(tmp_dir, req.symbol)
|
| 344 |
-
return result
|
| 345 |
finally:
|
| 346 |
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 347 |
|
| 348 |
|
| 349 |
@app.get("/symbols")
|
| 350 |
-
def list_symbols(repo_id: str):
|
| 351 |
-
"""List all functions in your
|
| 352 |
-
|
| 353 |
-
raise HTTPException(404, f"repo_id '{repo_id}' not found. Upload your project first via POST /upload.")
|
| 354 |
-
repo_path = SESSIONS[repo_id]
|
| 355 |
symbols = _list_symbols(repo_path)
|
| 356 |
-
return {
|
| 357 |
-
"repo_id": repo_id,
|
| 358 |
-
"count": len(symbols),
|
| 359 |
-
"symbols": symbols,
|
| 360 |
-
}
|
| 361 |
|
| 362 |
|
| 363 |
@app.post("/blast")
|
| 364 |
def blast_radius(req: BlastRequest):
|
| 365 |
"""
|
| 366 |
-
Get the blast radius for a function.
|
| 367 |
-
|
|
|
|
| 368 |
"""
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
repo_path = SESSIONS[req.repo_id]
|
| 372 |
-
result = _run_diffcontext(repo_path, req.symbol)
|
| 373 |
-
return result
|
| 374 |
|
| 375 |
|
| 376 |
@app.post("/compile")
|
| 377 |
def compile_context(req: CompileRequest):
|
| 378 |
-
"""
|
| 379 |
-
|
| 380 |
-
"""
|
| 381 |
-
if req.repo_id not in SESSIONS:
|
| 382 |
-
raise HTTPException(404, f"repo_id '{req.repo_id}' not found. Upload your project first.")
|
| 383 |
-
|
| 384 |
-
repo_path = SESSIONS[req.repo_id]
|
| 385 |
-
|
| 386 |
try:
|
| 387 |
-
|
| 388 |
-
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 389 |
-
from diffcontext.pipeline import index_repository, analyze_impact, compile as dc_compile
|
| 390 |
-
|
| 391 |
idx = index_repository(repo_path)
|
| 392 |
impact = analyze_impact(idx, [req.symbol])
|
| 393 |
ctx = dc_compile(idx, impact, max_tokens=req.max_tokens)
|
| 394 |
-
|
| 395 |
return {
|
| 396 |
"context_text": ctx.text,
|
| 397 |
"symbol_count": ctx.symbol_count,
|
|
@@ -400,20 +579,13 @@ def compile_context(req: CompileRequest):
|
|
| 400 |
"usage_tip": "Paste context_text into Claude/ChatGPT with a specific question about your change.",
|
| 401 |
}
|
| 402 |
except ImportError:
|
| 403 |
-
# Fallback: return basic info
|
| 404 |
result = _run_diffcontext(repo_path, req.symbol)
|
| 405 |
-
context_text = f"""
|
| 406 |
-
=== DIFFCONTEXT ANALYSIS ===
|
| 407 |
Symbol: {req.symbol}
|
| 408 |
-
|
| 409 |
-
Direct
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
Direct callees (dependencies):
|
| 413 |
-
{chr(10).join(f' - {c}' for c in result.get('direct_callees', [])) or ' (none found)'}
|
| 414 |
-
|
| 415 |
-
Full blast radius ({result.get('blast_radius_count', 0)} functions affected):
|
| 416 |
-
{chr(10).join(f' - {s}' for s in result.get('all_affected', [])[:20]) or ' (none found)'}
|
| 417 |
"""
|
| 418 |
return {
|
| 419 |
"context_text": context_text,
|
|
@@ -432,27 +604,22 @@ try:
|
|
| 432 |
|
| 433 |
if HAS_MCP and mcp_server is not None:
|
| 434 |
sse = SseServerTransport("/messages/")
|
| 435 |
-
|
| 436 |
-
# Mount the /messages endpoint
|
| 437 |
app.router.routes.append(Mount("/messages", app=sse.handle_post_message))
|
| 438 |
|
| 439 |
@app.get("/sse")
|
| 440 |
async def handle_sse(request: Request):
|
| 441 |
async with sse.connect_sse(
|
| 442 |
-
request.scope,
|
| 443 |
-
request.receive,
|
| 444 |
-
request._send
|
| 445 |
) as (read_stream, write_stream):
|
| 446 |
await mcp_server.run(
|
| 447 |
-
read_stream,
|
| 448 |
-
write_stream,
|
| 449 |
mcp_server.create_initialization_options()
|
| 450 |
)
|
| 451 |
except ImportError:
|
| 452 |
pass
|
| 453 |
|
| 454 |
|
| 455 |
-
# ββ Custom OpenAPI schema
|
| 456 |
from fastapi.openapi.utils import get_openapi
|
| 457 |
|
| 458 |
def custom_openapi():
|
|
@@ -461,27 +628,19 @@ def custom_openapi():
|
|
| 461 |
|
| 462 |
schema = get_openapi(
|
| 463 |
title="DiffContext API",
|
| 464 |
-
version="
|
| 465 |
-
description="
|
| 466 |
routes=app.routes,
|
| 467 |
)
|
| 468 |
|
| 469 |
-
|
| 470 |
-
schema["servers"] = [
|
| 471 |
-
{
|
| 472 |
-
"url": "https://trakshan-diffcontext.hf.space"
|
| 473 |
-
}
|
| 474 |
-
]
|
| 475 |
|
| 476 |
-
#
|
| 477 |
paths = schema.get("paths", {})
|
| 478 |
-
|
| 479 |
-
for p in paths_to_remove:
|
| 480 |
paths.pop(p, None)
|
| 481 |
|
| 482 |
app.openapi_schema = schema
|
| 483 |
return schema
|
| 484 |
|
| 485 |
app.openapi = custom_openapi
|
| 486 |
-
|
| 487 |
-
|
|
|
|
| 1 |
"""
|
| 2 |
+
DiffContext Web Service β FastAPI Backend (Phase 1 Agentic Upgrade)
|
| 3 |
+
|
| 4 |
+
New in this version:
|
| 5 |
+
- POST /clone β clone a GitHub repo by URL, index it, return repo_id
|
| 6 |
+
- GET /repo β list all active indexed sessions
|
| 7 |
+
- GET /search β BM25 keyword search over symbol names in a repo
|
| 8 |
+
- GET /resolve β resolve a plain function name to full symbol IDs
|
| 9 |
+
- GET /blast_file β file-level blast radius (all symbols in a file)
|
| 10 |
+
- SQLite-backed session store (survives in-container restarts)
|
| 11 |
"""
|
| 12 |
|
| 13 |
import ast
|
| 14 |
import json
|
| 15 |
import os
|
| 16 |
+
import re
|
| 17 |
+
import sqlite3
|
| 18 |
+
import subprocess
|
| 19 |
import tempfile
|
| 20 |
+
import time
|
| 21 |
+
import uuid
|
| 22 |
import zipfile
|
| 23 |
import shutil
|
| 24 |
+
from datetime import datetime
|
| 25 |
from pathlib import Path
|
| 26 |
+
from typing import Any, Dict, List, Optional
|
| 27 |
|
| 28 |
+
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
| 29 |
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
from fastapi.responses import JSONResponse
|
| 31 |
from pydantic import BaseModel
|
| 32 |
|
| 33 |
|
| 34 |
+
# ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
app = FastAPI(
|
| 37 |
title="DiffContext API",
|
| 38 |
+
description="Repository intelligence: blast radius, symbol search, call graphs β for AI coding agents.",
|
| 39 |
+
version="2.0.0",
|
| 40 |
)
|
| 41 |
|
| 42 |
app.add_middleware(
|
|
|
|
| 46 |
allow_headers=["*"],
|
| 47 |
)
|
| 48 |
|
| 49 |
+
|
| 50 |
+
# ββ SQLite session store ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
|
| 52 |
+
DB_PATH = os.environ.get("DIFFCONTEXT_DB", "/tmp/diffcontext_sessions.db")
|
| 53 |
+
|
| 54 |
+
def _db() -> sqlite3.Connection:
|
| 55 |
+
conn = sqlite3.connect(DB_PATH)
|
| 56 |
+
conn.row_factory = sqlite3.Row
|
| 57 |
+
conn.execute("""
|
| 58 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 59 |
+
repo_id TEXT PRIMARY KEY,
|
| 60 |
+
repo_path TEXT NOT NULL,
|
| 61 |
+
name TEXT,
|
| 62 |
+
git_url TEXT,
|
| 63 |
+
created_at TEXT
|
| 64 |
+
)
|
| 65 |
+
""")
|
| 66 |
+
conn.commit()
|
| 67 |
+
return conn
|
| 68 |
+
|
| 69 |
+
def _session_save(repo_id: str, repo_path: str, name: str, git_url: str = ""):
|
| 70 |
+
with _db() as conn:
|
| 71 |
+
conn.execute(
|
| 72 |
+
"INSERT OR REPLACE INTO sessions VALUES (?,?,?,?,?)",
|
| 73 |
+
(repo_id, repo_path, name, git_url, datetime.utcnow().isoformat())
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def _session_get(repo_id: str) -> Optional[dict]:
|
| 77 |
+
with _db() as conn:
|
| 78 |
+
row = conn.execute("SELECT * FROM sessions WHERE repo_id=?", (repo_id,)).fetchone()
|
| 79 |
+
if row:
|
| 80 |
+
return dict(row)
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
def _session_list() -> List[dict]:
|
| 84 |
+
with _db() as conn:
|
| 85 |
+
rows = conn.execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
|
| 86 |
+
return [dict(r) for r in rows if os.path.isdir(r["repo_path"])]
|
| 87 |
+
|
| 88 |
+
def _session_path(repo_id: str) -> str:
|
| 89 |
+
"""Return repo_path or raise 404."""
|
| 90 |
+
session = _session_get(repo_id)
|
| 91 |
+
if not session or not os.path.isdir(session["repo_path"]):
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
404,
|
| 94 |
+
f"repo_id '{repo_id}' not found. "
|
| 95 |
+
"Use POST /upload (zip) or POST /clone (GitHub URL) first."
|
| 96 |
+
)
|
| 97 |
+
return session["repo_path"]
|
| 98 |
+
|
| 99 |
+
# Initialise DB on startup
|
| 100 |
+
_db()
|
| 101 |
|
| 102 |
|
| 103 |
# ββ Pydantic models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
|
| 105 |
+
class FileItem(BaseModel):
|
| 106 |
+
filename: str
|
| 107 |
+
content: str
|
| 108 |
+
|
| 109 |
+
class InlineRequest(BaseModel):
|
| 110 |
+
"""Paste code directly β no upload needed."""
|
| 111 |
+
files: List[FileItem]
|
| 112 |
+
symbol: str
|
| 113 |
+
|
| 114 |
class BlastRequest(BaseModel):
|
| 115 |
repo_id: str
|
| 116 |
+
symbol: str
|
| 117 |
|
| 118 |
class CompileRequest(BaseModel):
|
| 119 |
repo_id: str
|
| 120 |
symbol: str
|
| 121 |
max_tokens: int = 8000
|
| 122 |
|
| 123 |
+
class CloneRequest(BaseModel):
|
| 124 |
+
"""Clone a GitHub (or any public git) repository by URL."""
|
| 125 |
+
git_url: str
|
| 126 |
+
name: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
class HealthResponse(BaseModel):
|
| 129 |
status: str
|
|
|
|
| 134 |
endpoints: List[str]
|
| 135 |
|
| 136 |
|
| 137 |
+
# βοΏ½οΏ½οΏ½ DiffContext pipeline helpers ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
|
| 139 |
+
def _try_import_dc():
|
| 140 |
+
import sys
|
| 141 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 142 |
+
from diffcontext.pipeline import index_repository, analyze_impact
|
| 143 |
+
from diffcontext.pipeline import compile as dc_compile
|
| 144 |
+
return index_repository, analyze_impact, dc_compile
|
| 145 |
|
| 146 |
+
def _list_symbols(repo_path: str) -> List[str]:
|
| 147 |
+
symbols = []
|
| 148 |
+
for py_file in Path(repo_path).rglob("*.py"):
|
| 149 |
+
rel = "./" + str(py_file.relative_to(repo_path))
|
| 150 |
+
try:
|
| 151 |
+
source = py_file.read_text(errors="ignore")
|
| 152 |
+
tree = ast.parse(source)
|
| 153 |
+
except SyntaxError:
|
| 154 |
+
continue
|
| 155 |
+
for node in ast.walk(tree):
|
| 156 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 157 |
+
symbols.append(f"{rel}:{node.name}")
|
| 158 |
+
return sorted(symbols)
|
| 159 |
|
| 160 |
+
def _run_diffcontext(repo_path: str, symbol: str) -> dict:
|
| 161 |
+
try:
|
| 162 |
+
index_repository, analyze_impact, _ = _try_import_dc()
|
| 163 |
idx = index_repository(repo_path)
|
| 164 |
if symbol not in idx.symbols and symbol not in idx.graph:
|
|
|
|
| 165 |
import difflib
|
| 166 |
suggestions = difflib.get_close_matches(symbol, list(idx.symbols.keys()), n=3, cutoff=0.4)
|
| 167 |
+
return {"error": f"Symbol '{symbol}' not found.", "suggestions": suggestions}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
impact = analyze_impact(idx, [symbol])
|
|
|
|
|
|
|
| 170 |
reverse: Dict[str, List[str]] = {}
|
| 171 |
for caller, callees in idx.graph.items():
|
| 172 |
for callee in callees:
|
| 173 |
reverse.setdefault(callee, []).append(caller)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
by_file: Dict[str, List[str]] = {}
|
| 176 |
for sym in impact.blast_radius:
|
| 177 |
+
fp = sym.split(":")[0]
|
| 178 |
+
by_file.setdefault(fp, []).append(sym.split(":")[-1])
|
| 179 |
|
| 180 |
return {
|
| 181 |
"symbol": symbol,
|
| 182 |
+
"direct_callers": reverse.get(symbol, []),
|
| 183 |
+
"direct_callees": idx.graph.get(symbol, []),
|
| 184 |
"blast_radius_count": len(impact.blast_radius),
|
| 185 |
"blast_radius_by_file": by_file,
|
| 186 |
"all_affected": impact.blast_radius[:50],
|
| 187 |
"scores": {k: round(v, 1) for k, v in list(impact.scores.items())[:30]},
|
| 188 |
"total_symbols_in_repo": len(idx.symbols),
|
| 189 |
}
|
|
|
|
| 190 |
except ImportError:
|
|
|
|
| 191 |
return _ast_fallback(repo_path, symbol)
|
| 192 |
|
|
|
|
| 193 |
def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
| 194 |
+
all_functions: Dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
call_graph: Dict[str, List[str]] = {}
|
| 196 |
|
| 197 |
for py_file in Path(repo_path).rglob("*.py"):
|
|
|
|
| 201 |
tree = ast.parse(source)
|
| 202 |
except SyntaxError:
|
| 203 |
continue
|
|
|
|
| 204 |
for node in ast.walk(tree):
|
| 205 |
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 206 |
fid = f"{rel}:{node.name}"
|
| 207 |
+
all_functions[fid] = ""
|
|
|
|
| 208 |
calls = []
|
| 209 |
for child in ast.walk(node):
|
| 210 |
if isinstance(child, ast.Call):
|
|
|
|
| 214 |
calls.append(child.func.attr)
|
| 215 |
call_graph[fid] = calls
|
| 216 |
|
|
|
|
| 217 |
name_to_ids: Dict[str, List[str]] = {}
|
| 218 |
for fid in all_functions:
|
| 219 |
+
name_to_ids.setdefault(fid.split(":")[-1], []).append(fid)
|
| 220 |
+
|
| 221 |
+
resolved: Dict[str, List[str]] = {}
|
| 222 |
+
for fid, raw in call_graph.items():
|
| 223 |
+
resolved[fid] = [c for name in raw for c in name_to_ids.get(name, []) if c != fid]
|
| 224 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
reverse: Dict[str, List[str]] = {}
|
| 226 |
+
for caller, callees in resolved.items():
|
| 227 |
for callee in callees:
|
| 228 |
reverse.setdefault(callee, []).append(caller)
|
| 229 |
|
| 230 |
if symbol not in all_functions:
|
| 231 |
import difflib
|
| 232 |
suggestions = difflib.get_close_matches(symbol, list(all_functions.keys()), n=3, cutoff=0.4)
|
| 233 |
+
return {"error": f"Symbol '{symbol}' not found.", "suggestions": suggestions,
|
| 234 |
+
"all_symbols": list(all_functions.keys())}
|
|
|
|
|
|
|
|
|
|
| 235 |
|
|
|
|
| 236 |
visited = {symbol}
|
| 237 |
+
blast, frontier = [], [symbol]
|
|
|
|
| 238 |
while frontier:
|
| 239 |
+
nxt = []
|
| 240 |
for node in frontier:
|
| 241 |
for caller in reverse.get(node, []):
|
| 242 |
if caller not in visited:
|
| 243 |
visited.add(caller)
|
| 244 |
blast.append(caller)
|
| 245 |
+
nxt.append(caller)
|
| 246 |
+
frontier = nxt
|
| 247 |
|
| 248 |
by_file: Dict[str, List[str]] = {}
|
| 249 |
for sym in blast:
|
|
|
|
| 253 |
return {
|
| 254 |
"symbol": symbol,
|
| 255 |
"direct_callers": reverse.get(symbol, []),
|
| 256 |
+
"direct_callees": resolved.get(symbol, []),
|
| 257 |
"blast_radius_count": len(blast),
|
| 258 |
"blast_radius_by_file": by_file,
|
| 259 |
"all_affected": blast[:50],
|
| 260 |
"scores": {},
|
| 261 |
"total_symbols_in_repo": len(all_functions),
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
|
| 265 |
+
# ββ BM25 keyword search helper ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
|
| 267 |
+
def _bm25_search(repo_path: str, query: str, top_k: int = 20) -> List[str]:
|
| 268 |
+
try:
|
| 269 |
+
from rank_bm25 import BM25Okapi
|
| 270 |
+
except ImportError:
|
| 271 |
+
# Fallback: simple substring match
|
| 272 |
+
symbols = _list_symbols(repo_path)
|
| 273 |
+
q = query.lower()
|
| 274 |
+
return [s for s in symbols if q in s.lower()][:top_k]
|
| 275 |
+
|
| 276 |
+
symbols = _list_symbols(repo_path)
|
| 277 |
+
if not symbols:
|
| 278 |
+
return []
|
| 279 |
+
|
| 280 |
+
# Tokenise each symbol ID into words
|
| 281 |
+
def tokenise(s: str) -> List[str]:
|
| 282 |
+
# Split on / : . _ uppercase boundaries
|
| 283 |
+
return re.split(r"[/:\._]|(?<=[a-z])(?=[A-Z])", s.lower())
|
| 284 |
+
|
| 285 |
+
corpus = [tokenise(s) for s in symbols]
|
| 286 |
+
bm25 = BM25Okapi(corpus)
|
| 287 |
+
query_tokens = tokenise(query)
|
| 288 |
+
scores = bm25.get_scores(query_tokens)
|
| 289 |
+
ranked = sorted(zip(scores, symbols), reverse=True)
|
| 290 |
+
return [sym for score, sym in ranked if score > 0][:top_k]
|
| 291 |
|
| 292 |
|
| 293 |
# ββ Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 295 |
@app.get("/", response_model=RootResponse)
|
| 296 |
def root():
|
| 297 |
return {
|
| 298 |
+
"service": "DiffContext API v2",
|
| 299 |
"docs": "/docs",
|
| 300 |
"endpoints": [
|
| 301 |
+
"POST /upload β upload a .zip of your project",
|
| 302 |
+
"POST /clone β clone a GitHub repo by URL",
|
| 303 |
+
"GET /repo β list all active indexed sessions",
|
| 304 |
+
"POST /inline β paste code directly (no upload needed)",
|
| 305 |
+
"GET /search β BM25 keyword search over symbols",
|
| 306 |
+
"GET /resolve β resolve a plain function name to symbol IDs",
|
| 307 |
+
"GET /blast_file β file-level blast radius",
|
| 308 |
+
"POST /blast β get blast radius for a function",
|
| 309 |
+
"GET /symbols β list all functions in uploaded repo",
|
| 310 |
+
"POST /compile β get LLM-ready context string",
|
| 311 |
+
"GET /health β service health check",
|
| 312 |
],
|
| 313 |
}
|
| 314 |
|
|
|
|
| 318 |
return {"status": "ok"}
|
| 319 |
|
| 320 |
|
| 321 |
+
@app.get("/repo")
|
| 322 |
+
def list_repos():
|
| 323 |
+
"""List all active indexed repository sessions."""
|
| 324 |
+
sessions = _session_list()
|
| 325 |
+
return {"count": len(sessions), "repos": sessions}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@app.post("/clone")
|
| 329 |
+
def clone_repo(req: CloneRequest):
|
| 330 |
+
"""
|
| 331 |
+
Clone a public GitHub (or any public git) repository by URL,
|
| 332 |
+
index it, and return a repo_id for use in all other endpoints.
|
| 333 |
+
|
| 334 |
+
Example body:
|
| 335 |
+
{
|
| 336 |
+
"git_url": "https://github.com/pallets/flask.git",
|
| 337 |
+
"name": "flask"
|
| 338 |
+
}
|
| 339 |
+
"""
|
| 340 |
+
git_url = req.git_url.strip()
|
| 341 |
+
if not git_url.startswith(("https://", "http://", "git@")):
|
| 342 |
+
raise HTTPException(400, "Invalid git URL. Must start with https:// or git@")
|
| 343 |
+
|
| 344 |
+
name = req.name or git_url.rstrip("/").split("/")[-1].replace(".git", "")
|
| 345 |
+
tmp_dir = tempfile.mkdtemp(prefix="diffctx_clone_")
|
| 346 |
+
repo_path = os.path.join(tmp_dir, name)
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
result = subprocess.run(
|
| 350 |
+
["git", "clone", "--depth=1", git_url, repo_path],
|
| 351 |
+
capture_output=True, text=True, timeout=120
|
| 352 |
+
)
|
| 353 |
+
if result.returncode != 0:
|
| 354 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 355 |
+
raise HTTPException(400, f"git clone failed: {result.stderr.strip()}")
|
| 356 |
+
except subprocess.TimeoutExpired:
|
| 357 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 358 |
+
raise HTTPException(408, "git clone timed out (120s). Try a smaller repo.")
|
| 359 |
+
|
| 360 |
+
py_files = list(Path(repo_path).rglob("*.py"))
|
| 361 |
+
if not py_files:
|
| 362 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 363 |
+
raise HTTPException(400, "No Python files found in repository.")
|
| 364 |
+
|
| 365 |
+
repo_id = "repo_" + str(uuid.uuid4())[:8]
|
| 366 |
+
_session_save(repo_id, repo_path, name, git_url)
|
| 367 |
+
symbols = _list_symbols(repo_path)
|
| 368 |
+
|
| 369 |
+
return {
|
| 370 |
+
"repo_id": repo_id,
|
| 371 |
+
"name": name,
|
| 372 |
+
"git_url": git_url,
|
| 373 |
+
"message": f"Cloned and indexed '{name}'. Found {len(symbols)} functions.",
|
| 374 |
+
"symbol_count": len(symbols),
|
| 375 |
+
"sample_symbols": symbols[:10],
|
| 376 |
+
"next_steps": [
|
| 377 |
+
f"GET /search?repo_id={repo_id}&query=<keyword>",
|
| 378 |
+
f"GET /resolve?repo_id={repo_id}&name=<function_name>",
|
| 379 |
+
f"POST /blast with {{\"repo_id\": \"{repo_id}\", \"symbol\": \"<symbol_id>\"}}",
|
| 380 |
+
],
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
|
| 384 |
@app.post("/upload")
|
| 385 |
async def upload_repo(file: UploadFile = File(...)):
|
|
|
|
| 390 |
if not file.filename.endswith(".zip"):
|
| 391 |
raise HTTPException(400, "Please upload a .zip file of your project folder.")
|
| 392 |
|
|
|
|
| 393 |
tmp_dir = tempfile.mkdtemp(prefix="diffctx_")
|
| 394 |
zip_path = os.path.join(tmp_dir, "repo.zip")
|
| 395 |
extract_path = os.path.join(tmp_dir, "repo")
|
|
|
|
| 398 |
with open(zip_path, "wb") as f:
|
| 399 |
f.write(content)
|
| 400 |
|
|
|
|
| 401 |
try:
|
| 402 |
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 403 |
zf.extractall(extract_path)
|
| 404 |
except zipfile.BadZipFile:
|
| 405 |
shutil.rmtree(tmp_dir)
|
| 406 |
+
raise HTTPException(400, "Could not open zip file.")
|
| 407 |
|
|
|
|
| 408 |
py_files = list(Path(extract_path).rglob("*.py"))
|
| 409 |
if not py_files:
|
| 410 |
shutil.rmtree(tmp_dir)
|
| 411 |
raise HTTPException(400, "No Python (.py) files found in your zip.")
|
| 412 |
|
|
|
|
| 413 |
repo_root = str(min(py_files, key=lambda p: len(p.parts)).parent)
|
| 414 |
+
repo_id = "repo_" + str(uuid.uuid4())[:8]
|
| 415 |
+
name = Path(file.filename).stem
|
| 416 |
+
_session_save(repo_id, repo_root, name)
|
|
|
|
|
|
|
|
|
|
| 417 |
symbols = _list_symbols(repo_root)
|
| 418 |
|
| 419 |
return {
|
| 420 |
"repo_id": repo_id,
|
| 421 |
+
"name": name,
|
| 422 |
"message": f"Uploaded successfully! Found {len(symbols)} functions.",
|
| 423 |
"symbol_count": len(symbols),
|
| 424 |
"sample_symbols": symbols[:10],
|
| 425 |
+
"next_step": f'GET /search?repo_id={repo_id}&query=<keyword>',
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
@app.get("/search")
|
| 430 |
+
def search_symbols(
|
| 431 |
+
repo_id: str = Query(..., description="The repo_id from /upload or /clone"),
|
| 432 |
+
query: str = Query(..., description="Keyword to search for (e.g. 'authentication', 'cache', 'jwt')"),
|
| 433 |
+
top_k: int = Query(20, description="Max results to return"),
|
| 434 |
+
):
|
| 435 |
+
"""
|
| 436 |
+
BM25 keyword search over all symbol names and file paths in the repository.
|
| 437 |
+
Use this to find functions related to a concept without knowing the exact name.
|
| 438 |
+
"""
|
| 439 |
+
repo_path = _session_path(repo_id)
|
| 440 |
+
results = _bm25_search(repo_path, query, top_k)
|
| 441 |
+
return {
|
| 442 |
+
"repo_id": repo_id,
|
| 443 |
+
"query": query,
|
| 444 |
+
"count": len(results),
|
| 445 |
+
"results": results,
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
@app.get("/resolve")
|
| 450 |
+
def resolve_symbol(
|
| 451 |
+
repo_id: str = Query(..., description="The repo_id from /upload or /clone"),
|
| 452 |
+
name: str = Query(..., description="Plain function name to resolve, e.g. 'create_user'"),
|
| 453 |
+
):
|
| 454 |
+
"""
|
| 455 |
+
Resolve a plain function name to all matching full symbol IDs.
|
| 456 |
+
Use this before calling /blast when you know the function name but not the file.
|
| 457 |
+
Returns all matches, e.g. ['./users.py:create_user', './service.py:create_user'].
|
| 458 |
+
"""
|
| 459 |
+
repo_path = _session_path(repo_id)
|
| 460 |
+
symbols = _list_symbols(repo_path)
|
| 461 |
+
# Match by exact function name (last part after the colon)
|
| 462 |
+
exact = [s for s in symbols if s.split(":")[-1] == name]
|
| 463 |
+
# Fuzzy match if no exact hits
|
| 464 |
+
if not exact:
|
| 465 |
+
import difflib
|
| 466 |
+
exact = difflib.get_close_matches(name, [s.split(":")[-1] for s in symbols], n=5, cutoff=0.6)
|
| 467 |
+
exact = [s for s in symbols if s.split(":")[-1] in exact]
|
| 468 |
+
return {
|
| 469 |
+
"repo_id": repo_id,
|
| 470 |
+
"name": name,
|
| 471 |
+
"match_count": len(exact),
|
| 472 |
+
"matches": exact,
|
| 473 |
+
"tip": "Use a match from 'matches' as the 'symbol' in POST /blast",
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
@app.get("/blast_file")
|
| 478 |
+
def blast_radius_file(
|
| 479 |
+
repo_id: str = Query(..., description="The repo_id from /upload or /clone"),
|
| 480 |
+
file: str = Query(..., description="Relative file path, e.g. './auth.py' or 'src/auth.py'"),
|
| 481 |
+
):
|
| 482 |
+
"""
|
| 483 |
+
Get the aggregated blast radius for all public symbols in a given file.
|
| 484 |
+
Use this when the user asks 'what breaks if I change auth.py?' or 'show imports of auth.py'.
|
| 485 |
+
"""
|
| 486 |
+
repo_path = _session_path(repo_id)
|
| 487 |
+
# Normalise file path
|
| 488 |
+
if not file.startswith("./"):
|
| 489 |
+
file = "./" + file.lstrip("/")
|
| 490 |
+
|
| 491 |
+
all_symbols = _list_symbols(repo_path)
|
| 492 |
+
file_symbols = [s for s in all_symbols if s.startswith(file + ":")]
|
| 493 |
+
|
| 494 |
+
if not file_symbols:
|
| 495 |
+
raise HTTPException(404, f"No symbols found in '{file}'. Check the file path.")
|
| 496 |
+
|
| 497 |
+
affected_modules = set()
|
| 498 |
+
affected_symbols = []
|
| 499 |
+
|
| 500 |
+
for symbol in file_symbols:
|
| 501 |
+
result = _run_diffcontext(repo_path, symbol)
|
| 502 |
+
if "error" not in result:
|
| 503 |
+
for f in result.get("blast_radius_by_file", {}).keys():
|
| 504 |
+
if f != file:
|
| 505 |
+
affected_modules.add(f)
|
| 506 |
+
affected_symbols.extend(result.get("all_affected", []))
|
| 507 |
+
|
| 508 |
+
affected_symbols = list(set(affected_symbols))
|
| 509 |
+
|
| 510 |
+
return {
|
| 511 |
+
"repo_id": repo_id,
|
| 512 |
+
"file": file,
|
| 513 |
+
"public_symbols": [s.split(":")[-1] for s in file_symbols],
|
| 514 |
+
"symbol_count": len(file_symbols),
|
| 515 |
+
"affected_modules": sorted(affected_modules),
|
| 516 |
+
"affected_module_count": len(affected_modules),
|
| 517 |
+
"affected_symbol_count": len(affected_symbols),
|
| 518 |
+
"all_affected": affected_symbols[:50],
|
| 519 |
}
|
| 520 |
|
| 521 |
|
| 522 |
@app.post("/inline")
|
| 523 |
def analyze_inline(req: InlineRequest):
|
| 524 |
"""
|
| 525 |
+
Paste code directly β no upload needed. Great for quick analysis of snippets.
|
|
|
|
| 526 |
|
| 527 |
Example body:
|
| 528 |
{
|
| 529 |
+
"files": [
|
| 530 |
+
{"filename": "service.py", "content": "def create_user(name): pass\\ndef onboard(name): create_user(name)"}
|
| 531 |
+
],
|
| 532 |
"symbol": "./service.py:onboard"
|
| 533 |
}
|
| 534 |
"""
|
| 535 |
tmp_dir = tempfile.mkdtemp(prefix="diffctx_inline_")
|
| 536 |
try:
|
|
|
|
| 537 |
for f in req.files:
|
| 538 |
+
safe_name = Path(f.filename).name
|
| 539 |
+
with open(os.path.join(tmp_dir, safe_name), "w") as fp:
|
| 540 |
+
fp.write(f.content)
|
| 541 |
+
return _run_diffcontext(tmp_dir, req.symbol)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
finally:
|
| 543 |
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 544 |
|
| 545 |
|
| 546 |
@app.get("/symbols")
|
| 547 |
+
def list_symbols(repo_id: str = Query(..., description="The repo_id from /upload or /clone")):
|
| 548 |
+
"""List all functions in your indexed repo."""
|
| 549 |
+
repo_path = _session_path(repo_id)
|
|
|
|
|
|
|
| 550 |
symbols = _list_symbols(repo_path)
|
| 551 |
+
return {"repo_id": repo_id, "count": len(symbols), "symbols": symbols}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
|
| 553 |
|
| 554 |
@app.post("/blast")
|
| 555 |
def blast_radius(req: BlastRequest):
|
| 556 |
"""
|
| 557 |
+
Get the blast radius for a specific function symbol.
|
| 558 |
+
Use /resolve first if you only know the function name (not the file path).
|
| 559 |
+
Symbol format: './relative/path.py:function_name'
|
| 560 |
"""
|
| 561 |
+
repo_path = _session_path(req.repo_id)
|
| 562 |
+
return _run_diffcontext(repo_path, req.symbol)
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
|
| 565 |
@app.post("/compile")
|
| 566 |
def compile_context(req: CompileRequest):
|
| 567 |
+
"""Get an LLM-ready context string optimised for token budget."""
|
| 568 |
+
repo_path = _session_path(req.repo_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
try:
|
| 570 |
+
index_repository, analyze_impact, dc_compile = _try_import_dc()
|
|
|
|
|
|
|
|
|
|
| 571 |
idx = index_repository(repo_path)
|
| 572 |
impact = analyze_impact(idx, [req.symbol])
|
| 573 |
ctx = dc_compile(idx, impact, max_tokens=req.max_tokens)
|
|
|
|
| 574 |
return {
|
| 575 |
"context_text": ctx.text,
|
| 576 |
"symbol_count": ctx.symbol_count,
|
|
|
|
| 579 |
"usage_tip": "Paste context_text into Claude/ChatGPT with a specific question about your change.",
|
| 580 |
}
|
| 581 |
except ImportError:
|
|
|
|
| 582 |
result = _run_diffcontext(repo_path, req.symbol)
|
| 583 |
+
context_text = f"""=== DIFFCONTEXT ANALYSIS ===
|
|
|
|
| 584 |
Symbol: {req.symbol}
|
| 585 |
+
Direct callers: {', '.join(result.get('direct_callers', [])) or 'none'}
|
| 586 |
+
Direct callees: {', '.join(result.get('direct_callees', [])) or 'none'}
|
| 587 |
+
Blast radius ({result.get('blast_radius_count', 0)} functions):
|
| 588 |
+
{chr(10).join(f' - {s}' for s in result.get('all_affected', [])[:20]) or 'none'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
"""
|
| 590 |
return {
|
| 591 |
"context_text": context_text,
|
|
|
|
| 604 |
|
| 605 |
if HAS_MCP and mcp_server is not None:
|
| 606 |
sse = SseServerTransport("/messages/")
|
|
|
|
|
|
|
| 607 |
app.router.routes.append(Mount("/messages", app=sse.handle_post_message))
|
| 608 |
|
| 609 |
@app.get("/sse")
|
| 610 |
async def handle_sse(request: Request):
|
| 611 |
async with sse.connect_sse(
|
| 612 |
+
request.scope, request.receive, request._send
|
|
|
|
|
|
|
| 613 |
) as (read_stream, write_stream):
|
| 614 |
await mcp_server.run(
|
| 615 |
+
read_stream, write_stream,
|
|
|
|
| 616 |
mcp_server.create_initialization_options()
|
| 617 |
)
|
| 618 |
except ImportError:
|
| 619 |
pass
|
| 620 |
|
| 621 |
|
| 622 |
+
# ββ Custom OpenAPI schema (ChatGPT Actions) βββββββββββββββββββββββββββββββββββ
|
| 623 |
from fastapi.openapi.utils import get_openapi
|
| 624 |
|
| 625 |
def custom_openapi():
|
|
|
|
| 628 |
|
| 629 |
schema = get_openapi(
|
| 630 |
title="DiffContext API",
|
| 631 |
+
version="2.0.0",
|
| 632 |
+
description="Repository intelligence: blast radius, symbol search, call graphs β for AI coding agents.",
|
| 633 |
routes=app.routes,
|
| 634 |
)
|
| 635 |
|
| 636 |
+
schema["servers"] = [{"url": "https://trakshan-diffcontext.hf.space"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
|
| 638 |
+
# Hide SSE/messages from GPT Actions schema
|
| 639 |
paths = schema.get("paths", {})
|
| 640 |
+
for p in [k for k in paths if k.startswith("/sse") or k.startswith("/messages")]:
|
|
|
|
| 641 |
paths.pop(p, None)
|
| 642 |
|
| 643 |
app.openapi_schema = schema
|
| 644 |
return schema
|
| 645 |
|
| 646 |
app.openapi = custom_openapi
|
|
|
|
|
|