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