back to portfolio
Healthcare · Data Science·May 2025 → ongoing

A data analyst on demand from natural language to SQL, with analysis in Python

How we built an agent that turns plain-language questions into SQL, runs them on the database and analyzes the results in Python, removing the data analyst bottleneck.

A data analyst on demand · case study cover
30+
Integrated data sources
2M+
Records evaluated
Weeks → seconds
Turnaround time

The agent a manager trusts is not the one that answers fastest. It is the one that understands "how many reports did Dr. So-and-so sign off?" is a multi-source question, not a simple query.

The analyst who was always busy

Every institution that runs on structured data has a familiar figure: the data analyst. They know the database. They know which table holds each piece of information. They can write the JOIN nobody else can. And so every request for data goes through them.

The large public healthcare institution we worked with was no exception. The management team came with questions that sounded simple: "how many reports did Dr. So-and-so sign off this month?", "what's the average for the head and neck group?", "how does MRI output compare with last semester?". Legitimate questions, the kind any manager has the right to answer on their own.

The problem was not a lack of data. The data was in the database. The problem was that there was a human standing in the way, and that human had other priorities, meetings, and a limited number of hours in the day.

The cycle was predictable: the manager sent a request, the analyst put it in the queue, wrote the SQL the following week and returned a spreadsheet, the manager came back with follow-up questions, and the cycle started over. Turnaround was days for simple questions and weeks for complex ones. And the analyst was a bottleneck that grew in direct proportion to management's ambition.

This is the fourth specialist in the system we described in the conversational agent case study. When we said in that post that "the Data agent deserves a story of its own," this is that story.

The data was in the database, and still out of reach

The root of the problem had a technical layer that any developer recognizes but that managers rarely put into words.

The data they needed did not live in a single source. Staff (physicians, technicians, coordinators) were registered in one system. The reports they produced lived in another. Work schedules lived in a third. Exam quality lived in a fourth. To answer "how many reports did Dr. So-and-so sign off this month?", you need at least two systems: one to identify who Dr. So-and-so is (their internal ID) and another to count the reports tied to that ID.

That sounds trivial. It isn't. When you have more than thirty data sources with different schemas, the manager's simple question hides an integration problem that the analyst solves from memory, because they spent years memorizing which table sits in which database, with which key, under which naming convention. The manager has no idea they asked a "hard" question. To them, it's just a question.

That was the core of this project: map every one of those sources, build an agent that could navigate them in a coordinated way, and give the manager the experience of having a senior analyst available over chat.

The data access problem in mature organizations is rarely a lack of data. It's the lack of an integration layer that makes the data queryable by people who don't write SQL.

Why n8n wasn't enough

As with other projects in this series, the starting point was n8n. Workflows that held up for simple cases: a predictable question, a fixed parameterized query, one answer. When the question was always the same, or varied along only a few dimensions, n8n was enough.

The limit showed up once the questions started changing shape. A human analyst adapts the SQL to the question. A fixed workflow can't. For questions with a variable structure (a different period, a different grouping, different entities), we would have had to build a separate workflow for every variant. Maintenance scaled linearly with the variety of questions. And the variety of questions was, by definition, unlimited.

The diagnosis was the same as in the RAG project: this is not a prompt problem. It's an architecture problem. An agent that generates SQL at runtime, shaped to the question rather than to a template, was the right path. n8n became the PoC that showed where the complexity ceiling was, and it drove the move to LangGraph.

The chain that mimics a data scientist

The Data agent is an independent FastAPI microservice inside the multi-agent system. Internally, it runs a LangGraph pipeline with a five-step chain that automates the reasoning a data scientist would do by hand.

The sequence is:

Query Augmenter → SQL Writer → Database Executor → Python Analyzer → Streamer

Each node has a clear responsibility, and the separation between them is not decorative. It defines where errors surface and where the safeguards have to go.

The Query Augmenter is the gatekeeper. It takes the manager's question and judges whether it's specific enough to produce a precise query. A closed question ("how many reports did Dr. So-and-so sign off in May?") goes straight to the SQL Writer. An open one ("how's output looking?") triggers a set of follow-up questions: whose output? Over what period? For which institute or modality? Without that filter, the SQL Writer produces queries that return "something," and "something" is rarely what the manager wanted.

The SQL Writer takes the augmented question and the DDLs of the relevant tables and generates the matching SQL. SELECT only, never UPDATE, INSERT or DELETE. That is a non-negotiable safeguard, enforced both in the prompt and in validation before execution.

The Database Executor runs the query against the real database and captures the result. This is where one of the central loops of the architecture lives.

The Python Analyzer takes the query result and processes it with Python code executed in the VPS shell. Not in a third-party sandbox, not in an external service. Local Python, with access to the libraries installed in the environment. This node exists for a specific reason: a SQL result can run to hundreds or thousands of rows, and sending that volume straight to the LLM for interpretation is wasteful (of tokens, of context window, and of answer quality). The Python Analyzer does the analytical work first: it computes aggregations, spots outliers, derives metrics, and formats the result with interpretive context. The LLM gets an analysis, not a raw table.

The Streamer assembles the final answer and delivers it to the user over SSE, using the same node-by-node streaming pattern described in the conversational agent case study.

The Python Analyzer is not a cost optimization. It's what separates "returning data" from "delivering analysis." A senior analyst doesn't send you a raw spreadsheet. They send you a spreadsheet with the numbers that matter highlighted.

How the agent learns the schema without hallucinating

One of the classic NL-to-SQL problems is schema hallucination: the LLM writes SQL that references columns or tables that don't exist, or uses slightly wrong names that break the query. With thirty data sources and hundreds of tables, that risk is not trivial.

The approach we took, and the one we recommend as the default for any data agent project, is straightforward: go to the relevant tables, export the DDL from the database, and add that DDL to the LLM's context at the moment it generates SQL. Not to the context of every call. Only when it's needed.

The difference lies in how it's organized. We created a separate canonical source: a folder holding the DDLs of every integrated system, each in its own file with context metadata (system name, purpose, integration keys with other systems). The agent doesn't load that full catalog on every call. During query augmentation, it identifies which systems are relevant to the question, and injects only those tables' DDLs into the SQL Writer's context.

# During augmentation, the agent identifies the relevant sources
relevant_sources = augmenter.identify_sources(query)
# E.g.: ["sistema_laudos", "sistema_profissionais"]

# In the SQL Writer, only the DDLs for those sources are injected
ddl_context = "\n\n".join([
    ddl_catalog.load(source) 
    for source in relevant_sources
])

The practical result: the LLM gets the exact schema it needs, without the noise of hundreds of irrelevant tables. The quality of the generated SQL goes up significantly. And token cost per call stays under control, because injecting the DDL of thirty systems into every query would be a proportional waste.

The schema is context. Injecting only the context each question needs is not premature optimization. It's what makes thirty sources possible without blowing up the context window.

The SQL loop: when zero results is not an answer

The SQL Writer generates the query. The Database Executor runs it. And sometimes the result is zero rows.

Zero rows can mean very different things: the data may genuinely not exist, the query may have used a filter that was too narrow, the date format may have differed from what was expected, the JOIN may have used the wrong key. A human analyst would see the zero and try another approach. The agent has to do the same.

The loop works like this: if execution returns zero results, the result (including the generated query) goes back to the LLM with an explicit instruction to try a different approach. The LLM looks at what was tried, reasons about why it came back empty, and writes a new query. The cycle repeats until it gets some result, or until it hits the limit of four iterations.

Four is a deliberate number. It isn't arbitrary. It came out of a production incident worth telling.

The incident that cost us, and what it taught us

A few days after the production deploy, the system went into an infinite loop. A sequence of queries was returning zero results on every iteration, and the agent kept trying, indefinitely. Each iteration, a call to the database. Each iteration, a call to the LLM. API costs spiked unexpectedly within a few hours.

The bug was fixed quickly. But the incident left a lesson that goes beyond this project: any system with a loop needs an explicit, configurable, monitored stop condition. Not as a safety feature, but as part of the design.

The four-iteration cap on the SQL loop is not the only stopping point. The same principle applies to the augmentation node: if the manager answers the follow-up questions unsatisfactorily for more than one round, the agent stops and says it doesn't have enough information to generate a reliable query. The system doesn't keep pushing indefinitely to extract clarity from a question that can't be salvaged.

The term we use internally is "stoppage safety": every loop in the system has an explicit limit, every agent has a point at which it gives up. It's the kind of thing that looks obvious after the incident and invisible before it.

The challenge that took the most time: simple versus complex

The loop incident was dramatic but fixed in hours. The problem that ate the most iterations over the course of weeks was subtler: teaching the LLM to tell a simple question from a complex one.

A simple question has a direct path to the database: one filter, one aggregation, one table. A complex question requires multiple sources, JOINs across systems, transformations, extra context. The agent has to know which treatment each one gets, because the wrong treatment creates a bad experience in opposite ways.

When the agent treated a simple question as complex, it fired off a string of follow-ups ("do you want to include canceled reports?", "which exact period?", "just this institute or all of them?") for a question the manager considered trivial. An irritating experience. The manager felt the system was bureaucratic.

When it treated a complex question as simple, it generated SQL that ignored the nuances and returned a wrong number that looked like a correct answer. Worse still: the manager accepted the wrong result without knowing it.

Calibrating between those two modes was the part that depended most on real examples from the institution. There's no shortcut here: you have to collect real user questions, classify them by hand, and use that dataset to tune the Augmenter's prompt with concrete examples of each class. A generic prompt never calibrates correctly, because every organization's vocabulary and conventions are too specific.

Guardrails: what the agent cannot do

In a system with access to a production database, the list of what the agent cannot do matters as much as what it can.

The most fundamental restriction is operational: the agent only generates SELECT. Every generated query is validated before execution by a simple parser that rejects any statement that isn't a SELECT. The LLM is instructed in the prompt never to generate anything else, but the validation in code doesn't depend on a prompt instruction. They are independent layers.

def validate_sql_safety(query: str) -> bool:
    """
    Validates that the query is SELECT only.
    Does not rely on the prompt alone; this is validation in code.
    """
    normalized = query.strip().upper()
    allowed_prefixes = ("SELECT", "WITH")
    if not any(normalized.startswith(p) for p in allowed_prefixes):
        raise SQLSafetyError(f"Query rejected: not a SELECT. Received: {normalized[:50]}")
    return True

The second layer is investigation depth: the SQL loop is capped at four iterations. The Augmenter has a cap on clarification rounds. The Python Analyzer has a timeout for code execution. Every component that could loop has its own limiter.

The third layer is access scope: the agent can only reach the tables it has configured credentials for. There is no access to systems outside the configured scope, no matter what the manager's question asks for.

How to replicate this architecture

If you're building a data agent for an organization with multiple structured sources, these are the decisions that matter most:

1. Start by mapping the sources, not by writing code. Before you write a single line of agent code, document every data source: system name, purpose, main tables, integration keys with other systems, data volume. That document becomes the foundation of the SQL Writer's context prompt, and it will save you weeks of debugging bad queries.

2. Decide whether a DDL index is enough or whether you need semantic retrieval. For systems with a handful of databases and stable schemas, a DDL directory with per-question dynamic injection works well. For systems with dozens of databases or schemas that change often, consider a separate vector store for semantic retrieval of the DDLs; similarity search can find the relevant tables more efficiently than hand-written rules. The choice depends on how volatile and how large the environment is.

3. Interpret before you present. If the SQL result can run past a few dozen rows, don't hand it straight to the LLM. Use Python (or any language you control) to aggregate, filter and format the result before synthesis. The LLM gets analysis, not raw data. The quality of the answer changes radically.

4. Build the SQL loop with an explicit limit from day one. Don't add the limit after your first loop incident. Decide in the initial architecture how many attempts are reasonable (three to five is a defensible range), implement the counter, and monitor how many queries hit the limit. That's a signal that the schema or the prompt needs adjusting.

5. Calibrate simple versus complex with real examples. The line between a simple and a complex question isn't universal. It's specific to the organization's vocabulary and conventions. Collect 20 to 30 real user questions before the deploy, classify them by hand, and use them as few-shot examples in the Augmenter's prompt.

6. Validate SQL in code, not just in the prompt. The prompt instructs the model. Validation in code guarantees. For security constraints (SELECT only, no access to certain tables), the code-level implementation doesn't depend on how the LLM behaves. The two layers are independent and complementary.

What we would do differently

Running Python in the VPS shell was the most pragmatic decision and the one carrying the most technical debt. The code runs in the same environment as the service, with no isolation sandbox. At current volume and reliability, it worked. In an environment with many concurrent users, or with less control over the questions coming in, the lack of isolation is a real risk. The next iteration will likely move Python execution into a proper sandbox. Not necessarily an external service, but at least an isolated process with limited resources.

Calibrating the Augmenter for simple versus complex questions was the most handcrafted work in the project. Every adjustment was a cycle of testing with real users, gathering feedback, and revising the prompt. There's no escaping that cycle, but it could have been systematized earlier. A suggestion: build an automated evaluation harness before the deploy, with a set of questions labeled simple or complex. That makes every prompt iteration measurable instead of dependent on qualitative impressions.

The business logic document (which describes how the institution works, which systems exist, what the naming conventions are, what the integration keys between systems are) was built incrementally over the course of the project. It should have been the first artifact, before any code. It's the hardest document to keep current and the most critical to the quality of the answers.

The principle that closes the loop

This agent is the fourth specialist in the system we built for the institution. The first was the enterprise RAG, which serves documents. The second and third were the Reports and HR specialists. The fourth, this one, serves operations: structured data, multiple sources, questions no dashboard anticipates.

What ties the four together isn't the technology. It's the conviction that the work starts by observing the process as it is, not as we'd like it to be. In the RAG project, observation revealed that the document titles were already the index. With the data, observation revealed that the real bottleneck wasn't a lack of SQL. It was a lack of integration between sources, and the lack of a layer that did the analytical work that, until then, only humans knew how to do.

The integration and indexing layer over the data sources is the most critical and most underestimated work in any data agent project. Without it, the agent is just fast at writing wrong SQL.

Cutting turnaround from weeks to seconds is not the result of a better model. It's the result of understanding where the time was going, and why, and building the layer that removed the real bottleneck, which was human, not technical.


What's the difference between this data agent and the enterprise RAG agent?

Both agents serve the same institution and belong to the same system, but they serve fundamentally different kinds of knowledge.

The enterprise RAG serves documentary knowledge: SOPs (standard operating procedures), policies, protocols, manuals. The user asks what a document says, when it expires, who owns it. The content lives in PDFs on a Drive. The search runs over unstructured text with semi-structured metadata.

The Data agent serves operational knowledge: reports produced, work schedules, volumes by period, comparisons between groups. The content lives in relational databases with a defined schema. The search runs over structured data that has to be queried with SQL.

The two are complementary. One asks "what does the protocol say?". The other asks "how many exams were performed under that protocol?". Together they cover institutional knowledge in ways neither can on its own.

Why does the agent run Python locally instead of using an external sandbox service?

The decision was pragmatic and has clear tradeoffs. Python running in the VPS's own shell is the simplest option to implement: no dependency on external services, no added network latency, and no third-party API costs.

The cost is the lack of isolation: the Python code runs in the same process and environment as the service. Given the volume and the controlled user base of this project, the risk was judged acceptable. For environments with less controlled users or at larger scale, the lack of a sandbox would be a real technical risk.

The next iteration would consider moving execution into an isolated process with limited resources. Not necessarily an external service, but with proper process isolation. The current decision is conscious technical debt, not an oversight.

How does the agent handle questions that span multiple systems?

This is the heart of the multi-source integration problem. The Data agent maintains a DDL catalog of every integrated system, with metadata on the join keys between them.

During question augmentation, the agent identifies which systems are needed to answer it. For a question like "how many reports did Dr. So-and-so sign off?", the Augmenter determines that two systems are required: the staff system (to map the name to the internal ID) and the reports system (to count the records tied to that ID). The DDLs of both are injected into the SQL Writer's context, which then generates a JOIN across the relevant tables.

The quality of this process depends directly on the quality of the business logic document, which describes the integration keys between systems, the naming conventions, and the cases where integration is most fragile.

What happens when the agent generates a query that returns zero results?

The agent enters a refinement loop with an explicit limit of four iterations. On each iteration, the generated SQL and the zero result go back to the LLM with an instruction to try a different approach: a less restrictive filter, a different date criterion, an alternative JOIN.

If the result is still zero after four iterations, the agent exits the loop and returns a message to the user explaining that it found no data matching the question, along with the queries it tried. That lets the user rephrase the question with more context.

The four-iteration limit came out of a production incident: without it, a sequence of zero-result queries caused an infinite loop that fired an unexpected volume of calls to the database and the LLM. "Stoppage safety" (explicit limiters on every loop in the system) is now a design convention, not a patch.

How does the agent tell a simple question from a complex one?

The Query Augmenter classifies every question before processing it. Simple questions have a direct path: one filter criterion, one aggregation, one or two tables. Complex questions involve multiple systems, compound criteria, or analyses that require transformations on the result.

For simple questions, the Augmenter checks that the essential parameters are present (period, reference entity, grouping criterion) and passes the augmented query straight to the SQL Writer without asking anything further.

For complex or ambiguous questions, the Augmenter sends a set of follow-up questions back to the user before triggering any expensive node. This behavior is calibrated with real examples from the institution, since each organization's vocabulary and conventions determine what counts as "simple" or "complex" in that specific context. A generic prompt never calibrates that threshold correctly.