code-compass / evals /run_eval.py
technophyle's picture
Sync from GitHub via hub-sync
d3dfd51 verified
Raw History Blame
10.5 kB
import json
import os
import re
import sys
import time
from pathlib import Path
from statistics import mean
from dotenv import load_dotenv
SERVER_ROOT = Path(__file__).resolve().parents[1]
if str(SERVER_ROOT) not in sys.path:
sys.path.insert(0, str(SERVER_ROOT))
load_dotenv(SERVER_ROOT / ".env")
from src.rag_system import CodebaseRAGSystem
EVAL_SESSION_KEY = "eval-session"
TOP_K = int(os.getenv("CODEBASE_RAG_TOP_K", "8"))
EVAL_SET_PATH = Path(
os.getenv("CODEBASE_RAG_EVAL_SET", Path(__file__).with_name("sample_eval_set.json"))
)
EVAL_OUTPUT_PATH = os.getenv("CODEBASE_RAG_EVAL_OUTPUT")
ENABLE_FAITHFULNESS = os.getenv("CODEBASE_RAG_ENABLE_FAITHFULNESS", "1") == "1"
def log(message: str):
print(f"[eval] {message}", file=sys.stderr, flush=True)
def load_eval_set():
return json.loads(EVAL_SET_PATH.read_text())
def validate_eval_set(repositories):
errors = []
seen_ids = set()
for repo in repositories:
if not repo.get("github_url"):
errors.append(f"repo {repo.get('id', '?')}: missing github_url")
if not repo.get("cases"):
errors.append(f"repo {repo.get('id', '?')}: has no cases")
for case in repo.get("cases", []):
case_id = case.get("id") or case.get("question", "?")
if case_id in seen_ids:
errors.append(f"duplicate case id: {case_id}")
seen_ids.add(case_id)
if not case.get("question", "").strip():
errors.append(f"{case_id}: missing question")
if not case.get("ground_truth", "").strip():
errors.append(f"{case_id}: missing ground_truth")
if not case.get("expected_sources"):
errors.append(f"{case_id}: expected_sources must be a non-empty list")
return errors
def normalize_path(path: str) -> str:
return path.strip().lstrip("./").lower()
def tokenize_text(text: str):
return re.findall(r"[a-z0-9_]+", (text or "").lower())
def matches_expected(actual_path: str, expected_sources) -> bool:
actual = normalize_path(actual_path)
for expected in expected_sources:
expected_norm = normalize_path(expected).rstrip("/")
is_dir = "." not in expected_norm.rsplit("/", 1)[-1]
if actual == expected_norm:
return True
if is_dir and actual.startswith(expected_norm + "/"):
return True
return False
def compute_retrieval_metrics(expected_sources, actual_sources):
matching_ranks = [
rank
for rank, path in enumerate(actual_sources, start=1)
if matches_expected(path, expected_sources)
]
hit = bool(matching_ranks)
top1 = bool(matching_ranks and matching_ranks[0] == 1)
reciprocal_rank = 1.0 / matching_ranks[0] if matching_ranks else 0.0
return {
"retrieval_hit": int(hit),
"top1_hit": int(top1),
"reciprocal_rank": reciprocal_rank,
}
def keyword_hits(answer: str, keywords):
if not keywords:
return 0, 0
tokens = set(tokenize_text(answer))
matched = 0
for keyword in keywords:
keyword_tokens = tokenize_text(keyword)
if keyword_tokens and all(token in tokens for token in keyword_tokens):
matched += 1
return matched, len(keywords)
def judge_faithfulness(rag_system, question: str, answer: str, sources: list):
if not ENABLE_FAITHFULNESS or not answer.strip() or not sources:
return None
context = "\n\n".join(
f"[{i}] {source['file_path']}\n{source['snippet'][:1500]}"
for i, source in enumerate(sources, start=1)
)
system_prompt = (
"You are a strict grading assistant. Given a question, retrieved code context, and a "
"generated answer, output ONLY a single number between 0 and 1 for how faithful the "
"answer is to the context (1.0 = every claim is supported, 0.0 = the answer invents or "
"contradicts facts not in the context). Output just the number."
)
user_prompt = f"Question: {question}\n\nContext:\n{context}\n\nAnswer:\n{answer}\n\nFaithfulness score:"
try:
text, _ = rag_system._generate_markdown_response(system_prompt, user_prompt)
match = re.search(r"(\d(?:\.\d+)?)", text)
if not match:
return None
return max(0.0, min(1.0, float(match.group(1))))
except Exception as exc:
log(f"Faithfulness judge failed: {exc}")
return None
def index_repo(rag_system, github_url: str, name: str):
repo = rag_system.create_or_reset_repository(github_url, EVAL_SESSION_KEY)
log(f"Indexing {name} ({github_url}), repo_id={repo.id}")
rag_system.index_repository(repo.id)
repo_state = rag_system.get_repository_for_session(repo.id, EVAL_SESSION_KEY)
if not repo_state or repo_state["status"] != "indexed":
detail = repo_state.get("error_message") if repo_state else "repository disappeared"
raise RuntimeError(f"Failed to index {name}: {detail}")
log(
f"Indexed {name}: {repo_state['file_count']} files, "
f"{repo_state['chunk_count']} chunks"
)
return repo.id
def run_case(rag_system, repo_id: int, repo_name: str, case: dict):
start = time.time()
result = rag_system.answer_question(
repo_id=repo_id,
session_key=EVAL_SESSION_KEY,
question=case["question"],
top_k=TOP_K,
history=case.get("turns", []),
debug_retrieval=True,
)
elapsed_ms = (time.time() - start) * 1000
sources = result.get("sources", [])
cited_paths = [source["file_path"] for source in sources]
retrieval = compute_retrieval_metrics(case.get("expected_sources", []), cited_paths)
matched, total_keywords = keyword_hits(result.get("answer", ""), case.get("must_include_any", []))
has_citations = bool(result.get("citations"))
expected_source_grounded = (
retrieval["retrieval_hit"] == 1
and has_citations
and (total_keywords == 0 or matched > 0)
)
retrieval_debug = result.get("retrieval_debug", [])
for item in retrieval_debug:
item["expected_source"] = matches_expected(
item.get("file_path", ""),
case.get("expected_sources", []),
)
return {
"id": case.get("id", case["question"]),
"repo": repo_name,
"category": case.get("category", "general"),
"question": case["question"],
"answer": result.get("answer", ""),
"citations": result.get("citations", []),
"expected_sources": case.get("expected_sources", []),
"retrieved_sources": cited_paths,
"retrieval_hit": retrieval["retrieval_hit"],
"top1_hit": retrieval["top1_hit"],
"reciprocal_rank": round(retrieval["reciprocal_rank"], 4),
"expected_source_grounded": int(expected_source_grounded),
# Backward-compatible alias for older report consumers.
"grounded": int(expected_source_grounded),
"retrieval_debug": retrieval_debug,
"faithfulness": judge_faithfulness(rag_system, case["question"], result.get("answer", ""), sources),
"latency_ms": round(elapsed_ms, 1),
}
def summarize(details):
if not details:
return {}
latencies = sorted(item["latency_ms"] for item in details)
p95_index = min(len(latencies) - 1, int(len(latencies) * 0.95))
faith_scores = [item["faithfulness"] for item in details if item["faithfulness"] is not None]
return {
"case_count": len(details),
"retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in details), 4),
"top1_hit_rate": round(mean(item["top1_hit"] for item in details), 4),
"mrr": round(mean(item["reciprocal_rank"] for item in details), 4),
"expected_source_grounded_rate": round(
mean(item["expected_source_grounded"] for item in details), 4
),
# Backward-compatible alias.
"grounded_answer_rate": round(mean(item["grounded"] for item in details), 4),
"faithfulness": round(mean(faith_scores), 4) if faith_scores else None,
"latency_p95_ms": round(latencies[p95_index], 1),
}
def summarize_by_repo(details):
grouped = {}
for item in details:
grouped.setdefault(item["repo"], []).append(item)
return {repo: summarize(items) for repo, items in grouped.items()}
def summarize_by_category(details):
grouped = {}
for item in details:
grouped.setdefault(item["category"], []).append(item)
return {category: summarize(items) for category, items in sorted(grouped.items())}
def run():
eval_set = load_eval_set()
repositories = eval_set["repositories"]
errors = validate_eval_set(repositories)
if errors:
raise RuntimeError("Eval set validation failed: " + "; ".join(errors))
total_cases = sum(len(repo["cases"]) for repo in repositories)
log(f"Loaded eval set: {len(repositories)} repositories, {total_cases} cases")
rag_system = CodebaseRAGSystem()
log(f"LLM provider={rag_system.llm_provider} model={rag_system.llm_model}")
details = []
try:
for repo_config in repositories:
repo_id = index_repo(rag_system, repo_config["github_url"], repo_config["name"])
cases = repo_config["cases"]
for index, case in enumerate(cases, start=1):
log(f"[{repo_config['id']} {index}/{len(cases)}] {case['id']}")
details.append(run_case(rag_system, repo_id, repo_config["name"], case))
finally:
rag_system.end_session(EVAL_SESSION_KEY)
report = {
"config": {
"llm_provider": rag_system.llm_provider,
"llm_model": rag_system.llm_model,
"top_k": TOP_K,
"eval_set": str(EVAL_SET_PATH),
"repositories": [
{"id": repo["id"], "name": repo["name"], "github_url": repo["github_url"]}
for repo in repositories
],
},
"headline_metrics": summarize(details),
"repo_breakdown": summarize_by_repo(details),
"category_breakdown": summarize_by_category(details),
"cases": details,
}
if EVAL_OUTPUT_PATH:
target = Path(EVAL_OUTPUT_PATH)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(report, indent=2))
log(f"Wrote JSON report to {target}")
log("Eval complete. Printing JSON report.")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
run()