- 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>
184 lines
7.6 KiB
Python
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
|