# evaluation/run_evaluation.py """ VilexAgent Evaluation Framework — RAGAS-free ============================================= Tier 1: Structural / Deterministic checks (no LLM needed) Tier 2: Golden Dataset checks (keyword + label matching) Tier 3: LLM-as-Judge using project's own LLM (sequential, no burst) """ import os import json import time import mlflow import pandas as pd from pathlib import Path from dotenv import load_dotenv from src.utils.logger import logger load_dotenv() # ------------------------------------------------------- # Configuration & Constants # ------------------------------------------------------- REQUIRED_ENV_VARS = ["FREELLM_API_KEY", "FREELLM_BASE_URL"] for var in REQUIRED_ENV_VARS: if not os.getenv(var): raise ValueError(f"Missing required environment variable: {var}") BENCHMARK_PATH = Path("evaluation/benchmark.json") RESULTS_DIR = Path("evaluation/results/final_eval") RESULTS_DIR.mkdir(parents=True, exist_ok=True) PER_QUESTION_DELAY = int(os.getenv("EVAL_QUESTION_DELAY", 5)) BETWEEN_SYSTEMS_DELAY = int(os.getenv("EVAL_SYSTEM_DELAY", 20)) LLM_JUDGE_DELAY = int(os.getenv("EVAL_JUDGE_DELAY", 5)) VALID_SOURCES = {"domestic", "international", "both"} VALID_DOMAINS = {"labor", "food_safety"} VALID_ALIGNMENTS = {"aligned", "conflict", "gap", "no_international"} # ------------------------------------------------------- # Singleton Managers # ------------------------------------------------------- _baseline_retriever = None _reranker = None _answer_llm = None def get_baseline_retriever(): global _baseline_retriever if _baseline_retriever is None: from src.retrieval.baseline import BaselineRetriever _baseline_retriever = BaselineRetriever() return _baseline_retriever def get_reranker(): global _reranker if _reranker is None: from sentence_transformers import CrossEncoder import torch logger.info("Initializing reranker...") _reranker = CrossEncoder( "BAAI/bge-reranker-v2-m3", device="cuda" if torch.cuda.is_available() else "cpu" ) logger.success("Reranker ready") return _reranker def get_answer_llm(): global _answer_llm if _answer_llm is None: from src.utils.llm import get_llm _answer_llm = get_llm() return _answer_llm # ------------------------------------------------------- # Data Loading # ------------------------------------------------------- def load_benchmark() -> list[dict]: if not BENCHMARK_PATH.exists(): raise FileNotFoundError(f"Benchmark file not found: {BENCHMARK_PATH}") with open(BENCHMARK_PATH, "r", encoding="utf-8") as f: data = json.load(f) logger.info(f"Loaded {len(data)} benchmark questions from {BENCHMARK_PATH}") return data # ------------------------------------------------------- # Domain Detection (for Baselines that don't decompose) # ------------------------------------------------------- LABOR_KEYWORDS = [ "lao động", "hợp đồng", "tiền lương", "công đoàn", "thử việc", "làm thêm", "thôi việc", "sa thải", "bảo hiểm xã hội", "người lao động", "tranh chấp", "đình công", "thương lượng", "trợ cấp" ] def detect_domain(query: str) -> str: q = query.lower().strip() return "labor" if any(w in q for w in LABOR_KEYWORDS) else "food_safety" # ------------------------------------------------------- # System Implementations # ------------------------------------------------------- def run_baseline1(query: str) -> tuple[str, list[str]]: retriever = get_baseline_retriever() llm = get_answer_llm() domain = detect_domain(query) chunks = retriever.retrieve(query, source="domestic", domain=domain, top_k=5) context = "\n\n".join([c["text"] for c in chunks]) if chunks else "Không có ngữ cảnh." prompt = ( f"Dựa trên thông tin pháp luật sau, hãy trả lời câu hỏi:\n\n" f"{context}\n\nCâu hỏi: {query}" ) return llm.invoke(prompt).content, [c["text"] for c in chunks] def run_baseline2(query: str) -> tuple[str, list[str]]: retriever = get_baseline_retriever() reranker = get_reranker() llm = get_answer_llm() domain = detect_domain(query) chunks = retriever.retrieve(query, source="domestic", domain=domain, top_k=10) try: pairs = [(query, c["text"]) for c in chunks] scores = reranker.predict(pairs) ranked = sorted(zip(scores, chunks), key=lambda x: x[0], reverse=True) chunks = [c for _, c in ranked[:5]] except Exception as e: logger.warning(f"Reranker failed: {e}. Using top-5 unranked.") chunks = chunks[:5] context = "\n\n".join([c["text"] for c in chunks]) if chunks else "Không có ngữ cảnh." prompt = ( f"Dựa trên thông tin pháp luật sau, hãy trả lời câu hỏi:\n\n" f"{context}\n\nCâu hỏi: {query}" ) return llm.invoke(prompt).content, [c["text"] for c in chunks] def run_vilexagent(query: str) -> tuple[str, list[str], dict]: from src.agents.graph import vilexagent initial_state = { "original_query": query, "sub_questions": [], "requires_international": False, "domestic_chunks": [], "international_chunks": [], "cross_reference": None, "final_answer": "", "citations": [], "has_expired_docs": False, "error": None, } result = vilexagent.invoke(initial_state) all_chunks = result.get("domestic_chunks", []) + result.get("international_chunks", []) contexts = [c["text"] for c in all_chunks[:10]] final_answer = result.get("final_answer", "") if not final_answer: logger.warning(f"ViLexAgent returned empty answer for: {query[:50]}...") return final_answer, contexts, result # ------------------------------------------------------- # TIER 1 — Structural / Deterministic Checks # ViLexAgent only. Checks internal pipeline correctness. # No LLM needed. Pure schema + string validation. # ------------------------------------------------------- def check_decomposition_schema(sub_questions: list[dict]) -> dict: """Every sub-question must have valid source, domain, and non-empty question.""" issues = [] if not isinstance(sub_questions, list): return {"passed": False, "issues": ["sub_questions is not a list"]} if len(sub_questions) == 0: return {"passed": False, "issues": ["sub_questions is empty"]} for i, sq in enumerate(sub_questions): if not isinstance(sq, dict): issues.append(f"sub_question[{i}] is not a dict") continue if sq.get("source") not in VALID_SOURCES: issues.append(f"sub_question[{i}] invalid source: '{sq.get('source')}'") if sq.get("domain") not in VALID_DOMAINS: issues.append(f"sub_question[{i}] invalid domain: '{sq.get('domain')}'") if not sq.get("question", "").strip(): issues.append(f"sub_question[{i}] empty question text") return {"passed": len(issues) == 0, "issues": issues} def check_alignment_schema(cross_reference: dict) -> dict: """cross_reference must have a valid alignment value and non-empty summary fields.""" issues = [] if not isinstance(cross_reference, dict): return {"passed": False, "issues": ["cross_reference is not a dict"]} if cross_reference.get("alignment") not in VALID_ALIGNMENTS: issues.append(f"invalid alignment: '{cross_reference.get('alignment')}'") for key in ["domestic_summary", "international_summary", "explanation"]: if not cross_reference.get(key, "").strip(): issues.append(f"missing/empty field: '{key}'") return {"passed": len(issues) == 0, "issues": issues} def check_citation_presence(final_answer: str, citations: list[str]) -> dict: """ At least one citation's so_ky_hieu should appear in final_answer. Soft check: no citations generated is a warning, not a fail. """ if not citations: return {"passed": True, "grounded": False, "note": "no citations generated"} answer_lower = final_answer.lower() # Each citation is "SO_KY_HIEU — article_number"; check first part found = [c for c in citations if c.split("—")[0].strip().lower() in answer_lower] return { "passed": True, # not a hard structural fail "grounded": len(found) > 0, "found_count": len(found), "total_citations": len(citations), } def check_expired_warning(has_expired_docs: bool, final_answer: str) -> dict: """If expired docs exist in retrieved chunks, answer MUST warn the user.""" if not has_expired_docs: return {"passed": True, "applicable": False} warn_phrases = [ "hết hiệu lực", "không còn hiệu lực", "đã bị thay thế", "cảnh báo", "lưu ý", "văn bản cũ", "đã hết hiệu lực" ] warned = any(p in final_answer.lower() for p in warn_phrases) return {"passed": warned, "applicable": True, "warned": warned} def run_tier1(full_state: dict, final_answer: str) -> dict: sub_questions = full_state.get("sub_questions", []) cross_reference = full_state.get("cross_reference") or {} citations = full_state.get("citations", []) has_expired = full_state.get("has_expired_docs", False) d = check_decomposition_schema(sub_questions) a = check_alignment_schema(cross_reference) c = check_citation_presence(final_answer, citations) e = check_expired_warning(has_expired, final_answer) all_issues = d["issues"] + a["issues"] if not e["passed"]: all_issues.append("expired docs not warned in answer") return { "tier1_passed": d["passed"] and a["passed"] and e["passed"], "decomp_schema_ok": d["passed"], "alignment_schema_ok": a["passed"], "citation_grounded": c.get("grounded", False), "expired_warning_ok": e["passed"], "tier1_issues": all_issues, } # ------------------------------------------------------- # TIER 2 — Golden Dataset Checks # All systems. Compares behavior against expected labels # and measures factual keyword coverage vs reference answer. # ------------------------------------------------------- def evaluate_source_routing(sub_questions: list[dict], expected_source: str): """ Did the decomposer correctly decide whether international sources are needed? Returns float (1.0/0.0) or None if not applicable. """ if not sub_questions: return None pred_needs_intl = any(sq.get("source") in ("international", "both") for sq in sub_questions) exp_needs_intl = expected_source in ("international", "both") return 1.0 if pred_needs_intl == exp_needs_intl else 0.0 def evaluate_alignment_label(predicted: str, expected: str): """Exact match of alignment label. Only meaningful for type_c questions.""" if not predicted or not expected: return None return 1.0 if predicted == expected else 0.0 def evaluate_keyword_coverage(final_answer: str, reference: str) -> float: """ Lightweight factual coverage proxy. Extracts meaningful tokens (>=4 chars, non-stopword) from the reference, checks what fraction appear in the answer. Score: 0.0 to 1.0 """ STOPWORDS = { "theo", "của", "trong", "được", "phải", "các", "này", "một", "và", "hoặc", "với", "cho", "tại", "từ", "đến", "là", "có", "không", "khi", "nếu", "đã", "sẽ", "bởi", "những", "như", "thì", "về", "lên", "trên", "dưới", "cũng", "rằng", "vào", "bằng", "đây", "đó", "mọi" } if not reference or not final_answer: return 0.0 ref_tokens = { t for t in reference.lower().split() if len(t) >= 4 and t not in STOPWORDS } if not ref_tokens: return 0.0 answer_lower = final_answer.lower() found = sum(1 for t in ref_tokens if t in answer_lower) return round(found / len(ref_tokens), 4) def run_tier2( final_answer: str, reference: str, expected_source: str, expected_alignment: str, question_type: str, sub_questions: list[dict] = None, cross_reference: dict = None, ) -> dict: source_score = evaluate_source_routing(sub_questions or [], expected_source) alignment_score = None if question_type == "type_c" and cross_reference: alignment_score = evaluate_alignment_label( cross_reference.get("alignment"), expected_alignment ) keyword_score = evaluate_keyword_coverage(final_answer, reference) return { "source_routing_score": source_score, "alignment_label_score": alignment_score, "keyword_coverage_score": keyword_score, } # ------------------------------------------------------- # TIER 3 — LLM-as-Judge # Uses the project's own LLM. Called SEQUENTIALLY — one # call per question. No parallel jobs, no burst → no timeout. # ------------------------------------------------------- LLM_JUDGE_PROMPT = """Bạn là chuyên gia đánh giá câu trả lời pháp lý Việt Nam. Chấm điểm câu trả lời theo 3 tiêu chí (mỗi tiêu chí 1–5): **Câu hỏi:** {query} **Câu trả lời tham chiếu:** {reference} **Câu trả lời cần đánh giá:** {answer} **Tiêu chí:** - relevance (1–5): Có trả lời đúng câu hỏi không? (1=hoàn toàn lạc đề, 5=chính xác đầy đủ) - groundedness (1–5): Có trích dẫn điều khoản/số hiệu văn bản cụ thể không? (1=không có, 5=rõ ràng cụ thể) - completeness (1–5): So với tham chiếu, có đề cập đủ các điểm chính không? (1=thiếu hầu hết, 5=đầy đủ) Trả lời ONLY bằng JSON hợp lệ, không có text nào khác: {{"relevance": N, "groundedness": N, "completeness": N}}""" def run_tier3_judge(query: str, reference: str, answer: str) -> dict: if not answer or not answer.strip(): return {"relevance": 1, "groundedness": 1, "completeness": 1, "judge_error": "empty answer"} llm = get_answer_llm() prompt = LLM_JUDGE_PROMPT.format( query=query, reference=reference, answer=answer[:1500], # prevent context overflow ) for attempt in range(2): try: raw = llm.invoke(prompt).content.strip() if "```" in raw: raw = raw.split("```")[1] if raw.startswith("json"): raw = raw[4:] raw = raw.strip() scores = json.loads(raw) for key in ["relevance", "groundedness", "completeness"]: if key not in scores: raise ValueError(f"Missing key: {key}") scores[key] = max(1, min(5, int(scores[key]))) # clamp to 1-5 scores["judge_error"] = None return scores except Exception as e: logger.warning(f"LLM Judge attempt {attempt+1} failed: {e}") time.sleep(3) return { "relevance": None, "groundedness": None, "completeness": None, "judge_error": "failed after 2 attempts" } # ------------------------------------------------------- # Main Evaluation Runner # ------------------------------------------------------- def run_evaluation( system_name: str, benchmark: list[dict], max_questions: int = None, ) -> tuple[pd.DataFrame, dict]: logger.info(f"\n{'='*60}") logger.info(f"Evaluating: {system_name}") questions = benchmark[:max_questions] if max_questions else benchmark is_vilexagent = system_name == "ViLexAgent" raw_results = [] for i, q in enumerate(questions): logger.info(f" [{i+1}/{len(questions)}] {q['id']}: {q['query'][:60]}...") start = time.time() answer = "" contexts = [] full_state = {} error_msg = None # --- Run system --- try: if is_vilexagent: answer, contexts, full_state = run_vilexagent(q["query"]) elif system_name == "Baseline1_NaiveRAG": answer, contexts = run_baseline1(q["query"]) else: answer, contexts = run_baseline2(q["query"]) latency = round(time.time() - start, 2) logger.info(f" ✓ {latency}s") except Exception as e: error_msg = str(e) latency = round(time.time() - start, 2) logger.error(f" ✗ {error_msg}") # --- Tier 1 --- tier1 = {} if is_vilexagent and not error_msg: tier1 = run_tier1(full_state, answer) if not tier1["tier1_passed"]: logger.warning(f" ⚠ T1 issues: {tier1['tier1_issues']}") # --- Tier 2 --- tier2 = {} if not error_msg: tier2 = run_tier2( final_answer = answer, reference = q["reference"], expected_source = q["expected_source"], expected_alignment = q["expected_alignment"], question_type = q["type"], sub_questions = full_state.get("sub_questions", []) if is_vilexagent else [], cross_reference = full_state.get("cross_reference") if is_vilexagent else None, ) # --- Tier 3 (throttled) --- tier3 = {} if not error_msg and answer.strip(): time.sleep(LLM_JUDGE_DELAY) tier3 = run_tier3_judge(q["query"], q["reference"], answer) if tier3.get("judge_error"): logger.warning(f" ⚠ Judge: {tier3['judge_error']}") else: logger.info( f" Judge → rel:{tier3['relevance']} " f"grnd:{tier3['groundedness']} comp:{tier3['completeness']}" ) raw_results.append({ # Metadata "id": q["id"], "type": q["type"], "domain": q["domain"], "expected_source": q["expected_source"], "expected_alignment": q["expected_alignment"], "query": q["query"], "answer": answer, "reference": q["reference"], "latency": latency, "error": error_msg, # Tier 1 "t1_passed": tier1.get("tier1_passed"), "t1_decomp_schema_ok": tier1.get("decomp_schema_ok"), "t1_alignment_schema_ok": tier1.get("alignment_schema_ok"), "t1_citation_grounded": tier1.get("citation_grounded"), "t1_expired_warning_ok": tier1.get("expired_warning_ok"), # Tier 2 "t2_source_routing": tier2.get("source_routing_score"), "t2_alignment_label": tier2.get("alignment_label_score"), "t2_keyword_coverage": tier2.get("keyword_coverage_score"), # Tier 3 "t3_relevance": tier3.get("relevance"), "t3_groundedness": tier3.get("groundedness"), "t3_completeness": tier3.get("completeness"), }) time.sleep(PER_QUESTION_DELAY) # --- Build DataFrame & Summary --- df = pd.DataFrame(raw_results) def safe_mean(series): clean = series.dropna() return round(float(clean.mean()), 4) if len(clean) > 0 else None ok = df[df["error"].isna()] type_c = ok[ok["type"] == "type_c"] def bool_rate(col): if is_vilexagent and col in ok.columns: return safe_mean(ok[col].map(lambda x: float(x) if x is not None else None)) return None summary = { "system": system_name, "total_questions": len(questions), "error_rate": round(float(df["error"].notna().mean()), 4), "avg_latency_s": safe_mean(ok["latency"]), # Tier 1 "t1_pass_rate": bool_rate("t1_passed"), "t1_decomp_schema_rate": bool_rate("t1_decomp_schema_ok"), "t1_citation_grounded_rate": bool_rate("t1_citation_grounded"), "t1_expired_warning_rate": bool_rate("t1_expired_warning_ok"), # Tier 2 "t2_source_routing_acc": safe_mean(ok["t2_source_routing"]) if is_vilexagent else None, "t2_alignment_acc": safe_mean(type_c["t2_alignment_label"]) if is_vilexagent else None, "t2_keyword_coverage": safe_mean(ok["t2_keyword_coverage"]), # Tier 3 "t3_relevance_avg": safe_mean(ok["t3_relevance"]), "t3_groundedness_avg": safe_mean(ok["t3_groundedness"]), "t3_completeness_avg": safe_mean(ok["t3_completeness"]), } logger.info(f"\n--- {system_name} Summary ---") for k, v in summary.items(): if v is not None: logger.info(f" {k}: {v}") return df, summary # ------------------------------------------------------- # Entry Point # ------------------------------------------------------- def main(): benchmark = load_benchmark() logger.info("Pre-initializing singletons...") get_baseline_retriever() get_reranker() get_answer_llm() MAX_Q = int(os.getenv("EVAL_MAX_QUESTIONS", 30)) timestamp = time.strftime("%Y%m%d_%H%M%S") mlflow.set_tracking_uri(os.getenv("MLFLOW_TRACKING_URI", "http://localhost:5000")) mlflow.set_experiment("vilexagent_evaluation") all_summaries = [] systems = ["Baseline1_NaiveRAG", "Baseline2_RAGRerank", "ViLexAgent"] for idx, system_name in enumerate(systems): logger.info(f"\n>>> [{idx+1}/{len(systems)}] {system_name}") with mlflow.start_run(run_name=f"{system_name}_{timestamp}"): df, summary = run_evaluation(system_name, benchmark, max_questions=MAX_Q) csv_path = RESULTS_DIR / f"{system_name}_{timestamp}.csv" df.to_csv(csv_path, index=False, encoding="utf-8-sig") logger.success(f"Saved → {csv_path}") for k, v in summary.items(): if v is not None and isinstance(v, (int, float)): mlflow.log_metric(k, v) mlflow.log_param("system", system_name) mlflow.log_param("num_questions", MAX_Q) mlflow.log_param("eval_framework", "deterministic_llm_judge") mlflow.log_artifact(str(csv_path)) all_summaries.append(summary) if idx < len(systems) - 1: logger.info(f"Cooling down {BETWEEN_SYSTEMS_DELAY}s...") time.sleep(BETWEEN_SYSTEMS_DELAY) # Save & print comparison df_compare = pd.DataFrame(all_summaries) compare_path = RESULTS_DIR / f"comparison_{timestamp}.csv" df_compare.to_csv(compare_path, index=False, encoding="utf-8-sig") display_cols = [ "system", "error_rate", "avg_latency_s", "t2_keyword_coverage", "t3_relevance_avg", "t3_groundedness_avg", "t3_completeness_avg", "t2_source_routing_acc", "t2_alignment_acc", "t1_pass_rate", ] available = [c for c in display_cols if c in df_compare.columns] logger.info(f"\n{'='*60}\nFINAL COMPARISON TABLE") logger.info("\n" + df_compare[available].to_string(index=False)) logger.success(f"Comparison saved → {compare_path}") if __name__ == "__main__": main() # import os # import json # import time # import mlflow # import pandas as pd # from pathlib import Path # from dotenv import load_dotenv # from ragas.metrics import faithfulness, context_precision # from ragas import evaluate as ragas_evaluate # from datasets import Dataset as HFDataset # from src.utils.logger import logger # from ragas.run_config import RunConfig # load_dotenv() # # ------------------------------------------------------- # # Configuration & Constants # # ------------------------------------------------------- # REQUIRED_ENV_VARS = ["FREELLM_API_KEY", "FREELLM_BASE_URL"] # for var in REQUIRED_ENV_VARS: # if not os.getenv(var): # raise ValueError(f"Missing required environment variable: {var}") # BENCHMARK_PATH = Path("evaluation/benchmark.json") # RESULTS_DIR = Path("evaluation/results") # RESULTS_DIR.mkdir(parents=True, exist_ok=True) # PER_QUESTION_DELAY = int(os.getenv("EVAL_QUESTION_DELAY", 7)) # BETWEEN_SYSTEMS_DELAY = int(os.getenv("EVAL_SYSTEM_DELAY", 30)) # # ------------------------------------------------------- # # Singleton Managers # # ------------------------------------------------------- # _baseline_retriever = None # _reranker = None # _answer_llm = None # _ragas_llm = None # def get_baseline_retriever(): # global _baseline_retriever # if _baseline_retriever is None: # from src.retrieval.baseline import BaselineRetriever # _baseline_retriever = BaselineRetriever() # return _baseline_retriever # def get_reranker(): # global _reranker # if _reranker is None: # from sentence_transformers import CrossEncoder # import torch # logger.info("Initializing reranker...") # _reranker = CrossEncoder( # "BAAI/bge-reranker-v2-m3", # device="cuda" if torch.cuda.is_available() else "cpu" # ) # return _reranker # def get_answer_llm(): # global _answer_llm # if _answer_llm is None: # from src.utils.llm import get_llm # _answer_llm = get_llm() # return _answer_llm # def get_ragas_llm(): # global _ragas_llm # if _ragas_llm is None: # from langchain_openai import ChatOpenAI # from langchain_core.callbacks import BaseCallbackHandler # from ragas.llms import LangchainLLMWrapper # class TokenUsageLogger(BaseCallbackHandler): # def on_llm_end(self, response, **kwargs): # for gen in response.generations: # for g in gen: # usage = getattr(g, "generation_info", {}) or {} # token_usage = usage.get("token_usage") or usage.get("usage", {}) # if token_usage: # logger.debug( # f"RAGAS LLM tokens — " # f"prompt: {token_usage.get('prompt_tokens', '?')}, " # f"completion: {token_usage.get('completion_tokens', '?')}, " # f"total: {token_usage.get('total_tokens', '?')}" # ) # _ragas_llm = LangchainLLMWrapper(ChatOpenAI( # model="gemini-2.5-flash", # base_url=os.getenv("FREELLM_BASE_URL", "http://localhost:3001/v1"), # api_key=os.getenv("FREELLM_API_KEY"), # temperature=0, # timeout=60, # max_retries=3, # max_tokens=4096, # temporarily high to observe real usage # callbacks=[TokenUsageLogger()] # )) # return _ragas_llm # def load_benchmark() -> list[dict]: # if not BENCHMARK_PATH.exists(): # raise FileNotFoundError(f"Benchmark file not found: {BENCHMARK_PATH}") # with open(BENCHMARK_PATH, "r", encoding="utf-8") as f: # data = json.load(f) # logger.info(f"Loaded {len(data)} benchmark questions from {BENCHMARK_PATH}") # return data # # ------------------------------------------------------- # # Domain Detection # # ------------------------------------------------------- # LABOR_KEYWORDS = [ # "lao động", "hợp đồng", "tiền lương", "công đoàn", # "thử việc", "làm thêm", "thôi việc", "sa thải", # "bảo hiểm xã hội", "người lao động" # ] # def detect_domain(query: str) -> str: # if any(w in query.lower().strip() for w in LABOR_KEYWORDS): # return "labor" # return "food_safety" # # ------------------------------------------------------- # # System Implementations # # ------------------------------------------------------- # def run_baseline1(query: str) -> tuple[str, list[str]]: # retriever = get_baseline_retriever() # llm = get_answer_llm() # domain = detect_domain(query) # chunks = retriever.retrieve(query, source="domestic", domain=domain, top_k=5) # context_text = "\n\n".join([c["text"] for c in chunks]) if chunks else "Không có ngữ cảnh." # prompt = f"Dựa trên thông tin sau, hãy trả lời câu hỏi:\n\n{context_text}\n\nCâu hỏi: {query}" # response = llm.invoke(prompt) # return response.content, [c["text"] for c in chunks] # def run_baseline2(query: str) -> tuple[str, list[str]]: # retriever = get_baseline_retriever() # reranker = get_reranker() # llm = get_answer_llm() # domain = detect_domain(query) # chunks = retriever.retrieve(query, source="domestic", domain=domain, top_k=10) # try: # pairs = [(query, c["text"]) for c in chunks] # scores = reranker.predict(pairs) # ranked = sorted(zip(scores, chunks), key=lambda x: x[0], reverse=True) # chunks = [c for _, c in ranked[:5]] # except Exception as e: # logger.warning(f"Reranker failed: {e}. Falling back to top-5 unranked.") # chunks = chunks[:5] # context_text = "\n\n".join([c["text"] for c in chunks]) if chunks else "Không có ngữ cảnh." # prompt = f"Dựa trên thông tin sau, hãy trả lời câu hỏi:\n\n{context_text}\n\nCâu hỏi: {query}" # response = llm.invoke(prompt) # return response.content, [c["text"] for c in chunks] # def run_vilexagent(query: str) -> tuple[str, list[str], dict]: # from src.agents.graph import vilexagent # initial_state = { # "original_query": query, # "sub_questions": [], # "requires_international": False, # "domestic_chunks": [], # "international_chunks": [], # "cross_reference": None, # "final_answer": "", # "citations": [], # "has_expired_docs": False, # "error": None # } # result = vilexagent.invoke(initial_state) # all_chunks = result.get("domestic_chunks", []) + result.get("international_chunks", []) # contexts = [c["text"] for c in all_chunks[:10]] # final_answer = result.get("final_answer", "") # if not final_answer: # logger.warning(f"ViLexAgent returned empty answer for: {query[:50]}...") # return final_answer, contexts, result # # ------------------------------------------------------- # # Tier 1: Component Evaluation (ViLexAgent only) # # ------------------------------------------------------- # def evaluate_decomposition(sub_questions: list[dict], expected_source: str) -> float: # if not sub_questions: # return 0.0 if expected_source in ("international", "both") else 1.0 # predicted_needs_intl = any( # sq.get("source") in ("international", "both") for sq in sub_questions # ) # expected_needs_intl = expected_source in ("international", "both") # return 1.0 if predicted_needs_intl == expected_needs_intl else 0.0 # def evaluate_cross_reference(predicted_alignment: str, expected_alignment: str) -> float: # return 1.0 if predicted_alignment == expected_alignment else 0.0 # def evaluate_legal_currency(has_expired_docs: bool, final_answer: str) -> float: # if not has_expired_docs: # return 1.0 # if not final_answer: # return 0.0 # warned = any(phrase in final_answer.lower() for phrase in [ # "hết hiệu lực", "không còn hiệu lực", "đã bị thay thế", # "cảnh báo", "lưu ý", "văn bản cũ" # ]) # return 1.0 if warned else 0.0 # # ------------------------------------------------------- # # Main Evaluation Runner # # ------------------------------------------------------- # def run_evaluation(system_name: str, benchmark: list[dict], max_questions: int = None): # logger.info(f"\n{'='*60}") # logger.info(f"Evaluating: {system_name}") # questions = benchmark[:max_questions] if max_questions else benchmark # is_vilexagent = system_name == "ViLexAgent" # raw_results = [] # ragas_samples = [] # # Step 1: Run all queries # for i, q in enumerate(questions): # logger.info(f" [{i+1}/{len(questions)}] {q['id']}: {q['query'][:60]}...") # start = time.time() # answer = "" # contexts = [] # decomp_score = crossref_score = currency_score = None # error_msg = None # try: # if is_vilexagent: # answer, contexts, full_state = run_vilexagent(q["query"]) # sub_questions = full_state.get("sub_questions", []) # cross_ref = full_state.get("cross_reference") or {} # has_expired = full_state.get("has_expired_docs", False) # decomp_score = evaluate_decomposition(sub_questions, q["expected_source"]) # crossref_score = evaluate_cross_reference( # cross_ref.get("alignment", "no_international"), # q["expected_alignment"] # ) # currency_score = evaluate_legal_currency(has_expired, answer) # elif system_name == "Baseline1_NaiveRAG": # answer, contexts = run_baseline1(q["query"]) # else: # answer, contexts = run_baseline2(q["query"]) # latency = round(time.time() - start, 2) # raw_results.append({ # "id": q["id"], "type": q["type"], "domain": q["domain"], # "expected_source": q["expected_source"], # "expected_alignment": q["expected_alignment"], # "query": q["query"], "answer": answer, # "reference": q["reference"], "latency": latency, # "decomp_score": decomp_score, # "crossref_score": crossref_score, # "currency_score": currency_score, # "error": None # }) # if answer and answer.strip() and contexts: # ragas_samples.append({ # "question": q["query"], # "answer": answer, # "contexts": contexts, # "ground_truth": q["reference"] # }) # logger.info(f" ✓ {latency}s") # except Exception as e: # error_msg = str(e) # logger.error(f" ✗ {error_msg}") # raw_results.append({ # "id": q["id"], "type": q["type"], "domain": q["domain"], # "expected_source": q["expected_source"], # "expected_alignment": q["expected_alignment"], # "query": q["query"], "answer": "", # "reference": q["reference"], "latency": 0, # "decomp_score": None, "crossref_score": None, # "currency_score": None, "error": error_msg # }) # time.sleep(PER_QUESTION_DELAY) # # Step 2: RAGAS 0.2.15 evaluation # logger.info(f"\nRunning RAGAS on {len(ragas_samples)} valid samples...") # df_ragas = None # if ragas_samples: # try: # ragas_llm = get_ragas_llm() # faithfulness.llm = ragas_llm # # answer_relevancy.llm = ragas_llm # context_precision.llm = ragas_llm # ragas_data = { # "question": [s["question"] for s in ragas_samples], # "answer": [s["answer"] for s in ragas_samples], # "contexts": [s["contexts"] for s in ragas_samples], # "ground_truth": [s["ground_truth"] for s in ragas_samples], # } # ragas_dataset = HFDataset.from_dict(ragas_data) # result = ragas_evaluate( # ragas_dataset, # metrics=[faithfulness, context_precision], # run_config=RunConfig(max_workers=1, timeout=60, max_retries=3) # ) # df_ragas = result.to_pandas() # logger.success(f"RAGAS complete: {result}") # except Exception as e: # logger.error(f"RAGAS failed: {e}") # # Step 3: Build dataframe # df_raw = pd.DataFrame(raw_results) # for col in ["faithfulness", "context_precision"]: # df_raw[col] = None # if df_ragas is not None: # valid_mask = ( # (df_raw["error"].isna()) & # (df_raw["answer"].notna()) & # (df_raw["answer"].str.strip().astype(bool)) # ) # valid_indices = df_raw[valid_mask].index.tolist() # for j, idx in enumerate(valid_indices): # if j < len(df_ragas): # for col in ["faithfulness", "context_precision"]: # if col in df_ragas.columns: # df_raw.loc[idx, col] = df_ragas[col].iloc[j] # # Step 4: Summary # def safe_mean(series): # clean = series.dropna() # return round(float(clean.mean()), 4) if len(clean) > 0 else None # summary = { # "system": system_name, # "total": len(questions), # "error_rate": round(df_raw["error"].notna().mean(), 4), # "avg_latency": safe_mean(df_raw[df_raw["error"].isna()]["latency"]), # "faithfulness": safe_mean(df_raw["faithfulness"]), # "context_precision": safe_mean(df_raw["context_precision"]), # "decomp_accuracy": safe_mean(df_raw["decomp_score"]) if is_vilexagent else None, # "crossref_accuracy": ( # safe_mean(df_raw[df_raw["type"] == "type_c"]["crossref_score"]) # if is_vilexagent else None # ), # "legal_currency_score": safe_mean(df_raw["currency_score"]) if is_vilexagent else None, # } # logger.info(f"\n--- {system_name} Summary ---") # for k, v in summary.items(): # logger.info(f" {k}: {v}") # return df_raw, summary # # ------------------------------------------------------- # # Main Entry Point # # ------------------------------------------------------- # def main(): # benchmark = load_benchmark() # logger.info("Pre-initializing singletons...") # get_baseline_retriever() # get_reranker() # get_answer_llm() # MAX_Q = int(os.getenv("EVAL_MAX_QUESTIONS", 30)) # timestamp = time.strftime("%Y%m%d_%H%M%S") # mlflow.set_tracking_uri(os.getenv("MLFLOW_TRACKING_URI", "http://localhost:5000")) # mlflow.set_experiment("vilexagent_evaluation") # all_summaries = [] # systems = ["Baseline1_NaiveRAG", "Baseline2_RAGRerank", "ViLexAgent"] # for system_name in systems: # logger.info(f"\n>>> Starting: {system_name}") # with mlflow.start_run(run_name=f"{system_name}_{timestamp}"): # df, summary = run_evaluation(system_name, benchmark, max_questions=MAX_Q) # csv_path = RESULTS_DIR / f"{system_name}_{timestamp}.csv" # df.to_csv(csv_path, index=False, encoding="utf-8-sig") # logger.success(f"Saved → {csv_path}") # for k, v in summary.items(): # if v is not None and not isinstance(v, str): # mlflow.log_metric(k, v) # mlflow.log_param("system", system_name) # mlflow.log_param("num_questions", MAX_Q) # mlflow.log_artifact(str(csv_path)) # all_summaries.append(summary) # if system_name != systems[-1]: # logger.info(f"Cooling down {BETWEEN_SYSTEMS_DELAY}s...") # time.sleep(BETWEEN_SYSTEMS_DELAY) # df_compare = pd.DataFrame(all_summaries) # compare_path = RESULTS_DIR / f"comparison_{timestamp}.csv" # df_compare.to_csv(compare_path, index=False, encoding="utf-8-sig") # cols = [ # "system", "faithfulness", "context_precision", # "decomp_accuracy", "crossref_accuracy", "legal_currency_score", "avg_latency" # ] # available_cols = [c for c in cols if c in df_compare.columns] # logger.info(f"\n{'='*60}\nCOMPARISON TABLE") # logger.info(df_compare[available_cols].to_string()) # logger.success(f"Saved comparison → {compare_path}") # if __name__ == "__main__": # main()