ewin-reg's picture
Weekly Analyst agent app
fad317b verified
Raw History Blame Contribute Delete
9.88 kB
"""Command line: watch, run, approve (spec 6.5, 6.7; ticket 023).
approve is the only path that can send. It needs an interactive terminal or a local token file that the
local user creates by hand. No model tool reaches it (plan.STATE_ALLOWLIST has no approve entry).
Run as: python -m weekly_analyst.cli <command> ...
"""
import argparse
import getpass
import logging
import os
import sys
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from weekly_analyst.analysis.checks import check_report
from weekly_analyst.analysis.model import LlamaCppClient
from weekly_analyst.core.config import Settings
from weekly_analyst.core.scheduler import file_sha256
from weekly_analyst.core.store import StateStore
from weekly_analyst.core.text import numerals
from weekly_analyst.data.extract import ExtractError, extract
from weekly_analyst.data.metrics_file import load_metrics_file
from weekly_analyst.delivery import watch
from weekly_analyst.delivery.outbox import DeliveryConfig, DeliveryRefused, Outbox
from weekly_analyst.report import render_pdf
from weekly_analyst.report.layout import stamp_label
from weekly_analyst.report.model import ReportModel, ReportTooLong, ShownNumber
from weekly_analyst.runner import RunConfig, RunLocked, run
from weekly_analyst.systems.check import pdf_text
TOKEN_FILE = "approve.token"
STATE_DB = "state.db"
LOCAL_SENT = "local-sent-folder" # the local sent folder is the only transport; approve sends to it through the gates
POLL_S = 30.0 # watch polls the folder this often (seconds)
N_CTX = 2048 # llama.cpp context length; a library-style default, not measured (spike ticket 028 decides)
class ApproveRefused(RuntimeError):
"""approve ran without an interactive terminal or a local token file."""
def approver_for(token_path: Path, *, interactive: bool) -> str:
token = Path(token_path)
local_token = token.is_file() and not token.is_symlink() and token.stat().st_size > 0
if not (interactive or local_token):
raise ApproveRefused("approve needs an interactive terminal or a local token file; nothing approved")
return getpass.getuser()
def _is_interactive() -> bool:
# isatty() is True for the Windows NUL device; a real console also answers the terminal-size query.
try:
os.get_terminal_size(sys.stdin.fileno())
except (OSError, ValueError, AttributeError):
return False
return True
class _Approval:
"""What approve re-renders and re-checks: the stored report bundle, the metrics file and the latest CSV."""
def __init__(self, report: dict, approver: str, approved_at: str, metrics, csv: Path, work: Path) -> None:
self._report = report
self._approver = approver
self._approved_at = approved_at
self._metrics = metrics
self._csv = csv
self._work = work
def render_stamped(self, draft: Path, approver: str) -> Path:
"""Re-render the stored ReportModel as approved: the same model, so the same text and numbers as the draft."""
out = self._work / Path(draft).name
try:
render_pdf(
ReportModel.from_dict(self._report["model"]),
out,
status="approved",
approver=approver,
paper=self._report["paper"],
approved_at=self._approved_at,
)
except (ReportTooLong, ValueError) as err:
raise DeliveryRefused(f"stamp: {err}") from None
return out
def recheck(self, path: Path) -> list[str]:
"""Check the stamped text against the stored numbers, on a snapshot rebuilt from the same CSV."""
snapshot_dir = self._work / "snapshot"
snapshot_dir.mkdir()
try:
snapshot = extract(self._csv, self._metrics, snapshot_dir, Settings())
except ExtractError as err:
return [f"snapshot: {err}"]
if snapshot.snapshot_hash != self._report["model"]["snapshot_hash"]:
return ["snapshot: the source changed during approve"]
shown = [ShownNumber.from_dict(d) for d in self._report["shown_numbers"]]
# The approved stamp has numerals of its own (the time), so they are exempt on top of the draft's list.
stamp = stamp_label("approved", self._approver, self._approved_at)
exempt = [*self._report["structural_numbers"], *numerals(stamp)]
failures = check_report(
pdf_text(path),
shown,
self._metrics,
snapshot.parquet_path,
exempt=exempt,
model_text=self._report["model_text"],
)
return [f"{failure.kind}: {failure.detail}" for failure in failures]
def _stored_payload(store: StateStore, run_id: str) -> dict:
row = store.load_run(run_id)
if row is None or row["state"] != "outbox" or not (row["payload"].get("report") or {}).get("model"):
raise DeliveryRefused(f"no pending brief with a stored report for run {run_id}; nothing approved")
return row["payload"]
def _latest_csv(folder: Path) -> Path:
files = watch.scan(folder)
if not files:
raise DeliveryRefused("no accepted CSV in the watched folder; nothing approved")
return max(files, key=lambda path: files[path][0])
def _model(args) -> LlamaCppClient:
return LlamaCppClient(args.model, threads=os.cpu_count() or 1, n_ctx=N_CTX)
def _run_config(args) -> RunConfig:
return RunConfig(
metrics_path=args.metrics,
work_dir=args.work_dir,
out_dir=args.out_dir,
delivery_mode="approval",
recipients=list(args.recipient),
paper=args.paper,
settings=Settings(),
)
def _print_outcome(outcome) -> None:
print(f"{outcome.kind} {outcome.run_id}")
if outcome.report_path is not None:
print(f"report {outcome.report_path}")
for reason in outcome.reasons:
print(f"reason {reason}")
def _run(args) -> int:
store = StateStore(args.work_dir / STATE_DB)
outcome = run(args.csv, _run_config(args), _model(args), store)
_print_outcome(outcome)
return 1 if outcome.kind == "halted" else 0
def _watch(args) -> int:
folder = args.folder.resolve()
cfg = _run_config(args)
store = StateStore(args.work_dir / STATE_DB)
client = _model(args)
seen = watch.scan(folder) # files already in the folder at start are baselined, not run
while True:
time.sleep(POLL_S)
current = watch.scan(folder)
for path, signature in current.items():
if seen.get(path) == signature:
continue
try:
outcome = run(path, cfg, client, store)
except RunLocked:
continue # not marked seen, so the next poll retries it
seen[path] = signature
_print_outcome(outcome)
def _approve(args) -> int:
approver = approver_for(args.work_dir / TOKEN_FILE, interactive=_is_interactive())
store = StateStore(args.work_dir / STATE_DB)
payload = _stored_payload(store, args.run_id)
metrics = load_metrics_file(args.metrics)
if metrics.checksum != payload["metrics_checksum"]:
raise DeliveryRefused("metrics file changed since the brief was built; nothing approved")
csv = _latest_csv(args.folder)
approved_at = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
outbox = Outbox(args.out_dir, store, Settings())
with tempfile.TemporaryDirectory() as tmp:
approval = _Approval(payload["report"], approver, approved_at, metrics, csv, Path(tmp))
stamped = outbox.approve(args.run_id, approver, file_sha256(csv), approval.render_stamped, approval.recheck)
outbox.send(stamped, LOCAL_SENT, DeliveryConfig(recipients={LOCAL_SENT}, max_bytes=Settings().max_bytes))
return 0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="weekly-analyst")
sub = parser.add_subparsers(dest="command", required=True)
def run_options(p: argparse.ArgumentParser) -> None:
p.add_argument("--metrics", type=Path, required=True, help="metrics file (YAML or JSON)")
p.add_argument("--model", type=Path, required=True, help="GGUF model path")
p.add_argument("--work-dir", type=Path, default=Path("work"))
p.add_argument("--out-dir", type=Path, default=Path("out"))
p.add_argument("--recipient", action="append", default=[], help="allowlisted recipient (repeatable)")
p.add_argument("--paper", choices=["A4", "Letter"], default="A4")
p_watch = sub.add_parser("watch", help="run each new or replaced CSV in a folder")
p_watch.add_argument("folder", type=Path)
run_options(p_watch)
p_watch.set_defaults(handler=_watch)
p_run = sub.add_parser("run", help="run one CSV snapshot")
p_run.add_argument("csv", type=Path)
run_options(p_run)
p_run.set_defaults(handler=_run)
p_approve = sub.add_parser("approve", help="approve a pending brief (local user only)")
p_approve.add_argument("run_id")
p_approve.add_argument("--folder", type=Path, required=True, help="watched folder holding the latest CSV")
p_approve.add_argument("--metrics", type=Path, required=True, help="metrics file the brief was built with")
p_approve.add_argument("--work-dir", type=Path, default=Path("work"))
p_approve.add_argument("--out-dir", type=Path, default=Path("out"))
p_approve.set_defaults(handler=_approve)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
return args.handler(args)
except (ApproveRefused, DeliveryRefused, RunLocked) as exc:
print(f"refused: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
raise SystemExit(main())