fix graphs
This commit is contained in:
+157
-70
@@ -41,7 +41,9 @@ OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
|||||||
os.environ["OLLAMA_HOST"] = OLLAMA_BASE_URL
|
os.environ["OLLAMA_HOST"] = OLLAMA_BASE_URL
|
||||||
os.environ["OLLAMA_API_BASE"] = OLLAMA_BASE_URL
|
os.environ["OLLAMA_API_BASE"] = OLLAMA_BASE_URL
|
||||||
|
|
||||||
CODEBASE_PATH = Path(os.environ.get("CODEBASE_PATH", ".")) # change as needed
|
CODEBASE_PATH = Path("./working_repo")
|
||||||
|
CODEBASE_PATH.mkdir(exist_ok=True)
|
||||||
|
|
||||||
VECTOR_DB_PATH = Path(os.environ.get("VECTOR_DB_PATH", "./chroma_db"))
|
VECTOR_DB_PATH = Path(os.environ.get("VECTOR_DB_PATH", "./chroma_db"))
|
||||||
BM25_INDEX_PATH = Path(os.environ.get("BM25_INDEX_PATH", "./bm25_index.json"))
|
BM25_INDEX_PATH = Path(os.environ.get("BM25_INDEX_PATH", "./bm25_index.json"))
|
||||||
RAGIGNORE_PATH = CODEBASE_PATH / ".ragignore"
|
RAGIGNORE_PATH = CODEBASE_PATH / ".ragignore"
|
||||||
@@ -3006,28 +3008,45 @@ def find_brace_block_end(lines: List[str], start_idx: int) -> Optional[int]:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def health_check() -> str:
|
def health_check() -> str:
|
||||||
"""What it does - Quick JSON status of the RAG server (ready, indexed path, chunk count, age).
|
"""What it does - Quick JSON status of the RAG server (ready, indexed path, chunk count, age, graph stats).
|
||||||
When to use - Before any other query, to confirm the index is current.
|
When to use - Before any other query, to confirm the index is current and see codebase structure.
|
||||||
When not to use - After you already know the server is healthy; it adds no value.
|
When not to use - After you already know the server is healthy; it adds no value.
|
||||||
Example - health_check() → { "status":"ready","total_chunks":11234,"index_age_hours":4.7 }"""
|
Example - health_check() → { "status":"ready","total_chunks":11234,"graph_nodes":4567,"graph_edges":12345,"index_age_hours":4.7 }"""
|
||||||
try:
|
try:
|
||||||
with _startup_lock:
|
with _startup_lock:
|
||||||
|
# Check if we have a git repo in working_repo
|
||||||
|
repo_info = "No repository loaded"
|
||||||
|
if (CODEBASE_PATH / ".git").exists():
|
||||||
|
try:
|
||||||
|
branch = subprocess.check_output(
|
||||||
|
["git", "-C", str(CODEBASE_PATH), "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
text=True
|
||||||
|
).strip()
|
||||||
|
repo_info = f"Loaded: {branch}"
|
||||||
|
except:
|
||||||
|
repo_info = "Git repository (no branch info)"
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
"status": "ready" if vectorstore is not None else "initializing",
|
"status": "ready" if vectorstore is not None else "no_index",
|
||||||
"codebase": str(CODEBASE_PATH),
|
"working_repository": str(CODEBASE_PATH),
|
||||||
|
"repo_status": repo_info,
|
||||||
"ollama_url": OLLAMA_BASE_URL,
|
"ollama_url": OLLAMA_BASE_URL,
|
||||||
"config": {
|
"config": {
|
||||||
"embedding_model": EMBEDDING_MODEL,
|
"embedding_model": EMBEDDING_MODEL,
|
||||||
"vector_weight": VECTOR_WEIGHT,
|
"vector_weight": VECTOR_WEIGHT,
|
||||||
"bm25_weight": BM25_WEIGHT,
|
"bm25_weight": BM25_WEIGHT,
|
||||||
"rerank_enabled": ENABLE_RERANK,
|
"rerank_enabled": ENABLE_RERANK,
|
||||||
"batch_size": EMBEDDING_BATCH_SIZE
|
"batch_size": EMBEDDING_BATCH_SIZE,
|
||||||
|
"graph_enabled": True
|
||||||
},
|
},
|
||||||
"Tools": {
|
"Tools": {
|
||||||
"NOTE": "THESE TOOLS ARE RESTRICTED BY .gitignore AS WELL AS .ragignore",
|
"NOTE": "THESE TOOLS ARE RESTRICTED BY .gitignore AS WELL AS .ragignore",
|
||||||
"search_codebase": search_codebase.__doc__,
|
"search_codebase": "Hybrid RAG search with graph context (cross-file deps, inheritance, calls)",
|
||||||
"find_code_references": find_code_references.__doc__,
|
"find_code_references": "Find exact symbol references across codebase",
|
||||||
"read_file_lines_tool": read_file_lines_tool.__doc__
|
"read_file_lines_tool": "Read specific file lines with syntax highlighting",
|
||||||
|
"find_path": "Find files by glob patterns",
|
||||||
|
"grep": "Search file contents with regex",
|
||||||
|
"list_directory": "List directory contents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3040,10 +3059,81 @@ Example - health_check() → { "status":"ready","total_chunks":11234,"index_age_
|
|||||||
"index_age_hours": round((time.time() - index_build_time) / 3600, 1) if index_build_time > 0 else None
|
"index_age_hours": round((time.time() - index_build_time) / 3600, 1) if index_build_time > 0 else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add graph statistics
|
||||||
|
if graph_loaded:
|
||||||
|
graph_stats = _calculate_graph_stats(graph)
|
||||||
|
status["graph"] = graph_stats
|
||||||
|
else:
|
||||||
|
status["graph"] = {
|
||||||
|
"status": "not_loaded",
|
||||||
|
"nodes": 0,
|
||||||
|
"edges": 0,
|
||||||
|
"message": "Graph will be built on next index rebuild"
|
||||||
|
}
|
||||||
|
|
||||||
return json.dumps(status, indent=2)
|
return json.dumps(status, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, indent=2)
|
return json.dumps({"status": "error", "message": str(e)}, indent=2)
|
||||||
|
|
||||||
|
def _calculate_graph_stats(graph: LocalGraph) -> dict:
|
||||||
|
"""Calculate comprehensive graph statistics."""
|
||||||
|
if graph.graph.number_of_nodes() == 0:
|
||||||
|
return {"status": "empty", "nodes": 0, "edges": 0}
|
||||||
|
|
||||||
|
# Basic counts
|
||||||
|
nodes = graph.graph.number_of_nodes()
|
||||||
|
edges = graph.graph.number_of_edges()
|
||||||
|
|
||||||
|
# Count by node type
|
||||||
|
node_types = {}
|
||||||
|
for _, data in graph.graph.nodes(data=True):
|
||||||
|
node_type = data.get('type', 'unknown')
|
||||||
|
node_types[node_type] = node_types.get(node_type, 0) + 1
|
||||||
|
|
||||||
|
# Count by edge type
|
||||||
|
edge_types = {}
|
||||||
|
for _, _, data in graph.graph.edges(data=True):
|
||||||
|
edge_type = data.get('type', 'unknown')
|
||||||
|
edge_types[edge_type] = edge_types.get(edge_type, 0) + 1
|
||||||
|
|
||||||
|
# Language distribution
|
||||||
|
languages = {}
|
||||||
|
for _, data in graph.graph.nodes(data=True):
|
||||||
|
lang = data.get('language', 'unknown')
|
||||||
|
languages[lang] = languages.get(lang, 0) + 1
|
||||||
|
|
||||||
|
# File statistics
|
||||||
|
files = [n for n, d in graph.graph.nodes(data=True) if d.get('type') == 'File']
|
||||||
|
|
||||||
|
# Relationship density
|
||||||
|
avg_edges_per_node = edges / nodes if nodes > 0 else 0
|
||||||
|
|
||||||
|
# Most connected nodes (hubs)
|
||||||
|
degree_centrality = dict(graph.graph.degree())
|
||||||
|
top_hubs = sorted(degree_centrality.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||||
|
top_hubs_info = []
|
||||||
|
for node_id, degree in top_hubs:
|
||||||
|
node_data = graph.graph.nodes[node_id]
|
||||||
|
top_hubs_info.append({
|
||||||
|
"name": node_data.get('name', node_id),
|
||||||
|
"type": node_data.get('type', 'unknown'),
|
||||||
|
"file": node_data.get('file', ''),
|
||||||
|
"connections": degree
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "loaded",
|
||||||
|
"nodes": nodes,
|
||||||
|
"edges": edges,
|
||||||
|
"node_types": node_types,
|
||||||
|
"edge_types": edge_types,
|
||||||
|
"languages": languages,
|
||||||
|
"files": len(files),
|
||||||
|
"avg_edges_per_node": round(avg_edges_per_node, 2),
|
||||||
|
"top_connected_nodes": top_hubs_info,
|
||||||
|
"relationship_density": "high" if avg_edges_per_node > 2.0 else "medium" if avg_edges_per_node > 1.0 else "low"
|
||||||
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def search_codebase(query: str, top_k: int = 5, rerank: bool = True) -> str:
|
def search_codebase(query: str, top_k: int = 5, rerank: bool = True) -> str:
|
||||||
"""Hybrid RAG search: returns ranked code snippets + file/line + docstrings + graph context (cross-file deps, inheritance, callers/callees, type refs).
|
"""Hybrid RAG search: returns ranked code snippets + file/line + docstrings + graph context (cross-file deps, inheritance, callers/callees, type refs).
|
||||||
@@ -3056,8 +3146,14 @@ rerank=False: fast, 65% accuracy | rerank=True: slower, 95% accuracy
|
|||||||
Output includes: code content, docs, cross_file:[calls/extends/uses@filepath], used_by:[entity@filepath], file:[same-file entities]
|
Output includes: code content, docs, cross_file:[calls/extends/uses@filepath], used_by:[entity@filepath], file:[same-file entities]
|
||||||
|
|
||||||
Example: search_codebase("user authentication", 5, True) → 5 results with code + "cross_file:calls:ValidateToken@auth/jwt.go|used_by:setupAuth@server/routes.go" """
|
Example: search_codebase("user authentication", 5, True) → 5 results with code + "cross_file:calls:ValidateToken@auth/jwt.go|used_by:setupAuth@server/routes.go" """
|
||||||
|
if vectorstore is None or bm25_corpus is None:
|
||||||
|
return "❌ Index not built. Please call init_repo() or rebuild_index() first."
|
||||||
|
|
||||||
graph = LocalGraph(CODEBASE_PATH)
|
graph = LocalGraph(CODEBASE_PATH)
|
||||||
graph.load()
|
graph_loaded = graph.load()
|
||||||
|
|
||||||
|
if not graph_loaded:
|
||||||
|
logger.warning("Graph not loaded - search will proceed without graph context")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hy = hybrid_search(query, k=max(top_k, RERANK_TOP_N))
|
hy = hybrid_search(query, k=max(top_k, RERANK_TOP_N))
|
||||||
@@ -3396,61 +3492,42 @@ Note: This tool treats / as '/home/popertots/Crussell/' so adjust paths accordin
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def init_repo(git_url: str) -> str:
|
def init_repo(git_url: str) -> str:
|
||||||
"""What it does - Initialise the RAG stack with a brand-new Git repository or update an existing one.
|
"""What it does - Initialise the RAG stack with a brand-new Git repository."""
|
||||||
|
|
||||||
The function will:
|
|
||||||
1. Clone the repo if it does not already exist locally,
|
|
||||||
2. Pull the latest changes if it does,
|
|
||||||
3. Reset the global `CODEBASE_PATH` to the local clone,
|
|
||||||
4. Remove any stale index artefacts,
|
|
||||||
5. Re-build the vector/BM25 indexes with the same lock-protected flow used by `rebuild_index`,
|
|
||||||
6. Return a human-readable status message.
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
• Call `health_check` to confirm that the server is ready.
|
|
||||||
• Use `search_codebase` or `find_code_references` to explore the new code.
|
|
||||||
• Use `read_file_lines_tool` to inspect any hit in detail.
|
|
||||||
"""
|
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# ----- 1. Determine where to clone ---------------------------------
|
# ----- 1. Always wipe and recreate working_repo ---------------------
|
||||||
repo_name = git_url.split('/')[-1]
|
if CODEBASE_PATH.exists():
|
||||||
if repo_name.endswith('.git'):
|
shutil.rmtree(CODEBASE_PATH)
|
||||||
repo_name = repo_name[:-4]
|
logger.info(f"Wiped existing working repo: {CODEBASE_PATH}")
|
||||||
clone_dir = Path.cwd() / repo_name
|
|
||||||
|
|
||||||
# ----- 2. Clone / pull -----------------------------------------------
|
CODEBASE_PATH.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# ----- 2. Clone fresh -----------------------------------------------
|
||||||
try:
|
try:
|
||||||
if clone_dir.exists():
|
subprocess.run(
|
||||||
# Existing repo → pull
|
["git", "clone", git_url, str(CODEBASE_PATH)],
|
||||||
subprocess.run(
|
check=True,
|
||||||
["git", "-C", str(clone_dir), "pull"],
|
stdout=subprocess.PIPE,
|
||||||
check=True,
|
stderr=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
text=True,
|
||||||
stderr=subprocess.PIPE,
|
)
|
||||||
text=True,
|
logger.info(f"Cloned {git_url} to {CODEBASE_PATH}")
|
||||||
)
|
|
||||||
else:
|
|
||||||
# New repo → clone
|
|
||||||
subprocess.run(
|
|
||||||
["git", "clone", git_url],
|
|
||||||
cwd=str(Path.cwd()),
|
|
||||||
check=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
return f"❌ Failed to clone/pull {git_url}: {e.stderr.strip()}"
|
return f"❌ Failed to clone {git_url}: {e.stderr.strip()}"
|
||||||
|
|
||||||
# ----- 3. Update the global CODEBASE_PATH --------------------------------
|
# ----- 3. Reset ALL global state ------------------------------------
|
||||||
global CODEBASE_PATH
|
global vectorstore, bm25, bm25_corpus, chunks_metadata, index_build_time
|
||||||
CODEBASE_PATH = clone_dir
|
|
||||||
|
|
||||||
# ----- 4. Remove stale indices ----------------------------------------
|
vectorstore = None
|
||||||
for artefact in ("chroma_db", "bm25_index.json", "embeddings"):
|
bm25 = None
|
||||||
|
bm25_corpus = None
|
||||||
|
chunks_metadata = None
|
||||||
|
index_build_time = 0
|
||||||
|
|
||||||
|
# ----- 4. Remove any stale indices ----------------------------------
|
||||||
|
for artefact in ("chroma_db", "bm25_index.json", "embeddings", ".mcp_cache"):
|
||||||
artefact_path = CODEBASE_PATH / artefact
|
artefact_path = CODEBASE_PATH / artefact
|
||||||
if artefact_path.exists():
|
if artefact_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -3458,27 +3535,37 @@ def init_repo(git_url: str) -> str:
|
|||||||
shutil.rmtree(artefact_path)
|
shutil.rmtree(artefact_path)
|
||||||
else:
|
else:
|
||||||
artefact_path.unlink()
|
artefact_path.unlink()
|
||||||
|
logger.info(f"Removed stale artefact: {artefact_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not delete {artefact_path}: {e}")
|
logger.warning(f"Could not delete {artefact_path}: {e}")
|
||||||
|
|
||||||
# ----- 5. Re-build the index (protected by the startup lock) ------------
|
# ----- 5. Re-build the index ----------------------------------------
|
||||||
try:
|
try:
|
||||||
rebuild_index()
|
build_indexes()
|
||||||
|
logger.info("Index rebuilt successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("Index rebuild failed")
|
||||||
return f"❌ Index rebuild failed: {e}"
|
return f"❌ Index rebuild failed: {e}"
|
||||||
|
|
||||||
# ----- 6. Report success -----------------------------------------------
|
# ----- 6. Report success --------------------------------------------
|
||||||
branch_name = subprocess.check_output(
|
try:
|
||||||
["git", "-C", str(CODEBASE_PATH), "rev-parse", "--abbrev-ref", "HEAD"],
|
branch_name = subprocess.check_output(
|
||||||
text=True,
|
["git", "-C", str(CODEBASE_PATH), "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
).strip()
|
text=True,
|
||||||
git_hash = subprocess.check_output(
|
).strip()
|
||||||
["git", "-C", str(CODEBASE_PATH), "rev-parse", "HEAD"],
|
git_hash = subprocess.check_output(
|
||||||
text=True,
|
["git", "-C", str(CODEBASE_PATH), "rev-parse", "HEAD"],
|
||||||
).strip()
|
text=True,
|
||||||
|
).strip()
|
||||||
|
|
||||||
return f"✅ Initialized repository at {CODEBASE_PATH} on branch {branch_name} with hash {git_hash}"
|
# Verify the index was actually built
|
||||||
|
if vectorstore is None or bm25_corpus is None:
|
||||||
|
return f"⚠️ Repository cloned but index failed to build properly"
|
||||||
|
|
||||||
|
return f"✅ Initialized repository at {CODEBASE_PATH}\n Branch: {branch_name}\n Commit: {git_hash[:8]}\n Indexed chunks: {len(bm25_corpus)}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"⚠️ Repository cloned but status check failed: {e}"
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def rebuild_index() -> str:
|
def rebuild_index() -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user