chatbot / evaluation /evaluate.py
anris05's picture
bot
8a2dcce
Raw
History Blame Contribute Delete
8.25 kB
"""
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")
# A tiny bit of fake prior conversation so the follow-up route can actually
# be exercised (is_followup_query requires has_conversation_history=True).
_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 # nothing expected (e.g. out_of_scope) => trivially satisfied
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: # keep the run going even if the LLM call fails
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()