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