360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""Mechanical validation of `profil/kandidat.md` against build-brief 5.1.
|
|
|
|
The brief's own instruction about this file is the one that shapes everything
|
|
here: *"The operator will supply an existing kandidat.md. Treat its structure
|
|
as the contract and validate against it rather than regenerating it."* So this
|
|
module reports; it never rewrites, and it never refuses a profile for being
|
|
richer than the contract anticipated. A missing required key is an error that
|
|
names the key path. Anything the contract has no opinion about is a warning.
|
|
|
|
Reading the operator's real file on 2026-09-04 produced seven places where the
|
|
file and 5.1 disagree. Six are settled here, and each is a decision rather
|
|
than an accident, so each is written down where it was taken.
|
|
|
|
**1. A mapping value that has to hold several items.** The operator accepts
|
|
two employment forms and rejects two more, but the Step 4 grammar allows only
|
|
a scalar beneath a mapping key (`child := " " KEY ":" SP scalar`). Three ways
|
|
out were open: widen the grammar, lift the key to the top level, or make the
|
|
separator a contract. The separator wins. Widening the grammar puts a second
|
|
parse mode on the hand-rolled parser that sits on the hard-filter path, and
|
|
that parser's whole value is that it is small enough to hold in the head
|
|
(risk H7); lifting the key changes the shape of a file the operator already
|
|
wrote. So: **a comma splits a scalar into items, a slash splits one item into
|
|
alternatives, and whitespace inside an alternative is a phrase.** One rule,
|
|
applied identically to `ansettelsesform.aksepterer`, `ansettelsesform.avviser`
|
|
and each entry of `absolutte_nei` -- which is why `åremål/engasjement, vikariat`
|
|
reads as two rejected forms, the first of which has two names, and
|
|
`nattarbeid / turnusvakt` reads as one no with two names. The rule costs
|
|
the ability to write a comma inside an employment form; no employment form has
|
|
one.
|
|
|
|
**2. Keys outside 5.1.** An unknown key is a warning, never an error: the
|
|
operator owns this file. But a key that scoring actually reads must not look
|
|
like the typo it is otherwise indistinguishable from, so the small set in
|
|
:data:`HONORERTE_UTVIDELSER` warns *and says it is honoured*.
|
|
|
|
**3.** Travel time as a soft filter is scoring's decision, and lives in
|
|
`scripts/vurdering.py`.
|
|
|
|
**4. `vurderer_flytting` is a word, not a boolean.** The grammar has no
|
|
boolean -- `render` refuses one outright -- so the value is the word the
|
|
operator wrote, and the check is a closed word set rather than a type.
|
|
|
|
**5. An explicitly unmeasured outcome.** 5.1 asks each signature project for a
|
|
measurable outcome. One of the operator's has none, and says so instead of
|
|
offering an estimate. Structure is checked here; whether an outcome is real is
|
|
judgement, and a validator that demanded a number would be a validator that
|
|
rewarded inventing one. So the body is checked for its sections and their
|
|
order, and for nothing inside them.
|
|
|
|
**6. `senioritet.min` / `.maks` are free text.** 5.1 gives no enum, and the
|
|
operator's values are `seniorrådgiver` and `fagdirektør/sjefsarkitekt`. The
|
|
ordering that makes the seniority filter more than decorative is a rank table,
|
|
which belongs to scoring; here the words are carried through intact.
|
|
|
|
**7. A breadth bullet need not name a skill.** Nothing in this module parses
|
|
inside a section, which is exactly what lets *"teknologisk breddeforståelse og
|
|
intuisjon"* stand as breadth.
|
|
"""
|
|
|
|
import json
|
|
|
|
from jobbsok_lib import frontmatter, paths
|
|
|
|
#: Body sections from build-brief 5.1, in the order the contract requires.
|
|
SEKSJONER = (
|
|
"Kjerne",
|
|
"Kompetanse",
|
|
"Retning",
|
|
"Signaturprosjekter",
|
|
"Kjente svakheter",
|
|
"Formuleringer som virker",
|
|
"Profilgap",
|
|
)
|
|
|
|
#: Required frontmatter keys from 5.1. A tuple of leaves means a nested block;
|
|
#: `None` means a top-level scalar or list. Seventeen keys counting leaves.
|
|
PAAKREVD = (
|
|
("oppdatert", None),
|
|
("geografi", ("base", "maks_reisetid_min", "hjemmekontor_min_dager", "vurderer_flytting")),
|
|
("lonn", ("gulv_nok", "onsket_nok", "kommentar")),
|
|
("ansettelsesform", ("aksepterer", "avviser")),
|
|
("absolutte_nei", None),
|
|
("senioritet", ("min", "maks")),
|
|
)
|
|
|
|
#: Keys whose value the hard filters compare numerically.
|
|
HELTALL = ("geografi.maks_reisetid_min", "geografi.hjemmekontor_min_dager",
|
|
"lonn.gulv_nok", "lonn.onsket_nok")
|
|
|
|
#: The closed word set for `geografi.vurderer_flytting` (divergence 4).
|
|
FLYTTING_ORD = ("ja", "nei", "kanskje")
|
|
|
|
#: Scoring criteria, and the weights shipped when the profile names none.
|
|
#: Percentages so the vector reads as a distribution at a glance; scoring
|
|
#: normalises by the sum, so any non-negative integers would do.
|
|
STANDARDVEKTER = {
|
|
"fagomrade": 40,
|
|
"oppgavetype": 30,
|
|
"teknologi": 20,
|
|
"selskapstype": 10,
|
|
}
|
|
|
|
#: Keys outside 5.1 that scoring nevertheless reads, with the reason. Warned
|
|
#: about like any unknown key, but named as honoured so an extension the
|
|
#: plugin acts on is distinguishable from a misspelling it ignores.
|
|
HONORERTE_UTVIDELSER = {
|
|
"arbeidssteder": (
|
|
"utenfor 5.1, men lest av vurdering: en eksplisitt liste over "
|
|
"akseptable arbeidssteder gjoer reisetid til en advarsel for disse "
|
|
"stedene"
|
|
),
|
|
}
|
|
|
|
#: Additive key from operator decision 14.3.
|
|
VEKTNOKKEL = "vekter"
|
|
|
|
KJENTE_TOPPNOKLER = tuple(key for key, _ in PAAKREVD) + (VEKTNOKKEL,)
|
|
|
|
FENCE = "```"
|
|
|
|
|
|
def validate(text):
|
|
"""Validate a `kandidat.md` document and return the report.
|
|
|
|
Malformed frontmatter is not a finding -- it is
|
|
:class:`~jobbsok_lib.frontmatter.FrontmatterError`, raised with the
|
|
offending line number, because a document that did not parse has no
|
|
findings to report on.
|
|
"""
|
|
meta, body = frontmatter.parse(text)
|
|
feil = []
|
|
advarsler = []
|
|
|
|
_sjekk_paakrevde(meta, feil)
|
|
_sjekk_typer(meta, feil)
|
|
_sjekk_ukjente(meta, advarsler)
|
|
|
|
seksjoner = _seksjoner(body)
|
|
_sjekk_seksjoner(seksjoner, feil)
|
|
|
|
vekter, kilde = _vekter(meta, feil, advarsler)
|
|
|
|
return {
|
|
"gyldig": not feil,
|
|
"feil": feil,
|
|
"advarsler": advarsler,
|
|
"seksjoner": seksjoner,
|
|
"senioritet": _blokk(meta, "senioritet"),
|
|
"ansettelsesform": {
|
|
felt: del_skalar(_blokk(meta, "ansettelsesform").get(felt))
|
|
for felt in ("aksepterer", "avviser")
|
|
},
|
|
"absolutte_nei": [del_alternativer(item) for item in _liste(meta, "absolutte_nei")],
|
|
"arbeidssteder": _liste(meta, "arbeidssteder"),
|
|
"vekter_kilde": kilde,
|
|
"vekter": vekter,
|
|
}
|
|
|
|
|
|
def validate_file(root, *parts):
|
|
"""Validate the profile at ``parts`` under ``root``, via ``paths.safe_join``."""
|
|
meta, body = frontmatter.read(root, *parts)
|
|
return validate(frontmatter.render(meta, body))
|
|
|
|
|
|
def report_json(report):
|
|
"""Render a report as the canonical JSON the golden files hold.
|
|
|
|
``ensure_ascii=False``: the report quotes the operator's own words back,
|
|
and `Sørdal` escaped to a `\\u00f8` sequence is no longer the word that
|
|
was written.
|
|
"""
|
|
return json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
|
|
|
|
def del_skalar(value):
|
|
"""Split a scalar into items, then each item into alternatives.
|
|
|
|
The divergence-1 contract: comma between items, slash between alternatives
|
|
for the same item. ``None`` and the empty string are an empty list, not a
|
|
list holding an empty name.
|
|
"""
|
|
if value is None:
|
|
return []
|
|
items = [part.strip() for part in str(value).split(",")]
|
|
return [del_alternativer(item) for item in items if item]
|
|
|
|
|
|
def del_alternativer(item):
|
|
"""Split one item on slashes into its alternative spellings."""
|
|
parts = [part.strip() for part in str(item).split("/")]
|
|
return [part for part in parts if part]
|
|
|
|
|
|
def normaliser(text):
|
|
"""Fold a term for comparison: lowercase ASCII, hyphen-separated tokens.
|
|
|
|
Goes through :func:`paths.slug` so the Norwegian folds are defined in one
|
|
place. A term that folds to nothing (punctuation only) returns ``""``
|
|
rather than raising, because a comparison helper must not be the thing
|
|
that stops a run.
|
|
"""
|
|
try:
|
|
return paths.slug(text)
|
|
except ValueError:
|
|
return ""
|
|
|
|
|
|
def _sjekk_paakrevde(meta, feil):
|
|
for key, leaves in PAAKREVD:
|
|
if key not in meta:
|
|
_feil(feil, key, "paakrevd noekkel mangler (build-brief 5.1)")
|
|
# Naming only the block would leave the operator guessing which
|
|
# leaves to write, so every leaf is named too.
|
|
for leaf in leaves or ():
|
|
_feil(feil, "%s.%s" % (key, leaf), "paakrevd noekkel mangler (build-brief 5.1)")
|
|
continue
|
|
if leaves is None:
|
|
continue
|
|
blokk = meta[key]
|
|
if not isinstance(blokk, dict):
|
|
_feil(feil, key, "forventet et nestet kart med %s" % ", ".join(leaves))
|
|
continue
|
|
for leaf in leaves:
|
|
if leaf not in blokk:
|
|
_feil(feil, "%s.%s" % (key, leaf), "paakrevd noekkel mangler (build-brief 5.1)")
|
|
|
|
|
|
def _sjekk_typer(meta, feil):
|
|
for path in HELTALL:
|
|
key, leaf = path.split(".")
|
|
blokk = meta.get(key)
|
|
if not isinstance(blokk, dict) or leaf not in blokk:
|
|
continue
|
|
if not isinstance(blokk[leaf], int):
|
|
_feil(feil, path,
|
|
"hardfiltrene sammenligner denne numerisk; %r er ikke et heltall"
|
|
% (blokk[leaf],))
|
|
|
|
if isinstance(meta.get("absolutte_nei"), dict):
|
|
_feil(feil, "absolutte_nei", "forventet en liste, fikk et kart")
|
|
|
|
geografi = meta.get("geografi")
|
|
if isinstance(geografi, dict) and "vurderer_flytting" in geografi:
|
|
verdi = geografi["vurderer_flytting"]
|
|
if str(verdi).strip().lower() not in FLYTTING_ORD:
|
|
_feil(feil, "geografi.vurderer_flytting",
|
|
"grammatikken har ingen boolsk verdi; skriv %s (fikk %r)"
|
|
% (", ".join(FLYTTING_ORD), verdi))
|
|
|
|
|
|
def _sjekk_ukjente(meta, advarsler):
|
|
for key in meta:
|
|
if key in KJENTE_TOPPNOKLER:
|
|
continue
|
|
if key in HONORERTE_UTVIDELSER:
|
|
_advarsel(advarsler, key, HONORERTE_UTVIDELSER[key])
|
|
else:
|
|
_advarsel(advarsler, key,
|
|
"utenfor build-brief 5.1; beholdt, men ingenting leser den")
|
|
|
|
for key, leaves in PAAKREVD:
|
|
if leaves is None or not isinstance(meta.get(key), dict):
|
|
continue
|
|
for leaf in meta[key]:
|
|
if leaf not in leaves:
|
|
_advarsel(advarsler, "%s.%s" % (key, leaf),
|
|
"utenfor build-brief 5.1; beholdt, men ingenting leser den")
|
|
|
|
|
|
def _seksjoner(body):
|
|
"""The `## ` headings of the body, in document order.
|
|
|
|
Only level two counts: the real profile opens with an H1 title and uses H3
|
|
inside Kompetanse, Retning and Signaturprosjekter, and neither is a
|
|
section in the 5.1 sense. Fenced blocks are skipped so a `##` inside an
|
|
example is prose, not structure.
|
|
"""
|
|
funnet = []
|
|
inne_i_fence = False
|
|
for line in body.split("\n"):
|
|
if line.startswith(FENCE):
|
|
inne_i_fence = not inne_i_fence
|
|
continue
|
|
if inne_i_fence:
|
|
continue
|
|
if line.startswith("## ") and not line.startswith("### "):
|
|
funnet.append(line[3:].strip())
|
|
return funnet
|
|
|
|
|
|
def _sjekk_seksjoner(funnet, feil):
|
|
mangler = [name for name in SEKSJONER if name not in funnet]
|
|
if mangler:
|
|
_feil(feil, "seksjoner",
|
|
"mangler seksjon(er): %s (build-brief 5.1)" % ", ".join(mangler))
|
|
|
|
kjente = [name for name in funnet if name in SEKSJONER]
|
|
forventet = [name for name in SEKSJONER if name in funnet]
|
|
if kjente != forventet:
|
|
_feil(feil, "seksjoner",
|
|
"seksjonene staar i rekkefoelgen %s; 5.1 krever %s"
|
|
% (" > ".join(kjente), " > ".join(forventet)))
|
|
|
|
|
|
def _vekter(meta, feil, advarsler):
|
|
if VEKTNOKKEL not in meta:
|
|
return dict(STANDARDVEKTER), "default"
|
|
|
|
raw = meta[VEKTNOKKEL]
|
|
if not isinstance(raw, dict):
|
|
_feil(feil, VEKTNOKKEL, "forventet et kart over kriterium: vekt")
|
|
return dict(STANDARDVEKTER), "default"
|
|
|
|
vekter = {}
|
|
for kriterium in STANDARDVEKTER:
|
|
if kriterium not in raw:
|
|
_advarsel(advarsler, "%s.%s" % (VEKTNOKKEL, kriterium),
|
|
"ikke oppgitt; bruker standardvekten %d"
|
|
% STANDARDVEKTER[kriterium])
|
|
vekter[kriterium] = STANDARDVEKTER[kriterium]
|
|
continue
|
|
verdi = raw[kriterium]
|
|
if not isinstance(verdi, int) or verdi < 0:
|
|
_feil(feil, "%s.%s" % (VEKTNOKKEL, kriterium),
|
|
"vekten maa vaere et heltall >= 0, fikk %r" % (verdi,))
|
|
vekter[kriterium] = STANDARDVEKTER[kriterium]
|
|
continue
|
|
vekter[kriterium] = verdi
|
|
|
|
for kriterium in raw:
|
|
if kriterium not in STANDARDVEKTER:
|
|
_advarsel(advarsler, "%s.%s" % (VEKTNOKKEL, kriterium),
|
|
"ukjent kriterium; scoringen leser det ikke")
|
|
|
|
if sum(vekter.values()) == 0:
|
|
_feil(feil, VEKTNOKKEL, "summen av vektene er 0; da har ingen kriterier vekt")
|
|
return vekter, "profil"
|
|
|
|
|
|
def _blokk(meta, key):
|
|
verdi = meta.get(key)
|
|
return dict(verdi) if isinstance(verdi, dict) else {}
|
|
|
|
|
|
def _liste(meta, key):
|
|
verdi = meta.get(key)
|
|
if isinstance(verdi, list):
|
|
return list(verdi)
|
|
if verdi is None or isinstance(verdi, dict):
|
|
return []
|
|
return [verdi]
|
|
|
|
|
|
def _feil(feil, nokkel, melding):
|
|
feil.append({"nokkel": nokkel, "melding": melding})
|
|
|
|
|
|
def _advarsel(advarsler, nokkel, melding):
|
|
advarsler.append({"nokkel": nokkel, "melding": melding})
|