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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue