SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
17.1 KB · 454 lines python
Raw Blame History
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