"""Build the two fictional example knowledge bases from ``kilde.json`` and pin them. Usage (from the repository root):: uv run python examples/syntetiske-kunnskapsbaser/generate.py Writes ``src/portfolio_optimiser/data/kunnskapsbaser/-/`` for each base, removes any older ``-*`` copy, and rewrites the ``bundles`` entries of ``src/portfolio_optimiser/frozen_bundles.json`` with the new digests. Renewing a base is therefore ONE command, and the new copy and the new pin land in the same diff. **Deterministic by construction**: no clock, no randomness, sorted output, and document ids are ``uuid5`` of a fixed namespace and the document's own number. Running it twice gives byte-identical bases and the same pins — ``--check`` says whether the tracked copies are what the source builds. **Why generated rather than built with ``okf build``**: the code consumes per-document identifier frontmatter (``req_number``, ``prosessnr``, ``seksjon``), and ``okf build`` takes frontmatter only as ONE ``--frontmatter KEY=VALUE`` applied to every concept. The OKF layout itself — a root ``index.md`` declaring ``okf_version`` and ``bundle_id``, a linked ``index.md`` per level, one concept document per file with a block ``sources`` sequence — is written here directly. Everything the bases say is invented: Eksempelvirksomheten, its requirements and its process catalogue describe no real organisation, and no text is taken from a real standard. """ from __future__ import annotations import argparse import json import shutil import sys import tempfile import uuid from pathlib import Path from typing import Any HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] SOURCE = HERE / "kilde.json" TARGET = REPO / "src" / "portfolio_optimiser" / "data" / "kunnskapsbaser" PIN_FILE = REPO / "src" / "portfolio_optimiser" / "frozen_bundles.json" _NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://example.invalid/eksempelvirksomheten") _DESCRIPTION_MAX = 160 def _uid(*parts: str) -> str: return str(uuid.uuid5(_NAMESPACE, "/".join(parts))) def _quoted(value: str) -> str: """A number-shaped scalar is quoted, as a YAML dumper would, so it stays a string.""" return f"'{value}'" def _float_like(value: str) -> bool: head, dot, tail = value.partition(".") return head.isdigit() and (not dot or (tail.isdigit() and "." not in tail)) def _capitalised(text: str) -> str: return text[:1].upper() + text[1:] def _short(text: str) -> str: if len(text) <= _DESCRIPTION_MAX: return text return text[: _DESCRIPTION_MAX - 1].rstrip() + "…" def _frontmatter(pairs: list[tuple[str, str]], source: tuple[str, str]) -> str: lines = ["---", *(f"{key}: {value}" for key, value in pairs)] lines += ["sources:", f" - resource: {source[0]}", f" title: {source[1]}", "---"] return "\n".join(lines) + "\n" def _write(root: Path, rel: str, text: str) -> None: path = root / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8", newline="\n") def _sort_key(number: str) -> tuple[int, ...]: """Natural order of a section number such as ``4.2.5.1``.""" return tuple(int(part) for part in number.split(".")) # ------------------------------------------------------------------------------ the requirements def _requirements(part: dict[str, Any], spec: dict[str, Any]) -> list[dict[str, str]]: """Every requirement of one part: explicit ones verbatim, the rest from the aspect templates.""" aspects = spec["aspects"] params = spec["parameters"] out: list[dict[str, str]] = [] for index, section in enumerate(part["sections"]): if "krav" in section: for suffix, text in section["krav"]: out.append( { "section": section["nr"], "section_title": section["title"], "suffix": suffix, "text": text, "guidance": section["veiledning"], } ) continue subject = section["subject"] for n in range(1, int(section["count"]) + 1): aspect = aspects[(index + n - 1) % len(aspects)] values = { "S": _capitalised(subject), "s": subject, **{ key: str(options[(index + n) % len(options)]) for key, options in params.items() }, } out.append( { "section": section["nr"], "section_title": section["title"], "suffix": str(n), "text": aspect["krav"].format(**values), "guidance": aspect["veiledning"].format(**values), } ) return out def build_requirements(spec: dict[str, Any], stamp: str, honesty: str, root: Path) -> None: source_root = spec["source_root"] level_lines: list[str] = [] standard_lines: list[str] = [] total = 0 for part in spec["parts"]: pid = part["id"] edition = f"{pid}:2027" source = (f"{source_root}/{pid.lower()}", edition) entries: list[tuple[tuple[int, ...], str, str]] = [] for req in _requirements(part, spec): number = f"Krav {req['section']}—{req['suffix']}" title = f"{number} {req['section_title']}" doc_id = f"id-{_uid('driftskrav', pid, number)}" section = req["section"] text = req["text"] pairs = [ ("type", "Krav"), ("title", title), ("description", _short(text)), ("kravtype", "bør" if " bør " in f" {text} " else "skal"), ("standard", pid), ("utgave", edition), ("req_number", number), ("status", "stable"), ("trust_tier", "unverified"), ("seksjon", _quoted(section) if _float_like(section) else section), ("seksjonstittel", req["section_title"]), ("ingested_at", stamp), ("source_element_id", doc_id), ] body = f"\n## Krav\n\n{text}\n\n## Veiledning (ikke-normativ)\n\n{req['guidance']}\n" _write(root, f"krav/{pid}/{doc_id}.md", _frontmatter(pairs, source) + body) entries.append( (_sort_key(section), req["suffix"], f"- [{title}]({doc_id}.md) — {_short(text)}") ) entries.sort(key=lambda e: (e[0], [int(p) for p in e[1].split("_")])) _write(root, f"krav/{pid}/index.md", "# Krav\n\n" + "\n".join(e[2] for e in entries) + "\n") level_lines.append( f"- [{pid}]({pid}/index.md) — {len(entries)} konsepter under krav/{pid}." ) total += len(entries) pairs = [ ("type", "Standard"), ("title", edition), ("description", f"Driftskrav del {pid[1:]}: {part['title']}"), ("standard", pid), ("utgave", edition), ("status", "stable"), ("trust_tier", "unverified"), ("krav_i_bundlen", str(len(entries))), ("ingested_at", stamp), ] body = ( f"\n## Om delen\n\n{part['scope']} Delen bærer {len(entries)} krav, hvert som eget " f"konsept under `krav/{pid}/`.\n\n## Opphav\n\n{honesty}\n" ) _write(root, f"standard/{pid}.md", _frontmatter(pairs, source) + body) standard_lines.append( f"- [{edition}]({pid}.md) — Driftskrav del {pid[1:]}: {part['title']}" ) _write(root, "krav/index.md", "# Kataloger\n\n" + "\n".join(level_lines) + "\n") _write(root, "standard/index.md", "# Standard\n\n" + "\n".join(standard_lines) + "\n") _write( root, "index.md", f"---\nokf_version: 0.2\nbundle_id: {spec['bundle_id']}\n---\n\n# Kataloger\n\n" f"- [krav](krav/index.md) — {total} konsepter under krav.\n" f"- [standard](standard/index.md) — {len(spec['parts'])} konsepter under standard.\n", ) # ------------------------------------------------------------------------- the process catalogue def _processes(spec: dict[str, Any]) -> list[dict[str, Any]]: """The catalogue as a flat list, parents before children, each with its number and relations.""" out: list[dict[str, Any]] = [] first = int(spec["default_subs_for_first"]) for main in spec["main"]: main_node = { "nr": main["nr"], "title": f"Hovedprosess {main['nr']} {main['title']}", "link": f"Hovedprosess {main['nr']} {main['title']}", "level": 1, "main": main["nr"], "parents": [], "omfang": None, "seksjon": f"Hovedprosess {main['nr']}", "seksjonstittel": main["title"], } out.append(main_node) for group in main["groups"]: group_node = { "nr": group["nr"], "title": group["title"], "link": f"{group['nr']} {group['title']}", "level": 2, "main": main["nr"], "parents": [main_node], "omfang": None, } out.append(group_node) for i, proc in enumerate(group["processes"], start=1): obj = proc.get("obj", proc["title"][:1].lower() + proc["title"][1:]) nr = f"{group['nr']}.{i}" node = { "nr": nr, "title": proc["title"], "link": f"{nr} {proc['title']}", "level": 3, "main": main["nr"], "parents": [group_node, main_node], "omfang": proc.get("omfang", spec["default_omfang"].format(obj=obj)), } out.append(node) subs = proc.get("subs") if subs is None: subs = spec["default_subs"] if i <= first else [] for j, sub in enumerate(subs, start=1): sub_nr = f"{nr}{j}" title = sub["title"].format(obj=obj) out.append( { "nr": sub_nr, "title": title, "link": f"{sub_nr} {title}", "level": 4, "main": main["nr"], "parents": [node, group_node, main_node], "omfang": sub["omfang"].format(obj=obj), } ) return out def build_catalogue(spec: dict[str, Any], stamp: str, honesty: str, root: Path) -> None: cat = spec["catalogue"] source = (f"{spec['source_root']}/{cat.lower()}", spec["edition"]) nodes = _processes(spec) for node in nodes: node["dir"] = node["nr"].replace(".", "-") node["file"] = f"_{node['main']}_id-{_uid('prosesskatalog', node['nr'])}.md" node["children"] = [] by_nr = {node["nr"]: node for node in nodes} for node in nodes: if node["parents"]: by_nr[node["parents"][0]["nr"]]["children"].append(node) def rel(node: dict[str, Any]) -> str: return f"../{node['dir']}/{node['file']}" for node in nodes: description = node["omfang"].split(". ")[0].rstrip(".") + "." if node["omfang"] else "" summary = _short(description or node["title"]) pairs = [ ("type", "Prosess"), ("title", node["title"]), ("description", summary), ("standard", spec["catalogue_title"]), ("utgave", spec["edition"]), ("prosessnr", _quoted(node["nr"])), ("hovedprosess", _quoted(node["main"])), ] if node["parents"]: pairs.append(("forelder", _quoted(node["parents"][0]["nr"]))) pairs += [ ("nivaa", str(node["level"])), ("status", "stable"), ("trust_tier", "unverified"), ("seksjon", node.get("seksjon", _quoted(node["nr"]))), ("seksjonstittel", node.get("seksjonstittel", node["title"])), ("ingested_at", stamp), ("source_element_id", node["file"][:-3]), ] body = "" if node["omfang"]: body += f"\n## a) Omfang\n\n{node['omfang']}\n" body += "\n## Relasjoner\n" if node["parents"]: body += "\n### Overordnede prosesser\n\n" body += "\n".join(f"- [{p['link']}]({rel(p)})" for p in node["parents"]) + "\n" if node["children"]: body += "\n### Underprosesser\n\n" body += "\n".join(f"- [{c['link']}]({rel(c)})" for c in node["children"]) + "\n" _write(root, f"{cat}/{node['dir']}/{node['file']}", _frontmatter(pairs, source) + body) _write( root, f"{cat}/{node['dir']}/index.md", f"# Prosess\n\n- [{node['title']}]({node['file']}) — {summary}\n", ) dirs = sorted(node["dir"] for node in nodes) _write( root, f"{cat}/index.md", "# Kataloger\n\n" + "\n".join(f"- [{d}]({d}/index.md) — 1 konsept under {cat}/{d}." for d in dirs) + "\n", ) mains = [node for node in nodes if node["level"] == 1] pairs = [ ("type", "Katalog"), ("title", spec["edition"]), ("description", spec["edition"]), ("standard", spec["catalogue_title"]), ("utgave", spec["edition"]), ("status", "stable"), ("trust_tier", "unverified"), ("prosesser_i_bundlen", str(len(nodes))), ("ingested_at", stamp), ] body = ( f"\n## Dekning\n\n{spec['scope']} Bundlen bærer {len(nodes)} prosesser, hver som eget " f"konsept under `{cat}//`.\n\n## Opphav\n\n{honesty}\n\n" "## Hovedprosesser\n\n" + "\n".join(f"- [{m['title']}]({cat}/{m['dir']}/{m['file']})" for m in mains) + "\n" ) _write(root, f"{cat}.md", _frontmatter(pairs, source) + body) _write( root, "index.md", f"---\nokf_version: 0.2\nbundle_id: {spec['bundle_id']}\n---\n\n# Kataloger\n\n" f"- [{cat}]({cat}/index.md) — {len(nodes)} konsepter under {cat}.\n\n# Katalog\n\n" f"- [{spec['edition']}]({cat}.md) — {spec['edition']}\n", ) # ------------------------------------------------------------------------------------ the pins def _digest(root: Path) -> tuple[str, int]: sys.path.insert(0, str(REPO / "src")) from portfolio_optimiser.frozen_bundles import digest_bundle return digest_bundle(root) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) parser.add_argument( "--check", action="store_true", help="build into a temporary directory and compare with the pins; write nothing", ) args = parser.parse_args(argv) source = json.loads(SOURCE.read_text(encoding="utf-8")) stamp, honesty = source["ingested_at"], source["honesty"] builders = ( (source["driftskrav"], build_requirements), (source["prosesskatalog"], build_catalogue), ) pins = json.loads(PIN_FILE.read_text(encoding="utf-8")) built: dict[str, dict[str, Any]] = {} drift = 0 with tempfile.TemporaryDirectory() as tmp: for spec, build in builders: staged = Path(tmp) / spec["name"] build(spec, stamp, honesty, staged) sha, files = _digest(staged) directory = f"{spec['name']}-{sha[:12]}" pinned = pins["bundles"].get(spec["name"], {}) same = pinned.get("sha256") == sha and (TARGET / directory).is_dir() print(f"{spec['name']}: {directory} ({files} files){'' if same else ' CHANGED'}") if args.check: drift += 0 if same else 1 continue for old in TARGET.glob(f"{spec['name']}-*"): shutil.rmtree(old) TARGET.mkdir(parents=True, exist_ok=True) shutil.copytree(staged, TARGET / directory) built[spec["name"]] = {"directory": directory, "sha256": sha, "files": files} if args.check: return 1 if drift else 0 pins["bundles"] = built PIN_FILE.write_text(json.dumps(pins, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main())