How a query flows¶
What happens, in order, when someone asks "How many VL tests were done last month?" in the chat.
The whole thing takes 5–15 seconds. The UI streams progress as it goes, so the user sees each step tick by instead of staring at a spinner.
The journey, in one picture¶
sequenceDiagram
autonumber
actor User as User
participant UI as Browser
participant API as POST /api/v1/query
participant Wf as Workflow
participant Qd as Qdrant
participant LLM as LLM provider
participant Db as InteLIS MySQL
participant Pg as Postgres (audit)
User->>UI: Types question, presses Enter
UI->>API: { question, sessionId? }
API->>API: Auth check → user + scope
API->>Pg: Save user message; create session if new
API->>Wf: Run the workflow
Wf-->>UI: "parse-question"
Wf->>Wf: Pattern match → table = form_vl, intent = count
Wf-->>UI: "retrieve-context"
Wf->>LLM: Embed the question
Wf->>Qd: Search relevant snippets (×2)
Qd-->>Wf: ~24 column/rule/example snippets
Wf-->>UI: "generate-sql"
Wf->>LLM: Question + context + schema → structured SQL
LLM-->>Wf: { sql, assumptions, citations, confidence }
Wf-->>UI: "validate-access"
Wf->>Wf: Scope check (national user → pass through)
Wf-->>UI: "validate-query"
Wf->>Wf: SELECT-only, allow-list, PII guard
Wf-->>UI: "execute-query"
Wf->>Db: Run SQL (read-only pool)
Db-->>Wf: 1 row: 9,990
Wf-->>UI: "format-response"
Wf->>Wf: Pick chart type (single-row → KPI)
API->>Pg: Save assistant message + audit row
API-->>UI: "done" { auditId, durationMs }
UI->>User: Renders the answer
Below, each numbered band of that diagram, in plain English.
1 — The browser sends the question¶
The user types a question and hits Enter. The browser POSTs to /api/v1/query with:
The response isn't JSON — it's an NDJSON stream. Each line is a small event. The browser reads them as they arrive and updates the UI piece by piece.
2 — The API checks who you are¶
Before anything else, the route handler calls Auth.js to confirm the request is signed in. It pulls your access level (national / province / district) and your allowed geographies. These become the user context that gets passed to every downstream check.
If you don't have a session yet, the API creates one in Postgres and saves your question as the first message.
3 — Parse the question¶
The workflow's first step is pure pattern matching against the question text — no LLM call, no API hop.
flowchart LR
Q["How many VL tests were done last month?"]
Q --> R1{"contains 'count' or 'how many'?"}
Q --> R2{"contains 'vl', 'viral load', or 'hiv'?"}
Q --> R3{"contains 'them', 'those', or 'of those'?"}
R1 -->|yes| I["intent = count"]
R2 -->|yes| T["table = form_vl"]
R3 -->|no| F["references_previous = false"]
For this question:
This is fast and deterministic. It's also the only thing in the pipeline that doesn't call an external service. Why no LLM here? Because routing decisions don't need one — the vocabulary the system handles is small and well-defined.
4 — Retrieve context from the vector DB¶
The system needs to remind itself what form_vl looks like, what "VL" means in this domain, and how rejected samples should be handled.
It embeds the question (turns it into a numeric vector) and runs two parallel searches in Qdrant:
flowchart LR
V["question vector"]
V --> S1["Search 1<br/>(general hints)<br/>synonyms, rules,<br/>thresholds, examples"]
V --> S2["Search 2<br/>(table-specific)<br/>columns + relationships<br/>for form_vl"]
S1 --> M["merge + rank"]
S2 --> M
M --> Pack["~24 snippets<br/>passed to the LLM"]
Two searches, not one, because they have different jobs:
- Search 1 pulls cross-cutting facts — what "VL" means, the rejection-rate convention, exemplar SQL patterns. These should always show up regardless of which table the question hits.
- Search 2 pulls table-specific facts — what columns
form_vlhas, how it joins tofacility_details. These should be filtered to the tables the question is about.
The merged set (capped at 24 items) plus a concise schema listing of the candidate tables is what the LLM will see next.
5 — Generate the SQL¶
This is the only step that costs LLM money on a typical query.
The system sends the LLM:
- A system prompt describing the rules (SELECT-only, no patient identifiers, alias every column, use the listed join paths, etc.).
- A user prompt with: the available tables, the retrieved snippets, the prior conversation (if any), and the question itself.
- A schema for the response (using OpenAI's structured-output mode, Anthropic's tool calling, or plain JSON mode depending on the provider).
The LLM replies with a typed object:
{
"sql": "SELECT COUNT(*) AS \"Total VL Tests\" FROM form_vl ...",
"confidence": 0.9,
"assumptions": [
"Filtered to the previous calendar month by default.",
"Excluded rejected samples by convention."
],
"citations": ["table:form_vl", "rule:default_assumptions.7"],
"clarificationNeeded": null
}
The assumptions array is what the UI shows the user — it builds trust by surfacing the defaults the model applied. If the model can't answer confidently, it returns clarificationNeeded instead of bad SQL and the UI asks a follow-up.
6 — Enforce access scope¶
The user might be a district operator who should only see their districts, or a province operator, or a national administrator who sees everything.
flowchart LR
Sql["Generated SQL"]
Sql --> Lvl{"Access level?"}
Lvl -->|national| Pass["Pass through unchanged"]
Lvl -->|province| Inj1["Inject:<br/>facility_state_id IN (...)"]
Lvl -->|district| Inj2["Inject:<br/>facility_district_id IN (...)"]
Inj1 --> Check{"Safe to inject?"}
Inj2 --> Check
Check -->|yes| Rewritten["Rewritten SQL"]
Check -->|no| Reject["Reject with reason"]
The injection parses the SQL into an abstract syntax tree, finds the facility_details table reference, and adds a WHERE clause restricting it. If the SQL contains a CTE or a subquery the injector doesn't yet handle, the query is rejected rather than silently mis-applied. The user sees a clear reason.
For our example question, the user is national-level, so the SQL passes through unchanged.
7 — Validate the SQL is safe¶
A safety net regardless of who's asking:
| Check | What it catches |
|---|---|
Must start with SELECT |
An LLM that tries DROP TABLE despite the prompt. |
| Every table must be in the allowlist | An LLM hallucinating secret_admin_table. |
| No forbidden patient-identifier columns | patient_first_name, patient_id, phone, email, etc. |
COUNT(DISTINCT patient_id) carve-out |
Anonymous uniqueness counts are allowed. |
No DROP, DELETE, UPDATE, UNION, INFORMATION_SCHEMA, … |
Belt-and-suspenders against escapes. |
If validation fails, the workflow gives the LLM one retry with the error message attached as context. If the retry also fails, the user sees the error stage explained.
8 — Execute against the lab DB¶
The validated SQL runs against the InteLIS MySQL pool. The pool is configured read-only (the operator provisions the user with GRANT SELECT only). A hard LIMIT 10000 is appended if the SQL didn't include one.
The result is just rows + columns + execution time. For our example: one row, one column, value 9990.
9 — Pick a chart type¶
Based on the result shape, the system suggests how to display it:
- One row, one or two columns → big KPI number (our example)
- Time column + numeric column → line or area chart
- One category + one numeric → pie (≤7 categories) or bar (more)
- Two categories + numeric → stacked bar
- Two numerics → scatter
- Anything wider than ~6 columns → table
If none of those fit cleanly, a small/fast model is asked to pick (this is the only place the secondary "fast" model gets used).
10 — Audit and respond¶
Before the response is finalised:
- A row is written to
audit_logwith the question, the generated SQL, the rewritten SQL (if different), the access decision, the row count, the duration, any error, the trace ID, and the LLM model + token usage. - The assistant's response is saved to
chat_messagesso the UI can show this turn on a later visit. - (Optional) LangFuse receives a trace with one span per workflow node and a
generationevent for the SQL-gen LLM call.
The browser receives a final done event with the audit ID and total duration, and renders the answer:
Total VL Tests 9,990
…plus the SQL the model generated, the assumptions it applied, the citations from the corpus, and the stats panel (1 row, 1,125 ms SQL execution, 6.0 s end-to-end).
What the user sees while this happens¶
The whole pipeline emits events as it runs. The chat UI binds each event to a state update:
| Event | What the UI does |
|---|---|
session |
Stores the session ID; sets the trace ID for "show details" later. |
stage |
Advances the progress indicator (one tick per workflow step). |
intent |
Shows the detected intent / table inline. |
rag |
Shows the number of citations gathered. |
sql |
Renders the generated SQL block with the confidence + assumptions tile. |
access |
Surfaces the scope-injection notice if RBAC rewrote the query. |
results |
Renders the table or KPI hero. |
chart |
Renders the chart in the Visualization tile. |
error |
Shows the error card with the stage and message. |
done |
Marks the turn complete; stops the progress animation. |
That's why the response feels "live" — the SQL appears mid-way through the pipeline, the chart appears once execution finishes, all without page reloads or full-screen spinners.
What happens on a follow-up¶
A second question in the same chat — say, "Split that by province" — follows exactly the same flow, with one addition: before sending the question to the LLM, the system attaches the previous turn's question and the SQL the LLM produced for it, after stripping any forbidden columns. That gives the model continuity ("that" = "VL tests last month") without resending raw lab data.
The conversation state is checkpointed in Postgres per session, so you can close the browser and come back later and the model still knows what "that" referred to.
See also¶
- Architecture — system shape and module map.
- Privacy & RBAC — the security and scope-enforcement model.
- LLM providers — choosing a provider, including the trade-offs.