ZeraBooks 的 AI 總分類帳設計:模型提出建議,確定性程式碼作出決定
Designing an AI general ledger: the model proposes, deterministic code disposes
ZeraBooks 的 AI 總分類帳讓模型只提出分類、配對或提問建議,由確定性程式碼驗證、套用、記錄並支援撤回。所有結餘和總額均由帳簿計算,模型不寫入帳簿,也不負責算術。經數月記帳後,ZeraBooks 的交易分類準確率為 99.6%;產品透過上載 PDF 或 CSV 結單運作,不連接銀行。
An AI general ledger works when the model only produces proposals (a category, a match, a question with a confidence) and deterministic code validates, applies, logs and can reverse them. Every balance and total comes from the ledger, never the model. That is how ZeraBooks is built. This post sketches that shape in TypeScript. It illustrates the design; it isn't our source code.
I'm on the ZeraBooks team. When people hear "AI does the bookkeeping" they picture a model writing journal entries. That's the design we avoided. The model never writes to the ledger and never does arithmetic. It returns a small structured proposal, and everything after that is ordinary code with tests.
Proposals, not writes
type Confidence = "sure" | "fairly_sure" | "not_sure";
type Proposal =
| { kind: "categorize"; lineId: string; categoryId: string;
confidence: Confidence; p: number; evidence: string }
| { kind: "match_invoice"; lineId: string; invoiceId: string;
confidence: Confidence; p: number; evidence: string }
| { kind: "pair_transfer"; outLineId: string; inLineId: string;
confidence: Confidence; p: number; evidence: string }
| { kind: "ask"; lineId: string; question: string;
options: { label: string; categoryId: string; p: number }[] };
Note what's missing: amounts. A categorize proposal says which account a statement line belongs to. The amount is already on the line, read from the statement. A match_invoice proposal says which invoice; the ledger decides whether the amounts agree. There's no field where the model could put a number that ends up in the books.
evidence is a short string quoting what the model relied on ("same vendor and amount as the last eight months"), shown to the user.
A policy layer that can say no
function admit(p: Proposal, ctx: LedgerContext): Decision {
if (p.kind === "ask") return { action: "queue_question" };
const line = ctx.line(p.kind === "pair_transfer" ? p.outLineId : p.lineId);
if (ctx.isClosedPeriod(line.date)) return { action: "reject", why: "closed period" };
if (line.setByUser) return { action: "flag_only", why: "user decision stands" };
if (line.payeeIsTaxAuthority) return { action: "queue_question", why: "tax payments always ask" };
if (p.kind === "pair_transfer" && line.isTransfer) return { action: "reject", why: "already paired" };
switch (p.confidence) {
case "sure": return { action: "apply" };
case "fairly_sure": return { action: "apply_and_flag" };
case "not_sure": return { action: "queue_question" };
}
}
These rules mirror the guardrails we publish: the AI doesn't overwrite a category you set, doesn't touch closed periods, and always asks about payments to a tax authority. They're plain if statements on purpose. You can read them, test them and be sure they hold.
Possible duplicates get their own path: the model can flag a pair, and there is no proposal type for deleting a line at all. Same for moving money: no bill pay, no transfers, no bank connection. The safest permission is the one that doesn't exist.
Every applied action is reversible
interface ActionLog {
id: string;
at: string;
proposal: Proposal;
before: Snapshot; // the fields this action changed, as they were
after: Snapshot;
undoneAt?: string;
}
function apply(p: Proposal, ctx: LedgerContext): ActionLog {
const before = ctx.snapshot(p);
ctx.write(p); // validated, transactional
const after = ctx.snapshot(p);
return ctx.log({ proposal: p, before, after });
}
function undo(log: ActionLog, ctx: LedgerContext) {
if (!ctx.unchangedSince(log)) throw new Error("line edited since; review manually");
ctx.restore(log.before);
ctx.markUndone(log.id);
}
In the product this is the "Done by AI" list. Each card has Undo. unchangedSince prevents an undo from clobbering a later human edit.
Arithmetic lives in the ledger
The month-close check is a pure function over data the model never touched:
function statementTies(s: Statement, prev?: Statement): TieResult {
const sum = s.lines.reduce((acc, l) => acc + l.amountCents, 0);
const adds = s.openingCents + sum === s.closingCents;
const follows = !prev || prev.closingCents === s.openingCents;
const agrees = ledgerBalanceCents(s.accountId, s.endDate) === s.closingCents;
return { adds, follows, agrees, ok: adds && follows && agrees };
}
Integer cents, no floats. A fictional October checking statement: opening 800000, lines summing to +117945, closing 917945. It adds. If last month closed at 800000, it follows. If the ledger balance at October 31 is 917945, it agrees, and only then can the month be signed off. When it doesn't tie, the model can be asked to re-read the statement, but its proposed fixes are only accepted if they make all three checks pass.
Where confidence comes from
In ZeraBooks, confidence comes from the company's own reviewed history: how lines like this one were categorized before. Below 60%, the question card says the best guess is a low-confidence one. Corrections become history, and the user can apply a correction to similar lines (same merchant, same direction, any account or date), with hand-set lines skipped.
The published accuracy number for this setup: after a couple of months of bookkeeping, ZeraBooks categorizes 99.6% of transactions correctly, tested on years of bookkeeping for several companies. The design goal is that the remaining errors are visible, explained and undoable.
The product
ZeraBooks is an AI-native general ledger. It works from uploaded statements (PDF or CSV, no bank feeds), proves each one against its printed balances to the cent, and costs $79 a month billed annually or $149 monthly after a 7-day trial. The non-technical explanation, including how this differs from a feed-and-rules ledger, is on our page about the AI general ledger.
Disclosure: I'm on the ZeraBooks team, and this describes our product.
來源:DEV Community · dev.to