jobbsok/tests/test_kandidatvurdering_scoring.py

380 lines
16 KiB
Python

"""Scoring arithmetic, hard filters and the seniority ordering (plan Step 10).
The split this file guards: the model supplies per-criterion judgement, the
script supplies the arithmetic. A score produced end to end by a model is not
reproducible, and M6 reads `score_da` as a number on a weight-dependent scale
-- so the number has to come from somewhere that can be run twice. Every test
here hands `vurdering` a recorded sub-score payload and asserts on what it
computes; none of them asks a model anything, and the suite runs with sockets
disabled, so "no network" is enforced by the runner rather than promised here.
The corpus records `delscore` on a 0-10 scale for readability. The payload
contract is 0-100. :func:`payload` is the bridge, and it is a bridge rather
than a rewrite of the corpus because tests/fixtures is Step 7's and this step
must not touch it. The scale matters: on the recorded 0-10 numbers, listings
01 and 02 differ by one point in one criterion and collapse to the same
integer after weighting, which would make "01 scores above 02" untestable.
Divergence 3 from the operator's real profile lands here rather than in the
schema: `maks_reisetid_min` is a **soft** filter when the profile also
declares `arbeidssteder`. Build-brief 5.1 says frontmatter is absolute, and
for a profile that declares no work locations it still is. But the operator's
own file names three places explicitly and accepts a longer commute to reach
them, and a hard travel-time filter would reject listings that operator would
take. So the precedent is coded, with the profile's own list as the trigger.
Style note: this file follows tests/test_kandidatprofil_schema.py.
"""
import os
import pytest
import kandidat_schema
import vurdering
from jobbsok_lib import frontmatter
def listing(fixtures_dir, name):
path = os.path.join(fixtures_dir, "listings", name)
with open(path, "r", encoding="utf-8") as handle:
return frontmatter.parse(handle.read())
def payload(meta, bekymringer=("konseptfase, ikke drift",)):
"""The recorded sub-scores, lifted from the corpus 0-10 scale onto 0-100."""
delscore = {k: v * 10 for k, v in meta["delscore"].items() if k != "sum"}
return {"delscore": delscore, "bekymringer": list(bekymringer)}
def profile(fixtures_dir, name="01-gyldig.md"):
path = os.path.join(fixtures_dir, "profiles", name)
with open(path, "r", encoding="utf-8") as handle:
return vurdering.les_profil(handle.read())
ALLE_ANNONSER = (
"01-alt-passer.md",
"02-lonn-under-gulv.md",
"03-for-lang-reisetid.md",
"04-for-lite-hjemmekontor.md",
"05-avvist-ansettelsesform.md",
"06-treffer-absolutt-nei.md",
"07-for-lav-senioritet.md",
"08-lonn-ikke-oppgitt.md",
)
#: Anonymous profile that declares work locations, so travel time becomes a
#: warning for those places. Same shape as the operator's real file.
MED_ARBEIDSSTEDER = """---
oppdatert: 2026-09-04
geografi:
base: Tromsø
maks_reisetid_min: 45
hjemmekontor_min_dager: 2
vurderer_flytting: nei
arbeidssteder:
- Tromsø
- Målselv
lonn:
gulv_nok: 850000
onsket_nok: 950000
kommentar: gulvet avviser mekanisk
ansettelsesform:
aksepterer: fast, konsulent via byrå
avviser: åremål/engasjement, vikariat
absolutte_nei:
- nattarbeid / turnusvakt
- turnus
senioritet:
min: seniorkonsulent
maks: sjefkonsulent/avdelingsdirektør
---
## Kjerne
Bygger fagsystemer som tas i bruk.
## Kompetanse
## Retning
## Signaturprosjekter
## Kjente svakheter
## Formuleringer som virker
## Profilgap
"""
def keys_of(entries):
return [entry["nokkel"] for entry in entries]
# ---------------------------------------------------------------------------
# The plan's eight behaviours
# ---------------------------------------------------------------------------
def test_every_fixture_listing_scores_with_a_verdict_and_carried_concerns(fixtures_dir):
profil = profile(fixtures_dir)
for name in ALLE_ANNONSER:
meta, body = listing(fixtures_dir, name)
last = payload(meta, bekymringer=["bekymring for %s" % name])
resultat = vurdering.vurder(profil, meta, body, last)
assert isinstance(resultat["score"], int), name
assert 0 <= resultat["score"] <= 100, name
assert resultat["verdikt"] in ("vurderes", "avvist"), name
# The script makes no model call, so the concerns it returns are the
# ones it was handed -- unchanged, in order.
assert resultat["bekymringer"] == ["bekymring for %s" % name], name
def test_a_broken_payload_is_rejected_by_the_name_of_the_criterion(fixtures_dir):
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
mangler = payload(meta)
del mangler["delscore"]["teknologi"]
with pytest.raises(vurdering.DelscoreError) as manglende:
vurdering.vurder(profil, meta, body, mangler)
assert "teknologi" in str(manglende.value)
utenfor = payload(meta)
utenfor["delscore"]["fagomrade"] = 101
with pytest.raises(vurdering.DelscoreError) as omraade:
vurdering.vurder(profil, meta, body, utenfor)
assert "fagomrade" in str(omraade.value) and "101" in str(omraade.value)
feil_type = payload(meta)
feil_type["delscore"]["oppgavetype"] = "sju"
with pytest.raises(vurdering.DelscoreError) as typen:
vurdering.vurder(profil, meta, body, feil_type)
assert "oppgavetype" in str(typen.value)
@pytest.mark.parametrize(
"fixture,nokkel",
[
("03-for-lang-reisetid.md", "geografi.maks_reisetid_min"),
("02-lonn-under-gulv.md", "lonn.gulv_nok"),
("05-avvist-ansettelsesform.md", "ansettelsesform.avviser"),
("06-treffer-absolutt-nei.md", "absolutte_nei"),
],
)
def test_each_hard_filter_names_its_frontmatter_key_and_still_scores(
fixtures_dir, fixture, nokkel
):
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, fixture)
resultat = vurdering.vurder(profil, meta, body, payload(meta))
assert resultat["verdikt"] == "avvist"
assert nokkel in keys_of(resultat["avvisninger"])
# Scored anyway: a rejection the operator cannot weigh against a number
# is a rejection they cannot argue with.
assert isinstance(resultat["score"], int) and resultat["score"] > 0
def test_listing_01_scores_above_listing_02(fixtures_dir):
profil = profile(fixtures_dir)
poeng = {}
for name in ("01-alt-passer.md", "02-lonn-under-gulv.md"):
meta, body = listing(fixtures_dir, name)
poeng[name] = vurdering.vurder(profil, meta, body, payload(meta))["score"]
assert poeng["01-alt-passer.md"] > poeng["02-lonn-under-gulv.md"]
def test_a_vague_listing_does_not_crash(fixtures_dir):
profil = profile(fixtures_dir)
vag = {"arbeidsgiver": "Ukjent AS", "rolle": "Spennende stilling"}
resultat = vurdering.vurder(
profil, vag, "Vi soeker deg som vil noe.",
{"delscore": {k: 50 for k in kandidat_schema.STANDARDVEKTER}, "bekymringer": []},
)
assert resultat["score"] == 50
# Nothing is known, so nothing is rejected -- but every filter that could
# not run says so, rather than passing silently.
assert resultat["verdikt"] == "vurderes"
for nokkel in ("lonn.gulv_nok", "geografi.maks_reisetid_min",
"geografi.hjemmekontor_min_dager", "ansettelsesform.aksepterer",
"senioritet.min"):
assert nokkel in keys_of(resultat["advarsler"])
def test_the_same_fixture_scored_twice_gives_the_same_integer(fixtures_dir):
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
forste = vurdering.vurder(profil, meta, body, payload(meta))
andre = vurdering.vurder(profil, meta, body, payload(meta))
assert forste["score"] == andre["score"] == 70
assert forste["vekt_hash"] == andre["vekt_hash"]
assert forste["vekt_hash"].startswith("sha256:")
def test_changing_a_weight_moves_the_score_in_the_declared_direction(fixtures_dir):
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
last = payload(meta)
grunn = vurdering.vurder(profil, meta, body, last)
# fagomrade carries the highest sub-score, so weighting it harder has to
# raise the total, and weighting it to nothing has to lower it.
tyngre = dict(profil["vekter"], fagomrade=profil["vekter"]["fagomrade"] * 4)
lettere = dict(profil["vekter"], fagomrade=0)
opp = vurdering.vurder(profil, meta, body, last, vekter=tyngre)
ned = vurdering.vurder(profil, meta, body, last, vekter=lettere)
assert opp["score"] > grunn["score"] > ned["score"]
# A different weight vector is a different hash, which is what makes a
# later change of weights visible instead of silently rescaling history.
assert opp["vekt_hash"] != grunn["vekt_hash"] != ned["vekt_hash"]
def test_an_absolute_no_is_word_bounded(fixtures_dir):
profil = profile(fixtures_dir)
meta, _body = listing(fixtures_dir, "06-treffer-absolutt-nei.md")
truffet = vurdering.vurder(
profil, meta, "Stillingen gaar i turnus.", payload(meta)
)
assert "absolutte_nei" in keys_of(truffet["avvisninger"])
# An inflection is a different token. `turnusplanleggeren` is a tool, and
# a substring match would reject the listing for naming one.
ren = dict(meta)
ren["turnus"] = "nei"
bomskudd = vurdering.vurder(
profil, ren, "Vi bruker Turnusplanleggeren som verktoey.", payload(meta)
)
assert "absolutte_nei" not in keys_of(bomskudd["avvisninger"])
def test_half_up_rounding_is_pinned_at_the_boundary(fixtures_dir):
# Exactly .5, which is where round() and half-up part company: round()
# rounds half to even, so the same inputs would land differently
# depending on which side of the boundary the even number happened to be.
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
last = {"delscore": {"fagomrade": 70, "oppgavetype": 71, "teknologi": 0,
"selskapstype": 0},
"bekymringer": []}
vekter = {"fagomrade": 1, "oppgavetype": 1, "teknologi": 0, "selskapstype": 0}
assert vurdering.vurder(profil, meta, body, last, vekter=vekter)["score"] == 71
def test_a_ja_flag_contributes_its_key_name_to_the_absolute_no_match(fixtures_dir):
# The corpus fixture that carries turnus also *says* "turnus" in its
# prose, so a match there proves nothing about the flag rule. This one
# keeps the word out of every text field and leaves only `turnus: ja`.
profil = profile(fixtures_dir)
stille = {"arbeidsgiver": "Kystdata AS", "rolle": "Driftsleder", "sted": "Tromsø",
"reisetid_min": 20, "hjemmekontor_dager": 2, "ansettelsesform": "fast",
"lonn_nok": 900000, "senioritet": "senior", "turnus": "ja"}
last = {"delscore": {k: 50 for k in kandidat_schema.STANDARDVEKTER},
"bekymringer": []}
truffet = vurdering.vurder(profil, stille, "Spennende rolle i drift.", last)
assert "absolutte_nei" in keys_of(truffet["avvisninger"])
av = vurdering.vurder(profil, dict(stille, turnus="nei"),
"Spennende rolle i drift.", last)
assert "absolutte_nei" not in keys_of(av["avvisninger"])
# ---------------------------------------------------------------------------
# The divergences the real profile forced
# ---------------------------------------------------------------------------
def test_divergence_3_arbeidssteder_turns_travel_time_into_a_warning(fixtures_dir):
meta, body = listing(fixtures_dir, "03-for-lang-reisetid.md")
# Same listing, same 90 minutes against the same 45-minute ceiling.
uten = profile(fixtures_dir)
avvist = vurdering.vurder(uten, meta, body, payload(meta))
assert "geografi.maks_reisetid_min" in keys_of(avvist["avvisninger"])
assert avvist["verdikt"] == "avvist"
med = vurdering.les_profil(MED_ARBEIDSSTEDER)
mykt = vurdering.vurder(med, meta, body, payload(meta))
assert "geografi.maks_reisetid_min" not in keys_of(mykt["avvisninger"])
assert "geografi.maks_reisetid_min" in keys_of(mykt["advarsler"])
assert mykt["verdikt"] == "vurderes"
def test_divergence_3_a_place_outside_the_list_is_still_rejected(fixtures_dir):
med = vurdering.les_profil(MED_ARBEIDSSTEDER)
meta, body = listing(fixtures_dir, "03-for-lang-reisetid.md")
annet_sted = dict(meta, sted="Kirkenes")
resultat = vurdering.vurder(med, annet_sted, body, payload(meta))
# The list is an override for the places on it, not a blanket amnesty.
assert "geografi.maks_reisetid_min" in keys_of(resultat["avvisninger"])
def test_divergence_5_salary_not_stated_is_not_salary_below_the_floor(fixtures_dir):
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "08-lonn-ikke-oppgitt.md")
resultat = vurdering.vurder(profil, meta, body, payload(meta))
assert "lonn.gulv_nok" not in keys_of(resultat["avvisninger"])
assert "lonn.gulv_nok" in keys_of(resultat["advarsler"])
assert resultat["verdikt"] == "vurderes"
under, under_body = listing(fixtures_dir, "02-lonn-under-gulv.md")
avslag = vurdering.vurder(profil, under, under_body, payload(under))
assert "lonn.gulv_nok" in keys_of(avslag["avvisninger"])
def test_divergence_6_free_text_seniority_is_ordered_by_the_rank_table(fixtures_dir):
med = vurdering.les_profil(MED_ARBEIDSSTEDER)
assert vurdering.rang("seniorrådgiver") < vurdering.rang("fagdirektør/sjefsarkitekt")
assert vurdering.rang("junior") < vurdering.rang("senior")
assert vurdering.rang("ordbok uten rang") is None
meta, body = listing(fixtures_dir, "07-for-lav-senioritet.md")
for lav in ("junior", "nyutdannet"):
resultat = vurdering.vurder(med, dict(meta, senioritet=lav), body, payload(meta))
assert "senioritet.min" in keys_of(resultat["avvisninger"]), lav
over = vurdering.vurder(med, dict(meta, senioritet="administrerende direktør"), body,
payload(meta))
assert "senioritet.maks" in keys_of(over["avvisninger"])
passer = vurdering.vurder(med, dict(meta, senioritet="seniorrådgiver"), body,
payload(meta))
assert not [k for k in keys_of(passer["avvisninger"]) if k.startswith("senioritet")]
def test_divergence_6_an_unrankable_seniority_warns_instead_of_passing_silently(
fixtures_dir
):
med = vurdering.les_profil(MED_ARBEIDSSTEDER)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
resultat = vurdering.vurder(med, dict(meta, senioritet="stillingskode 1364"), body,
payload(meta))
assert not [k for k in keys_of(resultat["avvisninger"]) if k.startswith("senioritet")]
assert "senioritet.min" in keys_of(resultat["advarsler"])
def test_the_two_hard_filters_beyond_the_plans_four_also_name_their_key(fixtures_dir):
# 5.1 has six keys the filters can reject on, and the corpus carries a
# fixture for each. The plan names four; these are the other two, and
# leaving them unasserted would leave two filters untested.
profil = profile(fixtures_dir)
for fixture, nokkel in (
("04-for-lite-hjemmekontor.md", "geografi.hjemmekontor_min_dager"),
("07-for-lav-senioritet.md", "senioritet.min"),
):
meta, body = listing(fixtures_dir, fixture)
resultat = vurdering.vurder(profil, meta, body, payload(meta))
assert resultat["verdikt"] == "avvist", fixture
assert nokkel in keys_of(resultat["avvisninger"]), fixture
def test_scoring_persists_nothing(empty_workspace, fixtures_dir):
from helpers import workspace as workspace_helper
profil = profile(fixtures_dir)
meta, body = listing(fixtures_dir, "01-alt-passer.md")
before = workspace_helper.snapshot_tree(empty_workspace)
vurdering.vurder(profil, meta, body, payload(meta))
workspace_helper.assert_tree_unchanged(empty_workspace, before)