"""Append-only audit log of moderator decisions. Each record is one human decision over a campaign: the AI's recommendation, the human's final call, who made it, whether it was an override, and the reason. This is the receipt that the **human** — not the AI — owns the decision. Two backends behind one interface: when `DATABASE_URL` is set (Neon Postgres on the deployed Space) the records persist there and survive restarts; otherwise they go to a flat JSONL file (easy to show locally, trivially greppable). Callers (`api.py`) don't care which is active. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from .config import CONFIG def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _use_db() -> bool: """Use Postgres when a database is configured; otherwise the local JSONL file.""" return bool(CONFIG.database_url) def append_decision(record: dict, path: str | Path | None = None) -> dict: """Append one decision record to the log (a `timestamp` is added if absent). Returns the stored record. Writes to Postgres when configured, else the JSONL file (creating its parent dir if needed). """ stored = {"timestamp": utc_now_iso(), **record} if _use_db(): from . import db # lazy: only pulls in psycopg when a database is configured db.insert_decision(stored) return stored p = Path(path or CONFIG.audit_log_path) p.parent.mkdir(parents=True, exist_ok=True) with p.open("a", encoding="utf-8") as f: f.write(json.dumps(stored, ensure_ascii=False) + "\n") return stored def load_decisions(path: str | Path | None = None) -> list[dict]: """Load all decision records in write order (oldest first). Tolerates a missing/partial file.""" if _use_db(): from . import db # lazy return db.fetch_decisions() p = Path(path or CONFIG.audit_log_path) if not p.exists(): return [] out: list[dict] = [] for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: out.append(json.loads(line)) except json.JSONDecodeError: continue # skip a half-written line rather than crash the UI return out def decided_campaign_ids(path: str | Path | None = None) -> dict[str, dict]: """Map each decided campaign_id to its MOST RECENT decision record.""" latest: dict[str, dict] = {} for rec in load_decisions(path): cid = rec.get("campaign_id") if cid: latest[cid] = rec return latest