Files
Jonathan KirkwoodandClaude Fable 5 1d1074b625 Fix 9 seam-review findings
- Pinned target's profitability flag now overrides the extractor's bucket guess
- Extractor/target periods canonicalized so ledger forecast chaining matches
- autoRunOnDrop no longer error-loops on ungradeable inbox content; failed
  batches count as seen
- Ship 2 default graders (pipeline requires >=2 valid reports per deck)
- UI styles 'failed' deck chips as errors; .markdown discoverable
- Unknown adjudicator model disables adjudication loudly instead of silently
- Reserved rids extractor/adjudicator; teardown also clears bm-grader-* containers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:26:41 -05:00

184 lines
7.6 KiB
Python

"""The per-company running score ledger under /data/ledger.
Layout:
/data/ledger/<slug>/company.json identity, aliases, pinned targets,
extracted forward targets, history
/data/ledger/<slug>/decks/<id>.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)
period = decks_mod.canonicalize_period(period)
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 []:
# Extractor-supplied target periods are free-form ("2026-q3");
# canonicalize so the next deck's filename period finds them.
tp = decks_mod.canonicalize_period(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