"""Đánh giá chất lượng RAG trên câu hỏi mèo lấy từ dataset công khai (HuggingFace playcat community Q&A), đã dịch sang tiếng Việt. Run: uv run python scripts/eval_external.py --with-llm uv run python scripts/eval_external.py --with-llm --delay 3 # nếu bị rate limit Output: scripts/eval_external_results.md """ from __future__ import annotations import argparse import json import sys import time from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8") from dotenv import load_dotenv # noqa: E402 load_dotenv(ROOT / ".env") from app.retriever import retrieve, warmup # noqa: E402 SET_PATH = Path(__file__).parent / "eval_external_set.json" JUDGE_PROMPT = """Bạn là giám khảo đánh giá câu trả lời của chatbot tư vấn về mèo. Cho CÂU HỎI, CONTEXT (các đoạn knowledge base bot được phép dùng) và CÂU TRẢ LỜI, hãy chấm 2 tiêu chí trên thang 1-5: - faithfulness: mọi khẳng định trong câu trả lời CÓ được context chống lưng không? 5 = hoàn toàn dựa vào context, không bịa. 1 = bịa nhiều / mâu thuẫn context. (Nếu bot nói "không đủ thông tin" mà context đúng là không liên quan → faithfulness=5.) - helpfulness: câu trả lời có thực sự giải đáp đúng trọng tâm câu hỏi không? 5 = trả lời trực tiếp, hữu ích. 1 = lạc đề / vô dụng. CHỈ trả về JSON đúng định dạng, không giải thích thêm: {"faithfulness": <1-5>, "helpfulness": <1-5>, "note": ""}""" def judge_reply(question: str, chunks: list[dict], reply: str) -> dict | None: """Dùng Gemini làm giám khảo chấm faithfulness + helpfulness. None nếu lỗi. Xoay vòng tất cả key (GEMINI_API_KEYS / _1.._N / GEMINI_API_KEY) × model để chịu được quota free tier như app.llm.generate_reply. """ from app.llm import DEFAULT_MODELS, gemini_api_keys keys = gemini_api_keys() if not keys: return None from google import genai from google.genai import types ctx = "\n\n".join( f"[{i+1}] (topic={c.get('topic')}, sev={c.get('severity')}) {c.get('text','')[:600]}" for i, c in enumerate(chunks) ) prompt = f"CÂU HỎI: {question}\n\nCONTEXT:\n{ctx}\n\nCÂU TRẢ LỜI:\n{reply}" config = types.GenerateContentConfig( system_instruction=JUDGE_PROMPT, temperature=0.0, # 2.5-flash dùng "thinking tokens" ăn vào ngân sách output → 256 quá nhỏ, # JSON bị cắt (finish_reason=MAX_TOKENS). Để 1024. max_output_tokens=1024, response_mime_type="application/json", ) for key in keys: client = genai.Client(api_key=key) for model_name in DEFAULT_MODELS: try: raw = client.models.generate_content( model=model_name, contents=prompt, config=config, ).text d = _parse_judge_json(raw) if d is None: continue # JSON hỏng/cắt → thử model/key kế tiếp return {"faithfulness": int(d["faithfulness"]), "helpfulness": int(d["helpfulness"]), "note": str(d.get("note", ""))[:200]} except Exception as e: err = str(e).lower() if "429" in err or "quota" in err or "exceeded" in err or "resourceexhausted" in err: continue # model/key kế tiếp return None return None def _parse_judge_json(raw: str) -> dict | None: """Bóc JSON từ output của judge dù model có bọc ```json / thêm lời dẫn. Trả None nếu không tìm được object hợp lệ có đủ faithfulness+helpfulness. """ if not raw: return None try: return json.loads(raw) except Exception: pass import re m = re.search(r"\{.*\}", raw, re.DOTALL) if not m: return None try: d = json.loads(m.group(0)) if "faithfulness" in d and "helpfulness" in d: return d except Exception: return None return None def score_one(q: dict, chunks: list[dict], reply: str | None) -> tuple[list[str], dict]: """Trả về (flags, metrics) cho 1 câu hỏi.""" flags: list[str] = [] m = {"retrieved": len(chunks), "top_score": chunks[0]["score"] if chunks else 0.0, "topic_match": None, "needs_vet": None, "grounded": None, "has_citation": None, "vet_prefix": None} if not chunks: flags.append("🔴 NO_RETRIEVAL") return flags, m top = chunks[0] # 1. Topic match (majority of top-5, like app/main.py topic_detected) counts: dict[str, int] = {} for c in chunks: counts[c.get("topic", "?")] = counts.get(c.get("topic", "?"), 0) + 1 detected = max(counts, key=counts.get) m["topic_detected"] = detected m["topic_match"] = (detected == q["topic"]) if not m["topic_match"]: flags.append(f"🟡 TOPIC: got={detected} exp={q['topic']}") # 2. Retrieval confidence if top["score"] < 0.90: flags.append(f"🔴 LOW_SCORE={top['score']}") elif top["score"] < 0.93: flags.append(f"🟡 LOW_SCORE={top['score']}") # 3. Safety: needs_vet logic mirrors app/main.py (rerank-gate + intent-gate). # PHẢI truyền câu hỏi (q["vi"]) — intent-gate đọc ngôn ngữ cấp tính từ đây. from app.main import _compute_needs_vet needs_vet = _compute_needs_vet(chunks, q["vi"]) m["needs_vet"] = needs_vet if q["emer"] and not needs_vet: flags.append("🔴 EMER_MISSED (needs_vet=False)") if (not q["emer"]) and needs_vet: flags.append("🟡 OVER_TRIGGER (needs_vet=True)") if reply is not None: low = reply.lower() m["grounded"] = not ("không đủ thông tin" in low or "không có đủ thông tin" in low) if not m["grounded"]: flags.append("🟡 LLM_GIVES_UP") if "[llm error]" in low or "hết quota" in low or "thiếu gemini" in low: flags.append("🔴 LLM_ERROR") m["grounded"] = None m["has_citation"] = ("[1]" in reply or "[2]" in reply or "[3]" in reply) if not m["has_citation"]: flags.append("🟡 NO_CITATION") m["vet_prefix"] = reply.strip().startswith("⚠") if q["emer"] and not m["vet_prefix"]: flags.append("🔴 NO_VET_WARNING_PREFIX") if not flags: flags.append("🟢 OK") return flags, m def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--with-llm", action="store_true") ap.add_argument("--judge", action="store_true", help="Gemini chấm faithfulness+helpfulness mỗi reply (cần --with-llm, tốn thêm quota)") ap.add_argument("--delay", type=float, default=2.5) ap.add_argument("--limit", type=int, default=0, help="chỉ chạy N câu đầu (debug)") ap.add_argument("--topic", default=None, help="chỉ chạy câu thuộc topic này (vd: health) — spot-check theo chủ đề") ap.add_argument("--emer-only", action="store_true", help="chỉ chạy câu emer=true") ap.add_argument("--output", default=str(Path(__file__).parent / "eval_external_results.md")) args = ap.parse_args() data = json.loads(SET_PATH.read_text(encoding="utf-8")) questions = data["questions"] if args.topic: questions = [q for q in questions if q.get("topic") == args.topic] if args.emer_only: questions = [q for q in questions if q.get("emer")] if args.limit: questions = questions[:args.limit] print("Loading retriever...") warmup() print(f"Ready. {len(questions)} questions (with_llm={args.with_llm})\n") gen = None if args.with_llm: from app.llm import generate_reply gen = generate_reply results = [] flag_counter: dict[str, int] = {} t0 = time.time() for i, q in enumerate(questions, 1): print(f" [{i:2d}/{len(questions)}] [{q['topic']:9s}]{'⚠' if q['emer'] else ' '} {q['vi'][:55]}", end=" ", flush=True) chunks = retrieve(q["vi"], k=5) reply = None if gen: from app.llm import LLMUnavailable try: reply = gen([{"role": "user", "content": q["vi"]}], chunks, user_level="auto") except LLMUnavailable as e: reply = None print(f"[LLM_UNAVAILABLE:{e.detail}]", end=" ", flush=True) # Mirror app/main.py: server-side vet banner khi needs_vet (đo đúng # hành vi ship, không phụ thuộc LLM tự chèn ⚠). from app.main import _VET_BANNER, _compute_needs_vet if reply and _compute_needs_vet(chunks, q["vi"]) and not reply.lstrip().startswith("⚠"): reply = _VET_BANNER + reply time.sleep(args.delay) flags, metrics = score_one(q, chunks, reply) jdg = None if args.judge and reply: jdg = judge_reply(q["vi"], chunks, reply) time.sleep(args.delay) if jdg: metrics["faithfulness"] = jdg["faithfulness"] metrics["helpfulness"] = jdg["helpfulness"] for f in flags: key = f.split(":")[0].split("=")[0].strip() flag_counter[key] = flag_counter.get(key, 0) + 1 results.append({"q": q, "chunks": chunks, "reply": reply, "flags": flags, "metrics": metrics, "judge": jdg}) suffix = f" | F={jdg['faithfulness']} H={jdg['helpfulness']}" if jdg else "" print(" ".join(flags) + suffix) elapsed = time.time() - t0 # Aggregate metrics n = len(results) topic_ok = sum(1 for r in results if r["metrics"].get("topic_match")) emer = [r for r in results if r["q"]["emer"]] emer_vet_ok = sum(1 for r in results if r["q"]["emer"] and r["metrics"].get("needs_vet")) nonemer = [r for r in results if not r["q"]["emer"]] over_trig = sum(1 for r in nonemer if r["metrics"].get("needs_vet")) if args.with_llm: grounded = sum(1 for r in results if r["metrics"].get("grounded")) cited = sum(1 for r in results if r["metrics"].get("has_citation")) gaveup = sum(1 for r in results if r["metrics"].get("grounded") is False) prefix_ok = sum(1 for r in emer if r["metrics"].get("vet_prefix")) judged = [r for r in results if r.get("judge")] if judged: avg_faith = sum(r["judge"]["faithfulness"] for r in judged) / len(judged) avg_help = sum(r["judge"]["helpfulness"] for r in judged) / len(judged) out = Path(args.output) with out.open("w", encoding="utf-8") as f: f.write("# RAG Eval — câu hỏi từ dataset công khai (dịch sang VN)\n\n") f.write(f"- Date: {datetime.now().isoformat(timespec='seconds')}\n") f.write(f"- Dataset: `{data['_meta']['source_dataset']}`\n") f.write(f"- Mode: {'with-llm' if args.with_llm else 'retrieval-only'}\n") f.write(f"- Questions: {n} | Elapsed: {elapsed:.0f}s\n\n") f.write("## Aggregate scores\n\n") f.write(f"- Topic match (detected==expected): **{topic_ok}/{n}** ({topic_ok/n:.0%})\n") f.write(f"- Emergency → needs_vet=True: **{emer_vet_ok}/{len(emer)}**\n") f.write(f"- Non-emergency over-trigger: **{over_trig}/{len(nonemer)}**\n") if args.with_llm: f.write(f"- Grounded (không 'bỏ cuộc'): **{grounded}/{n}**\n") f.write(f"- Has citation [n]: **{cited}/{n}**\n") f.write(f"- LLM gave up ('không đủ thông tin'): **{gaveup}/{n}**\n") f.write(f"- Emergency replies với ⚠️ prefix: **{prefix_ok}/{len(emer)}**\n") if judged: f.write(f"- Judge faithfulness (1-5): **{avg_faith:.2f}** (n={len(judged)})\n") f.write(f"- Judge helpfulness (1-5): **{avg_help:.2f}** (n={len(judged)})\n") f.write("\n## Flag summary\n\n") for fl, c in sorted(flag_counter.items(), key=lambda x: -x[1]): f.write(f"- `{fl}`: {c}\n") f.write("\n## Detailed results\n\n") for i, r in enumerate(results, 1): q = r["q"] f.write(f"### {i}. [{q['topic']}]{' ⚠EMER' if q['emer'] else ''} {q['vi']}\n\n") f.write(f"*EN gốc:* {q['en']}\n\n") f.write(f"**Flags:** {' / '.join(r['flags'])}\n\n") f.write("**Top retrieval:**\n\n") for j, c in enumerate(r["chunks"], 1): head = (c.get("section_title") or c.get("article_title") or "[no head]")[:70] rr = c.get("rerank_score") rr_s = f"`rr={rr}` " if rr is not None else "" f.write(f"{j}. {rr_s}`e5={c['score']}` `topic={c.get('topic')}` " f"`sev={c.get('severity')}` — {head} \n") if r.get("judge"): jdg = r["judge"] f.write(f"\n**Judge:** faithfulness={jdg['faithfulness']}/5, " f"helpfulness={jdg['helpfulness']}/5 — {jdg['note']}\n") if r["reply"]: f.write(f"\n**Reply:**\n\n> {r['reply'].strip()[:1500]}\n\n") f.write("---\n\n") print(f"\nDone in {elapsed:.0f}s → {out}") print("\n=== Aggregate ===") print(f" Topic match: {topic_ok}/{n}") print(f" Emergency needs_vet: {emer_vet_ok}/{len(emer)}") print(f" Over-trigger: {over_trig}/{len(nonemer)}") if args.with_llm: print(f" Grounded: {grounded}/{n} | Cited: {cited}/{n} | Gave up: {gaveup}/{n}") print(f" Emer ⚠ prefix: {prefix_ok}/{len(emer)}") if judged: print(f" Judge faithfulness: {avg_faith:.2f}/5 | helpfulness: {avg_help:.2f}/5") print("\n=== Flags ===") for fl, c in sorted(flag_counter.items(), key=lambda x: -x[1]): print(f" {fl}: {c}") if __name__ == "__main__": main()