Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """ | |
| Cross-language RAG evaluation loop. | |
| This runner reuses the normal E2E RAG eval flow, but adds a small target-language | |
| check for cases where the user asks in one language and requests the answer in | |
| another language. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any, Dict, List | |
| from rag_eval_loop import ( | |
| DEFAULT_ABSTAIN_PHRASE, | |
| DEFAULT_POLL_INTERVAL_SECONDS, | |
| DEFAULT_TIMEOUT_SECONDS, | |
| REPO_ROOT, | |
| EvalCaseResult, | |
| EvalRunner, | |
| _content_tokens, | |
| _number_claims, | |
| _sentences, | |
| _source_text, | |
| load_eval_file, | |
| normalize, | |
| resolve_doc_paths, | |
| score_answer_correctness, | |
| score_retrieval_recall, | |
| ) | |
| DEFAULT_EVAL_FILE = Path(__file__).with_name("rag_eval_cross_language.json") | |
| _GENERAL_LANGUAGE_MARKERS = { | |
| "en": { | |
| "the", "is", "are", "for", "with", "and", "scheduled", "required", | |
| "maximum", "minimum", "course", "lecturer", "office", "hour", | |
| "assignment", "submission", "deadline", "format", "weight", "worth", | |
| }, | |
| "id": { | |
| "adalah", "untuk", "dalam", "yang", "pukul", "ruang", "tanggal", | |
| "nilai", "batas", "hari", "jadwal", "dengan", "melalui", "ukuran", | |
| "harus", "dosen", "sesi", "berkontribusi", "terhadap", "memiliki", | |
| "bobot", "ditetapkan", "diperbolehkan", "diizinkan", "digunakan", | |
| "siswa", "mahasiswa", "buku", "teks", "inti", "sebesar", "denda", | |
| "keterlambatan", "ambang", "pengumpulan", "kelompok", "nama", | |
| }, | |
| } | |
| _BILINGUAL_TOKEN_GROUPS = [ | |
| {"course", "mata", "kuliah"}, | |
| {"lecturer", "instructor", "dosen", "pengajar"}, | |
| {"teaching", "assistant", "asisten"}, | |
| {"office", "hour", "jam"}, | |
| {"room", "ruang"}, | |
| {"held", "diadakan", "berlangsung"}, | |
| {"schedule", "jadwal"}, | |
| {"monday", "senin"}, | |
| {"tuesday", "selasa"}, | |
| {"wednesday", "rabu"}, | |
| {"thursday", "kamis"}, | |
| {"friday", "jumat"}, | |
| {"quiz", "quizzes", "kuis"}, | |
| {"minutes", "menit"}, | |
| {"lab", "praktikum"}, | |
| {"assessment", "grading", "grade", "penilaian", "nilai"}, | |
| {"assignment", "assignments", "tugas"}, | |
| {"midterm", "uts", "ujian", "tengah", "semester"}, | |
| {"final", "project", "demo", "proyek", "akhir"}, | |
| {"minimum", "minimal"}, | |
| {"passing", "pass", "lulus"}, | |
| {"score", "nilai"}, | |
| {"deadline", "batas", "tenggat", "waktu"}, | |
| {"submission", "submitted", "pengumpulan", "dikumpulkan"}, | |
| {"via", "through", "melalui", "lewat"}, | |
| {"exam", "ujian"}, | |
| {"date", "tanggal"}, | |
| {"public", "holiday", "libur"}, | |
| {"class", "kelas"}, | |
| {"make", "up", "make-up", "pengganti"}, | |
| {"format", "format"}, | |
| {"pdf"}, | |
| {"maximum", "maksimum"}, | |
| {"file", "berkas"}, | |
| {"size", "ukuran"}, | |
| {"required", "wajib", "diwajibkan", "harus"}, | |
| {"filename", "nama", "file"}, | |
| {"pattern", "pola"}, | |
| {"late", "terlambat", "keterlambatan"}, | |
| {"penalty", "penalti", "denda"}, | |
| {"window", "batas", "periode"}, | |
| {"days", "hari"}, | |
| {"plagiarism", "plagiarisme"}, | |
| {"threshold", "ambang", "batas"}, | |
| {"similarity", "kesamaan"}, | |
| {"below", "bawah"}, | |
| {"group", "kelompok"}, | |
| {"students", "mahasiswa", "siswa"}, | |
| {"branch"}, | |
| {"naming", "penamaan"}, | |
| {"commit"}, | |
| {"message", "pesan"}, | |
| {"convention", "konvensi"}, | |
| {"programming", "pemrograman"}, | |
| {"language", "bahasa"}, | |
| {"framework", "kerangka"}, | |
| {"core", "inti", "utama"}, | |
| {"textbook", "book", "buku", "teks"}, | |
| {"edition", "edisi"}, | |
| ] | |
| def _score_target_language(answer: str, case: Dict[str, Any]) -> List[str]: | |
| expected_language = (case.get("expected_language") or "").strip().lower() | |
| if not expected_language: | |
| return [] | |
| markers = [ | |
| normalize(marker) | |
| for marker in case.get("language_expected_any", []) | |
| if marker | |
| ] | |
| if not markers: | |
| return [] | |
| answer_norm = normalize(answer) | |
| if any(marker in answer_norm for marker in markers): | |
| return [] | |
| if _looks_like_language(answer_norm, expected_language): | |
| return [] | |
| label = { | |
| "en": "English", | |
| "id": "Bahasa Indonesia", | |
| }.get(expected_language, expected_language) | |
| return [f"language: expected {label} marker, found none of {markers}"] | |
| def _looks_like_language(answer_norm: str, expected_language: str) -> bool: | |
| markers = _GENERAL_LANGUAGE_MARKERS.get(expected_language, set()) | |
| if not markers: | |
| return False | |
| tokens = set(answer_norm.split()) | |
| return len(tokens & markers) >= 2 | |
| def _expand_with_bilingual_equivalents(tokens: set[str]) -> set[str]: | |
| expanded = set(tokens) | |
| for group in _BILINGUAL_TOKEN_GROUPS: | |
| if tokens & group: | |
| expanded.update(group) | |
| return expanded | |
| def _score_cross_language_faithfulness( | |
| answer: str, | |
| sources: List[Dict[str, Any]], | |
| abstain_phrase: str, | |
| min_overlap: float, | |
| ) -> tuple[bool, List[str]]: | |
| answer_norm = normalize(answer) | |
| if not answer_norm or abstain_phrase in answer_norm: | |
| return True, [] | |
| source_norm = normalize(_source_text(sources)) | |
| if not source_norm: | |
| return False, ["faithfulness cannot be checked because no sources were returned"] | |
| source_tokens = _expand_with_bilingual_equivalents(set(_content_tokens(source_norm))) | |
| source_numbers = _number_claims(source_norm) | |
| reasons: List[str] = [] | |
| for sentence in _sentences(answer): | |
| sentence_tokens = _content_tokens(sentence) | |
| sentence_numbers = _number_claims(sentence) | |
| if len(sentence_tokens) < 4 and not sentence_numbers: | |
| continue | |
| missing_numbers = sorted(sentence_numbers - source_numbers) | |
| if missing_numbers: | |
| reasons.append( | |
| f"faithfulness: sentence has numbers not found in sources: {', '.join(missing_numbers)}" | |
| ) | |
| continue | |
| overlap = sum(1 for token in sentence_tokens if token in source_tokens) | |
| ratio = overlap / max(len(sentence_tokens), 1) | |
| if ratio < min_overlap: | |
| reasons.append( | |
| f"faithfulness: low bilingual source overlap ({ratio:.2f}) for sentence: {sentence}" | |
| ) | |
| return len(reasons) == 0, reasons | |
| def _score_cross_language_case( | |
| answer: str, | |
| sources: List[Dict[str, Any]], | |
| case: Dict[str, Any], | |
| abstain_phrase: str, | |
| idx: int, | |
| faithfulness_min_overlap: float, | |
| ) -> EvalCaseResult: | |
| answer_reasons = score_answer_correctness(answer, case, abstain_phrase) | |
| retrieval_checked, retrieval_passed, retrieval_reasons = score_retrieval_recall(sources, case) | |
| faithfulness_passed, faithfulness_reasons = _score_cross_language_faithfulness( | |
| answer, | |
| sources, | |
| abstain_phrase, | |
| faithfulness_min_overlap, | |
| ) | |
| reasons = [ | |
| *(f"answer: {reason}" for reason in answer_reasons), | |
| *(f"retrieval: {reason}" for reason in retrieval_reasons), | |
| *(f"faithfulness: {reason}" for reason in faithfulness_reasons), | |
| ] | |
| answer_passed = len(answer_reasons) == 0 | |
| return EvalCaseResult( | |
| index=idx, | |
| question=case.get("question", ""), | |
| passed=answer_passed and retrieval_passed and faithfulness_passed, | |
| answer_passed=answer_passed, | |
| retrieval_checked=retrieval_checked, | |
| retrieval_passed=retrieval_passed, | |
| faithfulness_passed=faithfulness_passed, | |
| reasons=reasons, | |
| answer_text=(answer or ""), | |
| sources=sources, | |
| ) | |
| def _print_case_result(result: EvalCaseResult, max_preview_chars: int, show_full_answers: bool) -> None: | |
| verdict = "PASS" if result.passed else "FAIL" | |
| print(f"[{verdict}] Q{result.index}: {result.question}") | |
| print( | |
| " metrics: " | |
| f"answer={'PASS' if result.answer_passed else 'FAIL'}, " | |
| f"retrieval={'PASS' if result.retrieval_passed else 'FAIL'}" | |
| f"{'' if result.retrieval_checked else ' (not checked)'}, " | |
| f"faithfulness={'PASS' if result.faithfulness_passed else 'FAIL'}, " | |
| f"sources={len(result.sources)}" | |
| ) | |
| if not result.passed: | |
| for reason in result.reasons: | |
| print(f" - {reason}") | |
| shown_answer = result.answer_text if show_full_answers else result.answer_text[:max_preview_chars] | |
| print(f" answer: {shown_answer}") | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Run cross-language RAG E2E evaluation") | |
| parser.add_argument("--eval-file", default=str(DEFAULT_EVAL_FILE), help="Path to cross-language eval JSON file") | |
| parser.add_argument("--base-url", default="http://127.0.0.1:8000", help="Server base URL") | |
| parser.add_argument("--api-prefix", default="/api/v1", help="API prefix") | |
| parser.add_argument("--password", default="Pass1234!", help="Password for generated test users") | |
| parser.add_argument("--request-timeout", type=int, default=60, help="Per-request timeout seconds") | |
| parser.add_argument("--question-timeout", type=int, default=240, help="Timeout seconds for each chat answer request") | |
| parser.add_argument("--ingest-timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS, help="Ingestion wait timeout seconds") | |
| parser.add_argument("--poll-interval", type=int, default=DEFAULT_POLL_INTERVAL_SECONDS, help="Polling interval seconds") | |
| parser.add_argument("--abstain-phrase", default=DEFAULT_ABSTAIN_PHRASE, help="Expected abstention phrase") | |
| parser.add_argument( | |
| "--faithfulness-min-overlap", | |
| type=float, | |
| default=0.35, | |
| help="Minimum content-token overlap between each answer sentence and retrieved sources.", | |
| ) | |
| parser.add_argument("--max-preview-chars", type=int, default=240, help="Max characters shown per answer") | |
| parser.add_argument("--show-full-answers", action="store_true", help="Print full answer text for every case") | |
| parser.add_argument( | |
| "--reuse-course-id", | |
| action="store_true", | |
| help="Use the exact course_id from the eval file instead of appending a timestamp.", | |
| ) | |
| parser.add_argument( | |
| "--email-domain", | |
| default="sevima.co.id", | |
| help="Domain for temporary eval users. The backend rejects placeholder domains such as example.com.", | |
| ) | |
| args = parser.parse_args() | |
| payload = load_eval_file(Path(args.eval_file).resolve()) | |
| ts = datetime.utcnow().strftime("%Y%m%d%H%M%S") | |
| email_domain = args.email_domain.strip().lower().lstrip("@") | |
| runner = EvalRunner( | |
| base_url=args.base_url, | |
| api_prefix=args.api_prefix, | |
| request_timeout=args.request_timeout, | |
| ) | |
| print("[INFO] Starting cross-language RAG E2E evaluation loop") | |
| print(f"[INFO] Base URL: {args.base_url}{args.api_prefix}") | |
| lecturer_token = runner.ensure_user( | |
| role="lecturer", | |
| email=f"rag_cross_eval_lecturer_{ts}@{email_domain}", | |
| password=args.password, | |
| name="RAG Cross-Language Eval Lecturer", | |
| identity_number=f"198502{ts[-12:]}", | |
| ) | |
| student_token = runner.ensure_user( | |
| role="student", | |
| email=f"rag_cross_eval_student_{ts}@{email_domain}", | |
| password=args.password, | |
| name="RAG Cross-Language Eval Student", | |
| identity_number=f"{ts[-10:]}", | |
| ) | |
| eval_course_id = payload["course_id"] | |
| course_id = eval_course_id if args.reuse_course_id else f"{eval_course_id}-{ts}" | |
| print(f"[INFO] Eval course_id: {eval_course_id}") | |
| print(f"[INFO] Run course_id: {course_id}") | |
| docs = resolve_doc_paths(REPO_ROOT, payload["documents"]) | |
| uploaded_doc_ids = runner.upload_documents( | |
| token=lecturer_token, | |
| course_id=course_id, | |
| document_paths=docs, | |
| ) | |
| statuses = runner.wait_for_ingestion( | |
| token=student_token, | |
| course_id=course_id, | |
| uploaded_doc_ids=uploaded_doc_ids, | |
| timeout_seconds=args.ingest_timeout, | |
| poll_interval_seconds=args.poll_interval, | |
| ) | |
| failed = [doc_id for doc_id, details in statuses.items() if details.get("status") == "failed"] | |
| if failed: | |
| print(f"[ERROR] Ingestion failed for document_ids: {failed}") | |
| for doc_id in failed: | |
| details = statuses.get(doc_id, {}) | |
| print(f" - doc_id={doc_id} filename={details.get('filename') or '<unknown>'}") | |
| print(f" error={details.get('error') or '<no error message provided by backend>'}") | |
| return 2 | |
| print("[INFO] All uploaded documents are ready") | |
| session_id = runner.create_student_session( | |
| token=student_token, | |
| course_id=course_id, | |
| title=f"RAG Cross-Language Eval Session {ts}", | |
| ) | |
| results: List[EvalCaseResult] = [] | |
| for idx, case in enumerate(payload["questions"], start=1): | |
| answer_payload = runner.ask_question( | |
| token=student_token, | |
| session_id=session_id, | |
| question=case.get("question", ""), | |
| question_timeout_seconds=args.question_timeout, | |
| ) | |
| language_reasons = _score_target_language(answer_payload["content"], case) | |
| result = _score_cross_language_case( | |
| answer_payload["content"], | |
| answer_payload["sources"], | |
| case, | |
| normalize(args.abstain_phrase), | |
| idx, | |
| args.faithfulness_min_overlap, | |
| ) | |
| if language_reasons: | |
| result.reasons.extend(language_reasons) | |
| result.answer_passed = False | |
| result.passed = False | |
| results.append(result) | |
| _print_case_result(result, args.max_preview_chars, args.show_full_answers) | |
| total = len(results) | |
| passed = sum(1 for result in results if result.passed) | |
| failed_count = total - passed | |
| answer_passed = sum(1 for result in results if result.answer_passed) | |
| retrieval_checked = sum(1 for result in results if result.retrieval_checked) | |
| retrieval_passed = sum(1 for result in results if result.retrieval_checked and result.retrieval_passed) | |
| faithfulness_passed = sum(1 for result in results if result.faithfulness_passed) | |
| print("\n=== CROSS-LANGUAGE RAG E2E SUMMARY ===") | |
| print(f"total_cases: {total}") | |
| print(f"overall_passed: {passed}") | |
| print(f"overall_failed: {failed_count}") | |
| print(f"overall_score_percent: {(passed / total) * 100 if total else 0.0:.1f}") | |
| print(f"answer_and_language_passed: {answer_passed}") | |
| print(f"retrieval_recall_checked_cases: {retrieval_checked}") | |
| print(f"retrieval_recall_passed: {retrieval_passed}") | |
| print(f"faithfulness_passed: {faithfulness_passed}") | |
| return 0 if failed_count == 0 else 1 | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except KeyboardInterrupt: | |
| print("\n[INFO] Interrupted by user") | |
| raise SystemExit(130) | |