Files
srbhr--resume-matcher/apps/backend/tests/evals/test_tailoring_eval.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

101 lines
3.9 KiB
Python

"""LLM-as-judge eval for tailoring quality.
This is the layer the structural scorers cannot provide: it asks a *real* LLM
whether a tailored resume is actually good against its job description, scored
on a rubric (relevance, truthfulness, formatting). It is non-deterministic and
costs a model call, so:
* it is marked ``@pytest.mark.eval`` (excluded from the default selection in
CI / quick runs), and
* it SKIPS unless the developer has an LLM key configured, using their own
key/provider via ``app.llm`` — exactly the policy in
``docs/agent/testing-strategy.md`` §3.1.
CRITICAL: in a keyless environment this test must skip *before* it ever builds
or sends a request. ``_needs_key()`` is called as the very first statement in
the test body, so no real LLM call can happen ungated.
Run it on demand with a key configured::
uv run pytest tests/evals -m eval
"""
import json
import pytest
from app.llm import complete_json, get_llm_config
from tests.evals.golden.cases import GOLDEN_CASES
def _needs_key() -> None:
"""Skip the calling test unless a usable LLM key/provider is configured.
A key is considered "absent" only when there is no api_key AND the provider
is not one of the local/self-hosted providers that legitimately run without
one (``ollama``, ``openai_compatible``). This mirrors the gate used
throughout the backend.
"""
try:
cfg = get_llm_config()
except Exception as exc: # corrupt/unreadable config.json — skip, don't hard-fail
pytest.skip(f"could not read LLM config ({exc}); skipping LLM-judge eval")
if not cfg.api_key and cfg.provider not in ("ollama", "openai_compatible"):
pytest.skip("no LLM key configured; set one to run LLM-judge evals")
_JUDGE_RUBRIC = (
"You are a strict but fair technical recruiter grading how well a resume "
"was tailored to a job description. Grade on three axes:\n"
"1. RELEVANCE — does the resume emphasize skills/experience the JD asks for?\n"
"2. TRUTHFULNESS — does it avoid inventing employers, titles, or facts not "
"implied by the candidate's history?\n"
"3. FORMATTING — is it coherent, well-structured, and free of obvious "
"artifacts?\n\n"
"Return ONLY JSON of the form "
'{{"score": <integer 1-5>, "reasons": "<one or two sentences>"}}. '
"5 = excellent on all axes, 1 = poor. Be honest."
)
def _build_judge_prompt(job_description: str, tailored: dict) -> str:
"""Assemble the judge prompt for one (JD, tailored-resume) pair."""
return (
f"{_JUDGE_RUBRIC}\n\n"
f"=== JOB DESCRIPTION ===\n{job_description}\n\n"
f"=== TAILORED RESUME (JSON) ===\n"
f"{json.dumps(tailored, ensure_ascii=False, indent=2)}\n"
)
@pytest.mark.eval
async def test_llm_judge_scores_good_tailoring_highly():
"""A real LLM judge should rate a faithful, JD-aware tailoring >= 3/5.
In keyless environments this skips at ``_needs_key()`` before any request
is constructed or sent — it must NEVER make an ungated real call.
"""
_needs_key() # MUST be first — gates every line below behind a real key.
case = GOLDEN_CASES[0]
prompt = _build_judge_prompt(case["job_description"], case["tailored_good"])
# One cheap call. schema_type="enrichment" keeps truncation heuristics
# lenient for this small free-form JSON (not a full resume payload).
result = await complete_json(
prompt,
system_prompt="You are an impartial resume-tailoring evaluator.",
max_tokens=512,
schema_type="enrichment",
)
assert isinstance(result, dict), f"judge returned non-dict: {result!r}"
assert "score" in result, f"judge response missing 'score': {result!r}"
score = int(result["score"])
assert 1 <= score <= 5, f"score out of rubric range: {score}"
assert score >= 3, (
f"LLM judge scored the good tailoring below threshold: "
f"score={score}, reasons={result.get('reasons')!r}"
)