feat(m2): add append-only decision log and beslutning skill

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 22:00:13 +02:00
commit 06b45e9168
4 changed files with 761 additions and 0 deletions

446
scripts/beslutninger.py Normal file
View file

@ -0,0 +1,446 @@
"""The append-only sourcing-decision store from build-brief 5.4 (plan Step 22).
One line per decision the operator took about one listing. The store is the
input M6's learning loop reads, and it is append-only, which has a consequence
worth stating before the first line is written: **it cannot be migrated.**
There is no pass that can go back and add a field, fix a spelling or widen an
enum. So every rule this module will ever enforce is enforced from the first
append, and `type` and `skjema` are on every line (risk H10).
A correction is a new line, never an edit. `korriger` appends a `korrigering`
record naming the decision it restates; `jobbsok_lib.jsonl.fold` applies it on
read. Both lines stay on disk forever -- what the operator first thought is
part of the record, and a store that erased it would be a worse learning
signal than one that keeps both.
Three things this module deliberately does **not** do:
**It does not recompute `vekt_hash`.** The hash fingerprints the weight vector
the score was computed under, and computing it here would make the decision log
depend on `vurdering`, whose criteria are its own. What is checked is the shape
-- a `sha256:` string -- and that `delscore` and `vekter` describe the same set
of criteria. Which criteria those are is scoring's contract, not this store's,
and duplicating the list here would be a second copy free to drift from the
first.
**It does not score, and it does not decide.** `score_da` is handed in, and the
name says why it exists: the score as it stood *before* the human decided.
Build-brief 5.4 calls that the learning signal -- a high score paired with a
`nei` is the divergence 5.7 mines -- and a score recomputed at write time would
be the machine grading its own homework.
**It does not create the case.** On `ja` the `sak` skill does that, from the
sak-id :func:`sak_id_for` derives. This module offers
:func:`speil_i_sakslogg`, because the mirrored decision is the one record that
takes a case from `vurderer` to `soker`, and a case log missing it is a case
that never moves.
"""
from jobbsok_lib import jsonl, paths
#: The decision log, relative to the workspace root.
FILNAVN = "beslutninger.jsonl"
#: build-brief 5.4's closed reason enum, in the order the brief lists it.
ARSAKER = (
"lonn", "geografi", "arbeidsform", "fagomrade", "oppgavetype", "teknologi",
"senioritetsniva", "selskapstype", "arbeidsgiverrykte", "tidspunkt",
"konkurranse", "annet",
)
#: One to three of them, per 5.4. Three is a decision with nuance; a fourth is
#: a decision nobody can act on.
MIN_ARSAKER = 1
MAKS_ARSAKER = 3
#: 5.4's cap on the note. It keeps the field a label rather than a second body.
MAKS_NOTAT_ORD = 15
DECISIONS = ("ja", "nei")
#: The layout every decision line has, in the order the fixture corpus under
#: `tests/fixtures/beslutninger/` already writes it. That corpus is the fasit:
#: the store cannot be migrated, so a writer that laid the same fields out
#: differently would fork the format on line one.
FELTREKKEFOLGE = (
"type", "skjema", "id", "dato", "kilde", "url", "tittel", "arbeidsgiver",
"beslutning", "arsak", "notat", "score_da", "delscore", "vekter",
"vekt_hash", "korrigerer",
)
#: A correction's own fields, then whichever decision fields it restates.
KORRIGERING_HODE = ("type", "skjema", "id", "dato", "korrigerer")
#: What a correction is allowed to restate. Not `id`, not `dato`, not
#: `score_da`: the score as it stood when the human decided is a measurement,
#: and a measurement is not corrected by deciding differently later.
KORRIGERBARE = ("kilde", "url", "tittel", "arbeidsgiver", "beslutning", "arsak", "notat")
class BeslutningError(Exception):
"""A refusal from this module, naming the field it refused on."""
def sti(root):
"""The decision log's path under ``root``, resolved through safe_join."""
return paths.safe_join(root, FILNAVN)
def les(root):
"""Every line on disk, corrections and outcomes included. Pure reader."""
return jsonl.read_lines(sti(root))
def stromme(root):
"""The decision stream with corrections applied and outcomes removed."""
return jsonl.fold(les(root))
def legg_til(root, felt):
"""Validate ``felt`` and append it as exactly one decision line.
Validation runs before the file is opened, so a rejected decision leaves
no trace -- not a partial line, not an empty file.
"""
record = _bygg_beslutning(felt)
_krev_ubrukt_id(root, record["id"])
return jsonl.append_line(sti(root), record)
def korriger(root, felt):
"""Append a correction naming a decision that is already in the log.
The reference is checked here rather than left to read time: `jsonl.fold`
raises on a correction pointing at nothing, and a log that cannot be folded
is a log that cannot be read at all.
"""
record = _bygg_korrigering(felt)
kjente = {rec.get("id") for rec in les(root) if rec.get("type") == "beslutning"}
if record["korrigerer"] not in kjente:
raise BeslutningError(
"korrigerer=%r naming no decision in the log; a correction "
"restates a decision that exists" % (record["korrigerer"],)
)
_krev_ubrukt_id(root, record["id"])
return jsonl.append_line(sti(root), record)
def sak_id_for(record):
"""The sak-id a `ja` hands off to `sak`, or ``None`` for a `nei`.
Derived through :func:`paths.sak_id`, so the Norwegian folds and the NFC
normalisation are defined in one place (risk H8) rather than twice.
"""
if record.get("beslutning") != "ja":
return None
dato = record.get("dato") or ""
return paths.sak_id(dato[:7], record.get("arbeidsgiver"), record.get("tittel"))
def speil_i_sakslogg(root, sak_id, record):
"""Mirror a `ja` into the case log, where it is the move to `soker`.
build-brief 5.7 already mirrors outcome records into the case log; this is
the same movement at the other end of the case. The record carries the
decision's own id, so the case log points back into the decision log.
"""
if record.get("beslutning") != "ja":
raise BeslutningError(
"only a `ja` is mirrored into a case log; a `nei` creates no case"
)
paths.validate_sak_id(sak_id)
speil = {
"ts": record["dato"],
"type": "beslutning",
"skjema": jsonl.SKJEMA,
"beslutning": "ja",
"id": record["id"],
"notat": record.get("notat"),
}
return jsonl.append_line(
paths.safe_join(root, "saker", sak_id, "logg.jsonl"), speil
)
def _bygg_beslutning(felt):
_krev_kart(felt)
ukjente = sorted(set(felt) - set(FELTREKKEFOLGE))
if ukjente:
raise BeslutningError(
"unknown field(s) %r; build-brief 5.4 names %r"
% (ukjente, [f for f in FELTREKKEFOLGE if f not in ("type", "skjema")])
)
record = {"type": "beslutning", "skjema": jsonl.SKJEMA}
record["id"] = _tekst(felt, "id")
record["dato"] = _tidspunkt(felt, "dato")
record["kilde"] = _tekst(felt, "kilde")
record["url"] = _tekst_eller_null(felt, "url")
record["tittel"] = _tekst(felt, "tittel")
record["arbeidsgiver"] = _tekst(felt, "arbeidsgiver")
record["beslutning"] = _valg(felt, "beslutning", DECISIONS)
record["arsak"] = _arsak(felt.get("arsak"))
record["notat"] = _notat(felt.get("notat"))
record["score_da"] = _heltall(felt, "score_da")
record["delscore"] = _kriterietabell(felt, "delscore")
record["vekter"] = _kriterietabell(felt, "vekter")
_samme_kriterier(record["delscore"], record["vekter"])
record["vekt_hash"] = _vekt_hash(felt.get("vekt_hash"))
korrigerer = felt.get("korrigerer")
if korrigerer is not None:
raise BeslutningError(
"korrigerer=%r on a decision; a decision that restates another is "
"a `korrigering` record, written with korriger()" % (korrigerer,)
)
record["korrigerer"] = None
return record
def _bygg_korrigering(felt):
_krev_kart(felt)
tillatt = set(KORRIGERING_HODE) | set(KORRIGERBARE)
ukjente = sorted(set(felt) - tillatt)
if ukjente:
raise BeslutningError(
"a correction cannot restate %r; it may restate %r"
% (ukjente, list(KORRIGERBARE))
)
record = {"type": "korrigering", "skjema": jsonl.SKJEMA}
record["id"] = _tekst(felt, "id")
record["dato"] = _tidspunkt(felt, "dato")
korrigerer = felt.get("korrigerer")
if not isinstance(korrigerer, str) or not korrigerer.strip():
raise BeslutningError("korrigerer must name the decision being restated")
record["korrigerer"] = korrigerer
restatert = [navn for navn in KORRIGERBARE if navn in felt]
if not restatert:
raise BeslutningError(
"a correction that restates nothing corrects nothing; name at "
"least one of %r" % (list(KORRIGERBARE),)
)
for navn in restatert:
if navn == "arsak":
record["arsak"] = _arsak(felt.get("arsak"))
elif navn == "notat":
record["notat"] = _notat(felt.get("notat"))
elif navn == "beslutning":
record["beslutning"] = _valg(felt, "beslutning", DECISIONS)
elif navn == "url":
record["url"] = _tekst_eller_null(felt, "url")
else:
record[navn] = _tekst(felt, navn)
return record
def _krev_ubrukt_id(root, ident):
brukte = {rec.get("id") for rec in les(root)}
if ident in brukte:
raise BeslutningError(
"id %r is already in the log. Ids are how a correction names its "
"target, so a reused one makes that reference ambiguous." % (ident,)
)
def _krev_kart(felt):
if not isinstance(felt, dict):
raise BeslutningError(
"a decision must be an object, got %s" % type(felt).__name__
)
def _tekst(felt, navn):
verdi = felt.get(navn)
if not isinstance(verdi, str) or not verdi.strip():
raise BeslutningError("%s must be a non-empty string, got %r" % (navn, verdi))
return verdi
def _tekst_eller_null(felt, navn):
verdi = felt.get(navn)
if verdi is None:
return None
if not isinstance(verdi, str) or not verdi.strip():
raise BeslutningError("%s must be a string or null, got %r" % (navn, verdi))
return verdi
def _tidspunkt(felt, navn):
verdi = felt.get(navn)
if not isinstance(verdi, str):
raise BeslutningError("%s must be an ISO-8601 timestamp, got %r" % (navn, verdi))
# Delegated, so the UTC-offset rule is stated once. Norwegian local time
# repeats an hour every October and a naive stamp cannot be ordered after.
try:
jsonl.instant({navn: verdi})
except jsonl.JsonlError as feil:
raise BeslutningError("%s: %s" % (navn, feil))
return verdi
def _valg(felt, navn, lovlige):
verdi = felt.get(navn)
if verdi not in lovlige:
raise BeslutningError("%s must be one of %r, got %r" % (navn, list(lovlige), verdi))
return verdi
def _arsak(verdi):
if not isinstance(verdi, list):
raise BeslutningError(
"arsak must be a list of %d-%d values from the closed enum, got %r"
% (MIN_ARSAKER, MAKS_ARSAKER, verdi)
)
if not MIN_ARSAKER <= len(verdi) <= MAKS_ARSAKER:
raise BeslutningError(
"arsak holds %d value(s); build-brief 5.4 allows %d to %d"
% (len(verdi), MIN_ARSAKER, MAKS_ARSAKER)
)
if len(set(verdi)) != len(verdi):
raise BeslutningError("arsak repeats a value: %r" % (verdi,))
for enkelt in verdi:
if enkelt not in ARSAKER:
raise BeslutningError(
"arsak %r is outside the closed enum. The twelve are: %s"
% (enkelt, ", ".join(ARSAKER))
)
return list(verdi)
def _notat(verdi):
if verdi is None:
return None
if not isinstance(verdi, str):
raise BeslutningError("notat must be text or null, got %r" % (verdi,))
antall = len(verdi.split())
if antall > MAKS_NOTAT_ORD:
raise BeslutningError(
"notat is %d words; build-brief 5.4 allows at most %d"
% (antall, MAKS_NOTAT_ORD)
)
return verdi
def _heltall(felt, navn):
verdi = felt.get(navn)
# bool is an int in Python, and `score_da: True` would be recorded as 1 and
# averaged with real scores by M6.
if isinstance(verdi, bool) or not isinstance(verdi, int):
raise BeslutningError(
"%s must be an integer -- M6 reads it as a value on a scale, and a "
"string that looks like a number sorts as text; got %r" % (navn, verdi)
)
return verdi
def _kriterietabell(felt, navn):
verdi = felt.get(navn)
if not isinstance(verdi, dict) or not verdi:
raise BeslutningError(
"%s must be a non-empty mapping of criterion to integer, got %r"
% (navn, verdi)
)
for kriterium, tall in verdi.items():
if not isinstance(kriterium, str) or not kriterium:
raise BeslutningError("%s has a criterion that is not a name: %r"
% (navn, kriterium))
if isinstance(tall, bool) or not isinstance(tall, int):
raise BeslutningError(
"%s[%r] must be an integer, got %r" % (navn, kriterium, tall)
)
return dict(verdi)
def _samme_kriterier(delscore, vekter):
if set(delscore) != set(vekter):
raise BeslutningError(
"delscore covers %r and vekter covers %r; a sub-score without a "
"weight cannot have reached the score that was recorded"
% (sorted(delscore), sorted(vekter))
)
def _vekt_hash(verdi):
if not isinstance(verdi, str) or not verdi.startswith("sha256:"):
raise BeslutningError(
"vekt_hash must be the `sha256:` fingerprint scoring handed over, "
"got %r. It is not recomputed here; see the module docstring."
% (verdi,)
)
return verdi
# ---------------------------------------------------------------------------
# Command line (the degradation branch's other half)
#
# `beslutning` degrades to telling the operator to run this from a terminal
# when `jobbsok-tools` is absent, and an instruction to run a command line that
# does not exist would make that branch false.
# ---------------------------------------------------------------------------
EXIT_OK = 0
EXIT_FEIL = 2
def build_parser():
import argparse
parser = argparse.ArgumentParser(
prog="beslutninger.py",
description=(
"Append-only beslutningslogg (build-brief 5.4). Legger til en "
"beslutning eller en korrigering, eller skriver ut stroemmen."
),
)
parser.add_argument("--workspace", required=True,
help="arbeidsomraadets rot (ingen underforstaatt standard)")
parser.add_argument("--json", default=None, metavar="OBJEKT",
help="posten som skal legges til, som JSON")
parser.add_argument("--korriger", action="store_true",
help="posten er en korrigering av en tidligere beslutning")
parser.add_argument("--liste", action="store_true",
help="skriv ut stroemmen med korrigeringer anvendt")
return parser
def _som_json(data):
import json as _json
return _json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def main(argv=None, stdout=None, stderr=None):
import json as _json
import sys as _sys
argv = _sys.argv[1:] if argv is None else argv
stdout = _sys.stdout if stdout is None else stdout
stderr = _sys.stderr if stderr is None else stderr
args = build_parser().parse_args(argv)
if bool(args.json) == bool(args.liste):
stderr.write("beslutninger: velg enten --json <objekt> eller --liste\n")
return EXIT_FEIL
try:
root = paths.workspace_root(args.workspace)
if args.liste:
stdout.write(_som_json(stromme(root)))
else:
felt = _json.loads(args.json)
record = korriger(root, felt) if args.korriger else legg_til(root, felt)
stdout.write(_som_json(record))
except (BeslutningError, paths.WorkspaceError, jsonl.JsonlError, ValueError,
OSError) as feil:
stderr.write("beslutninger: %s\n" % feil)
return EXIT_FEIL
stdout.flush()
return EXIT_OK
if __name__ == "__main__":
import sys as _sys
_sys.exit(main())

102
skills/beslutning/SKILL.md Normal file
View file

@ -0,0 +1,102 @@
---
name: beslutning
description: "Registrerer ja eller nei paa en vurdert stilling i den append-only beslutningsloggen, med begrunnelse fra en lukket enum og scoren slik den stod foer mennesket bestemte seg. Paa ja overleveres saken til sak-skillen. Utloeses av registrer en beslutning, ja på denne stillingen, ja pa denne stillingen, nei på denne stillingen, nei pa denne stillingen og korriger en beslutning."
triggers:
- registrer en beslutning
- ja på denne stillingen
- ja pa denne stillingen
- nei på denne stillingen
- nei pa denne stillingen
- korriger en beslutning
---
# beslutning
Skriver én linje til `beslutninger.jsonl` per stilling operatøren har tatt
stilling til. Loggen er append-only og **kan ikke migreres** — det som skrives
feil i dag, står feil for alltid, og kan bare rettes av en ny linje.
All operatørrettet tekst er norsk bokmål.
## Rekkefølgen
1. Stillingen er allerede vurdert av `kandidatvurdering`. Ta vare på `score`,
`delscore`, `vekter` og `vekt_hash` fra den kjøringen — de skal inn i
posten.
2. Spør operatøren rett ut: **ja eller nei?** Ikke anbefal ut fra scoren. Et
tall er et innspill til beslutningen, ikke beslutningen.
3. Be om én til tre årsaker fra den lukkede enumen, og et notat på **høyst
femten ord**.
4. Skriv posten.
5. Er svaret `ja`, overlever til `sak`.
## Skriv posten
```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/beslutninger.py \
--workspace <arbeidsmappa> --json '<posten som JSON>'
```
På Windows heter tolken `python`, ikke `python3`.
Feltene er build-brief 5.4: `id`, `dato`, `kilde`, `url`, `tittel`,
`arbeidsgiver`, `beslutning`, `arsak`, `notat`, `score_da`, `delscore`,
`vekter`, `vekt_hash`, `korrigerer`. Scriptet legger til `type` og `skjema`.
`score_da` er scoren **slik den stod før mennesket bestemte seg**. Den er hele
læringssignalet: en høy score paret med et nei er nøyaktig det avviket M6 leter
etter. Regn den aldri ut på nytt, og rund den ikke av.
`dato` bærer alltid UTC-forskyvning, som ellers i denne pluginen.
Blir posten avvist, navngir avvisningen feltet. Rett feltet og skriv på nytt —
en avvist post ligger ikke halvveis på disken, valideringen kjører før fila
åpnes.
## Årsakene er tolv, og ikke flere
Hele enumen med norsk forklaring står i
`${CLAUDE_PLUGIN_ROOT}/skills/beslutning/references/arsak.md`. Les den før du
foreslår en årsak. Passer ingen, er svaret `annet` pluss et presist notat —
ikke en trettende årsak. En årsak utenfor enumen er en kategori `laering` aldri
kan telle, verken for eller mot.
## På `ja`: overlever til sak
Et `ja` skaper en sak; et `nei` skaper ingenting utover loggposten.
Sak-id-en utledes av beslutningen, ikke av skjønn: måneden i `dato`,
arbeidsgiveren og tittelen, foldet til ASCII. `sak_id_for(posten)` i
`${CLAUDE_PLUGIN_ROOT}/scripts/beslutninger.py` gir den.
Beslutningen speiles så inn i saksloggen med `speil_i_sakslogg(...)` fra samme
modul. **Dette steget hoppes aldri over.** Uten den speilede posten står saken
fast i `vurderer` for alltid — det er den ene posten som tar den til `soker`.
Deretter overtar `sak`: mappa, saksfila fra malen, og oppdateringen av
hurtigbufferen.
## En retting er en ny linje
Rediger aldri en linje som er skrevet. En korrigering er en egen post som
navngir beslutningen den retter:
```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/beslutninger.py \
--workspace <arbeidsmappa> --korriger --json '<korrigeringen som JSON>'
```
Den kan rette kilde, url, tittel, arbeidsgiver, beslutning, årsak og notat.
Den kan **ikke** rette `score_da`: det tallet er en måling av hva scoren var da
valget ble tatt, og en måling endres ikke av at man senere mener noe annet.
Begge linjene blir liggende. Det operatøren først mente er en del av
materialet, og en logg som slettet det ville vært et dårligere læringssignal
enn en som beholder begge.
## Uten verktøyserveren
Er `jobbsok-tools` ikke tilgjengelig, degraderer skillen i stedet for å stoppe:
be operatøren kjøre kommandoene over fra kommandolinja, og les posten som ble
skrevet ut. Ikke skriv linja for hånd inn i fila — da omgås valideringen, og en
ugyldig linje i en append-only logg kan ingen fjerne.

View file

@ -0,0 +1,50 @@
# Årsaks-enumen
Referanse for `beslutning`. Build-brief 5.4 lukker lista på tolv verdier.
Implementert som `ARSAKER` i
`${CLAUDE_PLUGIN_ROOT}/scripts/beslutninger.py`; er de to uenige, er scriptet
fasit.
Én til tre årsaker per beslutning. Én er det vanlige. Tre er en beslutning med
nyanse. En fjerde blir avvist, fordi en beslutning med fire likestilte grunner
ikke er en grunn noen kan handle på senere.
| Verdi | Betyr | Typisk formulering |
|---|---|---|
| `lonn` | Lønnen er under gulvet, eller spennet gjør stillingen uaktuell | «under gulvet» |
| `geografi` | Sted, reisevei eller flytting | «for lang reisevei hver dag» |
| `arbeidsform` | Hjemmekontor, kontorplikt, turnus, reisedager | «full kontorplikt» |
| `fagomrade` | Feltet treffer ikke, eller treffer godt | «treffer kjernen» |
| `oppgavetype` | Hva arbeidet faktisk består i | «forvaltning, ikke arkitektur» |
| `teknologi` | Stakk, verktøy, plattform | «hele stacken er egenutviklet» |
| `senioritetsniva` | Rollen ligger over eller under nivået | «for junior rolle» |
| `selskapstype` | Konsulent, byrå, produkt, offentlig, oppstart | «bemanningsbyraa» |
| `arbeidsgiverrykte` | Det som er kjent om arbeidsgiveren som arbeidsplass | «gjentatte oppsigelsesrunder» |
| `tidspunkt` | Tiltredelse, frist, timing i eget løp | «tiltredelse alt for tidlig» |
| `konkurranse` | Vurdert sjanse mot feltet av søkere | «intern kandidat er varslet» |
| `annet` | Ingen av de elleve passer | krever et presist notat |
## Hvordan velge
- **Årsaken er den som faktisk avgjorde**, ikke den som er lettest å skrive.
En stilling som ble avslått på reisevei og *også* hadde lav lønn, føres på
`geografi`. Feil årsak er verre enn ingen: `laering` foreslår først når den
samme årsaken går igjen i minst fire av tjue, og en feilført årsak flytter
den terskelen for begge kategoriene.
- **Positive årsaker gjelder også.** Et `ja` begrunnes med den samme enumen —
`fagomrade` betyr «feltet treffer» like gjerne som «feltet treffer ikke».
Retningen ligger i `beslutning`, ikke i årsaken.
- **`annet` er et gyldig svar, ikke en unnvikelse** — men det krever et notat
som sier hva som faktisk avgjorde. Går `annet` igjen, er det enumen som skal
utvides, og det er en beslutning som tas ett sted: i build-brief 5.4. Ikke
finn opp en trettende verdi her.
## Notatet
Høyst femten ord. Kappet er ikke pynt: feltet er en etikett, ikke en
sekundær brødtekst, og et notat som vokser til et avsnitt er et notat ingen
leser tjue beslutninger senere.
Skriv det som skiller denne beslutningen fra nabobeslutningen med samme årsak.
«under gulvet» sier lite; «gulvet er ikke naadd, spennet stopper 80k under»
sier hva som faktisk skjedde.

View file

@ -0,0 +1,163 @@
"""The append-only sourcing-decision store (plan Step 22).
Build-brief 5.4 is a schema, and this file is what keeps it one. The store
cannot be migrated -- that is the whole consequence of append-only (risk H10) --
so every rule it will ever have has to hold on the first line written, and the
fixture corpus under `tests/fixtures/beslutninger/` is the fasit the writer is
measured against rather than a sample it may drift from.
Four of the six rules below exist because the field they guard is read much
later by something that cannot ask again:
* **`score_da` is an integer.** M6 reads it as a value on a weight-dependent
scale (risk M8r). A string that looks like a number sorts as text and
averages as nothing.
* **One to three reasons, from the closed twelve.** `laering` proposes only on
"the same `arsak` in at least four of twenty". A thirteenth reason invented
at write time is a category that can never reach that threshold and never be
counted against it either.
* **A note is at most fifteen words.** The cap is what keeps the field a label
rather than a second body, and build-brief 5.4 states it as a number.
* **A correction names a decision that exists.** `jsonl.fold` raises when it
meets a correction pointing at nothing; catching it at write time is the
difference between a refusal and a log that cannot be read afterwards.
Style note: this file follows tests/test_jsonl.py.
"""
import json
import os
import pytest
import beslutninger
from jobbsok_lib import jsonl
def beslutning(**overrides):
record = {
"id": "b-0001",
"dato": "2026-09-01T09:00:00+02:00",
"kilde": "manuell",
"url": "https://stillinger.example/sak-0001",
"tittel": "Dataingeniør",
"arbeidsgiver": "Værøy Sjømat AS",
"beslutning": "ja",
"arsak": ["fagomrade", "teknologi"],
"notat": "treffer kjernen",
"score_da": 78,
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68, "selskapstype": 63},
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20, "selskapstype": 10},
"vekt_hash": "sha256:" + "e4" * 32,
}
record.update(overrides)
return record
def test_an_append_adds_one_line_and_leaves_prior_bytes_unchanged(empty_workspace):
sti = beslutninger.sti(empty_workspace)
beslutninger.legg_til(empty_workspace, beslutning(id="b-0001"))
with open(sti, "rb") as handle:
forst = handle.read()
beslutninger.legg_til(empty_workspace, beslutning(id="b-0002", beslutning="nei",
arsak=["lonn"]))
with open(sti, "rb") as handle:
etter = handle.read()
# Not "the file still parses" -- the earlier bytes must be the same bytes.
assert etter.startswith(forst)
assert etter[len(forst):].count(b"\n") == 1
assert [rec["id"] for rec in beslutninger.les(empty_workspace)] == ["b-0001", "b-0002"]
# And the line carries the two fields no later migration can add.
forste = json.loads(forst.decode("utf-8"))
assert forste["type"] == "beslutning" and forste["skjema"] == jsonl.SKJEMA
assert list(forste) == list(beslutninger.FELTREKKEFOLGE), (
"the record is laid out as %r; the fixture corpus is the fasit and it "
"lays it out as %r" % (list(forste), list(beslutninger.FELTREKKEFOLGE))
)
def test_one_to_three_reasons_from_the_enum_are_accepted_and_nothing_else_is(
empty_workspace,
):
for arsak in (["lonn"], ["lonn", "geografi"], ["lonn", "geografi", "annet"]):
record = beslutninger.legg_til(
empty_workspace,
beslutning(id="b-%d" % len(arsak) + "-".join(arsak), arsak=arsak),
)
assert record["arsak"] == arsak
for ugyldig in ([], ["lonn", "geografi", "annet", "tidspunkt"], ["pendling"],
["lonn", "lonn"], "lonn"):
with pytest.raises(beslutninger.BeslutningError) as fanget:
beslutninger.legg_til(empty_workspace, beslutning(id="b-avvist", arsak=ugyldig))
assert "arsak" in str(fanget.value)
# The refusal leaves nothing behind: validation runs before the file opens.
assert [rec["id"] for rec in beslutninger.les(empty_workspace)] == [
"b-1lonn", "b-2lonn-geografi", "b-3lonn-geografi-annet"
]
def test_a_note_longer_than_fifteen_words_is_refused(empty_workspace):
akkurat = " ".join("ord%d" % n for n in range(beslutninger.MAKS_NOTAT_ORD))
record = beslutninger.legg_til(empty_workspace, beslutning(notat=akkurat))
assert record["notat"] == akkurat
ett_for_mye = akkurat + " overflodig"
with pytest.raises(beslutninger.BeslutningError) as fanget:
beslutninger.legg_til(empty_workspace, beslutning(id="b-lang", notat=ett_for_mye))
assert "15" in str(fanget.value), (
"the refusal must name the cap it enforced: %r" % (str(fanget.value),)
)
def test_score_da_must_be_an_integer(empty_workspace):
for ugyldig in ("78", 78.5, None, True):
with pytest.raises(beslutninger.BeslutningError) as fanget:
beslutninger.legg_til(
empty_workspace, beslutning(id="b-score", score_da=ugyldig)
)
assert "score_da" in str(fanget.value)
record = beslutninger.legg_til(empty_workspace, beslutning(score_da=0))
assert record["score_da"] == 0 and isinstance(record["score_da"], int)
def test_a_correction_must_reference_a_decision_that_exists(empty_workspace):
beslutninger.legg_til(empty_workspace, beslutning(id="b-0001", arsak=["lonn"]))
with pytest.raises(beslutninger.BeslutningError) as fanget:
beslutninger.korriger(
empty_workspace,
{"id": "k-0001", "dato": "2026-09-02T09:00:00+02:00",
"korrigerer": "b-finnes-ikke", "arsak": ["geografi"]},
)
assert "b-finnes-ikke" in str(fanget.value)
rettelse = beslutninger.korriger(
empty_workspace,
{"id": "k-0001", "dato": "2026-09-02T09:00:00+02:00",
"korrigerer": "b-0001", "arsak": ["geografi"],
"notat": "reisevei var den egentlige grunnen"},
)
assert rettelse["type"] == "korrigering"
# A correction is a new line, never an edit: both are still on disk, and
# the folded stream shows the corrected decision rather than two rows.
linjer = beslutninger.les(empty_workspace)
assert [rec["id"] for rec in linjer] == ["b-0001", "k-0001"]
strom = beslutninger.stromme(empty_workspace)
assert len(strom) == 1
assert strom[0]["id"] == "b-0001" and strom[0]["arsak"] == ["geografi"]
def test_the_case_identifier_from_a_norwegian_employer_is_pure_ascii():
sak_id = beslutninger.sak_id_for(beslutning())
assert sak_id == "2026-09-vaeroy-sjomat-as-dataingenior"
sak_id.encode("ascii") # raises if a macOS NFD byte survived (risk H8)
# And the hand-off only happens on a yes: a `nei` creates no case at all.
assert beslutninger.sak_id_for(beslutning(beslutning="nei")) is None