Applied AI case study – Architecture

How a question becomes an answer

Every request goes through the same steps. The model decides what to try. Code decides what runs. A person decides what happens. The model cannot run a query, send a message or file a dispute. Only code and a person can, and the trace records every step.

The owner asks a questionSigned in as one restaurant.MODELCODEthe model cannot cross this lineThe model picks a toolOne or more of the four below, in any order.tool: run_sqlWrites one SELECT.GuardEight checks. Blocks writes. Adds therestaurant filter to every table.DatabaseRead only. Slow queries time out.rows, or the errortool: search_policyAsks for the rules on a topic.RetrieveThis restaurant's sections only, and onlythose above the score threshold.sections, or nonetool: proposeA dispute, a campaign, a comp, or anote to the owner.Check the proposalDispute windows, discount caps, consent,audience count.pending or blockedtool: declineSays why. Nothing runs.The model answersCites sections it used, like[House Policy §2, Comps].Citation checkEvery cited section must be onethat was retrieved.The owner reads the answerApproves or rejects a proposal. Until then nothing has happened.Record the actionA simulated record, marked as such.The traceEvery step above is recorded: who did it, what it decided, what it cost, how long it took.
The request path. Each box is a step the trace records, in the colour the trace uses. Scroll it sideways to see the code lane.

The model picks a tool

The model gets the question and a description of the seven tables it may read. It has four tools. run_sql writes one SELECT and says what it answers. search_policy asks for the rules on a topic. propose drafts an action: a dispute, a text campaign, a comp or a note to the owner. decline turns the question down and says why. The model can call up to six tools in one request, in any order, or answer in plain text with none. None of the four can write, send or file anything. A question that tells the model to delete or change something, like the injection scenario, has no tool to do it with. tools.py

The guard

The guard takes the SQL apart with sqlglot into the parts the database would see, a parse tree, instead of reading it as text. A string match misses a write hidden behind a comment or odd casing. A parsed query cannot hide one. It runs eight checks: the query parses, it is one statement, it is a SELECT, it contains no write, it calls no function that reads files or reaches the network, it reads only the seven allowed tables, it is scoped to one restaurant, and it fits under a row limit. The scope check does not look for a restaurant filter in the query. It adds one. Every table in the query is replaced by that restaurant's rows, so a join or a subquery cannot reach another restaurant. A blocked query goes back to the model with the name of the check that failed, and the model can try again. If nothing runs, the trace ends as blocked. guard.py

The database

The query runs in a read-only transaction with a five-second statement timeout. If a write got past the guard, the database refuses it. If a query is slow, the database stops it. The model gets at most a hundred rows, and the trace keeps exactly the rows it got, so a wrong figure can be checked against what the model saw. execute.py

Policy retrieval

Each restaurant has three policy documents written as a manual, with numbered sections. Each section is stored with an embedding in Postgres, using pgvector. A search is limited to the asking restaurant before any score is computed, the same way the guard limits a query. Up to four sections come back, and only those scoring above 0.40. Sections under the bar are written to the trace but not shown to the model. When nothing clears the bar, the model is told so. I chose a threshold over a fixed top four because a question the policies do not cover should return nothing, not the nearest miss. The two restaurants' rules differ on purpose, so a section from the wrong restaurant would be a wrong decision, not just a wrong answer. search.py

Proposals and the checker

A proposal is structured: a kind, an order or customer, an amount or a discount, an audience, a draft message. Code checks it against a limits file kept beside each restaurant's policies: dispute windows per platform, the discount cap, the consent rule, the audience cap, the comp limit, the required opt-out wording. For a text campaign the checker counts the audience itself from the consent flags in the data and overwrites whatever the model claimed. A proposal ends pending, pending for the owner, or blocked with the reason. Nothing is sent, filed or comped at this point. proposals.py

The citation check

The model cites the sections it used in brackets, like [House Policy §2, Comps]. After the answer, code checks every citation against the sections that were retrieved. A citation to a section the model was not shown fails the check. So does an answer that used policy and cites nothing. The answer still goes out, and the trace shows the failed check. On the site a citation opens the section it names. loop.py

The approval step

A pending proposal waits on the trace page for a person to approve or reject it. Either choice appends a human step to the trace. Approval then appends a code step that writes a record of the action, labelled as simulated. No marketplace, phone or register is reached. This is the only way anything changes. The model has no step in it. proposals.py

The trace and the cost

Every step is a row: who did it, the model, code or a person, what it decided, how long it took. Each model turn records its tokens, including how many came from the prompt cache, and the trace prices the request from the published rates. The cost per question on the Evaluation page is the sum of those rows. The Scenarios and Evaluation pages are rendered from these rows. trace.py

Decisions

What it does not do

Stack