Read-Only Data Plus Natural Language: How to Build a Personal Finance Analysis Assistant
This article walks through the architecture of an LLM-powered personal finance analysis assistant that is read-only by design. It covers why write capability must be absent at the permission layer rather than merely disabled, how to scope tokens down to the field level, how to build a four-layer pipeline from raw transaction feeds to a semantic metric layer, how to execute natural-language questions through a constrained read-only query engine, and why arithmetic must stay in the database while the model only handles phrasing. It also details neutrality constraints on output, prompt-injection defenses, layered explanations, a pre-launch evaluation gate, a staged rollout plan, and a list of common pitfalls.
1. Starting Point: Turning Reconciliation from Spreadsheet Work into a Conversation
Personal financial information is chronically fragmented. Checking accounts, credit accounts, third-party payment channels, and subscription billing each live in their own system, so answering “where did my money go this month” usually means opening several apps, exporting a handful of statements, and manually stitching them into one sheet. The cost here is not the single operation — it is the repetition. Every month, every large purchase, every refund verification starts the whole cycle over.
Bringing a large language model into this scenario creates value not because it is a “smarter calculator,” but because of three structural changes:
- Unified entry point: multi-source data is first aggregated into a single queryable data domain, so users stop switching between apps.
- Lower interaction barrier: natural language replaces filter combinations and formula writing, letting users without a finance background query for themselves.
- Explainable output: the system does not just return a number; it explains terminology, definitions, and the calculation path, turning a “result” into a “verifiable conclusion.”
At the same time, three hard constraints must be stated up front, because they determine every architectural decision that follows: the data plane is read-only, output carries no commercial bias, and the whole process is auditable. The sections below unfold layer by layer.
2. Capability Boundaries: Why “Read-Only” Is the Foundation of the Whole Design
2.1 Permission Tiering: Excluding “Can It Move Money” at the Design Level
Access to account-type data should be modeled by operation type, at minimum separating four categories: account metadata reads, balance reads, transaction detail reads, and write operations (payments, transfers, debit authorizations, card management).
A financial analysis scenario should request only “balance + transaction details” out of the first three categories, and should request no write permissions at all. This is not merely caution at the configuration level; it is an absence of capability. When the system holds no write-capable token in the first place, prompt injection, model overreach, and code defects cannot be converted into monetary loss. It is the most cost-effective layer in a defense-in-depth design.
2.2 Least Privilege and Field-Level Authorization
Even among “read-only” scopes, the surface should be narrowed further:
- Time window: authorize only a bounded number of months of transaction history, not unlimited history.
- Account scope: let users select and authorize individual accounts rather than granting everything in one click.
- Field granularity: merchant name, amount, timestamp, currency, and transaction type are necessary fields; reference numbers, full card numbers, and authentication elements must never enter the analysis data domain.
# Illustrative authorization scope declaration (pseudo-code)grant:capability: [account.summary.read, transaction.list.read] # no write / transfer / payaccounts: [acct_selected_by_user]lookback: { months: 24 }fields: [merchant_name, amount, currency, posted_at, category, status]expires_at: relative duration, requires user confirmation to renewrevoke: user can withdraw at any time, token invalidated immediately
2.3 Credential Custody and Token Lifecycle
User authorization credentials should not be stored in plaintext in the application’s own database. The recommended approach: credentials are held by an independent secret/credential management service, the business service holds only a reference handle, and data access happens through short-lived tokens. Tokens should have an expiration, a refresh limit, and a revocation channel, and every decryption and call action should be written to the audit log.
2.4 The Neutrality Principle on the Output Side
The wording of analytical conclusions must be constrained to facts and statistical definitions. The system does not issue credit products, does not match lenders with borrowers, and does not hold proprietary savings accounts — therefore there are no recommendation slots that need to be monetized. In engineering terms: the generation prompt explicitly forbids inserting product recommendations, referral traffic to institutions, or return promises, and a rule check runs during post-processing on the output.
3. Data Pipeline: From Raw Transaction Feeds to a Queryable Semantic Layer
3.1 Four Layers
| Layer | Responsibility | Key constraint |
|---|---|---|
| Ingestion | Connect to multiple account sources; pull and incremental sync | Idempotency, retry, rate limiting, failure isolation |
| Normalization | Field mapping, currency conversion, timezone unification, merchant name cleaning | Rules versioned and re-runnable |
| Fact detail | Immutable transaction records, append-only | Stable deduplication key, raw payload retained |
| Semantic metric | Definition of metrics and pre-aggregated results | One implementation per metric, no duplicate definitions |
The ingestion layer faces heterogeneity and instability: encoding conventions, pagination strategies, and rate-limit thresholds differ per source. Engineering-wise, a message queue smooths peaks and handles retries, decoupling the pull task from the write task so a single source failure does not block everything.
The normalization layer is the most underrated. Typical difficulties include: sign conventions for amounts (are expenses negative or positive), how refunds and reversals are paired with the original transaction, whether foreign currency is converted at the transaction-date rate or the booking-date rate, day-boundary attribution caused by timezone differences, and merchant name cleaning (the same chain merchant may appear in a dozen spellings across channels, which directly determines the accuracy of spending categorization).
-- Illustrative fact transaction table (column names reflect logical meaning)create table fact_transaction (tx_id char(32) primary key, -- stable dedup key: source + channel transaction idaccount_id char(32) not null,user_id char(32) not null, -- for row-level security isolationposted_at_utc timestamp not null, -- normalized to UTCsettle_date date, -- settlement date, kept separate from posting dateamount_minor bigint not null, -- integer in minor currency unit, avoids floating-point errorcurrency char(3) not null,direction smallint not null, -- 1=inflow, -1=outflowmerchant_raw text, -- raw payload, only for cleaning traceabilitymerchant_clean text, -- cleaned namecategory_l1 text,category_l2 text,status text, -- settled / pending / reversedingested_at timestamp not null);
Two easily missed details: store amounts as integers in the minor currency unit (to avoid floating point and accumulated rounding error); keep posted_at_utc and settle_date separate (balance definitions follow posting time, while spending statistics usually follow posting or settlement time — mixing the two is the direct cause of “the balance does not reconcile”).
3.2 Semantic Layer: Each Definition Must Have Exactly One Implementation
Metrics such as “total spending this month,” “share of essential spending,” “savings rate,” and “average monthly subscription spending” should never be inferred live by the model on each request. The correct approach is to predefine metrics and aggregation views with a single physical implementation, and have the Q&A path only call them.
create view v_monthly_spend asselect user_id,date_trunc('month', posted_at_utc) as month,sum(case when direction = -1 and status = 'settled' then amount_minor else 0 end) as spend_minor,sum(case when category_l1 = 'subscriptions' then amount_minor else 0 end) as subscription_minorfrom fact_transactiongroup by 1, 2;
Documenting definitions matters just as much: which categories count as “essential spending,” whether refunds reduce the current month, and whether pending transactions are included in statistics should all have written, version-controlled definitions. When a definition changes, historical results must be recomputed by version or annotated with the applicable range.
4. From Natural Language to Structured Queries: A Constrained Query Executor
4.1 Splitting the Pipeline
The recommended chain is: intent recognition → parameter and constraint extraction → constrained query generation → static validation → read-only execution → result normalization → response generation. Each step can be tested and degraded independently.
Intent classification should converge to a finite set, because the overwhelming majority of real questions fall into a few categories: check a balance, list transactions over a time range, aggregate by category or merchant, period-over-period comparison, term explanation, and data-gap explanation. The more converged the classification, the more reliable downstream generation becomes and the easier it is to measure accuracy.
4.2 Safety Guardrails (Non-Negotiable)
# Illustrative constrained query executor (pseudo-code, not a real API)FORBIDDEN = re.compile(r"\b(insert|update|delete|drop|alter|truncate|create|grant|copy)\b", re.I)def run_analysis_query(user_id: str, sql: str, max_rows: int = 500):if FORBIDDEN.search(sql):raise PolicyViolation("only a single select query is allowed")if not sql.lstrip().lower().startswith(("select", "with")):raise PolicyViolation("statement type not allowed")if ";" in sql.rstrip().rstrip(";"): # forbid statement stackingraise PolicyViolation("statement stacking is forbidden")if not only_whitelisted_views(sql): # semantic layer views onlyraise PolicyViolation("accessing base tables is forbidden")with db.read_only_transaction( # read-only session on the database sidestatement_timeout_ms=3000,default_row_limit=max_rows) as conn:# user_id is injected by the service, never taken from model outputrows = conn.execute_sql(sql, binds={"uid": user_id})return normalize(rows)
Four points carry the weight: only a single read-only statement is allowed; only whitelisted semantic-layer views may be accessed, not base tables; timeout, row count, and memory limits are enforced server-side; and user identity and data scope are injected by the service and never form part of the generated content. Row-level security policies should be a backstop on the database side rather than a reliance on the generator “not writing anything wrong.”
4.3 Clarifying Beats Guessing
When a question lacks necessary constraints (for example, “how much did I spend last month” while the user has multiple currency accounts), the correct behavior is to ask for clarification or explicitly state assumptions, rather than silently picking a default. Compared with an answer that looks precise but is wrong, explicit clarification buys far more trust.
5. Take the Arithmetic Away from the Model
This is the most frequent source of defects in systems of this kind: a large language model should not perform numerical aggregation. Once sums, period-over-period changes, percentages, and averages are computed implicitly during generation, deviations appear that cannot be reproduced, and their magnitude grows with the number of data rows.
The engineering principle is “aggregate in the database, phrase with the model”:
- All numbers come from query results; the model only organizes the wording.
- Numbers are filled in through structured placeholders; free rewriting of digits is prohibited.
- Run a consistency check before output: the set of numbers in the model’s text must be fully covered by the query result set.
# Illustrative result backfill and validationfacts = query_result # authoritative numbersdraft = render_template(facts) # narrative skeleton from a fixed templatetext = model.rephrase(draft) # wording may change, numbers may notassert all_number_tokens_in(text) <= set(facts.values()), "unsupported numeric value detected"
Display rules should also be unified: currency symbols, decimal places, time-range phrasing, and interval inclusivity (“October 1 to 31” — closed or half-open) all need a fixed convention, otherwise the same query produces seemingly contradictory numbers under different phrasings.
6. The Explanation Layer: From Giving an Answer to Making It Understandable
Users in personal finance are usually not finance professionals, so explanation capability is the dividing line for product usability.
Layered output is a fairly stable structure: a one-sentence conclusion → supporting data and definitions → optional drill-down breakdowns → data completeness notes. When answering “dining spending this month,” for example, also state the statistical period, whether pending transactions are included, and how refunds are treated.
Term explanations are better generated from a controlled glossary lookup than left to the model’s parametric memory — for concepts such as interest calculation methods, billing cycles, and minimum payments, definitions must match the official rules. The glossary needs versioned updates.
Data gaps must be stated proactively: a failed account sync, a period with no data, or a transaction in an abnormal state should all be surfaced explicitly rather than producing a complete conclusion on top of missing data.
Restating the boundary: the system only describes current state and produces statistical insights; it does not give personalized investment advice, does not forecast returns, and does not steer users toward specific financial products. This boundary is both a compliance requirement and a technical constraint that prevents the model from sliding into “advice that sounds professional but is wrong.”
7. Security and Compliance: Treat All Input as Untrusted
- Transit and storage: encrypt end to end; consider field-level encryption for sensitive fields; keys held by an independent key management service and rotated periodically.
- Prompt injection defense: merchant names, transaction memos, and statement remarks come from external sources and must be rendered strictly as data, never concatenated into the instruction area; detect and strip content such as “ignore previous instructions” in text fields.
- Output filtering: scan generated results for sensitive information (full card numbers, reference numbers, ID numbers) and validate for out-of-bounds content (investment advice, product recommendations, return promises).
- Audit trail: each Q&A records the user, the question, the generated query, a summary of the actual result, latency, and model version, supporting after-the-fact review; sensitive fields in logs follow a masking policy.
- Data minimization and lifecycle: retain only fields required for analysis; define tiered retention periods; support one-click export and deletion; when authorization is withdrawn, clean up derived data accordingly.
- Mapping to general cloud capabilities: object storage holds archives and export files, a message queue handles ingestion peak-shaving and asynchronous tasks, a log service carries auditing and search, monitoring and alerting cover sync failure rate and query timeouts, while credential/key management and a data warehouse or lakehouse service handle secrets management and metric-layer computation respectively.
8. The Evaluation System: A Quality Gate Before Launch
A Q&A system without evaluation cannot be iterated. At least four categories of metrics are recommended:
- Definition accuracy: the proportion of answers whose numbers exactly match a precomputed baseline on a golden question set.
- Execution health: query success rate, timeout rate, clarification rate, refusal rate.
- Faithfulness: the proportion of output numbers fully supported by query results (used to catch “number-rewriting” hallucinations).
- Engineering metrics: P95 latency, cost per Q&A, result cache hit rate.
# Illustrative evaluation flowfor case in golden_set:plan = parse(case.question)assert plan.intent == case.expect_intentgot = execute(plan, user_id=case.fixture_user)want = precomputed_facts[case.fixture_user][case.expect_metric]report.add(case.id,exact_match=(got == want),faithful=numbers_supported_by(report_text, got))gate = (report.accuracy >= 0.98 and report.faithfulness >= 0.99and report.p95_ms <= 2500)
The sample set should cover normal phrasings, paraphrases, month and year boundaries, multiple currencies, missing data, privilege-escalation attempts, and prompt-injection adversarial cases. For release strategy, use staged rollout with shadow traffic: the new path first runs in parallel with the old path over the same batch of questions, user traffic is switched only after result consistency reaches the bar, and metrics monitoring and alerting continue afterward.
9. Suggested Rollout Sequence
- Phase 1: read-only data aggregation plus fixed Q&A templates. Clear value, lowest risk, and it quickly reveals the real distribution of user phrasings.
- Phase 2: semantic layer plus constrained query generation. Stabilize metric definitions first, then loosen natural-language freedom.
- Phase 3: proactive insights, such as anomalous spending detection, recurring debit detection, and cash-flow trend notes. Still strictly read-only, and still without product recommendations.
10. Common Pitfalls
- Letting the model do arithmetic — numeric deviations are not reproducible and grow with data volume.
- Defining the same metric in multiple places — one “monthly spending” number diverges across phrasings, and trust collapses instantly.
- Not cleaning merchant names — categorization drifts with spelling, making insight quality impossible to assess.
- Overly broad authorization scope — requesting write permission “in case we need it later” amounts to abandoning defense in depth.
- Ignoring settlement delay and timezones — the majority of “balance does not match transactions” issues originate here.
- Delegating identity filtering to the model — it must be guaranteed by both server-side injection and database row-level security.
- Out-of-bounds output — once the system starts making product suggestions, neutrality and the compliance boundary fail at the same time.
11. Closing
The technical substance of this kind of system does not lie in “being able to chat,” but in confining uncertainty to a controllable range: on permissions, achieve an absence of capability rather than a configuration restriction; on data, achieve a single implementation per definition; on generation, separate aggregation from expression; on operations, make the whole chain auditable and measurable. Hold those four lines, and natural-language financial Q&A can move from a demo to something usable over the long term.
