"""The per-company running score ledger under /data/ledger. Layout: /data/ledger//company.json identity, aliases, pinned targets, extracted forward targets, history /data/ledger//decks/.json full scoring record per graded deck Config (bm_config `companies`) is the source of truth for name/aliases/pinned targets; extracted_targets and history are owned by the grading pipeline. Re-graded decks supersede (rename, never delete) the previous record. """ from __future__ import annotations import json import os from datetime import datetime, timezone import decks as decks_mod def atomic_write_json(path: str, obj) -> None: """Write JSON via temp file + os.replace so readers never see a torn file.""" tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, indent=2, sort_keys=False) f.write("\n") os.replace(tmp, path) def parse_alias_lines(text: str) -> dict: """Parse newline-separated "canonical=alias1;alias2" lines into a dict.""" out: dict[str, list[str]] = {} for line in (text or "").splitlines(): line = line.strip() if not line or "=" not in line: continue canonical, _, rest = line.partition("=") canonical = canonical.strip().lower() aliases = [a.strip() for a in rest.split(";") if a.strip()] if canonical and aliases: out[canonical] = aliases return out class Ledger: def __init__(self, base_dir: str): self.base = base_dir os.makedirs(self.base, exist_ok=True) # ------------------------------------------------------------- paths def _company_dir(self, slug: str) -> str: return os.path.join(self.base, slug) def _company_path(self, slug: str) -> str: return os.path.join(self._company_dir(slug), "company.json") def _decks_dir(self, slug: str) -> str: return os.path.join(self._company_dir(slug), "decks") # ------------------------------------------------------------- companies def ensure_company(self, slug: str, name: str | None = None) -> dict: """Load the company, creating a skeleton entry on first sight.""" existing = self.get_company(slug) if existing is not None: return existing company = { "schema_version": 1, "slug": slug, "name": name or slug, "auto_created": name is None, "kpi_aliases": {}, "pinned_targets": [], "extracted_targets": {}, "history": [], } os.makedirs(self._company_dir(slug), exist_ok=True) atomic_write_json(self._company_path(slug), company) return company def get_company(self, slug: str) -> dict | None: try: with open(self._company_path(slug), encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return None def all_slugs(self) -> list[str]: if not os.path.isdir(self.base): return [] return sorted(d for d in os.listdir(self.base) if os.path.isfile(self._company_path(d))) def all_companies(self) -> list[dict]: return [c for c in (self.get_company(s) for s in self.all_slugs()) if c] def merge_config_companies(self, companies_cfg: list) -> None: """Config wins for name/aliases/pinned targets; ledger keeps the rest.""" for cc in companies_cfg or []: name = (cc.get("name") or "").strip() slug = (cc.get("slug") or "").strip() or decks_mod.slugify(name) company = self.ensure_company(slug, name or slug) company["name"] = name or company["name"] company["auto_created"] = False company["kpi_aliases"] = parse_alias_lines(cc.get("kpiAliases") or "") company["pinned_targets"] = list(cc.get("pinnedTargets") or []) atomic_write_json(self._company_path(slug), company) # ------------------------------------------------------------- targets def prior_targets(self, slug: str, period: str) -> list[dict]: """Forward targets an earlier deck set for `period` (this deck's exam).""" company = self.get_company(slug) if not company or not period: return [] return (company.get("extracted_targets", {}).get(period) or {}).get("targets", []) # ------------------------------------------------------------- decks def record_deck(self, slug: str, record: dict, forward_targets: list[dict]) -> str: """Persist a scoring record + its forward targets. Returns the deck path. A re-graded deck_id supersedes (renames) the old record; the history entry for the same period is replaced; a target period's forward targets are replaced wholesale when set by a newer (or same) deck.""" company = self.ensure_company(slug) deck_id = record["deck_id"] ddir = self._decks_dir(slug) os.makedirs(ddir, exist_ok=True) deck_path = os.path.join(ddir, f"{deck_id}.json") if os.path.exists(deck_path): stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") os.replace(deck_path, os.path.join(ddir, f"{deck_id}.superseded-{stamp}.json")) atomic_write_json(deck_path, record) period = record.get("period") entry = {"period": period, "composite": record.get("composite"), "graded_at": record.get("graded_at"), "deck_file": f"decks/{deck_id}.json"} history = [h for h in company.get("history", []) if h.get("period") != period] history.append(entry) history.sort(key=lambda h: decks_mod.period_sort_key(h.get("period"))) company["history"] = history extracted = company.setdefault("extracted_targets", {}) from_key = decks_mod.period_sort_key(period) by_period: dict[str, list[dict]] = {} for ft in forward_targets or []: tp = ft.get("target_period") if tp: by_period.setdefault(tp, []).append(ft) for tp, targets in by_period.items(): cur = extracted.get(tp) if cur is None or from_key >= decks_mod.period_sort_key(cur.get("from_deck")): extracted[tp] = {"from_deck": period, "targets": targets} atomic_write_json(self._company_path(slug), company) return deck_path def deck_record(self, slug: str, deck_id: str) -> dict | None: try: with open(os.path.join(self._decks_dir(slug), f"{deck_id}.json"), encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return None def deck_records(self, slug: str) -> list[dict]: """All live (non-superseded) deck records, oldest period first.""" ddir = self._decks_dir(slug) if not os.path.isdir(ddir): return [] out: list[dict] = [] for fn in sorted(os.listdir(ddir)): if not fn.endswith(".json") or ".superseded-" in fn: continue try: with open(os.path.join(ddir, fn), encoding="utf-8") as f: out.append(json.load(f)) except (json.JSONDecodeError, OSError): continue out.sort(key=lambda r: decks_mod.period_sort_key(r.get("period"))) return out