HR-ATS-Portal/backend/tests/test_employment_extraction_...

200 lines
7.3 KiB
Python

"""employment_agent clamps for the new skills / years_experience fields.
These are the CV Bank's only structured data, and the model is the only source,
so the clamps are what stop a hallucinated skill becoming a searchable fact.
"""
from employment_agent.decorators import parse_employment_response
from employment_agent.prompt import CURRENT_TITLE, EDUCATION, NO_COMPANY
RESUME = (
"Ada Lovelace\n"
"Backend Engineer at Acme\n"
"Skills: Python, FastAPI, PostgreSQL, Docker\n"
"BS Computer Science\n"
"6 years of experience\n"
)
def parse(payload, resume_text=RESUME):
return parse_employment_response(payload, resume_text)
# --------------------------------------------------------------------------
# Backwards compatibility — the inbox match path predates these fields
# --------------------------------------------------------------------------
def test_response_without_the_new_keys_still_parses():
"""An older or partial reply must not break inbox matching."""
fields = parse({
"current_employment": "Acme",
"education": "BS Computer Science",
"current_title": "Backend Engineer",
"linkedin_url": "",
"phone": "",
})
assert fields["skills"] == []
assert fields["years_experience"] is None
assert fields["current_employment"] == "Acme"
def test_non_list_skills_degrade_to_empty():
assert parse({"skills": "Python, FastAPI"})["skills"] == []
assert parse({"skills": None})["skills"] == []
assert parse({"skills": {"a": 1}})["skills"] == []
# --------------------------------------------------------------------------
# skills
# --------------------------------------------------------------------------
def test_skills_present_in_the_resume_are_kept_with_their_own_spelling():
fields = parse({"skills": ["Python", "FastAPI", "PostgreSQL"]})
assert fields["skills"] == ["Python", "FastAPI", "PostgreSQL"]
def test_fabricated_skills_are_dropped():
"""The model crediting Kubernetes to a CV that never mentions it is the
exact defect this clamp exists for."""
fields = parse({"skills": ["Python", "Kubernetes", "Terraform"]})
assert fields["skills"] == ["Python"]
def test_skills_are_deduplicated_case_insensitively_keeping_first_spelling():
fields = parse({"skills": ["Python", "python", "PYTHON", "FastAPI"]})
assert fields["skills"] == ["Python", "FastAPI"]
def test_blank_and_whitespace_skills_are_removed():
fields = parse({"skills": ["Python", "", " ", "\n", "FastAPI"]})
assert fields["skills"] == ["Python", "FastAPI"]
def test_skills_are_trimmed_before_matching():
fields = parse({"skills": [" Python ", " FastAPI"]})
assert fields["skills"] == ["Python", "FastAPI"]
def test_dedup_runs_before_the_thirty_cap():
"""31 near-duplicates must collapse under the limit rather than push real
skills out of it — same ordering rule as ATSScore's keyword arrays."""
resume = "Skills: " + ", ".join(f"skill{i}" for i in range(30)) + ", Python\n"
noisy = ["Python"] * 5 + [f"skill{i}" for i in range(30)]
fields = parse_employment_response({"skills": noisy}, resume)
assert len(fields["skills"]) == 30
assert fields["skills"][0] == "Python"
assert fields["skills"].count("Python") == 1
def test_skills_are_capped_at_thirty():
resume = "Skills: " + ", ".join(f"skill{i}" for i in range(50))
fields = parse_employment_response(
{"skills": [f"skill{i}" for i in range(50)]}, resume,
)
assert len(fields["skills"]) == 30
def test_sentence_length_entries_are_rejected():
"""A responsibility is not a skill; a 60-char ceiling keeps chips renderable."""
long_entry = "Responsible for building and maintaining backend services at scale"
fields = parse_employment_response({"skills": [long_entry]}, long_entry)
assert fields["skills"] == []
def test_non_string_entries_are_ignored():
fields = parse({"skills": ["Python", 42, None, {"x": 1}, ["FastAPI"]]})
assert fields["skills"] == ["Python"]
def test_skills_pass_through_when_there_is_no_resume_text_to_check_against():
"""Nothing to verify against is not evidence of fabrication."""
fields = parse_employment_response({"skills": ["Python", "Kubernetes"]}, "")
assert fields["skills"] == ["Python", "Kubernetes"]
# --------------------------------------------------------------------------
# years_experience
# --------------------------------------------------------------------------
def test_stated_years_are_kept():
assert parse({"years_experience": 6})["years_experience"] == 6
def test_zero_years_is_a_real_value():
assert parse({"years_experience": 0})["years_experience"] == 0
def test_years_are_bounded_at_sixty():
assert parse({"years_experience": 61})["years_experience"] is None
assert parse({"years_experience": 60})["years_experience"] == 60
def test_negative_years_are_rejected():
assert parse({"years_experience": -3})["years_experience"] is None
def test_years_as_a_string_are_parsed():
assert parse({"years_experience": "6"})["years_experience"] == 6
assert parse({"years_experience": "6 years"})["years_experience"] == 6
def test_unparseable_years_read_as_unknown_not_zero():
"""0 would sort the candidate as a fresh graduate; unknown must stay unknown."""
for value in (None, "", "several", "many years", [], {}, True, False):
assert parse({"years_experience": value})["years_experience"] is None
def test_float_years_truncate_to_whole_years():
assert parse({"years_experience": 6.8})["years_experience"] == 6
# --------------------------------------------------------------------------
# The pre-existing fields are unaffected by the new clamps
# --------------------------------------------------------------------------
def test_existing_sentinels_still_normalize():
fields = parse({
"current_employment": NO_COMPANY,
"education": EDUCATION,
"current_title": CURRENT_TITLE,
"skills": ["Python"],
"years_experience": 6,
})
assert fields["current_employment"] == NO_COMPANY
assert fields["education"] == EDUCATION
assert fields["skills"] == ["Python"]
def test_city_from_the_model_is_kept_as_returned():
resume = "Ali Khan | Karachi(Malir) | 0321-5551234"
fields = parse_employment_response({"city": "Karachi"}, resume)
assert fields["city"] == "Karachi"
def test_messy_model_city_is_clamped_to_canonical_before_persist():
resume = "Ali Khan | Karachi(Malir) | 0321-5551234"
fields = parse_employment_response({"city": "Karachi(Malir)"}, resume)
assert fields["city"] == "Karachi"
assert parse({"city": "DHA Karachi"})["city"] == "Karachi"
assert parse({"city": "Wah Cantt"})["city"] == "Wah"
def test_city_sentinel_is_dropped():
assert parse({"city": "no city mentioned"})["city"] is None
def test_candidate_name_is_kept_when_it_appears_on_the_resume():
fields = parse({"candidate_name": "Ada Lovelace"})
assert fields["candidate_name"] == "Ada Lovelace"
def test_candidate_name_absent_from_resume_is_dropped():
fields = parse({"candidate_name": "Someone Else"})
assert fields["candidate_name"] == ""
def test_candidate_name_email_and_sentinel_are_dropped():
assert parse({"candidate_name": "ada@example.com"})["candidate_name"] == ""
assert parse({"candidate_name": "no name mentioned"})["candidate_name"] == ""
assert parse({})["candidate_name"] == ""