Spaces:
Sleeping
Sleeping
| """ | |
| LLM eval set: 30 fact questions (DE/EN) against the live agent chain. | |
| Expected values are read dynamically from data/database/programme_facts.json, so the | |
| eval stays valid after every facts regeneration. The core assertion per case: | |
| the answer must contain the correct programme's value AND must NOT contain | |
| another programme's value (cross-contamination guard — the historic bug). | |
| Opt-in (costs API credits, needs OPENAI_API_KEY + Weaviate): | |
| RUN_LLM_EVAL=1 pytest tests/test_llm_fact_eval.py -v | |
| Single case: | |
| RUN_LLM_EVAL=1 pytest tests/test_llm_fact_eval.py -v -k "de_price_emba" | |
| """ | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import pytest | |
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) | |
| from src.config import config | |
| pytestmark = pytest.mark.skipif( | |
| not os.getenv("RUN_LLM_EVAL"), | |
| reason="LLM eval is opt-in: set RUN_LLM_EVAL=1", | |
| ) | |
| def _facts() -> dict: | |
| path = os.path.join(config.paths.DATA, "database", "programme_facts.json") | |
| with open(path, encoding="utf-8") as f: | |
| return json.load(f)["programmes"] | |
| def _normalize(text: str) -> str: | |
| """Lowercase and strip thousand separators so CHF 77'500 / 77,500 / 77 500 | |
| all normalize to '77500'.""" | |
| text = text.lower() | |
| return re.sub(r"(?<=\d)[\s'’,.](?=\d{3}\b)", "", text) | |
| def _fee(prog: str, which: str = "final_deadline") -> str: | |
| return str(_facts()[prog]["tuition_chf"][which]["fee"]) | |
| def _start_year(prog: str) -> str: | |
| return _facts()[prog]["programme_start"][:4] | |
| def _ects(prog: str) -> str: | |
| return str(_facts()[prog]["ects_credits"]) | |
| # -------------------------------------------------------------------------- | |
| # Eval cases. Fields: | |
| # id unique test id (language prefix included) | |
| # lang conversation language the chain is initialized with | |
| # query user question | |
| # expect_any list of token-groups; AT LEAST ONE token of EVERY group | |
| # must appear in the normalized answer | |
| # forbid tokens that must NOT appear (cross-contamination guard) | |
| # -------------------------------------------------------------------------- | |
| def build_cases() -> list[dict]: | |
| f = _facts() | |
| emba_fee, iemba_fee, embax_fee = _fee("emba"), _fee("iemba"), _fee("emba_x") | |
| emba_fee1, iemba_fee1, embax_fee1 = ( | |
| _fee("emba", "first_deadline"), | |
| _fee("iemba", "first_deadline"), | |
| _fee("emba_x", "first_deadline"), | |
| ) | |
| return [ | |
| # ---------- Pricing (the historic hallucination hotspot) ---------- | |
| dict(id="de_price_emba", lang="de", | |
| query="Was kostet der EMBA?", | |
| expect_any=[[emba_fee, emba_fee1]], | |
| forbid=[iemba_fee, embax_fee]), | |
| dict(id="de_price_iemba", lang="de", | |
| query="Was kostet der IEMBA?", | |
| expect_any=[[iemba_fee, iemba_fee1]], | |
| forbid=[emba_fee, embax_fee]), | |
| dict(id="de_price_embax", lang="de", | |
| query="Was kostet emba X?", | |
| expect_any=[[embax_fee, embax_fee1]], | |
| forbid=[emba_fee, iemba_fee]), | |
| dict(id="en_price_emba", lang="en", | |
| query="How much does the EMBA HSG cost?", | |
| expect_any=[[emba_fee, emba_fee1]], | |
| forbid=[iemba_fee, embax_fee]), | |
| dict(id="en_price_iemba", lang="en", | |
| query="What is the tuition fee for the IEMBA?", | |
| expect_any=[[iemba_fee, iemba_fee1]], | |
| forbid=[emba_fee, embax_fee]), | |
| dict(id="en_price_embax", lang="en", | |
| query="How much is the emba X programme?", | |
| expect_any=[[embax_fee, embax_fee1]], | |
| forbid=[emba_fee, iemba_fee]), | |
| dict(id="de_price_comparison", lang="de", | |
| query="Vergleiche bitte die Kosten aller drei Programme.", | |
| expect_any=[[emba_fee, emba_fee1], [iemba_fee, iemba_fee1], [embax_fee, embax_fee1]], | |
| forbid=[]), | |
| dict(id="en_price_deadline_logic", lang="en", | |
| query="If I apply for the EMBA now, which fee applies?", | |
| expect_any=[[emba_fee, emba_fee1]], | |
| forbid=[iemba_fee, embax_fee]), | |
| # ---------------------------- Deadlines --------------------------- | |
| dict(id="de_deadline_emba", lang="de", | |
| query="Bis wann kann ich mich für den EMBA bewerben?", | |
| expect_any=[["2026"]], | |
| forbid=[]), | |
| dict(id="en_deadline_embax", lang="en", | |
| query="What is the application deadline for emba X?", | |
| expect_any=[["august", "october", "2026"]], | |
| forbid=[]), | |
| dict(id="en_deadline_iemba_passed", lang="en", | |
| query="Can I still get the early-bird fee for the IEMBA?", | |
| expect_any=[[iemba_fee, "passed", "expired", "no longer", "final"]], | |
| forbid=[]), | |
| # ------------------------------ Starts ----------------------------- | |
| dict(id="de_start_emba", lang="de", | |
| query="Wann startet der nächste EMBA?", | |
| expect_any=[[_start_year("emba")], ["september", "09"]], | |
| forbid=[]), | |
| dict(id="de_start_iemba", lang="de", | |
| query="Wann beginnt der IEMBA?", | |
| expect_any=[[_start_year("iemba")], ["august", "08"]], | |
| forbid=[]), | |
| dict(id="en_start_embax", lang="en", | |
| query="When does the next emba X cohort start?", | |
| expect_any=[[_start_year("emba_x")], ["february", "02"]], | |
| forbid=[]), | |
| # ----------------------------- Duration ---------------------------- | |
| dict(id="de_duration_emba", lang="de", | |
| query="Wie lange dauert der deutschsprachige EMBA HSG?", | |
| expect_any=[["18"]], | |
| forbid=[]), | |
| dict(id="de_duration_emba_short_name", lang="de", | |
| query="Wie lange dauert der EMBA?", | |
| expect_any=[["18"]], | |
| forbid=[]), | |
| dict(id="en_duration_embax", lang="en", | |
| query="How long does the emba X take and how many ECTS is it?", | |
| expect_any=[["18"], [_ects("emba_x")]], | |
| forbid=[]), | |
| # ------------------------- Language / format ----------------------- | |
| dict(id="de_language_iemba", lang="de", | |
| query="In welcher Sprache wird der IEMBA unterrichtet?", | |
| expect_any=[["englisch", "english"]], | |
| forbid=[]), | |
| dict(id="en_language_emba", lang="en", | |
| query="Is the EMBA HSG taught in English?", | |
| expect_any=[["german", "deutsch"]], | |
| forbid=[]), | |
| dict(id="de_locations_embax", lang="de", | |
| query="Wo findet emba X statt?", | |
| expect_any=[["zürich", "zurich"], ["st.gallen", "st. gallen", "gallen"]], | |
| forbid=[]), | |
| dict(id="en_structure_iemba", lang="en", | |
| query="How many weeks on campus and abroad does the IEMBA require?", | |
| expect_any=[["10"], ["4", "abroad"]], | |
| forbid=[]), | |
| # ----------------------------- Advisors ---------------------------- | |
| dict(id="de_advisor_emba", lang="de", | |
| query="Wer ist meine Ansprechpartnerin für den EMBA?", | |
| expect_any=[["cyra", "von müller", "von mueller"]], | |
| forbid=["kristin", "teyuna"]), | |
| dict(id="en_advisor_iemba", lang="en", | |
| query="Who can I contact about the IEMBA?", | |
| expect_any=[["kristin", "fuchs"]], | |
| forbid=["cyra", "teyuna"]), | |
| dict(id="en_advisor_embax", lang="en", | |
| query="Who is the admissions contact for emba X?", | |
| expect_any=[["teyuna", "giger"]], | |
| forbid=["cyra", "kristin"]), | |
| # ----------------------- Grounding / honesty ----------------------- | |
| dict(id="de_no_invented_accommodation", lang="de", | |
| query="Ist die Unterkunft in den Studiengebühren enthalten?", | |
| expect_any=[["nicht", "nein", "kein"]], | |
| forbid=[]), | |
| dict(id="en_no_price_range", lang="en", | |
| query="Roughly what price range do the HSG executive MBAs fall into?", | |
| expect_any=[[emba_fee, emba_fee1, iemba_fee, embax_fee]], | |
| forbid=["six-figure", "six figure", "sechsstellig"]), | |
| dict(id="de_unknown_fact_honesty", lang="de", | |
| query="Wie viele Parkplätze gibt es am Executive Campus?", | |
| expect_any=[["nicht", "keine", "admissions", "team", "leider"]], | |
| forbid=[]), | |
| # --------------------------- Conversational ------------------------ | |
| dict(id="de_fit_question", lang="de", | |
| query="Ich bin Softwarearchitekt mit 12 Jahren Erfahrung, welches Programm passt zu mir?", | |
| expect_any=[["emba x", "embax", "emba"]], | |
| forbid=[]), | |
| dict(id="en_fit_question", lang="en", | |
| query="I lead international teams and want a global programme. Which one fits?", | |
| expect_any=[["iemba", "international"]], | |
| forbid=[]), | |
| dict(id="de_booking_intent", lang="de", | |
| query="Ich möchte gerne einen Beratungstermin für den IEMBA vereinbaren.", | |
| expect_any=[["termin", "beratung", "kristin"]], | |
| forbid=[]), | |
| dict(id="en_overview", lang="en", | |
| query="Give me a short overview of all three executive MBA programmes.", | |
| expect_any=[["emba"], ["iemba", "international"], ["emba x", "embax"]], | |
| forbid=[]), | |
| ] | |
| CASES = build_cases() if os.getenv("RUN_LLM_EVAL") else [] | |
| def make_chain(): | |
| from src.rag.agent_chain import ExecutiveAgentChain | |
| def _factory(lang: str, attempt: int = 0): | |
| return ExecutiveAgentChain(language=lang, session_id=f"eval_{lang}_{attempt}") | |
| return _factory | |
| # Latency gate: a single turn must never exceed this (generous cap that still | |
| # catches gross regressions such as switching back to a reasoning model). | |
| MAX_TURN_SECONDS = 25.0 | |
| # LLM answers are sampled, so a single case can flake even though the facts are | |
| # in the prompt (observed on main: a different case failed on each smoke run). | |
| # One retry with a fresh chain/session filters sampling noise; a real | |
| # regression (wrong facts, cross-contamination, latency) still fails twice. | |
| FLAKE_RETRIES = 1 | |
| def _assert_case(case, chain): | |
| from time import perf_counter | |
| turn_start = perf_counter() | |
| result = chain.query(case["query"]) | |
| elapsed = perf_counter() - turn_start | |
| answer = _normalize( | |
| (result.response or "") + " " + (result.additional_details or "") | |
| ) | |
| assert elapsed < MAX_TURN_SECONDS, ( | |
| f"Latency regression: turn took {elapsed:.1f}s (cap {MAX_TURN_SECONDS}s) " | |
| f"for: {case['query']}" | |
| ) | |
| assert answer.strip(), f"Empty answer for: {case['query']}" | |
| for group in case["expect_any"]: | |
| assert any(_normalize(tok) in answer for tok in group), ( | |
| f"\nQuery: {case['query']}" | |
| f"\nExpected one of: {group}" | |
| f"\nAnswer: {answer[:600]}" | |
| ) | |
| for forbidden in case["forbid"]: | |
| assert _normalize(forbidden) not in answer, ( | |
| f"\nQuery: {case['query']}" | |
| f"\nFORBIDDEN token found (cross-programme contamination): {forbidden}" | |
| f"\nAnswer: {answer[:600]}" | |
| ) | |
| def test_fact_eval(case, make_chain): | |
| for attempt in range(FLAKE_RETRIES + 1): | |
| try: | |
| _assert_case(case, make_chain(case["lang"], attempt)) | |
| return | |
| except AssertionError: | |
| if attempt == FLAKE_RETRIES: | |
| raise | |