"""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 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())