380 lines
16 KiB
Python
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:
|
|
- personalansvar / linjeledelse
|
|
- turnus
|
|
senioritet:
|
|
min: seniorrådgiver
|
|
maks: fagdirektør/sjefsarkitekt
|
|
---
|
|
|
|
## 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)
|