446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""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())
|