feat(m1): compute scores from sub-scores and weights in a script

This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 21:27:38 +02:00
commit 3c74968ec1
2 changed files with 746 additions and 0 deletions

366
scripts/vurdering.py Normal file
View file

@ -0,0 +1,366 @@
"""Hard filters and the weighted score, kept out of the model (plan Step 10).
Scoring is split in two. Judgement -- how well a listing matches a criterion --
is the model's. Arithmetic is this module's, and nothing here calls a model.
Two reasons, both measured risks. A number produced end to end by a model is
not reproducible (H5), and M6's learning loop reads `score_da` as a value on a
weight-dependent scale (M8r), so a later change of weights has to be visible
rather than silently rescaling the history. Hence :func:`vurder` returns the
sub-scores it was handed and a hash of the weight vector alongside the score.
It persists nothing. `kandidatvurdering` writes nothing (build-brief 7), which
is what makes it safe to run on unguarded pasted text in M1, before the
ingestion guard exists. The sub-scores and the weight hash are written later,
at decision time, by `beslutninger.py` in Step 22.
Hard filters run first and are collected, not short-circuited into the first
one found: an operator who sees only "rejected on salary" cannot tell whether
fixing the salary would help. Every rejection names the frontmatter key it
rejected on, and the listing is scored anyway, because a rejection without a
number is a rejection that cannot be argued with.
Two rules here are precedents rather than mechanics, and both come from the
operator's real profile.
**Travel time is soft when the profile names its work locations.** Build-brief
5.1 says frontmatter is absolute, and for a profile with no `arbeidssteder`
key it still is. But a profile that lists the places it will actually work has
already answered the question the travel-time ceiling was asking, and answered
it more precisely. So a listing whose location is on that list turns
`maks_reisetid_min` into a warning -- for that location only. Anywhere else,
the ceiling rejects exactly as before.
**Seniority is free text ordered by a rank table.** 5.1 gives no enum, and the
operator's own values are `seniorrådgiver` and `fagdirektør/sjefsarkitekt`.
Without an ordering the seniority filter cannot decide anything and is
decoration. :data:`SENIORITETSRANG` supplies one. A title the table does not
know produces a warning and never a rejection: refusing to place a word is not
the same as placing it below the floor.
Absolute-no matching is normalised and word-bounded. Both sides fold through
`kandidat_schema.normaliser`, which is `paths.slug`'s rules, so `Ålesund` and `alesund` are one word, and a term matches only
as a whole token sequence -- `turnus` is a hit, `turnusplanleggeren` is not.
The alternatives contract from `kandidat_schema` carries over unchanged: a
slash separates spellings of one no, whitespace inside one spelling is a
phrase.
"""
import hashlib
import json
import kandidat_schema
from jobbsok_lib import frontmatter
#: Seniority titles the ordering knows, folded through `paths.slug`. The
#: numbers are ordinals with gaps, so a level can be inserted later without
#: renumbering a scale that is already recorded in decisions.
SENIORITETSRANG = {
"junior": 10, "nyutdannet": 10, "trainee": 10, "graduate": 10, "laerling": 10,
"radgiver": 20, "konsulent": 20, "medarbeider": 20, "ingenior": 20,
"saksbehandler": 20,
"senior": 30, "seniorradgiver": 30, "seniorkonsulent": 30, "senioringenior": 30,
"spesialist": 30, "spesialradgiver": 30, "arkitekt": 30, "seniorarkitekt": 30,
"fagleder": 40, "teamleder": 40, "teamlead": 40, "leder": 40, "fagansvarlig": 40,
"sjefkonsulent": 40, "sjefingenior": 40, "losningsarkitekt": 40,
"principal": 40, "prinsipal": 40,
"fagdirektor": 50, "sjefsarkitekt": 50, "avdelingsdirektor": 50, "direktor": 50,
"teknisk-direktor": 50, "cto": 50,
"administrerende-direktor": 60, "adm-direktor": 60, "konsernsjef": 60, "ceo": 60,
}
#: The affirmative word that turns a listing flag into an attribute of the
#: listing. `turnus: ja` contributes the token `turnus` to what the
#: absolute-no list is matched against; `turnus: nei` contributes nothing.
JA = "ja"
MIN_DELSCORE = 0
MAX_DELSCORE = 100
class DelscoreError(Exception):
"""A sub-score payload that is missing a criterion or out of range."""
def les_profil(text):
"""Parse a `kandidat.md` document into what scoring needs from it.
Goes through :func:`kandidat_schema.validate` rather than re-reading the
frontmatter, so the comma/slash contract and the weight resolution are
defined once. The raw metadata comes along because the numeric hard
filters read keys the report does not restate.
"""
rapport = kandidat_schema.validate(text)
meta, _body = frontmatter.parse(text)
return {
"meta": meta,
"rapport": rapport,
"vekter": rapport["vekter"],
"vekter_kilde": rapport["vekter_kilde"],
}
def les_profil_fil(root, *parts):
"""Read and parse the profile at ``parts`` under ``root``."""
meta, body = frontmatter.read(root, *parts)
return les_profil(frontmatter.render(meta, body))
def vurder(profil, annonse, brodtekst, delscore_payload, vekter=None):
"""Score ``annonse`` against ``profil`` and run the hard filters.
``delscore_payload`` is the model's contribution: a mapping of criterion to
an integer 0-100 under ``delscore``, and a free-text ``bekymringer`` list
that is returned untouched. Nothing is written anywhere.
"""
delscore = _valider_delscore(delscore_payload)
vekter = dict(vekter if vekter is not None else profil["vekter"])
avvisninger = []
advarsler = []
_filter_lonn(profil, annonse, avvisninger, advarsler)
_filter_reisetid(profil, annonse, avvisninger, advarsler)
_filter_hjemmekontor(profil, annonse, avvisninger, advarsler)
_filter_ansettelsesform(profil, annonse, avvisninger, advarsler)
_filter_absolutte_nei(profil, annonse, brodtekst, avvisninger, advarsler)
_filter_senioritet(profil, annonse, avvisninger, advarsler)
return {
"score": _score(delscore, vekter),
"delscore": dict(delscore),
"vekter": vekter,
"vekter_kilde": profil["vekter_kilde"],
"vekt_hash": vekt_hash(vekter),
"verdikt": "avvist" if avvisninger else "vurderes",
"avvisninger": avvisninger,
"advarsler": advarsler,
"bekymringer": list(delscore_payload.get("bekymringer", [])),
}
def vekt_hash(vekter):
"""A stable fingerprint of a weight vector, for recording beside a score."""
canonical = json.dumps(vekter, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def rang(tekst):
"""Rank a free-text seniority title, or ``None`` when it is not placeable.
Slash alternatives are resolved to the **highest** rank any spelling
reaches. For a ceiling that is the generous read and for a floor the
conservative one, which is the right way round: a profile that writes two
names for the same level should not be filtered by whichever name the
table happened to rank lower.
"""
rangeringer = []
for alternativ in kandidat_schema.del_alternativer(tekst):
folded = kandidat_schema.normaliser(alternativ)
if folded in SENIORITETSRANG:
rangeringer.append(SENIORITETSRANG[folded])
continue
tokens = [SENIORITETSRANG[t] for t in folded.split("-") if t in SENIORITETSRANG]
if tokens:
rangeringer.append(max(tokens))
return max(rangeringer) if rangeringer else None
def treffer(termer, tekst):
"""True when any alternative in ``termer`` appears as whole tokens in ``tekst``."""
hoystakk = _tokens(tekst)
for alternativ in termer:
naal = _tokens(alternativ)
if naal and _delsekvens(naal, hoystakk):
return True
return False
def _valider_delscore(payload):
raw = (payload or {}).get("delscore")
if not isinstance(raw, dict):
raise DelscoreError("delscore mangler; forventet et kart over kriterium til 0-100")
delscore = {}
for kriterium in kandidat_schema.STANDARDVEKTER:
if kriterium not in raw:
raise DelscoreError("delscore mangler kriteriet %r" % kriterium)
verdi = raw[kriterium]
if isinstance(verdi, bool) or not isinstance(verdi, int):
raise DelscoreError(
"delscore for %r er %r; forventet et heltall" % (kriterium, verdi)
)
if not MIN_DELSCORE <= verdi <= MAX_DELSCORE:
raise DelscoreError(
"delscore for %r er %d; utenfor %d-%d"
% (kriterium, verdi, MIN_DELSCORE, MAX_DELSCORE)
)
delscore[kriterium] = verdi
for kriterium in raw:
if kriterium not in kandidat_schema.STANDARDVEKTER:
raise DelscoreError("delscore har ukjent kriterium %r" % kriterium)
return delscore
def _score(delscore, vekter):
sum_vekt = sum(vekter.get(k, 0) for k in delscore)
if sum_vekt <= 0:
return 0
total = sum(delscore[k] * vekter.get(k, 0) for k in delscore)
# Half up, and explicitly: round() rounds half to even, which would make
# the same inputs land differently either side of .5.
return max(0, min(100, int(total / sum_vekt + 0.5)))
def _filter_lonn(profil, annonse, avvisninger, advarsler):
gulv = _tall(profil["meta"].get("lonn"), "gulv_nok")
tilbudt = annonse.get("lonn_nok")
if gulv is None:
return
if not isinstance(tilbudt, int):
# Not stated is not below the floor. Treating the two alike would
# reject every listing that keeps its salary out of the advert.
_si(advarsler, "lonn.gulv_nok",
"annonsen oppgir ingen lonn; gulvet paa %d kunne ikke sjekkes" % gulv)
return
if tilbudt < gulv:
_si(avvisninger, "lonn.gulv_nok",
"annonsen oppgir %d, gulvet er %d" % (tilbudt, gulv))
def _filter_reisetid(profil, annonse, avvisninger, advarsler):
tak = _tall(profil["meta"].get("geografi"), "maks_reisetid_min")
reisetid = annonse.get("reisetid_min")
if tak is None:
return
if not isinstance(reisetid, int):
_si(advarsler, "geografi.maks_reisetid_min",
"annonsen oppgir ingen reisetid; taket paa %d min kunne ikke sjekkes" % tak)
return
if reisetid <= tak:
return
if _paa_arbeidsstedslista(profil, annonse.get("sted")):
_si(advarsler, "geografi.maks_reisetid_min",
"%d min mot et tak paa %d, men %r staar i arbeidssteder; nedvektes, "
"avvises ikke" % (reisetid, tak, annonse.get("sted")))
return
_si(avvisninger, "geografi.maks_reisetid_min",
"%d min mot et tak paa %d" % (reisetid, tak))
def _filter_hjemmekontor(profil, annonse, avvisninger, advarsler):
krav = _tall(profil["meta"].get("geografi"), "hjemmekontor_min_dager")
dager = annonse.get("hjemmekontor_dager")
if krav is None:
return
if not isinstance(dager, int):
_si(advarsler, "geografi.hjemmekontor_min_dager",
"annonsen oppgir ingen hjemmekontordager; kravet paa %d kunne ikke sjekkes"
% krav)
return
if dager < krav:
_si(avvisninger, "geografi.hjemmekontor_min_dager",
"annonsen gir %d dag(er), kravet er %d" % (dager, krav))
def _filter_ansettelsesform(profil, annonse, avvisninger, advarsler):
former = profil["rapport"]["ansettelsesform"]
oppgitt = annonse.get("ansettelsesform")
if not (former["aksepterer"] or former["avviser"]):
return
if oppgitt is None:
_si(advarsler, "ansettelsesform.aksepterer",
"annonsen oppgir ingen ansettelsesform")
return
for alternativer in former["avviser"]:
if treffer(alternativer, str(oppgitt)):
_si(avvisninger, "ansettelsesform.avviser",
"annonsen er %r, som staar paa avviser-lista" % oppgitt)
return
if not former["aksepterer"]:
return
for alternativer in former["aksepterer"]:
if treffer(alternativer, str(oppgitt)):
return
_si(avvisninger, "ansettelsesform.aksepterer",
"annonsen er %r, som ikke staar paa aksepterer-lista" % oppgitt)
def _filter_absolutte_nei(profil, annonse, brodtekst, avvisninger, _advarsler):
hoystakk = _hoystakk(annonse, brodtekst)
for alternativer in profil["rapport"]["absolutte_nei"]:
if treffer(alternativer, hoystakk):
_si(avvisninger, "absolutte_nei",
"annonsen treffer %r" % " / ".join(alternativer))
def _filter_senioritet(profil, annonse, avvisninger, advarsler):
niva = profil["meta"].get("senioritet")
if not isinstance(niva, dict):
return
oppgitt = annonse.get("senioritet")
if oppgitt is None:
_si(advarsler, "senioritet.min", "annonsen oppgir ingen senioritet")
return
annonserang = rang(oppgitt)
if annonserang is None:
# Not placeable is not below the floor. A stillingskode the table has
# never seen must not be filtered as if it were junior.
_si(advarsler, "senioritet.min",
"%r finnes ikke i rangtabellen; senioritet ble ikke filtrert" % oppgitt)
return
minimum = rang(niva.get("min")) if niva.get("min") is not None else None
maksimum = rang(niva.get("maks")) if niva.get("maks") is not None else None
if minimum is not None and annonserang < minimum:
_si(avvisninger, "senioritet.min",
"%r ligger under %r" % (oppgitt, niva.get("min")))
if maksimum is not None and annonserang > maksimum:
_si(avvisninger, "senioritet.maks",
"%r ligger over %r" % (oppgitt, niva.get("maks")))
def _paa_arbeidsstedslista(profil, sted):
if sted is None:
return False
maal = kandidat_schema.normaliser(sted)
if not maal:
return False
return any(
kandidat_schema.normaliser(kandidat) == maal
for kandidat in profil["rapport"]["arbeidssteder"]
)
def _hoystakk(annonse, brodtekst):
"""What the absolute-no list is matched against.
The role, the location and the body, plus the name of every flag the
listing set to `ja` -- `turnus: ja` says the listing has turnus as plainly
as a sentence would, and a match that only read prose would miss it.
"""
deler = [str(annonse.get(key, "")) for key in ("rolle", "sted", "arbeidsgiver")]
deler.append(brodtekst or "")
for key, verdi in annonse.items():
if not isinstance(verdi, dict) and str(verdi).strip().lower() == JA:
deler.append(key)
return " ".join(deler)
def _tokens(tekst):
folded = kandidat_schema.normaliser(tekst)
return [token for token in folded.split("-") if token]
def _delsekvens(naal, hoystakk):
for start in range(len(hoystakk) - len(naal) + 1):
if hoystakk[start:start + len(naal)] == naal:
return True
return False
def _tall(blokk, key):
if not isinstance(blokk, dict):
return None
verdi = blokk.get(key)
return verdi if isinstance(verdi, int) and not isinstance(verdi, bool) else None
def _si(samling, nokkel, begrunnelse):
samling.append({"nokkel": nokkel, "begrunnelse": begrunnelse})

View file

@ -0,0 +1,380 @@
"""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)