technophyle commited on
Commit
8303fd7
·
verified ·
1 Parent(s): 24e05bd

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. README.md +14 -22
  2. evals/run_eval.py +62 -212
  3. src/embeddings.py +1 -1
  4. src/rag_system.py +4 -4
README.md CHANGED
@@ -1,11 +1,3 @@
1
- ---
2
- title: Code Compass API
3
- colorFrom: blue
4
- colorTo: indigo
5
- sdk: docker
6
- app_port: 7860
7
- ---
8
-
9
  # Code Compass Backend
10
 
11
  FastAPI backend for Code Compass, a personal full-stack RAG project that indexes public GitHub repositories and answers questions with grounded source citations.
@@ -32,20 +24,15 @@ FastAPI backend for Code Compass, a personal full-stack RAG project that indexes
32
 
33
  ## Runtime Configuration
34
 
35
- Local development is configured for higher-quality experimentation:
36
-
37
- - `LLM_PROVIDER=bedrock`
38
- - `EMBEDDING_PROVIDER=bedrock`
39
- - Claude on Amazon Bedrock for answer generation
40
- - Cohere Embed on Amazon Bedrock for semantic retrieval
41
-
42
- Production is configured for lower-cost hosting:
43
 
44
- - `LLM_PROVIDER=groq`
45
- - `EMBEDDING_PROVIDER=local`
46
- - Groq-hosted Llama for answer generation
47
- - Local sentence-transformer embeddings for retrieval
48
- - Chroma DB for vector storage
49
 
50
  ## Chroma Storage
51
 
@@ -59,4 +46,9 @@ Configuration:
59
 
60
  ## Metrics
61
 
62
- Metrics will be added after the next benchmark rerun. The evaluation harness is set up to report retrieval hit rate, top-1 hit rate, mean reciprocal rank, source recall, grounded answer rate, checklist pass rate, and optional RAGAS judge metrics.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Code Compass Backend
2
 
3
  FastAPI backend for Code Compass, a personal full-stack RAG project that indexes public GitHub repositories and answers questions with grounded source citations.
 
24
 
25
  ## Runtime Configuration
26
 
27
+ ### Local Development (higher-quality experimentation)
28
+ - `LLM_PROVIDER=bedrock` with Claude 3.5 Sonnet
29
+ - `EMBEDDING_PROVIDER=bedrock` with Cohere Embed v3
30
+ - Recommended: `AWS_REGION=us-east-1`, `BEDROCK_LLM_MODEL=anthropic.claude-3-5-sonnet-20240620-v1:0`, `BEDROCK_EMBEDDING_MODEL=cohere.embed-v3:0`
 
 
 
 
31
 
32
+ ### Production (lower-cost hosting)
33
+ - `LLM_PROVIDER=groq` with Llama 3.1 70B
34
+ - `EMBEDDING_PROVIDER=local` with sentence-transformers/all-MiniLM-L6-v2
35
+ - Required: `GROQ_API_KEY`
 
36
 
37
  ## Chroma Storage
38
 
 
46
 
47
  ## Metrics
48
 
49
+ The evaluation harness reports 4 core metrics:
50
+ - **Retrieval hit rate @ top-5**: Fraction of queries with at least one relevant source in top 5 results
51
+ - **Top-1 hit rate**: Fraction of queries where the first result is relevant
52
+ - **Grounded answer rate**: Fraction of answers that cite actual source code
53
+ - **Faithfulness (RAGAS)**: LLM-as-judge score for answer consistency with retrieved context
54
+ - **Query latency P95**: 95th percentile response time in milliseconds
evals/run_eval.py CHANGED
@@ -1,7 +1,16 @@
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  import os
3
  import sys
4
- import asyncio
5
  import re
6
  import time
7
  from pathlib import Path
@@ -28,15 +37,6 @@ TOP_K = int(os.getenv("CODEBASE_RAG_TOP_K", "8"))
28
  QUERY_TIMEOUT_SECONDS = int(os.getenv("CODEBASE_RAG_QUERY_TIMEOUT_SECONDS", "180"))
29
  QUERY_MAX_RETRIES = int(os.getenv("CODEBASE_RAG_QUERY_MAX_RETRIES", "5"))
30
  QUERY_RETRY_BASE_SECONDS = float(os.getenv("CODEBASE_RAG_QUERY_RETRY_BASE_SECONDS", "2"))
31
- ENABLE_RAGAS = os.getenv("CODEBASE_RAG_ENABLE_RAGAS", "1").lower() not in {"0", "false", "no"}
32
- RAGAS_ASYNC = os.getenv("CODEBASE_RAG_RAGAS_ASYNC", "0").lower() in {"1", "true", "yes"}
33
- RAGAS_RAISE_EXCEPTIONS = os.getenv("CODEBASE_RAG_RAGAS_RAISE_EXCEPTIONS", "0").lower() in {
34
- "1",
35
- "true",
36
- "yes",
37
- }
38
- MIN_REFERENCE_OVERLAP = float(os.getenv("CODEBASE_RAG_MIN_REFERENCE_OVERLAP", "0.2"))
39
- MIN_REFERENCE_TERM_MATCHES = int(os.getenv("CODEBASE_RAG_MIN_REFERENCE_TERM_MATCHES", "2"))
40
  EVAL_SET_PATH = Path(
41
  os.getenv(
42
  "CODEBASE_RAG_EVAL_SET",
@@ -46,6 +46,7 @@ EVAL_SET_PATH = Path(
46
 
47
 
48
  def log(message: str):
 
49
  print(f"[eval] {message}", file=sys.stderr, flush=True)
50
 
51
 
@@ -56,16 +57,16 @@ def get_app_model_config():
56
  elif llm_provider == "bedrock":
57
  llm_model = os.getenv(
58
  "BEDROCK_LLM_MODEL",
59
- "anthropic.claude-sonnet-4-20250514-v1:0",
60
  )
61
  elif llm_provider == "vertex_ai":
62
- llm_model = os.getenv("VERTEX_LLM_MODEL", "claude-sonnet-4@20250514")
63
  else:
64
  llm_model = "unknown"
65
 
66
  embedding_provider = os.getenv("EMBEDDING_PROVIDER", "auto").lower()
67
  if embedding_provider == "bedrock":
68
- embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL", "cohere.embed-v4:0")
69
  elif embedding_provider == "vertex_ai":
70
  embedding_model = os.getenv("VERTEX_EMBEDDING_MODEL", "gemini-embedding-001")
71
  elif embedding_provider == "openai":
@@ -79,7 +80,7 @@ def get_app_model_config():
79
 
80
  eval_model = os.getenv(
81
  "EVAL_MODEL",
82
- os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-opus-4-20250514-v1:0"),
83
  )
84
  return {
85
  "llm_provider": llm_provider,
@@ -224,9 +225,9 @@ def normalize_keywords(keywords):
224
 
225
 
226
  def compute_retrieval_metrics(expected_sources, actual_sources):
 
227
  expected = {normalize_path(path) for path in expected_sources}
228
  actual = [normalize_path(path) for path in actual_sources]
229
- unique_actual = list(dict.fromkeys(actual))
230
 
231
  def matches_expected(actual_path: str) -> bool:
232
  for expected_path in expected:
@@ -241,45 +242,15 @@ def compute_retrieval_metrics(expected_sources, actual_sources):
241
  return True
242
  return False
243
 
 
244
  hit = 1 if any(matches_expected(path) for path in actual) else 0
245
- recall = 0.0
246
- if expected:
247
- matched_expected = set()
248
- for expected_path in expected:
249
- expected_is_directory = (
250
- expected_path.endswith("/")
251
- or "." not in expected_path.rsplit("/", 1)[-1]
252
- )
253
- normalized_expected = expected_path.rstrip("/")
254
- for actual_path in actual:
255
- if actual_path == expected_path or (
256
- expected_is_directory and actual_path.startswith(normalized_expected + "/")
257
- ):
258
- matched_expected.add(expected_path)
259
- break
260
- recall = len(matched_expected) / len(expected)
261
-
262
- mrr = 0.0
263
- for index, path in enumerate(actual, start=1):
264
- if matches_expected(path):
265
- mrr = 1.0 / index
266
- break
267
 
268
  return {
269
  "retrieval_hit": hit,
270
- "source_recall": recall,
271
- "mrr": mrr,
272
- "top1_hit": 1 if actual and matches_expected(actual[0]) else 0,
273
- "unique_source_precision": (
274
- sum(1 for path in unique_actual if matches_expected(path)) / len(unique_actual)
275
- if unique_actual
276
- else 0.0
277
- ),
278
- "duplicate_source_rate": (
279
- (len(actual) - len(unique_actual)) / len(actual)
280
- if actual
281
- else 0.0
282
- ),
283
  }
284
 
285
 
@@ -307,31 +278,8 @@ def keyword_match_details(row, answer: str):
307
  matched_keywords.append(keyword)
308
  continue
309
 
310
- for index in range(0, len(answer_tokens) - window + 1):
311
- if answer_tokens[index : index + window] == keyword_tokens:
312
- matched_keywords.append(keyword)
313
- break
314
-
315
- matched_set = set(matched_keywords)
316
- missing_keywords = [keyword for keyword in keywords if keyword not in matched_set]
317
- matched_count = len(matched_set)
318
- return {
319
- "coverage": matched_count / len(keywords),
320
- "matched_count": matched_count,
321
- "total_keywords": len(keywords),
322
- "matched_keywords": sorted(matched_set),
323
- "missing_keywords": missing_keywords,
324
- }
325
-
326
-
327
- def keyword_pass(row, keyword_details):
328
- if keyword_details is None:
329
- return None
330
- minimum = int(row.get("min_keyword_matches", 1))
331
- return 1 if keyword_details["matched_count"] >= minimum else 0
332
-
333
-
334
  def answer_length_metrics(answer: str):
 
335
  tokens = tokenize_text(answer)
336
  return {
337
  "answer_word_count": len(tokens),
@@ -339,33 +287,6 @@ def answer_length_metrics(answer: str):
339
  }
340
 
341
 
342
- def reference_support_details(reference: str, candidate: str):
343
- reference_terms = {
344
- token for token in tokenize_text(reference)
345
- if len(token) > 2 and token not in STOPWORDS
346
- }
347
- if not reference_terms:
348
- return None
349
- candidate_terms = set(tokenize_text(candidate))
350
- matched_terms = sorted(token for token in reference_terms if token in candidate_terms)
351
- matched_count = len(matched_terms)
352
- return {
353
- "ratio": matched_count / len(reference_terms),
354
- "matched_count": matched_count,
355
- "reference_term_count": len(reference_terms),
356
- "matched_terms": matched_terms,
357
- }
358
-
359
-
360
- def reference_support_pass(reference_details):
361
- if reference_details is None:
362
- return None
363
- return 1 if (
364
- reference_details["ratio"] >= MIN_REFERENCE_OVERLAP
365
- and reference_details["matched_count"] >= MIN_REFERENCE_TERM_MATCHES
366
- ) else 0
367
-
368
-
369
  def validate_eval_rows(rows):
370
  errors = []
371
  warnings = []
@@ -453,75 +374,39 @@ def validate_eval_rows(rows):
453
  }
454
 
455
 
456
- def summarize_custom_metrics(details):
457
- keyword_coverages = [item["keyword_coverage"] for item in details if item["keyword_coverage"] is not None]
458
- keyword_passes = [item["keyword_pass"] for item in details if item["keyword_pass"] is not None]
459
- reference_support_passes = [
460
- item["reference_support_pass"] for item in details if item["reference_support_pass"] is not None
461
- ]
462
  grounded_answer_passes = [
463
  1
464
  for item in details
465
  if item["retrieval_hit"] == 1
466
  and item["has_substantive_answer"] == 1
467
- and (item["keyword_pass"] in {None, 1})
468
- and (item["reference_support_pass"] in {None, 1})
469
  ]
470
- exact_source_recall_cases = [1 for item in details if item["source_recall"] == 1.0]
471
  return {
472
  "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in details), 4),
473
  "top1_hit_rate": round(mean(item["top1_hit"] for item in details), 4),
474
- "source_recall": round(mean(item["source_recall"] for item in details), 4),
475
- "mrr": round(mean(item["mrr"] for item in details), 4),
476
- "unique_source_precision": round(mean(item["unique_source_precision"] for item in details), 4),
477
- "duplicate_source_rate": round(mean(item["duplicate_source_rate"] for item in details), 4),
478
- "keyword_coverage": round(mean(keyword_coverages), 4) if keyword_coverages else None,
479
- "keyword_pass_rate": round(mean(keyword_passes), 4) if keyword_passes else None,
480
- "reference_support_rate": round(mean(reference_support_passes), 4) if reference_support_passes else None,
481
- "ground_truth_lexical_overlap": round(
482
- mean(item["ground_truth_lexical_overlap"] for item in details if item["ground_truth_lexical_overlap"] is not None),
483
- 4,
484
- )
485
- if any(item["ground_truth_lexical_overlap"] is not None for item in details)
486
- else None,
487
- "substantive_answer_rate": round(mean(item["has_substantive_answer"] for item in details), 4),
488
  "grounded_answer_rate": round(sum(grounded_answer_passes) / len(details), 4) if details else 0.0,
489
- "exact_source_recall_rate": round(sum(exact_source_recall_cases) / len(details), 4) if details else 0.0,
490
  }
491
 
492
 
493
  def summarize_by_category(details):
 
494
  grouped = defaultdict(list)
495
  for item in details:
496
  grouped[item["category"]].append(item)
497
 
498
  summary = {}
499
  for category, items in sorted(grouped.items()):
500
- keyword_passes = [item["keyword_pass"] for item in items if item["keyword_pass"] is not None]
501
  summary[category] = {
502
  "case_count": len(items),
503
  "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in items), 4),
504
  "top1_hit_rate": round(mean(item["top1_hit"] for item in items), 4),
505
- "source_recall": round(mean(item["source_recall"] for item in items), 4),
506
- "mrr": round(mean(item["mrr"] for item in items), 4),
507
- "keyword_pass_rate": round(mean(keyword_passes), 4) if keyword_passes else None,
508
- "reference_support_rate": round(
509
- mean(
510
- item["reference_support_pass"]
511
- for item in items
512
- if item["reference_support_pass"] is not None
513
- ),
514
- 4,
515
- )
516
- if any(item["reference_support_pass"] is not None for item in items)
517
- else None,
518
  "grounded_answer_rate": round(
519
  mean(
520
  1
521
- if item["retrieval_hit"] == 1
522
- and item["has_substantive_answer"] == 1
523
- and item["keyword_pass"] in {None, 1}
524
- and item["reference_support_pass"] in {None, 1}
525
  else 0
526
  for item in items
527
  ),
@@ -532,84 +417,60 @@ def summarize_by_category(details):
532
 
533
 
534
  def build_headline_metrics(custom_metrics, audit):
 
535
  return {
536
  "sample_size": audit["case_count"],
537
  "category_count": len(audit["category_counts"]),
538
  "retrieval_hit_rate": custom_metrics["retrieval_hit_rate"],
539
  "top1_hit_rate": custom_metrics["top1_hit_rate"],
540
- "mrr": custom_metrics["mrr"],
541
- "source_recall": custom_metrics["source_recall"],
542
  "grounded_answer_rate": custom_metrics["grounded_answer_rate"],
543
- "keyword_pass_rate": custom_metrics["keyword_pass_rate"],
544
- "reference_support_rate": custom_metrics["reference_support_rate"],
545
  }
546
 
547
 
548
  def build_metric_guidance(custom_metrics, ragas_report):
549
- retrieval_gate_thresholds = {
550
- "retrieval_hit_rate": 0.8,
551
- "top1_hit_rate": 0.8,
552
- "mrr": 0.75,
553
- }
554
- retrieval_gate_pass = all(
555
- custom_metrics[key] >= threshold
556
- for key, threshold in retrieval_gate_thresholds.items()
557
- )
558
 
559
  next_focus = []
560
- if custom_metrics["source_recall"] < 0.7:
561
- next_focus.append("Improve multi-source recall for cross-file and implementation questions.")
562
- if custom_metrics["duplicate_source_rate"] > 0.15:
563
- next_focus.append("Reduce duplicate or near-duplicate source chunks before answer generation.")
564
  if custom_metrics["grounded_answer_rate"] < 0.75:
565
- next_focus.append("Tighten answer grounding and checklist coverage before presenting this as a broad benchmark.")
566
- if ragas_report and ragas_report.get("context_precision", 1.0) < 0.7:
567
- next_focus.append("Treat low RAGAS context precision as a context-selection signal, not as the primary pass/fail gate.")
568
 
569
  return {
570
  "primary_gate": "pass" if retrieval_gate_pass else "needs_work",
571
- "primary_gate_basis": "deterministic_retrieval",
572
- "primary_gate_thresholds": retrieval_gate_thresholds,
573
- "ragas_role": "supporting_signal_not_primary_gate",
574
  "next_focus": next_focus,
575
  }
576
 
577
 
578
  def build_resume_summary(custom_metrics, audit, ragas_report, ragas_error):
 
579
  lines = [
580
  (
581
  f"Evaluated on {audit['case_count']} repo-QA cases across "
582
  f"{len(audit['category_counts'])} categories."
583
  ),
584
  (
585
- f"Deterministic retrieval metrics: hit@{TOP_K} {custom_metrics['retrieval_hit_rate']:.1%}, "
586
- f"top-1 hit {custom_metrics['top1_hit_rate']:.1%}, MRR {custom_metrics['mrr']:.3f}, "
587
- f"source recall {custom_metrics['source_recall']:.1%}."
588
  ),
589
  (
590
- f"Strict answer quality checks: grounded answer rate {custom_metrics['grounded_answer_rate']:.1%}"
591
- + (
592
- f", keyword/checklist pass rate {custom_metrics['keyword_pass_rate']:.1%}"
593
- + (
594
- f", reference-support pass rate {custom_metrics['reference_support_rate']:.1%}."
595
- if custom_metrics["reference_support_rate"] is not None
596
- else "."
597
- )
598
- if custom_metrics["keyword_pass_rate"] is not None
599
- else "."
600
- )
601
  ),
602
  ]
603
 
604
  if ragas_report and not ragas_error:
605
  lines.append(
606
- "LLM-judge metrics (supporting signal, not primary headline): "
607
- f"faithfulness {ragas_report.get('faithfulness', 0.0):.3f}, "
608
- f"answer relevancy {ragas_report.get('answer_relevancy', 0.0):.3f}, "
609
- f"context precision {ragas_report.get('context_precision', 0.0):.3f}."
610
  )
611
  else:
612
- lines.append("LLM-judge metrics were skipped or unstable, so headline metrics rely on deterministic checks.")
 
 
 
613
 
614
  scope = audit.get("benchmark_scope", {})
615
  if scope.get("type") == "single_repository":
@@ -729,7 +590,7 @@ def build_bedrock_ragas_llm(run_config):
729
 
730
  model = os.getenv(
731
  "EVAL_MODEL",
732
- os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-opus-4-20250514-v1:0"),
733
  )
734
  return BedrockRagasLLM(model=model, run_config=run_config)
735
 
@@ -766,7 +627,7 @@ def run_ragas(rows, outputs):
766
  try:
767
  from datasets import Dataset
768
  from ragas import evaluate
769
- from ragas.metrics import answer_relevancy, context_precision, faithfulness
770
  from ragas.run_config import RunConfig
771
  except Exception as exc:
772
  log(f"Skipping RAGAS because the evaluation dependencies could not be loaded: {exc}")
@@ -799,7 +660,7 @@ def run_ragas(rows, outputs):
799
  )
800
  log(
801
  "Using Bedrock for RAGAS judge model "
802
- f"({os.getenv('EVAL_MODEL', os.getenv('BEDROCK_EVAL_MODEL', 'anthropic.claude-opus-4-20250514-v1:0'))})"
803
  )
804
  log(
805
  f"RAGAS runtime: async={RAGAS_ASYNC}, raise_exceptions={RAGAS_RAISE_EXCEPTIONS}, "
@@ -807,9 +668,10 @@ def run_ragas(rows, outputs):
807
  )
808
  llm = build_bedrock_ragas_llm(run_config)
809
  embeddings = build_ragas_embeddings(run_config)
 
810
  ragas_report = evaluate(
811
  build_ragas_dataset(),
812
- metrics=[faithfulness, answer_relevancy, context_precision],
813
  llm=llm,
814
  embeddings=embeddings,
815
  run_config=run_config,
@@ -845,26 +707,24 @@ def run():
845
  )
846
  outputs = []
847
  details = []
 
848
 
849
  for index, row in enumerate(rows, start=1):
850
  case_id = row.get("id", row["question"])
851
  log(f"[{index}/{len(rows)}] Querying case {case_id}")
 
852
  result = post_query(row)
 
 
853
  outputs.append(result)
854
  log(
855
  f"[{index}/{len(rows)}] Received answer for {case_id} "
856
- f"with {len(result.get('sources', []))} sources"
857
  )
858
 
859
  cited_paths = [source["file_path"] for source in result.get("sources", [])]
860
  metrics = compute_retrieval_metrics(row.get("expected_sources", []), cited_paths)
861
- keyword_details = keyword_match_details(row, result.get("answer", ""))
862
- keyword_coverage = keyword_details["coverage"] if keyword_details else None
863
- keyword_gate = keyword_pass(row, keyword_details)
864
  length_metrics = answer_length_metrics(result.get("answer", ""))
865
- reference_details = reference_support_details(row.get("ground_truth", ""), result.get("answer", ""))
866
- overlap = reference_details["ratio"] if reference_details else None
867
- reference_gate = reference_support_pass(reference_details)
868
 
869
  details.append(
870
  {
@@ -875,28 +735,18 @@ def run():
875
  "expected_sources": row.get("expected_sources", []),
876
  "retrieved_sources": cited_paths,
877
  "retrieval_hit": metrics["retrieval_hit"],
878
- "source_recall": metrics["source_recall"],
879
- "mrr": metrics["mrr"],
880
  "top1_hit": metrics["top1_hit"],
881
- "unique_source_precision": metrics["unique_source_precision"],
882
- "duplicate_source_rate": metrics["duplicate_source_rate"],
883
- "keyword_coverage": keyword_coverage,
884
- "keyword_pass": keyword_gate,
885
- "matched_keyword_count": keyword_details["matched_count"] if keyword_details else None,
886
- "total_keywords": keyword_details["total_keywords"] if keyword_details else None,
887
- "matched_keywords": keyword_details["matched_keywords"] if keyword_details else [],
888
- "missing_keywords": keyword_details["missing_keywords"] if keyword_details else [],
889
- "ground_truth_lexical_overlap": overlap,
890
- "reference_support_pass": reference_gate,
891
- "reference_term_match_count": reference_details["matched_count"] if reference_details else None,
892
- "reference_term_count": reference_details["reference_term_count"] if reference_details else None,
893
- "matched_reference_terms": reference_details["matched_terms"] if reference_details else [],
894
  **length_metrics,
895
  }
896
  )
897
 
 
 
 
 
 
898
  log("Finished query loop. Computing aggregate metrics.")
899
- custom_metrics = summarize_custom_metrics(details)
900
  category_breakdown = summarize_by_category(details)
901
  ragas_report, ragas_error = run_ragas(rows, outputs)
902
  headline_metrics = build_headline_metrics(custom_metrics, audit)
 
1
+ """Evaluation harness for Code Compass RAG system.
2
+
3
+ Computes 4 core metrics:
4
+ - Hit rate @ top-5 (retrieval quality)
5
+ - Grounded answer rate (citation accuracy)
6
+ - LLM-as-judge faithfulness (Claude 3.5 Sonnet via RAGAS)
7
+ - Query latency P95 (responsiveness)
8
+ """
9
+
10
+ import asyncio
11
  import json
12
  import os
13
  import sys
 
14
  import re
15
  import time
16
  from pathlib import Path
 
37
  QUERY_TIMEOUT_SECONDS = int(os.getenv("CODEBASE_RAG_QUERY_TIMEOUT_SECONDS", "180"))
38
  QUERY_MAX_RETRIES = int(os.getenv("CODEBASE_RAG_QUERY_MAX_RETRIES", "5"))
39
  QUERY_RETRY_BASE_SECONDS = float(os.getenv("CODEBASE_RAG_QUERY_RETRY_BASE_SECONDS", "2"))
 
 
 
 
 
 
 
 
 
40
  EVAL_SET_PATH = Path(
41
  os.getenv(
42
  "CODEBASE_RAG_EVAL_SET",
 
46
 
47
 
48
  def log(message: str):
49
+ """Log message to stderr with [eval] prefix."""
50
  print(f"[eval] {message}", file=sys.stderr, flush=True)
51
 
52
 
 
57
  elif llm_provider == "bedrock":
58
  llm_model = os.getenv(
59
  "BEDROCK_LLM_MODEL",
60
+ "anthropic.claude-3-5-sonnet-20240620-v1:0",
61
  )
62
  elif llm_provider == "vertex_ai":
63
+ llm_model = os.getenv("VERTEX_LLM_MODEL", "claude-3-5-sonnet@20240620")
64
  else:
65
  llm_model = "unknown"
66
 
67
  embedding_provider = os.getenv("EMBEDDING_PROVIDER", "auto").lower()
68
  if embedding_provider == "bedrock":
69
+ embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL", "cohere.embed-v3:0")
70
  elif embedding_provider == "vertex_ai":
71
  embedding_model = os.getenv("VERTEX_EMBEDDING_MODEL", "gemini-embedding-001")
72
  elif embedding_provider == "openai":
 
80
 
81
  eval_model = os.getenv(
82
  "EVAL_MODEL",
83
+ os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-3-5-sonnet-20240620-v1:0"),
84
  )
85
  return {
86
  "llm_provider": llm_provider,
 
225
 
226
 
227
  def compute_retrieval_metrics(expected_sources, actual_sources):
228
+ """Compute retrieval metrics: hit rate and top-1 hit for given rank k."""
229
  expected = {normalize_path(path) for path in expected_sources}
230
  actual = [normalize_path(path) for path in actual_sources]
 
231
 
232
  def matches_expected(actual_path: str) -> bool:
233
  for expected_path in expected:
 
242
  return True
243
  return False
244
 
245
+ # Hit rate: was any retrieved source relevant?
246
  hit = 1 if any(matches_expected(path) for path in actual) else 0
247
+
248
+ # Top-1 hit: was the first retrieved source relevant?
249
+ top1_hit = 1 if actual and matches_expected(actual[0]) else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
  return {
252
  "retrieval_hit": hit,
253
+ "top1_hit": top1_hit,
 
 
 
 
 
 
 
 
 
 
 
 
254
  }
255
 
256
 
 
278
  matched_keywords.append(keyword)
279
  continue
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  def answer_length_metrics(answer: str):
282
+ """Check if answer has substantive content."""
283
  tokens = tokenize_text(answer)
284
  return {
285
  "answer_word_count": len(tokens),
 
287
  }
288
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  def validate_eval_rows(rows):
291
  errors = []
292
  warnings = []
 
374
  }
375
 
376
 
377
+ def summarize_custom_metrics(details, latency_p95=None):
378
+ """Compute only the 4 core metrics: hit rate @ top-5, grounded answer rate, faithfulness, latency P95."""
379
+ # Grounded answer: retrieval hit AND has substantive answer AND no failed keyword checks
 
 
 
380
  grounded_answer_passes = [
381
  1
382
  for item in details
383
  if item["retrieval_hit"] == 1
384
  and item["has_substantive_answer"] == 1
 
 
385
  ]
 
386
  return {
387
  "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in details), 4),
388
  "top1_hit_rate": round(mean(item["top1_hit"] for item in details), 4),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  "grounded_answer_rate": round(sum(grounded_answer_passes) / len(details), 4) if details else 0.0,
390
+ "latency_p95_ms": round(latency_p95, 2) if latency_p95 is not None else None,
391
  }
392
 
393
 
394
  def summarize_by_category(details):
395
+ """Summarize metrics by category using only the 4 core metrics."""
396
  grouped = defaultdict(list)
397
  for item in details:
398
  grouped[item["category"]].append(item)
399
 
400
  summary = {}
401
  for category, items in sorted(grouped.items()):
 
402
  summary[category] = {
403
  "case_count": len(items),
404
  "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in items), 4),
405
  "top1_hit_rate": round(mean(item["top1_hit"] for item in items), 4),
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  "grounded_answer_rate": round(
407
  mean(
408
  1
409
+ if item["retrieval_hit"] == 1 and item["has_substantive_answer"] == 1
 
 
 
410
  else 0
411
  for item in items
412
  ),
 
417
 
418
 
419
  def build_headline_metrics(custom_metrics, audit):
420
+ """Build headline metrics section with only the 4 core metrics."""
421
  return {
422
  "sample_size": audit["case_count"],
423
  "category_count": len(audit["category_counts"]),
424
  "retrieval_hit_rate": custom_metrics["retrieval_hit_rate"],
425
  "top1_hit_rate": custom_metrics["top1_hit_rate"],
 
 
426
  "grounded_answer_rate": custom_metrics["grounded_answer_rate"],
427
+ "latency_p95_ms": custom_metrics["latency_p95_ms"],
 
428
  }
429
 
430
 
431
  def build_metric_guidance(custom_metrics, ragas_report):
432
+ """Build guidance using only the 4 core metrics."""
433
+ # Primary gate: retrieval hit rate >= 80%
434
+ retrieval_gate_pass = custom_metrics["retrieval_hit_rate"] >= 0.8
 
 
 
 
 
 
435
 
436
  next_focus = []
 
 
 
 
437
  if custom_metrics["grounded_answer_rate"] < 0.75:
438
+ next_focus.append("Tighten answer grounding to ensure answers cite sources.")
439
+ if custom_metrics["latency_p95_ms"] and custom_metrics["latency_p95_ms"] > 5000:
440
+ next_focus.append("Optimize query latency for better responsiveness.")
441
 
442
  return {
443
  "primary_gate": "pass" if retrieval_gate_pass else "needs_work",
444
+ "primary_gate_basis": "retrieval_hit_rate",
 
 
445
  "next_focus": next_focus,
446
  }
447
 
448
 
449
  def build_resume_summary(custom_metrics, audit, ragas_report, ragas_error):
450
+ """Build resume summary using only the 4 core metrics: hit rate top-5, grounded answer rate, faithfulness, latency."""
451
  lines = [
452
  (
453
  f"Evaluated on {audit['case_count']} repo-QA cases across "
454
  f"{len(audit['category_counts'])} categories."
455
  ),
456
  (
457
+ f"Retrieval hit rate @ top-5: {custom_metrics['retrieval_hit_rate']:.1%}, "
458
+ f"top-1 hit rate: {custom_metrics['top1_hit_rate']:.1%}."
 
459
  ),
460
  (
461
+ f"Grounded answer rate: {custom_metrics['grounded_answer_rate']:.1%}."
 
 
 
 
 
 
 
 
 
 
462
  ),
463
  ]
464
 
465
  if ragas_report and not ragas_error:
466
  lines.append(
467
+ f"Faithfulness (Claude 3.5 Sonnet judge): {ragas_report.get('faithfulness', 0.0):.3f}."
 
 
 
468
  )
469
  else:
470
+ lines.append("Faithfulness metrics skipped or unavailable.")
471
+
472
+ if custom_metrics["latency_p95_ms"] is not None:
473
+ lines.append(f"Query latency P95: {custom_metrics['latency_p95_ms']:.0f}ms.")
474
 
475
  scope = audit.get("benchmark_scope", {})
476
  if scope.get("type") == "single_repository":
 
590
 
591
  model = os.getenv(
592
  "EVAL_MODEL",
593
+ os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-3-5-sonnet-20240620-v1:0"),
594
  )
595
  return BedrockRagasLLM(model=model, run_config=run_config)
596
 
 
627
  try:
628
  from datasets import Dataset
629
  from ragas import evaluate
630
+ from ragas.metrics import faithfulness
631
  from ragas.run_config import RunConfig
632
  except Exception as exc:
633
  log(f"Skipping RAGAS because the evaluation dependencies could not be loaded: {exc}")
 
660
  )
661
  log(
662
  "Using Bedrock for RAGAS judge model "
663
+ f"({os.getenv('EVAL_MODEL', os.getenv('BEDROCK_EVAL_MODEL', 'anthropic.claude-3-5-sonnet-20240620-v1:0'))})"
664
  )
665
  log(
666
  f"RAGAS runtime: async={RAGAS_ASYNC}, raise_exceptions={RAGAS_RAISE_EXCEPTIONS}, "
 
668
  )
669
  llm = build_bedrock_ragas_llm(run_config)
670
  embeddings = build_ragas_embeddings(run_config)
671
+ # Only use faithfulness as the RAGAS metric (simplified to 4-core metrics)
672
  ragas_report = evaluate(
673
  build_ragas_dataset(),
674
+ metrics=[faithfulness],
675
  llm=llm,
676
  embeddings=embeddings,
677
  run_config=run_config,
 
707
  )
708
  outputs = []
709
  details = []
710
+ latencies = []
711
 
712
  for index, row in enumerate(rows, start=1):
713
  case_id = row.get("id", row["question"])
714
  log(f"[{index}/{len(rows)}] Querying case {case_id}")
715
+ start_time = time.time()
716
  result = post_query(row)
717
+ elapsed_ms = (time.time() - start_time) * 1000
718
+ latencies.append(elapsed_ms)
719
  outputs.append(result)
720
  log(
721
  f"[{index}/{len(rows)}] Received answer for {case_id} "
722
+ f"with {len(result.get('sources', []))} sources in {elapsed_ms:.0f}ms"
723
  )
724
 
725
  cited_paths = [source["file_path"] for source in result.get("sources", [])]
726
  metrics = compute_retrieval_metrics(row.get("expected_sources", []), cited_paths)
 
 
 
727
  length_metrics = answer_length_metrics(result.get("answer", ""))
 
 
 
728
 
729
  details.append(
730
  {
 
735
  "expected_sources": row.get("expected_sources", []),
736
  "retrieved_sources": cited_paths,
737
  "retrieval_hit": metrics["retrieval_hit"],
 
 
738
  "top1_hit": metrics["top1_hit"],
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  **length_metrics,
740
  }
741
  )
742
 
743
+ # Compute P95 latency
744
+ latencies.sort()
745
+ p95_index = int(len(latencies) * 0.95)
746
+ latency_p95 = latencies[p95_index] if latencies else None
747
+
748
  log("Finished query loop. Computing aggregate metrics.")
749
+ custom_metrics = summarize_custom_metrics(details, latency_p95)
750
  category_breakdown = summarize_by_category(details)
751
  ragas_report, ragas_error = run_ragas(rows, outputs)
752
  headline_metrics = build_headline_metrics(custom_metrics, audit)
src/embeddings.py CHANGED
@@ -380,7 +380,7 @@ class EmbeddingGenerator:
380
  if explicit_model:
381
  return explicit_model
382
  if self.provider == "bedrock":
383
- return os.getenv("BEDROCK_EMBEDDING_MODEL", "cohere.embed-v4:0")
384
  if self.provider == "vertex_ai":
385
  return os.getenv("VERTEX_EMBEDDING_MODEL", "gemini-embedding-001")
386
  if self._is_hf_space() or self._is_test_context():
 
380
  if explicit_model:
381
  return explicit_model
382
  if self.provider == "bedrock":
383
+ return os.getenv("BEDROCK_EMBEDDING_MODEL", "cohere.embed-v3:0")
384
  if self.provider == "vertex_ai":
385
  return os.getenv("VERTEX_EMBEDDING_MODEL", "gemini-embedding-001")
386
  if self._is_hf_space() or self._is_test_context():
src/rag_system.py CHANGED
@@ -488,7 +488,7 @@ class CodebaseRAGSystem:
488
  You are answering questions as a knowledgeable teammate who has carefully read this repository.
489
 
490
  Rules:
491
- 1. Use only the supplied repository context.
492
  2. Answer conversationally and directly, as if the repo is explaining itself to the user.
493
  3. Do not say "Based on the provided context", "The repository is about", or similar throat-clearing phrases.
494
  4. Be concrete about files, functions, and behavior.
@@ -498,7 +498,7 @@ Rules:
498
  8. Use short sections or bullets only when they genuinely help readability.
499
  9. Do not leave unfinished headings, dangling bullets, or trailing markdown markers like #, ##, or ###.
500
  10. Do not include inline citation markers like [Source 1] in the prose. The UI already shows sources separately.
501
- 11. Do not make claims that are not directly supported by the supplied sources.
502
  12. Prefer the most canonical source files for API and implementation questions, such as package exports, core modules, and session/query code, over tutorial prose when they disagree in specificity.
503
  13. Keep the answer tight. Lead with the direct answer, then add only the most important supporting detail.
504
  """
@@ -584,7 +584,7 @@ Do not leave the answer unfinished.
584
  self.llm_client = create_bedrock_runtime_client()
585
  self.llm_model = os.getenv(
586
  "BEDROCK_LLM_MODEL",
587
- "anthropic.claude-sonnet-4-20250514-v1:0",
588
  )
589
  return
590
 
@@ -604,7 +604,7 @@ Do not leave the answer unfinished.
604
  "GOOGLE_CLOUD_PROJECT must be set when using Vertex AI LLMs."
605
  )
606
 
607
- self.llm_model = os.getenv("VERTEX_LLM_MODEL", "claude-sonnet-4@20250514")
608
  if self.llm_model.startswith("claude-"):
609
  try:
610
  from anthropic import AnthropicVertex
 
488
  You are answering questions as a knowledgeable teammate who has carefully read this repository.
489
 
490
  Rules:
491
+ 1. Use ONLY the supplied repository context to answer. Do not use external knowledge.
492
  2. Answer conversationally and directly, as if the repo is explaining itself to the user.
493
  3. Do not say "Based on the provided context", "The repository is about", or similar throat-clearing phrases.
494
  4. Be concrete about files, functions, and behavior.
 
498
  8. Use short sections or bullets only when they genuinely help readability.
499
  9. Do not leave unfinished headings, dangling bullets, or trailing markdown markers like #, ##, or ###.
500
  10. Do not include inline citation markers like [Source 1] in the prose. The UI already shows sources separately.
501
+ 11. If you cannot answer the question using the provided context, say: "I cannot find sufficient evidence in the codebase to answer this question."
502
  12. Prefer the most canonical source files for API and implementation questions, such as package exports, core modules, and session/query code, over tutorial prose when they disagree in specificity.
503
  13. Keep the answer tight. Lead with the direct answer, then add only the most important supporting detail.
504
  """
 
584
  self.llm_client = create_bedrock_runtime_client()
585
  self.llm_model = os.getenv(
586
  "BEDROCK_LLM_MODEL",
587
+ "anthropic.claude-3-5-sonnet-20240620-v1:0",
588
  )
589
  return
590
 
 
604
  "GOOGLE_CLOUD_PROJECT must be set when using Vertex AI LLMs."
605
  )
606
 
607
+ self.llm_model = os.getenv("VERTEX_LLM_MODEL", "claude-3-5-sonnet@20240620")
608
  if self.llm_model.startswith("claude-"):
609
  try:
610
  from anthropic import AnthropicVertex