Skip to content

Repository files navigation

OpenMRS AI Query Agent

Ask an OpenMRS or Bahmni database clinical questions in plain language, and get an answer, the SQL behind it, and an explanation of how it was derived.

"How many deliveries occurred last month?"
"Show maternal deaths by district."
"How many newborns weighed below 2500 grams this quarter?"
"Compare OPD visits between hospitals."

The agent understands the question, resolves clinical terms against the concept dictionary of your installation, retrieves grounding context, generates read-only SQL, validates that SQL at the abstract-syntax-tree level, executes it under hard limits, and explains its reasoning.

Status: working end to end. Point it at an OpenMRS database, ask a question in English, and it resolves the clinical concepts against that installation's dictionary, generates read-only SQL, validates it at the AST level, executes it, and shows you both the answer and the query. Verified against a live OpenMRS 8.0 database with 392,181 observations.

Retrieval over documentation (Phase 3), the MCP server (Phase 4) and authentication (Phase 8) are still to come. See docs/PROGRESS.md for exactly what exists today.

What it does

Ask "How many patients are registered?" and you get:

There are 7,226 patients registered in total.

SELECT COUNT(DISTINCT patient_id) AS total_patients
FROM patient WHERE voided = 0

1 row · 9 ms · ~7,182 rows examined

Ask "What is the average weight recorded?" and it first resolves the concept against your dictionary, then queries:

Concepts resolved against this database: weight = concept 119 (Weight).

SELECT AVG(obs.value_numeric) AS average_weight
FROM obs WHERE concept_id = 119 AND voided = 0

Note value_numeric, chosen because concept 119 has the Numeric datatype, and voided = 0, which is the filter whose absence silently inflates most OpenMRS reports.

Ask "How many deliveries were recorded?" against a dictionary with several matching concepts, and it refuses to guess:

This installation's concept dictionary offers several readings, and picking one silently risks a plausible but wrong number.

For delivery, did you mean:

  • PMTCT-Delivery (concept 6298)
  • PMTCT, Delivery (concept 2364)
  • ANC-Expected delivery date (concept 5970)

That last behaviour is the point of the whole system.

The chat interface

Open http://localhost:8000 after starting the stack.

A conversational interface for the OpenMRS data model: streaming answers, multi-turn memory, Markdown with SQL syntax highlighting, and a conversation sidebar. It is a single self-contained page with no external requests, so it works in the air-gapped hospital deployments this system targets.

The interface is honest about what it can do. A banner reports which capabilities are wired, and the assistant is instructed to refuse rather than invent. Asked "how many deliveries occurred last month?", it answers:

I cannot query the database yet to provide the actual count. However, here is the SQL that would be used:

SELECT COUNT(DISTINCT patient_id) AS delivery_count
FROM obs
WHERE concept_id = :delivery_concept_id
  AND voided = 0
  ...

Replace :delivery_concept_id with the actual ID from your OpenMRS instance's concept and concept_name tables.

Note what it did not do: it did not invent a number, and it did not invent a concept ID. Both are enforced by the system prompt, and both are asserted by tests.


Why this is not just text-to-SQL

OpenMRS stores clinical observations in obs, an entity-attribute-value table. A question about deliveries does not become SELECT ... FROM deliveries; it becomes WHERE obs.concept_id = 1856. That number is different in every installation, because every site curates its own concept dictionary.

So the hard problem is not SQL syntax. It is concept resolution: mapping a clinical phrase to a concrete concept_id in this database. A general-purpose text-to-SQL tool will confidently invent one.

This agent resolves concepts by searching the live dictionary (preferred names, synonyms, and mappings to ICD-11, SNOMED CT, LOINC and CIEL), returns ranked candidates with confidence scores, and asks a clarifying question when the match is ambiguous rather than guessing.

A second, quieter trap: OpenMRS soft-deletes rows with voided and retired flags. Omitting that filter is the most common cause of silently inflated figures in OpenMRS reports, so the planner checks for it on every table it touches.

Safety model

SQL safety is enforced deterministically, not by asking a model to behave. Generated SQL is parsed into an AST with sqlglot and checked against an explicit allowlist before it goes anywhere near the database.

Defence in depth, in order:

Layer Guarantee
AST validator Only SELECT/WITH at root; no DDL or DML anywhere in the tree; single statement; no INTO OUTFILE, LOAD_FILE, SLEEP, BENCHMARK
Database grant The agent's account holds SELECT and nothing else
Execution plan EXPLAIN runs first; queries whose plan exceeds the row-scan budget are refused
Runtime limits Server-side statement timeout, result-row cap, connection pool ceiling
Privacy filter Identifier redaction by role, and small-cell suppression on aggregates
Audit trail Every attempt recorded, including refusals; row counts stored, never rows

Patient data does not leave the site: the default inference stack is Ollama running locally, because a clinical question is itself protected health information.

Architecture

Clean architecture, dependencies pointing inwards:

interfaces/     REST API, MCP server, CLI          <- frameworks live only here
   |
infrastructure/ MySQL, Qdrant, Redis, Ollama       <- adapters implement ports
   |
application/    use cases + port protocols         <- orchestration, no I/O
   |
domain/         models + policies                  <- pure, no dependencies

The domain layer imports nothing but the standard library and Pydantic, which is what lets the SQL safety policy and the privacy rules be tested exhaustively without Docker, a database or a model.

Full detail: docs/04-architecture.md.

Quick start

Prerequisites: Python 3.12+, Docker with Compose v2.

git clone <repository-url> && cd Query-Agent

cp .env.example .env          # then edit: at minimum set OQA_DB__PASSWORD
python -m venv .venv
source .venv/Scripts/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"

oqa config check              # validates your .env and reports any problem

Pull a model so the chat has something to talk to, then start everything:

ollama pull qwen2.5-coder:7b     # SQL and schema questions
docker compose up -d             # Qdrant, Redis, and the API

Then open http://localhost:8000 for the chat interface, or check the API directly:

curl http://localhost:8000/health/ready
curl http://localhost:8000/v1/chat/status
curl -N -X POST http://localhost:8000/v1/chat/stream \
  -H 'Content-Type: application/json' \
  -d '{"message":"How is an observation stored in OpenMRS?"}'

Interactive API documentation is at /docs.

Models. .env defaults to qwen2.5-coder:7b. Any Ollama model works; set OQA_LLM__SQL_MODEL. If the model is not installed, the API says so and lists what is available rather than returning a bare 404.

Ollama in Docker. A container cannot reach the host through localhost, so Compose points the API at host.docker.internal. To use the containerised runtime instead, start with --profile ai and set OQA_DOCKER_LLM_URL=http://ollama:11434.

Connecting to your OpenMRS database

Create a read-only account. The AST validator blocks writes, but the grant is what makes a validator bug non-catastrophic:

CREATE USER 'oqa_readonly'@'%' IDENTIFIED BY '<strong-password>';
GRANT SELECT ON openmrs.* TO 'oqa_readonly'@'%';
FLUSH PRIVILEGES;

Then set OQA_DB__HOST, OQA_DB__NAME, OQA_DB__USER and OQA_DB__PASSWORD in .env. Every setting is documented in .env.example.

No OpenMRS instance handy? docker compose --profile demo up -d starts a reference database to develop against.

Development

make install     # virtualenv + editable install with dev extras
make lint        # ruff check + format --check
make typecheck   # mypy --strict
make test        # pytest, unit tests only
make test-all    # includes integration tests (needs Docker services)
make check       # everything CI runs

Quality gates enforced in CI: ruff clean, mypy --strict clean, tests passing, and the Docker image building.

Configuration

All configuration comes from the environment; nothing is hardcoded and no secret belongs in source control. Variables use the OQA_ prefix with __ for nesting (OQA_DB__HOST maps to settings.db.host).

Production deployments refuse to start when configuration is unsafe: debug mode on, authentication disabled, read-only mode off, result-row logging enabled, or a wildcard CORS origin all abort startup with a specific error rather than running in an exposed state.

Documentation

Vision The problem, the users, and what success means
Functional requirements Numbered, testable requirements
Non-functional requirements Performance, security, privacy budgets
Architecture Components, boundaries, data flow
Evaluation framework Benchmark and continuous-learning platform design
Security guide Threat model and controls
ADRs Architecture decisions and their trade-offs
Progress What is built, phase by phase
Changelog Release history

Delivery plan

Phase Scope Status
1 Requirements, architecture, foundation Complete
2 Database reverse engineering, metadata extraction Not started
3 RAG pipeline: collect, chunk, embed, hybrid search, re-rank Not started
4 MCP server and tool surface Not started
5 SQL generation, validation and optimisation engine Not started
6 AI orchestration (LangGraph state machine) Not started
7 Web frontend Not started
8 Authentication and authorisation Not started
9 Reporting and export Not started
10 Test suite hardening Not started
11 Performance tuning and benchmarking Not started
12 Production deployment Not started

Licence

Mozilla Public License 2.0, matching the OpenMRS core licence.

A note on clinical use

This is an analytical tool. It does not provide clinical decision support, does not write to the medical record, and does not replace validated statutory reporting pipelines. Figures intended for statutory submission should be reviewed by a person who can check the generated SQL, which is why the SQL is always shown alongside the answer.

About

Python service with a production-grade delivery pipeline: strict typing, Ruff, OS matrix CI, layered unit/integration/e2e tests, ADRs and containerised deployment. Natural-language querying over a clinical database.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages