hsg_rag_eea / tests /test_llm_fact_eval.py
Pygmales's picture
Sync from GitHub e62d03ef02b9841efce67001c03c245ee6166417
bbf0a9c verified
Raw
History Blame
11.7 kB
"""
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 []
@pytest.fixture(scope="module")
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]}"
)
@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES])
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