"""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)