Module 1: The problem a bank assistant solves
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
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
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.
| Practice | What it is | Why you need it | How this project does it | When you need it | Where it lives |
|---|---|---|---|---|---|
| Grounding answers in the bank's documents | The 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 actions | The 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 requirements | What 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 data | Invented 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
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
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 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.
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
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 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
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.
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
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.
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.
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
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.
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
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.pyfile is a module, and a folder of modules is a package. The filebackend/app/agent/llm.pyis the moduleapp.agent.llm, and the script below borrows one function from it withfrom 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 withpython -m scripts.plain_vs_grounded: the-mflag runs it as a module, so imports likeapp.agent.llmresolve from thebackendfolder.
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 back9238.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.
# 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
QUESTIONis 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.POLICYis copied word for word fromdata/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.ACCOUNTis 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.-> Noneis 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=0asks 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.textis its words.print("PLAIN MODEL\n", ...)prints a heading and thenplain.text[:600], the first 600 characters at most.\nstarts a new line and[:600]is a slice, a cut of the first 600 characters.grounded = llm.invoke(f"""...""")is the second call. Thefin 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 ... >= 500checks the rule with plain Python, no model.>=means "at least", andorgives 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
printshows 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. WritingRole.CUSTOMERinstead of the bare string"customer"means a typo such asRole.CUSTOMRfails at once instead of silently matching nothing. A StrEnum is an Enum whose members are also strings, soRole.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: strmeans "a string", and-> boolmeans "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".
# 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.CUSTOMERandADMINare the only two, matching the two kinds of users.# ...hides a second StrEnum,Perm, with ten permissions written asarea:action, such asbanking:own,assistant:useanddocuments: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), andhas(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 withjson.loads(line).
{"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_sourcesnames the passage that must be cited, writtendocument::section: the "Monthly maintenance fee" section of the fee schedule.must_includelists facts the answer text must contain, here the fee$12and the1,500balance bar.expected_toolslists the data that must be fetched:search_bank_policiesfor the rule andget_accountsfor her balances. These are the first two of the three needs from How it works.expected_tiersays 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 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
- You type
uv run python -m scripts.plain_vs_groundedinsource-code/backend. Python loads the module, which importschat_modelfromapp.agent.llmand createsQUESTION,POLICYandACCOUNT. The last lines of the file callmain(). chat_model(tier="small", temperature=0)reads the settings. WithLLM_PROVIDER=groqfrom.env.example, it returns a Groq chat model foropenai/gpt-oss-20b; withgeminiit returnsgemini-3.5-flash-lite.llm.invoke(QUESTION)sends the bare question. A message object comes back, and its first 600 characters are printed underPLAIN MODEL. The wording differs every run, but it can only come from the model's general knowledge.- The f-string builds the grounded prompt. Its account line reads
Account data: {'checking_balance': 9238.12, 'direct_deposits_this_month': 6500.0}(Python prints6500.00as6500.0). - The second
llm.invokesends that prompt, and the reply is printed underGROUNDED. You should see the $500 direct deposit rule named and the fee called waived. waivedis worked out:9238.12 >= 1500is already True, so Python skips the deposits check (which would also be True). The script printsRULE CHECK (no model): fee waived = True.
What happens when the code asks what an admin may do
- When any part of the API first imports
app.core.rbac, Python buildsRole,Permand thenROLE_PERMS, once. frozenset(Perm)holds all ten permissions; removingbanking:ownleaves nine forRole.ADMIN.- A request from
admin@kestrel.localcarries the role string"admin".permissions("admin")turns it intoRole.ADMINand returns those nine permissions. has("admin", Perm.BANKING_OWN)checks whetherbanking:ownis in that frozenset. It is not, so the answer is False and the banking pages refuse the admin.- A role string nobody defined, such as
"auditor", makesRole("auditor")raiseValueError.permissionscatches it and returns an empty frozenset, so every check answers False.
Design decisions
| Decision | Chosen | Alternatives | Why |
|---|---|---|---|
| Who uses the system | two roles: customer and admin | one role; four staff roles | matches how a bank bot is actually used; permissions keep it extensible |
| What the admin uploads | bank documents only | documents and customer data | account data comes from the core banking system, as in a real bank |
| Source of truth for rules | the uploaded documents, with citations | the model's general knowledge | the bank owns every answer, so every rule must be traceable |
| Unknown answers | say so and offer a banker | best guess | a confident guess is how the Air Canada case happened |
| Moving money | prepare, then wait for a click | let the model execute | an instruction in a prompt can be talked around; a pause in code cannot |
| Data in the demo | synthetic: 26 customers in 8 profiles, 19 documents | real or scraped data | no real personal data ever enters the project |
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 ... | Why | In this project |
|---|---|---|---|
| bank staff need different rights (support agents, auditors) | more roles in the permission table, mapped from OIDC/SSO groups | staff sign in with the bank's own identity system, and each team sees only what it needs | Not built |
| real customers sign up | no seeded demo customers; accounts loaded from the core banking system | demo customers share a published password | Setting: SEED_DEMO_DATA=false |
| every answer must use the strongest model, whatever the cost | one large model for every turn | removes the risk of a simple-looking question reaching the small model | Setting: ROUTING_ENABLED=false |
| customers ask in other languages | a multilingual embedding model such as bge-m3, and a golden set in each language | the current models and guards are English-only | Not built |
| customers want to pay other people or open accounts | the same prepare-then-approve pause, plus a second sign-in check per payment | paying a stranger is far easier to abuse than moving money between your own accounts | Not built |
| the golden set stops matching what customers really ask | add masked real conversations to the golden set | 43 hand-written questions miss real wording | Not built |
What can go wrong
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
cd source-code/backend
uv run python -m scripts.plain_vs_groundedWhat 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.
uv run python -m evals.run --suite routing --rules-onlyWhat 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
- 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).
- 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.
- 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.
- 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
| Term | Meaning |
|---|---|
| Customer | a signed-in bank user who sees their own accounts and asks questions |
| Admin | bank staff who upload documents and watch answer quality |
| Account question | asks about the customer's own data, answered through a tool |
| Policy question | asks about the bank's rules, answered from the bank's documents |
| Grounding | making the model answer from material you supply, not from memory |
| Human in the loop | the system pauses for a person to approve before acting |
| Functional requirement | something the system must do |
| Non-functional requirement | a quality the system must have, such as speed, cost or privacy |
| Golden set | a fixed list of questions with known right answers, used to test the system |