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