184 lines
6.7 KiB
Python
184 lines
6.7 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
|