Spaces:
Build error
Build error
| """ | |
| 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}") | |