ProjectBuild an AI-Powered Digital Bank with RAG: the explanation · Module 1: The problem a bank assistant solves · part 3 of 20
Part 3 · Module 1: The problem a bank assistant solves

Module 1: The problem a bank assistant solves

20 min read·
About 30 minutes · Needs: nothing, this is where the project starts · Code: backend/scripts/plain_vs_grounded.py, backend/app/core/rbac.py, backend/evals/datasets/golden.jsonl

Where this fits

WHERE THIS FITS The Kestrel Bank system this module CLIENT API LAYER FastAPI SAFETY AGENT LangGraph MODELS RETRIEVAL DATA AND INGESTION Postgres, Redis QUALITY Langfuse Customer app React Admin app React FastAPI REST + streaming Auth + RBAC tokens, roles Document upload admin only, checked Guardrails injection, PII Router small or large Agent LangGraph Banking tools ledger, transfers LLMs Groq, Gemini Retrieval hybrid + rerank Ingestion parse, chunk, embed PostgreSQL + pgvector ledger, chunks, state Redis limits, queue Observability Langfuse, metrics, logs Evaluation golden set, release gate
The whole system at a glance (blue: the parts this module motivates). Every later module opens one of these boxes.

This module is about the whole system before any of it is built: who uses it, what they ask, and what must never go wrong. Every highlighted part on the map exists because of a requirement you will meet here. The later modules each take one part and open it up.

What it does

THE PEOPLE Two roles, one assistant Customer asks, approves Assistant answers, prepares Admin uploads documents question answer Account data from the ledger Bank documents the bank's own text uploads
Two roles: customers ask and approve, admins supply the documents (green), and the assistant (amber) answers from those and the customer's account data.

Kestrel Bank is a fictional bank with two kinds of users. Customers sign in, see their accounts and ask questions. Admins are bank staff who upload the bank's documents, such as the fee schedule and the card terms, and watch the quality of the answers. The assistant sits between them: it answers customers from the documents the admin uploaded and from the customer's own account data.

That sounds simple, and the hard part is in one sentence from the test log. Priya Sharma, a customer, asks: "Will I be charged the monthly maintenance fee on my checking account this month?" This module takes that question apart and turns it into the list of things the system must do.

At a glance

This module owns four production practices. Each is explained in the sections below; the table is the short version.

PracticeWhat it isWhy you need itHow this project does itWhen you need itWhere it lives
Grounding answers in the bank's documentsThe model answers only from passages we fetch and hand it.A model's memory holds other banks' rules, never this bank's.A search tool fetches passages, the prompt demands a citation per rule, and the output guard drops invented ones.Whenever an answer states your organisation's own facts; not for small talk.agent/tools.py:search_bank_policies, agent/prompts.py, agent/guardrails.py
Human approval for risky actionsThe system prepares an action and waits for a person's click.A misread or manipulated message must never move money on its own.prepare_transfer only validates; the approval node calls interrupt() and /api/assistant/approve resumes it.For anything hard to undo; read-only lookups need no approval.agent/graph.py:approval, api/routes/assistant.py
Functional and non-functional requirementsWhat the system must do, and how well it must do it.Without both lists, nobody can say when the system is good enough to ship.Each failure becomes a "must do"; speed, cost, privacy and tracing become measured targets.Before building; every later design choice traces back to one.this module; measured by evals/run.py, listed in SECURITY.md
Synthetic test dataInvented customers, transactions and documents that look real.Real customer data must never sit on a laptop, in a test or in a course.bootstrap creates 26 customers from 8 profiles with 60 days of activity each.Always in development and demos; production loads real accounts instead.services/seed.py, cli/__main__.py, data/synthetic/customers.md

Use cases and examples

Grounding answers in the bank's documents

  • Priya asks about the monthly fee; the assistant quotes Kestrel's $500 direct deposit rule with a citation. Ungrounded, it invented a debit card rule Kestrel never had.
  • An insurer's assistant tells a policyholder whether water damage is covered by quoting clause 4.2 of her own policy. From memory, it would describe a typical policy and promise a payout the insurer never offered.
  • A school's help desk bot answers "When does the spring term start?" from the published calendar. Without it, the bot guesses a date from other schools and parents book holidays wrongly.
  • A SaaS company's support bot explains its refund window from the current terms page. If the terms change from 30 to 14 days, only a grounded bot follows the change.

Human approval for risky actions

  • Priya asks to move $250 from savings to checking; she sees a card with both accounts and the amount, and nothing moves until she clicks Approve.
  • An online shop's assistant drafts a $180 refund, but a support agent must confirm it. Without that step, one cleverly worded chat message empties the refund budget.
  • A hospital system suggests a dose change from lab results, and a doctor signs it off. A wrong suggestion stays a suggestion.
  • When not to bother: showing Priya her balance changes nothing, so asking her to approve it would only add a click.

Functional and non-functional requirements

  • Functional, in Kestrel: "pause before moving money". Non-functional: "first words within seconds" and "no personal data in logs". Both must hold before release.
  • An online shop's checkout must take payment (functional) and finish within two seconds even on a sale day (non-functional). A slow but correct checkout still loses orders.
  • A government benefits portal must calculate the right amount (functional) and be usable with a screen reader (non-functional). Missing the second shuts some citizens out.

Synthetic test data

  • Kestrel's student profile, customer02@kestrel.local, really pays the $12 fee, so the fee questions can be tested without anyone's real statement.
  • A hospital builds its appointment system with invented patients. Copying real records to a test server would expose diagnoses if that server leaked.
  • An insurer tests a claims bot with invented claims covering theft, flood and fire. Real claims would carry names and addresses into every developer's laptop.

How it works

One question, three needs

ANATOMY OF A QUESTION One question, three needs "Will I be charged the maintenance fee this month?" Her account balance, deposits 1 The bank's rule fee schedule 2 Reasoning does data meet rule? 3
One question, three needs: Priya's account data (blue, the system), the bank's written rule (green) and the reasoning that joins them (amber, the model's job).

Priya's question hides three separate needs. First, her account data: her balance and whether her salary arrived. That lives in the bank's ledger and changes every day. Second, the bank's rule: when the fee is charged and when it is waived. That lives in a document the admin uploaded. Third, the reasoning that joins them. None of the three is enough on its own.

Three kinds of questions

WHAT CUSTOMERS ASK Three kinds of question Account "How much did I spend on dining last month?" NEEDS her data Policy "What does an international wire cost?" NEEDS bank documents Action "Move $250 from savings to checking" NEEDS approval
Customers ask about their own accounts, about the bank's rules, or for an action. Priya's question mixes the first two.

Most questions fall into three kinds. Account questions ask about the customer's own data. Policy questions ask about the bank's rules. Action requests ask the bank to do something, such as move money. Priya's is a mixed question, which is common and the hardest to get right. Anything else, like "What is the capital of France?", is out of scope and gets a polite no.

What a plain model does with it

THE KNOWLEDGE GAP What a general model has seen What the model read public web text up to its training date Kestrel fee schedule private document Priya's ledger changes every day never seen by the model
A general model (amber) has read public text. Kestrel's fee schedule and Priya's ledger (grey) are private and change, so the model has never seen them.

The quickest prototype sends Priya's question straight to a language model. A general model has read a lot of public text, but never Kestrel's private fee schedule, and it cannot see Priya's ledger. It still writes a fluent answer from what banks usually do.

EXPERIMENT 1 The model on its own "Will I be charged the fee this month?" Model alone no documents "Typically waived if you use your debit card..." not a Kestrel rule
Asked alone, the model answered with what banks typically do (red: wrong for this bank), including a debit card rule Kestrel does not have.

We ran exactly that on 18 October 2026 (Gemini 3.5 Flash-Lite, the script is in the Code tour). The model said it could not see her account, then listed "usually" waived conditions, including using a Kestrel debit card a number of times. Kestrel has no such rule. The answer is polite, confident and partly invented. The model is not lying; it is answering from the wrong source.

What the same model does with the right material

EXPERIMENT 2 The same model, given the facts Fee schedule text Priya's numbers Her question Same model "$6,500 meets the $500 rule: waived" traceable to sources
Given the bank's own text and Priya's numbers, the same model answers correctly (green), and every fact can be traced to its source.

Now give the same model two things: the paragraph from Kestrel's fee schedule and Priya's numbers. It answered that $6,500 of direct deposits meets the $500 requirement, so she will not be charged. Every fact in that answer can be traced to something we handed over. Supplying the right material and telling the model to use only that is called grounding.

THE CHECK Deposits against the $500 bar Direct deposits this month $6,500.00 $500 needed 13x the bar $12 fee waived no model needed for this check
Priya's $6,500.00 of deposits is far past the $500 bar (red dashed line), so the $12 fee is waived. This check needs no model at all.

The decision itself is arithmetic. Priya received $6,500.00 in direct deposits, far past the $500 bar, so the $12 fee is waived. The whole system is an engine for doing this reliably: fetch the right rule, fetch the right data, and show where each came from.

Why mistakes here are expensive

BY THE NUMBERS Bank chatbots are already everywhere 37% of the US population 98M+ people used a bank chatbot in 2022 10 of 10 largest US banks run one
About 37% of the US population, over 98 million people, used a bank's chatbot in 2022, so even rare mistakes reach many customers.

Scale is the first reason. In 2022 about 37% of the US population, over 98 million people, used a bank's chatbot, and all ten largest US banks run one. A mistake in one conversation in a thousand still reaches tens of thousands of people. And where information is legally required to be accurate, being wrong may break the law.

CASE STUDY Moffatt v. Air Canada Chatbot claim within 90 days Policy page not after travel Tribunal, 2024 airline responsible contradicts
The chatbot (amber) promised what the airline's policy page denied, and the tribunal held the airline responsible for both (red).

Ownership is the second. In Moffatt v. Air Canada (2024), the airline's chatbot promised a refund that its own policy page denied. Air Canada argued it was not responsible for what its chatbot said; the tribunal called that a "remarkable submission" and held the airline responsible. Whatever Kestrel's assistant says about a fee, Kestrel has said.

Five ways it can go wrong

WHAT CAN GO WRONG Five ways an assistant does harm FAILURE EXAMPLE COST Wrong rules a fee Kestrel never set refund, complaint Wrong or leaked data someone else's balance privacy breach Unapproved action money moved without a click loss, dispute Manipulation "ignore your rules" any of the above Guessing an invented mortgage rate the Air Canada case
Five ways a bank assistant causes harm (red: danger). Each one becomes a requirement.

From these cases come five failures the design must prevent. Wrong rules: a fee or limit the bank never set. Wrong or leaked data: the wrong balance, or someone else's. Unapproved action: money moved without a clear yes. Manipulation: a message written to make the assistant break its rules. Guessing: answering when the documents are silent.

WHO CHOOSES THE ACCOUNT? The same message, two designs Unsafe "balance of acct 4471" tool(account_id=4471) id chosen by the model someone else's data Safe "balance of acct 4471" tool(user from session) id from the sign-in only Priya's accounts
If the model chooses whose account to read, a message can steer it (red). If the user comes from the signed-in session, it cannot (green).

Leaked data is easy to build in by accident. If a balance tool takes an account number that the model fills in, a customer can type someone else's number and the model will pass it along. The safe design takes the customer from the signed-in session, so the model never chooses whose data is read.

MOVING MONEY Four steps, one human click "Move $250" the request Model prepares Pause shows details Click then execute money moves only after step 4
The model (amber) may only prepare a transfer. The system (blue) pauses and shows the details, and only the customer's click executes it (green).

Money movement gets the strictest rule. The model may prepare a transfer, but only the customer's click executes it. This pause is called human in the loop, and it is enforced by the code, not requested in the prompt.

Requirements

FROM RISK TO REQUIREMENT Every risk becomes something we build RISK REQUIREMENT Wrong rules cite bank documents Wrong data tools scoped to user Unapproved action approval before transfer Manipulation guard input and output Silent documents refuse, offer a human
Each risk (red) turns into one functional requirement (green) that we can build and test.

Each failure turns into one functional requirement: answer rules only from the bank's documents with citations, read data only through tools scoped to the signed-in customer, pause for approval before moving money, guard inputs and outputs, and say "I could not find that" instead of guessing.

NON-FUNCTIONAL TARGETS Correct is not enough to ship Speed seconds measured: 3.2 s median turn Cost < 1 cent measured: $0.00025 to $0.0005 Privacy 0 PII in logs or traces, masked Traceability every step recorded, one trace per answer Testability auto-checked golden set, CI gate
The non-functional requirements decide whether the system can ship, even when every answer is correct.

The non-functional requirements decide whether it can ship at all: answers that start appearing within seconds, a cost of a fraction of a cent per answer, no personal data in logs or traces, a step-by-step trace for every answer, and quality tests that run automatically. Two of these were measured on 18 October 2026: a median turn of 3.2 s in one routed run, and $0.00025 to $0.00028 per small-model turn, about $0.0005 per large-model turn.

Code tour

CODE TOUR The files behind this module backend/ plain_vs_grounded.py the experiment core/rbac.py two roles golden.jsonl 43 test questions
The three files behind this module: the experiment (amber, calls a model), the roles (blue) and the test questions (green).

Three files turn this module's ideas into code: a script that reproduces the plain-versus-grounded experiment, the role definitions for the two kinds of users, and the test questions that measure the requirements.

New concept: Python modules and imports Every .py file is a module, and a folder of modules is a package. The file backend/app/agent/llm.py is the module app.agent.llm, and the script below borrows one function from it with from app.agent.llm import chat_model. Importing runs the other file once and hands you the names you asked for, so a large program can live in many small files. That is also why the script is started with python -m scripts.plain_vs_grounded: the -m flag runs it as a module, so imports like app.agent.llm resolve from the backend folder.
New concept: dicts, sets and frozensets A dict (dictionary) stores pairs of a key and a value, like a phone book: ACCOUNT["checking_balance"] looks up the key and gives back 9238.12. A set is a bag of unique items with no order, built for one fast question: "is this item in it?". A frozenset is a set that can never change after it is made. You will meet sets in the second file below, where they hold permissions.
python
# backend/scripts/plain_vs_grounded.py
QUESTION = "Will I be charged the monthly maintenance fee on my Kestrel Bank checking account this month?"
POLICY = ("Everyday Checking has a monthly maintenance fee of $12. The fee is waived for any statement cycle in which "
          "you keep a minimum daily balance of $1,500, or receive at least $500 in qualifying direct deposits.")
ACCOUNT = {"checking_balance": 9238.12, "direct_deposits_this_month": 6500.00}  # Priya, 18 October 2026


def main() -> None:
    llm = chat_model(tier="small", temperature=0)
    plain = llm.invoke(QUESTION)
    print("PLAIN MODEL\n", plain.text[:600], "\n")

    grounded = llm.invoke(f"""Answer the customer using ONLY the policy and account data below.
If they do not contain the answer, say you do not know.

Policy (Kestrel Bank fee schedule): {POLICY}
Account data: {ACCOUNT}
Question: {QUESTION}""")
    print("GROUNDED\n", grounded.text[:600], "\n")

    waived = ACCOUNT["checking_balance"] >= 1500 or ACCOUNT["direct_deposits_this_month"] >= 500
    print("RULE CHECK (no model): fee waived =", waived)

Code walkthrough

  • QUESTION is the exact sentence Priya asked, kept as a plain string. Names in capitals are a Python habit for values that never change while the program runs.
  • POLICY is copied word for word from data/policies/fee-schedule.md, one of the bank's documents. Two strings side by side inside brackets are joined into one; the brackets only let the long line wrap.
  • ACCOUNT is a dict holding Priya's real demo numbers on 18 October 2026: her checking balance and the direct deposits she received that month. Plain decimal numbers are fine here because the script only compares them; the app itself stores money differently (Module 5).
  • def main() -> None: defines the function that does the work. -> None is a type hint saying it returns nothing (see the box before the next file).
  • chat_model(tier="small", temperature=0) builds the small model from settings, so the same script runs on Groq or Gemini. temperature=0 asks for the least random wording, so repeated runs stay as alike as the provider allows.
  • llm.invoke(QUESTION) sends only the question and waits for the reply. The reply is a message object, and .text is its words.
  • print("PLAIN MODEL\n", ...) prints a heading and then plain.text[:600], the first 600 characters at most. \n starts a new line and [:600] is a slice, a cut of the first 600 characters.
  • grounded = llm.invoke(f"""...""") is the second call. The f in front makes an f-string: each {NAME} inside is replaced by that value. The triple quotes let the text run over several lines.
  • The prompt has four parts: an instruction to use ONLY what follows and to say "I do not know" otherwise, the policy, the account data and the question. This is grounding by hand.
  • {ACCOUNT} turns the dict into text such as {'checking_balance': 9238.12, ...}, so the model sees the field names as well as the numbers.
  • waived = ... >= 1500 or ... >= 500 checks the rule with plain Python, no model. >= means "at least", and or gives True if either side is true. Using today's balance is only a proxy, because the rule says minimum daily balance; the deposits rule is exact and already decides the answer.
  • The last print shows that result, so you can compare each model answer with the arithmetic.
New concept: Enum An Enum (enumeration) is a fixed list of named choices, like the options on a form's drop-down. Writing Role.CUSTOMER instead of the bare string "customer" means a typo such as Role.CUSTOMR fails at once instead of silently matching nothing. A StrEnum is an Enum whose members are also strings, so Role.CUSTOMER == "customer" is true and the value can be saved in a database or a login token as plain text.
New concept: type hints A type hint is a note after a name that says what kind of value belongs there: role: str means "a string", and -> bool means "this function returns True or False". Python does not enforce hints when the program runs, but editors and checkers use them to catch mistakes before it runs, and they tell a reader what to expect. Hints can nest: dict[Role, frozenset[Perm]] reads "a dict whose keys are roles and whose values are frozensets of permissions".
python
# backend/app/core/rbac.py
class Role(StrEnum):
    CUSTOMER = "customer"
    ADMIN = "admin"
# ...
ROLE_PERMS: dict[Role, frozenset[Perm]] = {
    Role.CUSTOMER: frozenset({Perm.BANKING_OWN, Perm.ASSISTANT_USE}),
    Role.ADMIN: frozenset(Perm) - {Perm.BANKING_OWN},
}

Code walkthrough

  • class Role(StrEnum): declares the roles. CUSTOMER and ADMIN are the only two, matching the two kinds of users.
  • # ... hides a second StrEnum, Perm, with ten permissions written as area:action, such as banking:own, assistant:use and documents:write.
  • ROLE_PERMS: dict[Role, frozenset[Perm]] = { starts the one table that answers "what may this role do?". The type hint says each role maps to a frozenset of permissions.
  • Role.CUSTOMER: frozenset({...}): a customer can use their own banking data and the assistant, nothing else.
  • Role.ADMIN: frozenset(Perm) - {Perm.BANKING_OWN}: frozenset(Perm) collects all ten permissions from the enum, and - removes one, leaving nine. Admins manage documents and quality, and have no bank accounts in this app.
  • The sets are frozen so no code elsewhere can add a permission to a role by accident while the server runs.
  • Below the excerpt, permissions(role) looks a role up in this table and returns an empty set for any unknown role (fail closed: when in doubt, allow nothing), and has(role, perm) answers yes or no.
  • The code checks permissions, never role names, so a third role later is one new entry here. Module 6 explains this in full.
New concept: JSON Lines files JSON is a text format for data: {"key": "value"} for a dict, [...] for a list. A JSON Lines file, ending in .jsonl, holds one complete JSON object per line. Each line can be read, added or changed on its own, so a test set can grow one question at a time and a script can read it line by line with json.loads(line).
json
{"id": "mix-01", "category": "mixed", "question": "Will I be charged the monthly maintenance fee on my checking account this month?", "expected_sources": ["fee-schedule::Monthly maintenance fee"], "must_include": ["$12", "1,500"], "expected_tools": ["search_bank_policies", "get_accounts"], "expected_tier": "large"}

Code walkthrough

  • This is Priya's question as a test in evals/datasets/golden.jsonl, the golden set of 43 questions with known right answers, one per line.
  • "id": "mix-01" is a stable name, so a report can say exactly which question failed.
  • "category": "mixed" is the kind of question from How it works: her data plus a rule.
  • "question" is the text sent to the assistant, as a customer would type it.
  • expected_sources names the passage that must be cited, written document::section: the "Monthly maintenance fee" section of the fee schedule.
  • must_include lists facts the answer text must contain, here the fee $12 and the 1,500 balance bar.
  • expected_tools lists the data that must be fetched: search_bank_policies for the rule and get_accounts for her balances. These are the first two of the three needs from How it works.
  • expected_tier says it needs the large model, because it mixes data with a rule (Module 11).
  • The real line has one more field, reference, a model answer written by a person, which a grading model compares against later (Module 14).
THE GOLDEN SET Questions with known answers 43 questions policy 24 account 5 mixed 7 should refuse 3 attack 2 transfer 2
The golden set: 43 questions with known answers, one square each. Every change to the system is measured against it.

The golden set covers every kind of question, so each requirement above becomes a number that a script measures on every change (Module 14).

What happens when you run the plain-versus-grounded script

  1. You type uv run python -m scripts.plain_vs_grounded in source-code/backend. Python loads the module, which imports chat_model from app.agent.llm and creates QUESTION, POLICY and ACCOUNT. The last lines of the file call main().
  2. chat_model(tier="small", temperature=0) reads the settings. With LLM_PROVIDER=groq from .env.example, it returns a Groq chat model for openai/gpt-oss-20b; with gemini it returns gemini-3.5-flash-lite.
  3. llm.invoke(QUESTION) sends the bare question. A message object comes back, and its first 600 characters are printed under PLAIN MODEL. The wording differs every run, but it can only come from the model's general knowledge.
  4. The f-string builds the grounded prompt. Its account line reads Account data: {'checking_balance': 9238.12, 'direct_deposits_this_month': 6500.0} (Python prints 6500.00 as 6500.0).
  5. The second llm.invoke sends that prompt, and the reply is printed under GROUNDED. You should see the $500 direct deposit rule named and the fee called waived.
  6. waived is worked out: 9238.12 >= 1500 is already True, so Python skips the deposits check (which would also be True). The script prints RULE CHECK (no model): fee waived = True.

What happens when the code asks what an admin may do

  1. When any part of the API first imports app.core.rbac, Python builds Role, Perm and then ROLE_PERMS, once.
  2. frozenset(Perm) holds all ten permissions; removing banking:own leaves nine for Role.ADMIN.
  3. A request from admin@kestrel.local carries the role string "admin". permissions("admin") turns it into Role.ADMIN and returns those nine permissions.
  4. has("admin", Perm.BANKING_OWN) checks whether banking:own is in that frozenset. It is not, so the answer is False and the banking pages refuse the admin.
  5. A role string nobody defined, such as "auditor", makes Role("auditor") raise ValueError. permissions catches it and returns an empty frozenset, so every check answers False.

Design decisions

DecisionChosenAlternativesWhy
Who uses the systemtwo roles: customer and adminone role; four staff rolesmatches how a bank bot is actually used; permissions keep it extensible
What the admin uploadsbank documents onlydocuments and customer dataaccount data comes from the core banking system, as in a real bank
Source of truth for rulesthe uploaded documents, with citationsthe model's general knowledgethe bank owns every answer, so every rule must be traceable
Unknown answerssay so and offer a bankerbest guessa confident guess is how the Air Canada case happened
Moving moneyprepare, then wait for a clicklet the model executean instruction in a prompt can be talked around; a pause in code cannot
Data in the demosynthetic: 26 customers in 8 profiles, 19 documentsreal or scraped datano real personal data ever enters the project
SCOPE What version one does, and what waits Version one does answer from bank documents read her own accounts move money between her own accounts, approved Not yet pay other people investment or tax advice open accounts answer from general knowledge
Version one (green) keeps to what can be grounded, scoped and approved; the rest (grey) waits for later releases.

Version one keeps to what can be grounded, scoped and approved. Paying other people, investment advice and opening accounts all wait for later releases.

The synthetic customers are generated, not typed by hand. Each new customer gets checking, savings and a card, and create_demo_accounts writes 60 days of activity from the customer's profile, using a random generator seeded with the user id, so a given customer record always gets the same history. A student gets small pay and the maintenance fee; an overdraft-prone customer gets overdraft fees. Dates are relative to the day of seeding, and each install creates new user ids, which is why your balances differ from the ones quoted here.

When to choose differently

These first decisions fit a demo bank with two kinds of users and synthetic data; real banks hit other situations early.

If ...Switch to ...WhyIn this project
bank staff need different rights (support agents, auditors)more roles in the permission table, mapped from OIDC/SSO groupsstaff sign in with the bank's own identity system, and each team sees only what it needsNot built
real customers sign upno seeded demo customers; accounts loaded from the core banking systemdemo customers share a published passwordSetting: SEED_DEMO_DATA=false
every answer must use the strongest model, whatever the costone large model for every turnremoves the risk of a simple-looking question reaching the small modelSetting: ROUTING_ENABLED=false
customers ask in other languagesa multilingual embedding model such as bge-m3, and a golden set in each languagethe current models and guards are English-onlyNot built
customers want to pay other people or open accountsthe same prepare-then-approve pause, plus a second sign-in check per paymentpaying a stranger is far easier to abuse than moving money between your own accountsNot built
the golden set stops matching what customers really askadd masked real conversations to the golden set43 hand-written questions miss real wordingNot built

What can go wrong

THE DEFENCES Where each failure gets fixed FAILURE DEFENCE MODULE Wrong rules retrieve, cite, refuse when nothing found 9 12 Leaked data tools scoped to the session, RBAC 6 10 Unapproved action approval pause in the graph 10 Manipulation input and output guardrails 12 Guessing relevance threshold, number check 9 12
Every failure has a defence (green) built in a later module and proven by a test.

Each failure from the How it works section has a matching defence, built in a later module and proven by a test. Wrong rules are stopped by retrieval that refuses when nothing is found (Module 9) and by the output guardrail that checks citations (Module 12). Leaked data is stopped by tools scoped to the session and by RBAC (Modules 6 and 10). Unapproved transfers are stopped by the approval pause (Module 10). Manipulation is stopped by input and output guardrails (Module 12). The test names are listed in source-code/SECURITY.md.

Try it yourself

bash
cd source-code/backend
uv run python -m scripts.plain_vs_grounded

What you should see

Two answers. The plain one says it cannot see your account and lists general bank rules; check whether any of them appear in data/policies/fee-schedule.md. The grounded one names the $500 direct deposit rule and says the fee is waived. The last line prints fee waived = True. Model wording varies between runs and providers.

bash
uv run python -m evals.run --suite routing --rules-only

What you should see

A table with routing_accuracy, no model calls, in under two seconds. It classifies every golden question into the kinds you met above.

Then start the app (docker compose up --build from source-code/), sign in as customer02@kestrel.local with password DemoPass-2026, a student who really pays the fee, and ask "Why am I paying a monthly fee on my checking, and how can I stop it?".

Check yourself

  1. Priya asks "Why was I charged $3 a few weeks ago?". Which kind of question is it, and what two sources does the answer need?

Mixed. Her transactions (to find the $3 out-of-network ATM fee) and the fee schedule (to explain why that fee exists).

  1. Why is "be careful with money" in the prompt not enough to stop unapproved transfers?

A prompt is a request the model can be talked out of. The approval pause is enforced by code: nothing moves until the customer clicks.

  1. A balance tool takes an account number filled in by the model. What is the risk, and the fix?

A message can steer the model to pass someone else's number. Take the customer from the signed-in session so the model never chooses whose data is read.

  1. Why can the admin not upload customer transactions in this design?

Account data comes from the core banking system. The admin manages the bank's documents, which are what the assistant answers rules from.

Key terms

TermMeaning
Customera signed-in bank user who sees their own accounts and asks questions
Adminbank staff who upload documents and watch answer quality
Account questionasks about the customer's own data, answered through a tool
Policy questionasks about the bank's rules, answered from the bank's documents
Groundingmaking the model answer from material you supply, not from memory
Human in the loopthe system pauses for a person to approve before acting
Functional requirementsomething the system must do
Non-functional requirementa quality the system must have, such as speed, cost or privacy
Golden seta fixed list of questions with known right answers, used to test the system