| """ |
| Evaluation harness for the DSA RAG chatbot. |
| |
| Runs the predefined question set (evaluation/questions.json) through the |
| agent router + retriever (and optionally the full LLM pipeline) and reports: |
| |
| - routing accuracy (predicted route_type vs expected_route) |
| - retrieval topic recall (expected topics found among retrieved chunks) |
| - retrieval latency (avg / p95, ms) |
| - generation latency (avg / p95, ms) -- only with --with-llm |
| |
| Usage: |
| python -m evaluation.evaluate |
| python -m evaluation.evaluate --with-llm |
| python -m evaluation.evaluate --questions evaluation/questions.json |
| |
| Run from the project root (dsa-rag-chatbot/) so `config` and the app |
| packages resolve correctly. |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import statistics |
| import time |
|
|
| import config |
| from agents.followup import gather_followup_context |
| from agents.router import RouteType, classify, gather_context |
| from logs.logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
| DEFAULT_QUESTIONS_PATH = os.path.join(os.path.dirname(__file__), "questions.json") |
|
|
| |
| |
| _FAKE_HISTORY_FOR_FOLLOWUPS = [ |
| {"role": "user", "content": "Explain merge sort"}, |
| {"role": "assistant", "content": "Merge sort is a divide-and-conquer sorting algorithm..."}, |
| ] |
|
|
|
|
| def _load_questions(path: str) -> list: |
| with open(path, "r", encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| def _percentile(values: list, pct: float) -> float: |
| if not values: |
| return 0.0 |
| values = sorted(values) |
| k = (len(values) - 1) * (pct / 100) |
| f, c = int(k), min(int(k) + 1, len(values) - 1) |
| if f == c: |
| return values[f] |
| return values[f] + (k - f) * (values[c] - values[f]) |
|
|
|
|
| def _retrieved_topics(chunks: list) -> set: |
| return {c.get("metadata", {}).get("topic") for c in chunks if c.get("metadata", {}).get("topic")} |
|
|
|
|
| def _topic_recall(expected_topics: list, retrieved: set) -> float: |
| if not expected_topics: |
| return 1.0 |
| hits = sum(1 for t in expected_topics if t in retrieved) |
| return hits / len(expected_topics) |
|
|
|
|
| def evaluate_one(item: dict, retriever, with_llm: bool) -> dict: |
| query = item["query"] |
| expected_route = item["expected_route"] |
| expected_topics = item.get("expected_topics", []) |
| recent_messages = _FAKE_HISTORY_FOR_FOLLOWUPS if item.get("requires_history") else [] |
|
|
| t0 = time.perf_counter() |
| decision = classify(query, has_conversation_history=len(recent_messages) > 0) |
| decision = gather_context(decision, retriever, recent_messages=recent_messages) |
| retrieval_latency_ms = (time.perf_counter() - t0) * 1000 |
|
|
| if decision.route_type == RouteType.SINGLE_TOPIC: |
| retrieved = _retrieved_topics(decision.single_chunks) |
| elif decision.route_type == RouteType.COMPARISON: |
| retrieved = set() |
| for chunks in decision.comparison_context.values(): |
| retrieved |= _retrieved_topics(chunks) |
| elif decision.route_type == RouteType.FOLLOWUP: |
| retrieved = _retrieved_topics(decision.followup_context.get("additional_chunks", [])) |
| else: |
| retrieved = set() |
|
|
| route_correct = decision.route_type.value == expected_route |
| topic_recall = _topic_recall(expected_topics, retrieved) |
|
|
| result = { |
| "id": item.get("id"), |
| "query": query, |
| "expected_route": expected_route, |
| "predicted_route": decision.route_type.value, |
| "route_correct": route_correct, |
| "expected_topics": expected_topics, |
| "retrieved_topics": sorted(t for t in retrieved if t), |
| "topic_recall": round(topic_recall, 3), |
| "retrieval_latency_ms": round(retrieval_latency_ms, 2), |
| } |
|
|
| if with_llm: |
| from llm.generate import generate |
| from llm.prompts import ( |
| build_comparison_prompt, |
| build_followup_prompt, |
| build_reject_prompt, |
| build_single_prompt, |
| ) |
|
|
| if decision.route_type == RouteType.SINGLE_TOPIC: |
| prompt = build_single_prompt(decision.single_chunks, [], recent_messages, query) |
| elif decision.route_type == RouteType.COMPARISON: |
| prompt = build_comparison_prompt(decision.comparison_context, [], recent_messages, query) |
| elif decision.route_type == RouteType.FOLLOWUP: |
| prompt = build_followup_prompt(decision.followup_context, [], recent_messages, query) |
| else: |
| prompt = build_reject_prompt(query) |
|
|
| t1 = time.perf_counter() |
| try: |
| response_text = generate(prompt) |
| gen_error = None |
| except Exception as exc: |
| response_text = None |
| gen_error = str(exc) |
| generation_latency_ms = (time.perf_counter() - t1) * 1000 |
|
|
| result["generation_latency_ms"] = round(generation_latency_ms, 2) |
| result["response_preview"] = (response_text or "")[:200] |
| if gen_error: |
| result["generation_error"] = gen_error |
|
|
| return result |
|
|
|
|
| def run(questions_path: str = DEFAULT_QUESTIONS_PATH, with_llm: bool = False) -> dict: |
| from rag.retriever import get_retriever |
|
|
| questions = _load_questions(questions_path) |
| retriever = get_retriever() |
|
|
| results = [evaluate_one(item, retriever, with_llm) for item in questions] |
|
|
| route_accuracy = sum(r["route_correct"] for r in results) / len(results) |
| avg_topic_recall = statistics.mean(r["topic_recall"] for r in results) |
| retrieval_latencies = [r["retrieval_latency_ms"] for r in results] |
|
|
| summary = { |
| "total_questions": len(results), |
| "route_accuracy": round(route_accuracy, 3), |
| "avg_topic_recall": round(avg_topic_recall, 3), |
| "retrieval_latency_ms_avg": round(statistics.mean(retrieval_latencies), 2), |
| "retrieval_latency_ms_p95": round(_percentile(retrieval_latencies, 95), 2), |
| } |
|
|
| if with_llm: |
| gen_latencies = [r["generation_latency_ms"] for r in results if "generation_latency_ms" in r] |
| if gen_latencies: |
| summary["generation_latency_ms_avg"] = round(statistics.mean(gen_latencies), 2) |
| summary["generation_latency_ms_p95"] = round(_percentile(gen_latencies, 95), 2) |
| summary["generation_errors"] = sum(1 for r in results if r.get("generation_error")) |
|
|
| report = {"summary": summary, "results": results} |
|
|
| logger.info("Evaluation complete: %s", summary) |
| return report |
|
|
|
|
| def _print_report(report: dict) -> None: |
| summary = report["summary"] |
| print("\n=== DSA RAG Chatbot — Evaluation Summary ===") |
| for key, value in summary.items(): |
| print(f" {key}: {value}") |
|
|
| print("\n=== Per-question results ===") |
| header = f"{'id':<5} {'route (exp->got)':<28} {'recall':<8} {'ret_ms':<8} query" |
| print(header) |
| print("-" * len(header)) |
| for r in report["results"]: |
| route_str = f"{r['expected_route']} -> {r['predicted_route']}" |
| mark = "OK" if r["route_correct"] else "MISS" |
| print( |
| f"{r['id']:<5} {route_str:<28} {r['topic_recall']:<8} " |
| f"{r['retrieval_latency_ms']:<8} [{mark}] {r['query']}" |
| ) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Evaluate DSA RAG chatbot retrieval + routing.") |
| parser.add_argument( |
| "--questions", default=DEFAULT_QUESTIONS_PATH, help="Path to questions.json" |
| ) |
| parser.add_argument( |
| "--with-llm", |
| action="store_true", |
| help="Also call the configured LLM provider end-to-end (uses API quota).", |
| ) |
| parser.add_argument( |
| "--out", |
| default=None, |
| help="Path to write the full JSON report (default: evaluation/last_report.json)", |
| ) |
| args = parser.parse_args() |
|
|
| report = run(questions_path=args.questions, with_llm=args.with_llm) |
| _print_report(report) |
|
|
| out_path = args.out or os.path.join(os.path.dirname(__file__), "last_report.json") |
| with open(out_path, "w", encoding="utf-8") as f: |
| json.dump(report, f, indent=2) |
| print(f"\nFull report written to: {out_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|