OpenAI SDK: Project: retail analyst assistant
Ask 'which category earned the most from completed orders?' in plain English and get a correct answer, with the numbers, the SQL that produced them, and caveats…
In this post
What this post covers
Ask “which category earned the most from completed orders?” in plain English and get a correct answer, with the numbers, the SQL that produced them, and caveats about data quality. The assistant queries the SQL guide’s retail database through a read-only SQL tool, answers in a structured format, can search products by meaning, and tracks tokens and cost per question. Every piece is tested offline against the real SDK with a fake server.
1. Architecture
2. Run it
from the repository root
cd openai-sdk/project
python demo.py # offline: real SDK, real SQL, a stand-in "model"
python -m pytest tests # 21 offline tests
# with a key (lesson 02):
python cli.py
python demo.py (real output)
Q: Which product category earned the most revenue from completed orders in 2024?
tools used: [('run_sql', 'SELECT p.category,\n ROUND(SUM(oi.q…')]
Electronics earned the most revenue from completed orders: INR 65,639,080.77 across 309 orders, ahead of Furniture (INR 57,179,788.12).
- Electronics revenue (INR): 65,639,080.77
- Furniture revenue (INR): 57,179,788.12
- Stationery revenue (INR): 54,165,571.05
- Appliances revenue (INR): 50,146,578.61
caveats: Lines with zero or negative quantity were excluded.; Discounts with no value were treated as 0%.
tokens: 398 in / 235 out ≈ $0.000362 (fake token counts, real price table)
The SDK calls, the tool loop, the read-only SQL gateway, the query against retail.db, the
numbers, the structured parsing and the cost arithmetic are all real. The only stand-in is the "model":
a few lines in demo.py that request one SQL query, then write the answer from the rows that
query returned. Run cli.py with a key to let a real model choose the queries and wording.
3. The code that matters
Read-only gateway
analyst/db.py (core)
_ALLOWED = {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION,
sqlite3.SQLITE_RECURSIVE}
self._con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) # layer 1: read-only file handle
self._con.set_authorizer(self._authorize) # layer 2: deny non-read operations
def query(self, sql):
text = sql.strip().rstrip(";").strip()
if ";" in text:
raise QueryRejected("only one statement is allowed")
if not text.lower().startswith(("select", "with")):
raise QueryRejected("only SELECT (or WITH ... SELECT) queries are allowed")
deadline = time.monotonic() + MAX_SECONDS # layer 3: time and row limits
self._con.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, 10_000)
...
rows = cur.fetchmany(MAX_ROWS + 1)
Three independent layers, because the model’s SQL is untrusted text (part 16). The tests prove it: a
WITH … DELETE passes the prefix check but is stopped by the authorizer, and the table
still has 800 orders afterwards.
The loop
analyst/assistant.py (core)
response = self.client.responses.parse(model=MODEL, instructions=INSTRUCTIONS, input=question,
tools=TOOL_SCHEMAS, text_format=Answer)
for _ in range(self.max_rounds):
turn.input_tokens += response.usage.input_tokens
turn.output_tokens += response.usage.output_tokens
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
return response.output_parsed, turn # the structured Answer
outputs = [self._run_tool(c, turn) for c in calls] # errors become outputs, not exceptions
response = self.client.responses.parse(model=MODEL, instructions=INSTRUCTIONS,
previous_response_id=response.id, input=outputs,
tools=TOOL_SCHEMAS, text_format=Answer)
raise RuntimeError(f"no answer after {self.max_rounds} tool rounds")
The answer schema
analyst/assistant.py
class KeyNumber(BaseModel):
label: str
value: str
class Answer(BaseModel):
summary: str = Field(description="2-4 sentences answering the question")
key_numbers: list[KeyNumber]
sql: list[str] = Field(description="The SQL queries whose results support the answer")
caveats: list[str]
Returning the SQL makes answers auditable: an analyst can re-run the query. The instructions
require every number to come from a query result, and the caveats field gives the model a
place to mention the dataset’s known problems (duplicate customers, zero quantities, guest orders).
4. Tests
python -m pytest tests (real output)
..................... [100%]
21 passed in 4.65s
| File | Checks |
|---|---|
test_db.py | SELECT works; DELETE, UPDATE, DROP, INSERT, PRAGMA and ATTACH are rejected; multiple statements rejected; WITH … DELETE blocked by the authorizer; row cap; runaway recursive query stopped; readable SQL errors |
test_assistant.py | SQL results really reach the model; instructions and schema sent every round; previous_response_id chaining; rejected SQL and unknown tools returned as errors; parallel calls; round cap; token and cost accumulation |
test_search.py | embeddings requested in batches; ranking order; k clamped |
5. Extend it
- Streaming. Stream the summary to the terminal while the tool loop runs, with
responses.stream(..., text_format=Answer). - Charts. Add a
chartfield (type, x, y) toAnswerand render it with matplotlib. - An eval set. Write 20 questions with known answers (compute them with SQL yourself), and score the assistant with the harness from part 18. Try a smaller and a larger model.
- Row-level security. Pretend each user is a regional manager, and restrict
run_sqlto theirshipping_cityby querying through views, not by prompting. - Agents SDK. Rebuild the loop with
Agentand@function_tool(part 17) and compare the code size. - Batch. Answer 100 canned questions overnight with the Batch API (part 13) and compare the cost.
Recap
- Tools + structured output in one loop: the model gathers data, then answers in your schema.
- The model's SQL is untrusted: a read-only file handle, an authorizer, and time and row limits.
- Errors are tool outputs, so the model can fix its query and continue.
- Auditable answers (the SQL used) and per-question cost make it production-shaped.
Project files
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.