Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/OpenAI SDK

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
  1. 1. Architecture
  2. 2. Run it
  3. 3. The code that matters
  4. 4. Tests
  5. 5. Extend it
  6. Recap

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

question plain English Assistant.ask() responses.parse( tools=TOOL_SCHEMAS, text_format=Answer) loop ≤ 6 rounds · cost per turn describe_tables CREATE statements run_sql read-only gateway search_products embeddings + NumPy retail.db mode=ro + authorizer Structured output + tools in one call: the loop continues while the model asks for tools, and ends with a parsed Answer.

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)
What is real in the demo

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
FileChecks
test_db.pySELECT 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.pySQL 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.pyembeddings requested in batches; ranking order; k clamped

5. Extend it

  1. Streaming. Stream the summary to the terminal while the tool loop runs, with responses.stream(..., text_format=Answer).
  2. Charts. Add a chart field (type, x, y) to Answer and render it with matplotlib.
  3. 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.
  4. Row-level security. Pretend each user is a regional manager, and restrict run_sql to their shipping_city by querying through views, not by prompting.
  5. Agents SDK. Rebuild the loop with Agent and @function_tool (part 17) and compare the code size.
  6. 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.

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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries