lecturelens / eval /eval_generation.py
Nitesh Ranjan Singh
feat: initial LectureLens — hybrid RAG learning copilot (phases 0-7)
14b6003
Raw
History Blame Contribute Delete
6.71 kB
"""
LLM-as-judge generation evaluation over a 40-question subset.
Metrics (scored by Gemini Flash):
- Faithfulness: every claim supported by a cited chunk
- Answer relevance: answer addresses the question
Also reports abstention rate on 10 deliberately unanswerable questions.
Usage:
PYTHONPATH=backend python eval/eval_generation.py
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
QUESTIONS_PATH = Path(__file__).parent / "queries.jsonl"
RESULTS_PATH = Path(__file__).parent / "generation_results.md"
UNANSWERABLE_TAG = "unanswerable"
def load_questions(path: Path) -> list[dict]:
questions = []
with path.open() as f:
for line in f:
d = json.loads(line.strip())
questions.append(d)
return questions
def evaluate_with_llm_judge(question: str, answer: str, citations: list[dict]) -> dict:
"""Score faithfulness + relevance using Gemini Flash as judge."""
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.config import settings
context_snippets = "\n".join(
f"[{c['index']}] {c.get('text_snippet', '')}" for c in citations
)
faithfulness_prompt = f"""You are evaluating whether an AI answer is faithful to its cited sources.
Question: {question}
Answer: {answer}
Cited sources:
{context_snippets}
Score faithfulness from 0.0 to 1.0 (1.0 = every claim fully supported by citations, 0.0 = major unsupported claims).
Return JSON: {{"faithfulness": float, "reason": str}}"""
relevance_prompt = f"""Score how well this answer addresses the question.
Question: {question}
Answer: {answer}
Score from 0.0 to 1.0 (1.0 = fully answers the question, 0.0 = completely misses it).
Return JSON: {{"relevance": float, "reason": str}}"""
try:
import google.generativeai as genai
genai.configure(api_key=settings.gemini_api_key)
model = genai.GenerativeModel(
settings.gemini_model,
generation_config=genai.GenerationConfig(
temperature=0.0,
response_mime_type="application/json",
),
)
f_resp = model.generate_content(faithfulness_prompt)
r_resp = model.generate_content(relevance_prompt)
f_result = json.loads(f_resp.text)
r_result = json.loads(r_resp.text)
return {
"faithfulness": f_result.get("faithfulness", 0.0),
"faithfulness_reason": f_result.get("reason", ""),
"relevance": r_result.get("relevance", 0.0),
"relevance_reason": r_result.get("reason", ""),
}
except Exception as e:
return {"faithfulness": -1.0, "relevance": -1.0, "error": str(e)}
def run_generation_eval(questions: list[dict], workspace_id: str = "uofg-msds-demo"):
sys.path.insert(0, str(Path(__file__).parent.parent / "backend"))
from app.generation.answer import generate_answer
from app.retrieval.retriever import retrieve
results = []
abstentions = 0
answerable_count = 0
unanswerable_count = 0
for i, q in enumerate(questions[:50]):
qid = q.get("qid", str(i))
query = q.get("query", "")
is_unanswerable = q.get("tag") == UNANSWERABLE_TAG
print(f" [{i+1}/{len(questions[:50])}] {qid}: {query[:60]}...")
chunks = retrieve(query, workspace_id)
result = generate_answer(query, chunks)
answer = result.get("answer", "")
citations = result.get("citations", [])
abstained = "couldn't find" in answer.lower() or "not enough information" in answer.lower()
if is_unanswerable:
unanswerable_count += 1
if abstained:
abstentions += 1
scores = {"faithfulness": None, "relevance": None}
else:
answerable_count += 1
if not abstained and citations:
scores = evaluate_with_llm_judge(query, answer, citations)
else:
scores = {"faithfulness": 0.0, "relevance": 0.0}
results.append({
"qid": qid,
"query": query,
"unanswerable": is_unanswerable,
"abstained": abstained,
"answer_length": len(answer),
**scores,
})
time.sleep(0.5) # Rate limit
abstention_rate = abstentions / unanswerable_count if unanswerable_count else 0.0
answerable_results = [r for r in results if not r["unanswerable"] and r.get("faithfulness") is not None and r["faithfulness"] >= 0]
avg_faithfulness = sum(r["faithfulness"] for r in answerable_results) / len(answerable_results) if answerable_results else 0.0
avg_relevance = sum(r["relevance"] for r in answerable_results) / len(answerable_results) if answerable_results else 0.0
summary = {
"total_questions": len(results),
"answerable": answerable_count,
"unanswerable": unanswerable_count,
"abstention_rate": round(abstention_rate, 3),
"avg_faithfulness": round(avg_faithfulness, 3),
"avg_relevance": round(avg_relevance, 3),
}
return results, summary
def write_results_md(results: list[dict], summary: dict, path: Path):
lines = [
"# LectureLens Generation Quality Evaluation",
"",
"## Summary",
"",
f"| Metric | Value |",
"|--------|-------|",
f"| Total questions | {summary['total_questions']} |",
f"| Answerable | {summary['answerable']} |",
f"| Unanswerable | {summary['unanswerable']} |",
f"| Abstention rate (on unanswerables) | {summary['abstention_rate']:.1%} |",
f"| Avg faithfulness | {summary['avg_faithfulness']:.3f} |",
f"| Avg answer relevance | {summary['avg_relevance']:.3f} |",
"",
"## Per-question results",
"",
"| QID | Unanswerable | Abstained | Faithfulness | Relevance |",
"|-----|-------------|-----------|-------------|-----------|",
]
for r in results:
lines.append(
f"| {r['qid']} | {r['unanswerable']} | {r['abstained']} "
f"| {r.get('faithfulness', 'N/A')} | {r.get('relevance', 'N/A')} |"
)
path.write_text("\n".join(lines))
if __name__ == "__main__":
if not QUESTIONS_PATH.exists():
print(f"queries file not found: {QUESTIONS_PATH}")
sys.exit(1)
questions = load_questions(QUESTIONS_PATH)
print(f"Evaluating generation on {min(50, len(questions))} questions...")
results, summary = run_generation_eval(questions)
write_results_md(results, summary, RESULTS_PATH)
print(f"\nSummary: {summary}")
print(f"Results written to {RESULTS_PATH}")