spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : cli.py6# Description : Standalone CLI — AIR home, compile, incremental recompile, statements, exports.7# =============================================================================8"""AIR standalone command line.910AIR needs no third-party system: keep your books in a managed local AIR home11(hash-chained ledger + archived documents + exports) and produce every12statement in several formats. QuickBooks/CSV are optional export targets.1314 # one-time: create your books directory15 python -m sdk.cli init --home books --policies alsl/policies/ca-qc-2026.yaml1617 # day to day: compile events into your books (document is archived)18 python -m sdk.cli compile doc.yaml --home books --report trial-balance1920 # a source document changed? post only the delta (reversal + replacement)21 python -m sdk.cli recompile old.yaml new.yaml --home books2223 # statements any time, any format24 python -m sdk.cli report balance-sheet --home books --format markdown25 python -m sdk.cli status --home books2627 # optional exports (no QuickBooks account required for qbo-export)28 python -m sdk.cli compile doc.yaml --home books --backend csv29 python -m sdk.cli compile doc.yaml --home books --backend qbo-export30"""31from __future__ import annotations3233import argparse34import hashlib35import json36import sys37from pathlib import Path3839from aic.compiler import compile_document40from aic.diagnostics import CompilationError, Diagnostic41from aic.incremental import recompile42from alsl.loader import load_policy_set43from backends.generic_csv.backend import GenericCsvBackend44from backends.native.backend import NativeLedgerBackend45from backends.quickbooks.backend import QuickBooksBackend46from core.document_io import load_air_document47from core.events import AirDocument48from core.journal import CompiledJournal49from ingestion.approval import ApprovalQueue50from ingestion.extractor import MockExtractor51from ingestion.pipeline import DEFAULT_CONFIDENCE_THRESHOLD, Route, ingest52from kernel.ledger import Ledger53from kernel.reporting import FORMATS, REPORTS54from kernel.workspace import Workspace, WorkspaceError555657def _print_diags(diags: list[Diagnostic]) -> None:58 for d in diags:59 print(d.render(), file=sys.stderr)606162def _resolve(args: argparse.Namespace) -> tuple[Workspace | None, str, str]:63 """Resolve (workspace, policies_path, ledger_path) from --home/--policies."""64 workspace: Workspace | None = None65 if getattr(args, "home", None):66 workspace = Workspace.open(args.home)67 policies = getattr(args, "policies", None) or (68 workspace.default_policies() if workspace else None69 )70 if not policies:71 raise WorkspaceError(72 "no policy set: pass --policies, or init the home with a default "73 "(air init --home <dir> --policies <set.yaml>)"74 )75 ledger = str(workspace.ledger_path) if workspace else getattr(76 args, "ledger", "books/ledger.jsonl"77 )78 return workspace, policies, ledger798081def _idempotency_key(*parts: str) -> str:82 return hashlib.sha256("".join(parts).encode("utf-8")).hexdigest()[:16]838485def _dispatch_backend(86 args: argparse.Namespace,87 workspace: Workspace | None,88 ledger_path: str,89 journal: CompiledJournal,90 key: str,91) -> str:92 """Post the journal to the chosen backend; returns a human summary."""93 if args.backend == "csv":94 out = (workspace.exports_dir if workspace else Path(args.out))95 backend = GenericCsvBackend(out)96 receipt = backend.post(backend.compile(journal), key)97 return f"{len(journal.entries)} entries -> CSV {receipt.reference}"98 if args.backend == "qbo-export":99 # Offline QuickBooks-shaped JSON export: NO QuickBooks account needed.100 payload = QuickBooksBackend().compile(journal)101 body = json.dumps(102 {"_author": "Simon-Pierre Boucher <contact@spboucher.ai>",103 "journal_entries": payload.body},104 indent=2, default=str,105 )106 out_dir = workspace.exports_dir if workspace else Path(args.out)107 out_dir.mkdir(parents=True, exist_ok=True)108 target = out_dir / f"qbo_journal_{key}.json"109 target.write_text(body, encoding="utf-8")110 return f"{len(journal.entries)} entries -> QBO JSON export {target}"111 backend = NativeLedgerBackend(ledger_path)112 receipt = backend.post(backend.compile(journal), key)113 for name in getattr(args, "report", None) or []:114 print(REPORTS[name](backend.ledger, args.format))115 return (116 f"{len(journal.entries)} entries -> native ledger {receipt.reference} "117 f"(+{receipt.details['entries_appended']} appended)"118 )119120121def _init(args: argparse.Namespace) -> int:122 workspace = Workspace.init(args.home, name=args.name, policies=args.policies)123 print(f"initialized AIR home at {workspace.root} "124 f"(default policies: {args.policies or 'none'})")125 return 0126127128def _status(args: argparse.Namespace) -> int:129 status = Workspace.open(args.home).status()130 for k, v in status.items():131 print(f"{k:20} {v}")132 return 0133134135def _compile(args: argparse.Namespace) -> int:136 workspace, policies_path, ledger_path = _resolve(args)137 document = load_air_document(args.document)138 policies = load_policy_set(policies_path)139 try:140 journal, diagnostics = compile_document(141 document, policies, optimize=getattr(args, "optimize", False))142 except CompilationError as exc:143 _print_diags(exc.diagnostics)144 return 1145 _print_diags(diagnostics)146147 key = _idempotency_key(148 Path(args.document).read_text(encoding="utf-8"),149 policies.name, policies.version,150 )151 summary = _dispatch_backend(args, workspace, ledger_path, journal, key)152 if summary:153 print(f"compiled {summary}")154 if workspace:155 archived = workspace.archive_document(args.document)156 print(f"document archived: {archived}")157 return 0158159160def _recompile(args: argparse.Namespace) -> int:161 workspace, policies_path, ledger_path = _resolve(args)162 old_doc = load_air_document(args.old_document)163 new_doc = load_air_document(args.new_document)164 policies = load_policy_set(policies_path)165 try:166 result = recompile(old_doc, new_doc, policies)167 except CompilationError as exc:168 _print_diags(exc.diagnostics)169 return 1170 _print_diags(result.diagnostics)171172 diff = result.diff173 print(f"diff: +{len(diff.added)} added, -{len(diff.removed)} removed, "174 f"~{len(diff.changed)} changed, ={len(diff.unchanged)} unchanged")175 if diff.is_empty():176 print("nothing to post: documents compile identically")177 return 0178179 key = _idempotency_key(180 Path(args.old_document).read_text(encoding="utf-8"),181 Path(args.new_document).read_text(encoding="utf-8"),182 policies.name, policies.version,183 )184 summary = _dispatch_backend(args, workspace, ledger_path, result.journal, key)185 if summary:186 print(f"posted {len(result.reversals)} reversal(s) + "187 f"{len(result.new_entries)} new -> {summary}")188 if workspace:189 workspace.archive_document(args.new_document)190 return 0191192193def _verify(args: argparse.Namespace) -> int:194 _, policies_path, _ = _resolve(args)195 document = load_air_document(args.document)196 policies = load_policy_set(policies_path)197 try:198 journal, diagnostics = compile_document(document, policies)199 except CompilationError as exc:200 _print_diags(exc.diagnostics)201 return 1202 _print_diags(diagnostics)203 print(f"OK: {len(document.events)} events -> {len(journal.entries)} balanced entries")204 return 0205206207def _compile_and_post(workspace: Workspace, policies_path: str,208 document: AirDocument, key: str) -> str:209 policies = load_policy_set(policies_path)210 journal, diagnostics = compile_document(document, policies)211 _print_diags(diagnostics)212 backend = NativeLedgerBackend(workspace.ledger_path)213 receipt = backend.post(backend.compile(journal), key)214 return (f"{len(journal.entries)} entries posted to {receipt.reference} "215 f"(+{receipt.details['entries_appended']} appended)")216217218def _ingest(args: argparse.Namespace) -> int:219 workspace, policies_path, _ = _resolve(args)220 assert workspace is not None221 text = Path(args.source).read_text(encoding="utf-8")222223 if args.llm:224 from ingestion.extractor import ClaudeExtractor225 extractor = ClaudeExtractor()226 else:227 extractor = MockExtractor()228229 from decimal import Decimal230 outcome = ingest(text, extractor, threshold=Decimal(args.threshold))231232 if outcome.route is Route.AUTO_APPROVED:233 assert outcome.document is not None234 key = _idempotency_key(text, "ingest")235 try:236 summary = _compile_and_post(workspace, policies_path,237 outcome.document, key)238 except CompilationError as exc:239 _print_diags(exc.diagnostics)240 return 1241 workspace.archive_document(args.source)242 print(f"auto-approved (confidence {outcome.extraction.confidence}): {summary}")243 return 0244245 queue = ApprovalQueue(workspace.root)246 item_id = queue.submit(outcome, text)247 print(f"routed to human review: {item_id}")248 for reason in outcome.reasons:249 print(f" reason: {reason}")250 for error in outcome.validation_errors:251 print(f" schema: {error}")252 print(f"review with: air inbox --home {workspace.root} | "253 f"air approve {item_id} --home {workspace.root} --approver <name>")254 return 0255256257def _inbox(args: argparse.Namespace) -> int:258 workspace = Workspace.open(args.home)259 items = ApprovalQueue(workspace.root).pending()260 if not items:261 print("inbox empty: nothing awaiting review")262 return 0263 for item in items:264 print(f"{item.id} confidence={item.confidence} "265 f"events={len(item.events)}")266 for reason in item.reasons:267 print(f" reason: {reason}")268 for error in item.validation_errors:269 print(f" schema: {error}")270 return 0271272273def _approve(args: argparse.Namespace) -> int:274 workspace, policies_path, _ = _resolve(args)275 assert workspace is not None276 queue = ApprovalQueue(workspace.root)277 document = queue.approve(args.item_id, approver=args.approver)278 key = _idempotency_key(args.item_id, "approve")279 try:280 summary = _compile_and_post(workspace, policies_path, document, key)281 except CompilationError as exc:282 _print_diags(exc.diagnostics)283 return 1284 print(f"approved by {args.approver}: {summary}")285 return 0286287288def _reject(args: argparse.Namespace) -> int:289 workspace = Workspace.open(args.home)290 ApprovalQueue(workspace.root).reject(args.item_id, reason=args.reason)291 print(f"rejected {args.item_id}")292 return 0293294295def _reconcile(args: argparse.Namespace) -> int:296 from kernel.bank_formats import parse_statement297 from kernel.reconcile import cash_movements, reconcile298299 workspace = Workspace.open(args.home)300 ledger = Ledger(path=workspace.ledger_path)301 transactions = parse_statement(Path(args.statement))302 result = reconcile(transactions,303 cash_movements(ledger, args.cash_account),304 tolerance_days=args.tolerance_days)305 print(result.render())306 return 0 if result.is_clean else 2 # 2: differences found (not an error)307308309def _audit(args: argparse.Namespace) -> int:310 from kernel.audit import AuditLog311 workspace = Workspace.open(args.home)312 log = AuditLog(path=workspace.root / "audit.jsonl")313 if not log.verify_chain():314 print("error: audit log hash chain verification FAILED", file=sys.stderr)315 return 1316 if not log.records:317 print("audit log empty: no syscalls recorded yet")318 return 0319 for record in log.records:320 status = "OK " if record["status"] == "ok" else "ERR"321 detail = f" <- {record['detail']}" if record.get("detail") else ""322 print(f"#{record['seq']:04d} {status} {record['actor']:<16} "323 f"{record['syscall']:<20} {json.dumps(record['params'])}{detail}")324 print(f"\n{len(log.records)} syscalls, hash chain VALID")325 return 0326327328def _report(args: argparse.Namespace) -> int:329 ledger_path = (330 Workspace.open(args.home).ledger_path if args.home else Path(args.ledger)331 )332 ledger = Ledger(path=Path(ledger_path))333 if not ledger.verify_chain():334 print("error: ledger hash chain verification FAILED", file=sys.stderr)335 return 1336 print(REPORTS[args.name](ledger, args.format))337 return 0338339340def _add_common(p: argparse.ArgumentParser, *, backend: bool = True) -> None:341 p.add_argument("--home", help="AIR home directory (managed books)")342 p.add_argument("--policies", help="ALSL policy set (default: the home's)")343 p.add_argument("--ledger", default="books/ledger.jsonl",344 help="ledger path when no --home is used")345 if backend:346 p.add_argument("--backend", choices=["native", "csv", "qbo-export"],347 default="native")348 p.add_argument("--out", default="out",349 help="export directory when no --home is used")350351352def main(argv: list[str] | None = None) -> int:353 parser = argparse.ArgumentParser(354 prog="air", description="AIR — the language of accounting (standalone mode)"355 )356 sub = parser.add_subparsers(dest="command", required=True)357358 p = sub.add_parser("init", help="create a managed AIR home (your books directory)")359 p.add_argument("--home", required=True)360 p.add_argument("--name", default="my-books")361 p.add_argument("--policies", help="default ALSL policy set for this home")362 p.set_defaults(func=_init)363364 p = sub.add_parser("status", help="show AIR home health: entries, chain, archives")365 p.add_argument("--home", required=True)366 p.set_defaults(func=_status)367368 p = sub.add_parser("compile", help="compile AIR events and post to a backend")369 p.add_argument("document")370 _add_common(p)371 p.add_argument("--optimize", action="store_true",372 help="enable optimization passes: duplicate detection, "373 "netting, payment fusion")374 p.add_argument("--report", action="append", choices=sorted(REPORTS))375 p.add_argument("--format", choices=FORMATS, default="text")376 p.set_defaults(func=_compile)377378 p = sub.add_parser(379 "recompile",380 help="incremental compile: diff two AIR documents, post reversal + replacement",381 )382 p.add_argument("old_document")383 p.add_argument("new_document")384 _add_common(p)385 p.set_defaults(func=_recompile)386387 p = sub.add_parser("verify", help="compile without posting; print diagnostics")388 p.add_argument("document")389 _add_common(p, backend=False)390 p.set_defaults(func=_verify)391392 p = sub.add_parser(393 "ingest",394 help="extract AIR events from a source document; route by confidence",395 )396 p.add_argument("source", help="text file (invoice text, email, OCR output)")397 p.add_argument("--home", required=True)398 p.add_argument("--policies")399 p.add_argument("--threshold", default=str(DEFAULT_CONFIDENCE_THRESHOLD),400 help="auto-approve confidence threshold (default 0.85)")401 p.add_argument("--llm", action="store_true",402 help="use the Claude extractor (needs an Anthropic API key); "403 "default is the offline mock extractor")404 p.set_defaults(func=_ingest)405406 p = sub.add_parser("inbox", help="list extractions awaiting human review")407 p.add_argument("--home", required=True)408 p.set_defaults(func=_inbox)409410 p = sub.add_parser("approve", help="approve a pending extraction and post it")411 p.add_argument("item_id")412 p.add_argument("--home", required=True)413 p.add_argument("--policies")414 p.add_argument("--approver", required=True)415 p.set_defaults(func=_approve)416417 p = sub.add_parser("reject", help="reject a pending extraction")418 p.add_argument("item_id")419 p.add_argument("--home", required=True)420 p.add_argument("--reason", default="")421 p.set_defaults(func=_reject)422423 p = sub.add_parser(424 "reconcile",425 help="match a bank statement (camt.053 / MT940 / CSV) against the books",426 )427 p.add_argument("statement")428 p.add_argument("--home", required=True)429 p.add_argument("--cash-account", default="1000")430 p.add_argument("--tolerance-days", type=int, default=3)431 p.set_defaults(func=_reconcile)432433 p = sub.add_parser("audit", help="show and verify the agent syscall audit log")434 p.add_argument("--home", required=True)435 p.set_defaults(func=_audit)436437 p = sub.add_parser("report", help="generate a statement from the books")438 p.add_argument("name", choices=sorted(REPORTS))439 p.add_argument("--home")440 p.add_argument("--ledger", default="books/ledger.jsonl")441 p.add_argument("--format", choices=FORMATS, default="text")442 p.set_defaults(func=_report)443444 args = parser.parse_args(argv)445 try:446 return int(args.func(args))447 except WorkspaceError as exc:448 print(f"error: {exc}", file=sys.stderr)449 return 1450451452if __name__ == "__main__":453 raise SystemExit(main())454