feat(m1): compute scores from sub-scores and weights in a script
This commit is contained in:
parent
f75718d82c
commit
3c74968ec1
2 changed files with 746 additions and 0 deletions
366
scripts/vurdering.py
Normal file
366
scripts/vurdering.py
Normal 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})
|
||||||
380
tests/test_kandidatvurdering_scoring.py
Normal file
380
tests/test_kandidatvurdering_scoring.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue