feat(m2): add append-only decision log and beslutning skill
This commit is contained in:
parent
4496c19045
commit
06b45e9168
4 changed files with 761 additions and 0 deletions
446
scripts/beslutninger.py
Normal file
446
scripts/beslutninger.py
Normal 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
102
skills/beslutning/SKILL.md
Normal 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.
|
||||
50
skills/beslutning/references/arsak.md
Normal file
50
skills/beslutning/references/arsak.md
Normal 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.
|
||||
163
tests/test_beslutninger_log.py
Normal file
163
tests/test_beslutninger_log.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue