OfinTech Desk 開源財務 AI 工作空間,為每個數字標示來源
OfinTech Desk: an open-source AI workspace for finance where every number has a source
OfinTech Desk v1.0.0 以 Apache-2.0 授權全面開源,提供財務 AI 桌面工作空間。它可讀取帳簿、試算表、文件及收件匣,並以 figure check 核對回答中的數字;寫入 Excel 或 Word 文件時附上來源,發送或修改前須經人工審批。
Most "AI for finance" demos have the same flaw: the model writes a confident paragraph full of numbers, and nobody can tell which of them came from the ledger and which the model made up.
**[OfinTech Desk](https://github.com/OfinTech/ofinTech-Dask)** is our answer to that, and as of **v1.0.0 (27 Oct 2026)** it is fully open source under **Apache-2.0**. It's a desktop app (plus an optional self-hosted Team Server) that reads your ledger, spreadsheets, documents and inbox, does the arithmetic *in code*, fills in your own Excel and Word files with a source for every figure, and asks a human before it sends or changes anything.
This post is not a feature tour. It's a walk through the engineering decisions inside the engine, the places where we think the design is interesting, and the places where we'd love your help.
> **TL;DR for the impatient**
> - Python engine (Starlette + SQLite), React/TypeScript UI, Electron shell. ~29k lines.
> - Bring any model: OpenAI, Anthropic, Gemini, DeepSeek, Mistral, Groq, OpenRouter, xAI, Azure, or local via Ollama / LM Studio.
> - Our own agent loop (no framework) so runs can pause for approval and resume after a restart.
> - A *figure check* flags every number in an answer that doesn't appear in a tool result.
> - A tiny formula language, parsed (no `eval`), compiled to Excel, and evaluated with Excel's own rules — verified cell-by-cell against LibreOffice.
> - Tool selection per request: **54% smaller requests** on a simple question.
> - 99 engine tests, a browser smoke test, UI in English, Arabic (RTL), Spanish and French.
---
## The architecture in one picture
Desktop app (Electron) Team Server (Docker)
┌───────────────────────────────┐ ┌──────────────────────────────┐
│ Renderer: React UI (sandboxed)│ │ Caddy (HTTPS) ─► engine │
│ ▲ token via preload bridge │ or, in team mode │ server mode, accounts, │
│ Main: starts engine, keeps │ ─────────────────► │ invitations, webhooks │
│ master key in OS storage │ loads server UI │ (WhatsApp, Telegram) │
│ Engine (Python) on 127.0.0.1 │ └──────────────────────────────┘
└───────────────────────────────┘
**One engine, two modes.** The same Python engine runs as a single-user sidecar on loopback inside the desktop app, or as a multi-user server behind Caddy. In desktop mode it creates a master key, stores it in the OS keychain (Keychain / DPAPI / libsecret), generates a fresh access token on every launch, and rejects foreign `Host` headers to defeat DNS rebinding.
**SQLite everywhere.** WAL mode, one connection per thread, serialised writes, FTS5 for search over documents, conversations and memory. It needs zero administration on a laptop and is plenty for a team-sized server. (PostgreSQL is on the roadmap — see "Where you can help".)
**Few, permissive dependencies.** Starlette, uvicorn, pydantic, httpx, mcp, openpyxl, cryptography, PyYAML. Charts, the Markdown renderer and the workflow editor are written in the project, so there's no runtime CDN or font download.
---
## 1. Why we wrote our own agent loop
We looked at agent frameworks and kept bumping into one requirement: **a run must be able to stop in the middle, wait hours for a human, and resume — even after the process restarts.**
So the loop in `engine/desk_engine/agent/harness.py` is deliberately boring:
model call → tool calls → results → model call … until the model answers (max 16 steps)
Every tool declares an `effect`:
python
Effect = Literal["read", "write"]
def needs_approval(self) -> bool:
if self.policy is not None: # MCP tools: auto | ask | off
return self.policy != "auto"
return self.effect == "write"
- `read` tools (calculators, queries, document search) run immediately.
- `write` tools (send an e-mail, post a Telegram message, run a workflow) don't run at all. The loop records an approval with the **exact arguments**, persists the pending calls into the run's `state`, sets the run to `waiting_approval`, and returns.
When an editor approves or rejects in the inbox, `_resolve_pending` replays the stored calls. A rejection is fed back to the model as a tool result — *"The user rejected this action. Do not retry it unless they ask; offer an alternative."* — so the conversation keeps going instead of dead-ending.
Because state lives in SQLite rather than in a Python coroutine, a restart is just another resume. The loop speaks two wire protocols — OpenAI Chat Completions and Anthropic Messages — which together cover every provider we list.
**The model is treated as untrusted input.** It never gets SQL, a shell, or code execution. Its output is rendered from Markdown into React elements, never as HTML. Tools return text for the model *plus* typed blocks (`table`, `kpis`, `link`, `skill`) that the UI draws itself.
---
## 2. The figure check: catching numbers that came from nowhere
After the final answer, the harness collects every tool result, every user message and every consulted memory as **evidence**, then extracts all numbers from the answer and checks each one against it:
python
def unverified_figures(answer: str, evidence: list[str]) -> list[str]:
known = [v for text in evidence for _, v in _numbers(text)]
flagged = []
for raw, v in _numbers(answer):
a = abs(v)
if a < 100 and "." not in raw and "%" not in raw:
continue # small counts, list numbers, days
if 1900 <= a <= 2100 and "." not in raw and "," not in raw:
continue # years
ok = any(abs(abs(cand) - a) <= max(abs(cand) * 0.005, 0.011)
for k in known for cand in (k, k * 100, k / 100))
if not ok and raw not in flagged:
flagged.append(raw)
return flagged[:20]
A few details that took iteration:
- The parser understands `1,234.5`, `(1,200)` as negative, the Unicode minus `−`, and suffixes like `k`, `m`, `bn`, `million`, `%`.
- `k * 100` and `k / 100` let `0.153` in a tool result verify `15.3%` in the answer.
- A 0.5% relative tolerance lets the model round `1,234,567` to `1.23m` without being flagged.
It's a heuristic, not a proof — and we say so in the UI. But it turns "trust me" into a short list a reviewer can actually check. **Ideas for making it smarter (units, currencies, derived figures) are very welcome.**
---
## 3. A formula language that agrees with Excel
When the assistant builds a workbook, computed columns and totals aren't pasted values — they're **live Excel formulas**. That raises a hard problem: the value we show in the app must equal what Excel computes when you open the file.
So `engine/desk_engine/files/expr.py` implements a tiny language:
plaintext
variance = actual - budget
variance_pct = (actual - budget) / budget
gross margin = [pl:Gross profit:amount] / [pl:Revenue:amount]
same, dotted form = pl.total.amount
Only `ABS, ROUND, MIN, MAX, SUM, AVERAGE`, the four operators and parentheses. Each expression is tokenised and parsed into a small AST of frozen dataclasses (`Num`, `Col`, `Ref`, `Neg`, `Bin`, `Call`, `Span`). That single AST is then either:
1. **compiled** to an Excel formula with real cell references, or
2. **evaluated** in Python with Excel's semantics: blank as zero, division by zero shown blank, and — the fun one — rounding.
python
ROUND(7250.5 * 1.15, 2) is 8338.08 as in Excel, not 8338.07.
return float(Decimal(f"{x:.15g}").quantize(q, rounding=ROUND_HALF_UP))
Excel rounds half away from zero at 15 significant digits; naive float rounding disagrees on values like this one. No `eval` anywhere.
The test suite then does something we're quite proud of: **every formula cell in a generated workbook is recalculated by LibreOffice and compared with the engine's value.** If the two disagree, the build fails.
The same "resolve once" idea applies to the whole file: a definition is resolved into a model where every cell has value, number format, live formula, readable formula, source and address. The Excel writer, the Word writer, the in-app viewer and the reviewer all read that one model, so they *can't* disagree.
### Maker and checker
Every generated file is annotated by an **independent reviewer**: a separate model call with a fresh context containing only the file, its sources and read-only tools. It never sees the drafting conversation, so it can't be talked into agreeing with it. Findings land as notes on specific cells and a Review sheet.
---
## 4. Shipping fewer tokens: tool selection and cache-friendly prompts
An agent re-sends its whole context on every step, and tool schemas are the biggest chunk (~32,000 characters for all built-in tools). `agent/context.py` keeps requests small:
python
CORE = {"finance", "knowledge", "memory", "skills", "meta", "mcp"}
TRIGGERS = {
"files": ["workbook", "excel", "xlsx", …, "مصنف", "تقرير", …, "libro", "informe", …, "classeur", …],
"accounting": ["invoice", "ledger", "p&l", …, "فاتورة", "دفتر", …],
…
}
- Every request carries the core groups, plus groups whose trigger words appear in the last three user messages (in **four languages**), plus groups already used in the thread.
- If the model needs something that wasn't sent, it calls `more_tools` and gets the group on the next step. A tool that wasn't sent still runs if called by name — **selection never blocks work**.
- Tools are sorted by `(category, name)`, and the system prompt is split into a **stable prefix** and a small **changing part** (date, language, memories for this message), so provider prompt caches stay valid. Anthropic cache breakpoints are set after the tools, after the stable system block, and on the latest message.
- Tool results older than two turns are cut to 600 characters *in what is resent* — never in what is stored, so they still count as evidence for the figure check.
Result, reproduced by `tests/test_tokens.py`: a simple calculation question went from **32,783 to 14,973 characters (54% smaller), 27 tools to 16.** Set `DESK_TOOL_SELECTION=off` if a model misbehaves.
---
## 5. Memory you can audit
Long-term memory is where assistants get creepy, so ours is conservative:
- The assistant only **proposes** a fact (a materiality threshold, an account mapping, a close deadline). Nothing is saved until a person approves it.
- Every entry shows its origin, who saved it and how often it was used; answers list which memories they consulted.
- Secrets are refused by pattern: passwords (in four languages), API keys, IBANs, full account numbers, and card numbers validated with a Luhn check.
---
## 6. Extending it
There are four well-defined extension points, and each is a good first contribution:
**Skills** — plain `SKILL.md` files with YAML front matter. They're instructions only; nothing in them is executed. Fifteen built-in finance skills ship today (month-end close, bank reconciliation, 13-week cash forecast, IFRS 15 / ASC 606…). If you know a finance process well, you can contribute without writing Python.
**The Open Accounting Connector** — QuickBooks, Xero, Zoho Books and Odoo are built in. Any other ledger plugs in through an OpenAPI 3.1 contract with five endpoints, in pull mode (a reference adapter in `examples/accounting-adapter/adapter.py`, standard library only) or push mode (an ingest API with a hashed key).
bash
ADAPTER_KEY=change-me python examples/accounting-adapter/adapter.py
curl -H "Authorization: Bearer change-me" \
"http://127.0.0.1:8787/v1/profit-and-loss?start=2026-01-01&end=2026-03-31"
**MCP, both directions** — connect external MCP servers as agent tools with an `ask/auto/off` policy per tool (and `auto` is downgraded for write tools), or expose the workspace as a read-only MCP server at `/mcp`.
**Languages** — UI strings are written in English inside `tr("…")` and translated in `ui/src/i18n/{ar,es,fr}.ts`. `npm run i18n:check` reports coverage (currently 899/899). Arabic flips the layout with logical CSS properties.
---
## Run it in five minutes
bash
git clone https://github.com/OfinTech/ofinTech-Dask.git && cd ofinTech-Dask
pip install -r engine/requirements.txt
(cd ui && npm install && node build.mjs)
engine + UI in a browser
cd engine
DESK_DEV=1 DESK_UI_DIR=../ui/dist DESK_LOCAL_TOKEN=$(python -c "print('x'*40)") python -m desk_engine
open http://localhost:47821/#token=xxxxxxxx… (same token)
Point it at a local model through Ollama if you'd rather not use an API key. For the full desktop app: `(cd desktop && npm install && npm start)`. For a team: `cd server && cp .env.example .env && docker compose up -d --build`.
Tests:
bash
cd engine && python -m unittest discover -s tests -t . # 99 tests, ~11 s
cd ui && npx tsc --noEmit && npm run i18n:check
python e2e/ui_smoke.py # real UI in Chromium + a fake model
External services are replaced by an HTTP mock transport shaped like each provider's documented responses; the MCP tests run a real MCP server process.
---
## What we don't claim
We think honesty about limits is part of open source, so the README has a section for it:
- Tax and accounting output is general guidance. **No** certification or compliance is claimed (ZATCA, PDPL, SOC 2, SAMA or anyone else).
- Accounting connectors are **read-only**. There is no live bank feed and no live FX feed.
- The reviewer is a second model check, not an audit.
- Connectors were tested against mocks of documented APIs, not live accounts.
---
## Where you can help
Here's what's on the roadmap and where a contributor could make a real dent:
- 🐘 **PostgreSQL** support for larger Team Servers.
- 🔌 **New connectors**: Sage, NetSuite, FreshBooks, MYOB — or an adapter for *your* ERP via the Open Accounting Connector.
- ✍️ **Write-back** to accounting systems (draft bills and journals, behind approvals).
- 🔐 **SAML SSO and TOTP.**
- 📊 Keeping charts and images when editing a user's own workbook; scheduled dashboard e-mails and PDF export.
- 🌍 **Translations**: native-speaker review of Arabic, Spanish and French, or a new language.
- 🧪 **Live testing**: try the connectors against a QuickBooks sandbox, a Xero demo company or an Odoo trial and tell us what breaks.
- 📚 **Skills**: write a `SKILL.md` for a finance process you know.
The contribution rules are short and opinionated, because the project handles other people's money: no secrets or real data in fixtures, no vendor lock-in, the model is untrusted, no compliance claims, and **every figure stays traceable**. Changes touching auth, permissions, encryption, approvals or agent tools get a second reviewer.
---
⭐ **Repo:** https://github.com/OfinTech/ofinTech-Dask
📄 **Docs:** `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/COSTS.md`, `docs/FILES.md`
🤝 **Contributing:** `CONTRIBUTING.md` · **Security reports:** `SECURITY.md`
If you've built agents that touch money, I'd especially like to hear how you handle the "where did this number come from?" problem. Drop a comment, open an issue, or send a PR.
來源:DEV Community · dev.to