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
Files changed (1) hide show
  1. 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
- Exposes DiffContext as a REST API so anyone can use it online.
 
 
 
 
 
 
 
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, UploadFile, HTTPException
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="Analyse your Python code's blast radius β€” which functions break when you change one?",
24
- version="1.0.0",
25
  )
26
 
27
  app.add_middleware(
@@ -31,29 +46,84 @@ app.add_middleware(
31
  allow_headers=["*"],
32
  )
33
 
34
- # ── In-memory session store (repo_id -> temp dir path) ──────────────────────
35
- SESSIONS: Dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  # ── Pydantic models ──────────────────────────────────────────────────────────
39
 
 
 
 
 
 
 
 
 
 
40
  class BlastRequest(BaseModel):
41
  repo_id: str
42
- symbol: str # e.g. "./service.py:onboard_user"
43
 
44
  class CompileRequest(BaseModel):
45
  repo_id: str
46
  symbol: str
47
  max_tokens: int = 8000
48
 
49
- class FileItem(BaseModel):
50
- filename: str
51
- content: str
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
- # ── Helpers ─────────────────────────────────────────────────────────────────
 
 
 
 
 
69
 
70
- def _run_diffcontext(repo_path: str, symbol: str):
71
- """
72
- Run the DiffContext pipeline on repo_path for one symbol.
73
- Returns a plain dict so it's JSON-serialisable.
74
- Falls back to a lightweight AST-only analysis if DiffContext
75
- isn't installed (so the service still works standalone).
76
- """
77
- try:
78
- # Try real DiffContext first
79
- import sys
80
- sys.path.insert(0, str(Path(__file__).parent.parent.parent))
81
- from diffcontext.pipeline import index_repository, analyze_impact
 
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
- file_part = sym.split(":")[0]
109
- by_file.setdefault(file_part, []).append(sym.split(":")[-1])
110
 
111
  return {
112
  "symbol": symbol,
113
- "direct_callers": direct_callers,
114
- "direct_callees": 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] = ast.get_source_segment(source, node) or ""
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
- name = fid.split(":")[-1]
167
- name_to_ids.setdefault(name, []).append(fid)
168
-
169
- resolved_graph: Dict[str, List[str]] = {}
170
- for fid, raw_calls in call_graph.items():
171
- resolved = []
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 resolved_graph.items():
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
- "error": f"Symbol '{symbol}' not found.",
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
- next_f = []
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
- next_f.append(caller)
205
- frontier = next_f
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": resolved_graph.get(symbol, []),
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
- def _list_symbols(repo_path: str) -> List[str]:
226
- """Return all function symbol IDs in the repo."""
227
- symbols = []
228
- for py_file in Path(repo_path).rglob("*.py"):
229
- rel = "./" + str(py_file.relative_to(repo_path))
230
- try:
231
- source = py_file.read_text(errors="ignore")
232
- tree = ast.parse(source)
233
- except SyntaxError:
234
- continue
235
- for node in ast.walk(tree):
236
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
237
- symbols.append(f"{rel}:{node.name}")
238
- return sorted(symbols)
 
 
 
 
 
 
 
 
 
 
 
 
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 β€” upload a .zip of your project",
250
- "POST /inline β€” paste code directly (no upload needed)",
251
- "POST /blast β€” get blast radius for a function",
252
- "GET /symbols β€” list all functions in uploaded repo",
253
- "POST /compile β€” get LLM-ready context string",
254
- "GET /health β€” service health check",
 
 
 
 
 
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. Make sure it's a valid .zip.")
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
- # Store session
301
- import uuid
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'Now call POST /blast with {{"repo_id": "{repo_id}", "symbol": "<pick from symbols above>"}}',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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): ...\\ndef onboard(name): 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
- files_dict[f.filename] = f.content
335
-
336
- for filename, code in files_dict.items():
337
- # Safety: strip any path traversal
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 uploaded repo."""
352
- if repo_id not in SESSIONS:
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
- Tells you: who calls it, what it calls, what breaks if you change it.
 
368
  """
369
- if req.repo_id not in SESSIONS:
370
- raise HTTPException(404, f"repo_id '{req.repo_id}' not found. Upload your project first.")
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
- Get an LLM-ready context string you can paste into Claude or ChatGPT.
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
- import sys
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 callers (will break if signature changes):
410
- {chr(10).join(f' - {c}' for c in result.get('direct_callers', [])) or ' (none found)'}
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 for ChatGPT Actions ────────────────────────────────
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="1.0.0",
465
- description="Analyse your Python code's blast radius β€” which functions break when you change one?",
466
  routes=app.routes,
467
  )
468
 
469
- # Force the mandatory servers URL for GPT Actions
470
- schema["servers"] = [
471
- {
472
- "url": "https://trakshan-diffcontext.hf.space"
473
- }
474
- ]
475
 
476
- # Exclude SSE / message endpoints from GPT Actions schema
477
  paths = schema.get("paths", {})
478
- paths_to_remove = [p for p in paths if p.startswith("/sse") or p.startswith("/messages")]
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