Skip to content

Architecture

InteLIS Insights is a single Next.js application. The UI, the HTTP API, and the workflow that turns natural-language questions into SQL all run in one Node process.

For a step-by-step picture of what happens when a user asks a question, see How a query flows.

High-level

flowchart LR
  subgraph App["Next.js 16 app (one Node 22 process)"]
    UI["Web UI<br/>React Server Components<br/>shadcn/ui · Tailwind CSS v4 · Recharts"]
    API["HTTP API<br/>Next.js Route Handlers<br/>app/api/v1/**"]
    Graph["Workflow<br/>LangGraph.js + Vercel AI SDK<br/>lib/graph/**"]
    UI --> API
    API --> Graph
  end

  Qdrant[("Qdrant<br/>vector DB")]
  Postgres[("PostgreSQL + Drizzle ORM<br/>users · sessions · audit")]
  MySQL[("InteLIS MySQL<br/>read-only")]
  LF[("LangFuse<br/>optional traces")]
  Provider[/"LLM provider<br/>(OpenAI · Anthropic · Google ·<br/>Mistral · DeepSeek · Groq · Ollama · …)"/]

  Graph --> Qdrant
  Graph --> Postgres
  Graph --> MySQL
  Graph -.-> LF
  Graph --> Provider

Stack

Every load-bearing component is an industry-standard, permissively-licensed, FOSS project with an active maintainer community. No proprietary runtime, no SaaS dependency, no obscure framework that ties the codebase to one vendor or one contributor. A typical full-stack TypeScript engineer can be productive on day one.

Concern Choice Why this, not the alternative
App framework Next.js 16 (App Router) Most widely deployed React framework. Huge talent pool. Vercel-led but fully self-hostable.
Workflow engine LangGraph.js The de facto graph runtime for agentic LLM apps; checkpointing, retries, and observability built in.
LLM provider layer Vercel AI SDK One API across every major provider — swap OpenAI / Anthropic / Google / local without code changes.
Vector DB Qdrant Production-grade, Apache-2.0, runs as a single container. Self-hosted; no per-vector pricing.
Auth Auth.js v5 Standard for Next.js auth; supports email/password today, SSO/SAML when ministries need it.
App database PostgreSQL + Drizzle ORM The default boring choice. Every operator already knows how to run, back up, and monitor it.
UI shadcn/ui + Tailwind CSS v4 Copy-in components, no UI-library lock-in. Themeable per deployment.
Charts Recharts Composable React charts; renders in RSC; no commercial licence.
Observability LangFuse Self-hostable LLM observability. Prompts, costs, latency, eval scores — all in one place.
Runtime Node 22 LTS Long-term support, broadly available in every cloud and on-prem environment.

The combined effect: a country IT team operates four containers (app, init, Postgres, Qdrant) — all recognisable — and depends on zero hosted services beyond the LLM endpoint they choose.

Tool deep-dive

For each load-bearing dependency: what it actually does, why this one over the alternatives, and where it shows up in the codebase. If you're new to the stack, this is the section that ties the brand names to the file paths.

Next.js 16 — the application framework

What it does. React framework that handles everything from page rendering to API endpoints in one process. Server Components render on the server (no client JS shipped unless needed); Route Handlers are the HTTP API; streaming responses ship updates to the browser as they happen.

Why this. Most widely deployed React framework on earth — every TypeScript engineer has either used it or can pick it up in days. Fully self-hostable (Vercel-led but not Vercel-locked). The Server Components + streaming model is what makes the live "card-by-card" chat experience possible without bolting on a separate WebSocket layer.

Where it fits. Everything under app/ is Next.js: pages, layouts, the /api/v1/* endpoints. app/api/v1/query/route.ts is the streaming endpoint that drives the whole workflow.

LangGraph.js — the workflow engine

What it does. A state-machine runtime. You declare nodes (functions) and edges (transitions), give it an initial state, and it threads the state through the graph one node at a time, streaming events as each node finishes. Built-in checkpointing means state can be saved between steps.

Why this, not LangChain. LangChain is a sprawling toolkit of pre-built "chains" — useful for prototypes, heavy and opaque in production. LangGraph is just the orchestration core: ~5 imports, no surprise abstractions. It does NOT touch LLMs, prompts, or retrievers — those stay in our own code where we can audit them. Critically, the "agent loop with tool calls" pattern (LangChain's default) doesn't fit us: the LLM never holds a database connection. Our flow is fixed and linear-ish, which is exactly what a graph models.

Where it fits. All of lib/graph/. workflow.ts declares the graph; nodes/*.ts are the 8 steps; state.ts is the row threaded through; routing.ts is the branching logic; runner.ts adapts LangGraph's event stream into the NDJSON the browser consumes.

Vercel AI SDK — the LLM provider layer

What it does. One API across every major LLM provider. generateText({ model, prompt }) works whether model is OpenAI, Anthropic, Google, Mistral, DeepSeek, Groq, or a local Ollama endpoint. Also handles streaming, structured output, and tool calling — but we use only the basic text-in / text-out path.

Why this. Vendor independence is non-negotiable for ministry deployments: a country might pick a regional provider, an air-gapped local model via Ollama, or switch when contracts change. The SDK gives us provider-swap with no code change — just an env var. Alternative would be calling each provider's SDK directly and writing our own switch, which is what most projects end up doing badly.

Where it fits. lib/llm/providers.ts is the switch (read the LLM_PROVIDER env, return the right model). lib/llm/structured.ts wraps the SDK with typed helpers, caching, retries. Every node that calls an LLM (generate-sql.ts, narrate-result.ts) goes through these helpers.

Qdrant — the vector database (RAG)

What it does. Stores text snippets as embeddings (high-dimensional vectors) and finds nearest matches by cosine similarity. We use it for retrieval-augmented generation: at query time, look up the most relevant schema descriptions, business rules, and column hints, then paste them into the prompt.

Why this. Apache-2.0 licensed, runs as a single Docker container, no managed-service lock-in, no per-vector pricing. Alternatives like Pinecone or Weaviate are either hosted-only or operationally heavier. For our scale (a few thousand snippets per deployment), Qdrant on a small instance is more than enough.

Where it fits. lib/rag/: qdrant.ts is the client, embeddings.ts generates vectors, search.ts runs the two parallel searches used during retrieval, schema-corpus.ts ingests the schema + business-rules corpus on init. The retrieve-context.ts graph node consumes all of these.

PostgreSQL + Drizzle ORM — the application database

What it does. Postgres stores users, sessions, audit logs, saved reports, and the LangGraph checkpointer state. Drizzle is the type-safe query builder — schema-in-code, no hidden runtime magic, queries that read like SQL.

Why this. Postgres is the most boring possible database choice, which is the point: every ministry IT team already knows how to back it up, monitor it, and recover it. Drizzle is the modern alternative to Prisma — same type safety without Prisma's binary-engine, codegen, and connection-pool headaches that bite production deployments.

Where it fits. lib/db/app.ts is the Drizzle client for Postgres. lib/db/schema.ts defines the tables. lib/db/lab.ts is a separate mysql2 pool for the read-only InteLIS connection (NOT Drizzle — that schema is external and we only SELECT from it).

Auth.js v5 — authentication

What it does. Sign-in, sessions, and CSRF protection for Next.js. Today we use email/password against the app database; the same library supports SSO/SAML/OAuth when ministries need it later.

Why this. The standard for Next.js auth — large community, well-audited, supports the auth flows ministries actually use (SSO, OAuth) without us rolling crypto. Alternatives like Clerk or Auth0 are hosted services that fail the "no SaaS dependency" rule.

Where it fits. auth.ts and auth.config.ts at the repo root (the framework expects them there). lib/auth/ extends it with the InteLIS-specific RBAC helpers — turning a session into a UserContext with district/province/national scope. Every route handler calls auth() first.

shadcn/ui + Tailwind CSS — UI components

What it does. Tailwind is utility CSS — instead of writing stylesheets, you compose classes (flex items-center gap-2 text-sm) directly on elements. shadcn/ui is a set of accessible, themeable React components (buttons, inputs, dialogs, etc.) — but unlike traditional component libraries, you copy the source into your repo and own it.

Why this. No UI-library version lock-in (a perennial React headache). Per-deployment theming is trivial — change CSS variables in app/globals.css and the whole app rebrands. shadcn components are accessible by default (WCAG-compliant focus, ARIA, keyboard navigation) without us doing the work.

Where it fits. components/ui/ holds the shadcn primitives. components/chat/ and components/chart/ are app-specific components built on top. Tokens live in app/globals.css.

Recharts — charts

What it does. Composable React chart components. Pass it data + a chart type (line/bar/pie/scatter), get an SVG chart that's responsive and themeable.

Why this. Permissively licensed, renders inside React Server Components, no commercial dependency. Heavier alternatives (Plotly, Highcharts) bring licensing complexity or huge bundle sizes; lighter alternatives (Chart.js) don't fit the React composition model as cleanly.

Where it fits. components/chart/chart-renderer.tsx is the dispatcher — it reads the ChartSuggestion from the graph (produced by lib/graph/chart-heuristics.ts) and renders the matching Recharts component.

LangFuse — observability

What it does. Captures every LLM call: prompt, response, latency, token cost, eval scores, error traces. Lets you replay a failing query, see which prompt produced a bad SQL, and track cost trends per provider.

Why this. Self-hostable (this matters: prompts can contain sensitive context even after PII scrub). Purpose-built for LLM workflows — generic APM tools like Datadog don't understand prompts as a first-class object. Optional in the codebase: if no LANGFUSE_* env vars are set, tracing is a no-op.

Where it fits. lib/observability/langfuse.ts is the client wrapper. Every LLM call in lib/llm/structured.ts writes a span; runner.ts flushes traces when a query finishes. Setting the env vars is the only step to turn it on.

Node 22 LTS — the runtime

What it does. The JavaScript runtime. Single language end-to-end means the same engineer reads UI code and workflow code; no Python ↔ Node handoff at the LLM boundary.

Why this. Long-term support release — security patches through April 2027. Available in every cloud, on-prem environment, and Docker base image. Alternatives (Bun, Deno) are interesting but lack the ecosystem maturity ministries need.

Where it fits. Everywhere. The node:22-alpine image is the base for both the app container and the init container in docker-compose.yml.

External boundary

The InteLIS MySQL database is the country's live, operational lab system. We are a read-only consumer. The credentials in LAB_DB_* should be granted SELECT only. This service never bundles, replicates, replaces, or migrates lab data.

The most important rule

The LLM never holds a database connection.

The LLM only sees text: the user's question, the database schema (table and column names), and the business rules. It emits text: a SQL string with structured metadata. The application — not the LLM — opens the database connection, validates the SQL, and runs it.

flowchart LR
  classDef trust fill:#0a7,stroke:#0a7,color:#fff
  classDef untrust fill:#a55,stroke:#a55,color:#fff
  classDef neutral fill:#888,stroke:#888,color:#fff

  User["User<br/>(signed in)"]:::untrust
  LLMSurface["LLM provider"]:::untrust
  App["App process<br/>(your environment)"]:::trust
  Lab[("Lab DB")]:::trust

  User -- "question text" --> App
  App -- "question + schema + rules<br/>no patient data" --> LLMSurface
  LLMSurface -- "SQL text" --> App
  App -- "validated SELECT" --> Lab
  Lab -- "rows" --> App
  App -- "answer" --> User

This is enforced by the code shape, not by the prompt. Patient data never leaves the application boundary. Every SQL the LLM emits goes through validation and access-control checks before it touches the lab DB. See Privacy & RBAC.

The workflow

Every question follows the same seven steps. Each step is a function that reads the current state and returns updates. Branching happens between steps — failures route to the response builder; SQL-safety failures get one retry.

stateDiagram-v2
  direction LR
  [*] --> parse_question
  parse_question --> retrieve_context
  retrieve_context --> generate_sql
  generate_sql --> validate_access: sql ok
  generate_sql --> format_response: error / clarification
  validate_access --> validate_query: allowed
  validate_access --> format_response: denied
  validate_query --> execute_query: passed
  validate_query --> generate_sql: failed (retries < 1)
  validate_query --> format_response: failed (retries ≥ 1)
  execute_query --> format_response
  format_response --> [*]
Step What it does
parse-question Looks at the question, picks the likely tables, detects "those" / "them" follow-ups. Pure pattern matching — no LLM call.
retrieve-context Embeds the question, runs two parallel searches in Qdrant: one for general domain hints, one for table-specific facts. Builds a compact context bundle for the prompt.
generate-sql Calls the LLM with the question + context + a schema listing. Gets back SQL plus the assumptions it applied (default time window, default test type, etc.).
validate-access If the user is a district or province operator, injects a WHERE clause restricting results to their geographic scope. National users pass through.
validate-query SELECT-only. Tables must be in the allowlist. No patient-identifier columns (with one carve-out: COUNT(DISTINCT …) is allowed for unique counts).
execute-query Runs the SQL against the read-only lab DB. Enforces a hard LIMIT 10000.
format-response Suggests a chart (table / line / bar / pie / scatter, etc.) based on the result shape. Writes the audit row.

What runs where

Concern Where it lives Built on
Web UI app/(app)/** Next.js 16 React Server Components, shadcn/ui, Tailwind, Recharts.
HTTP API app/api/v1/** Next.js Route Handlers. One streaming endpoint for queries (/api/v1/query); REST for sessions and admin.
Workflow lib/graph/** LangGraph.js. Nodes, state, routing, Postgres-backed checkpointer.
Prompts + provider switch lib/llm/** Vercel AI SDK across OpenAI / Anthropic / Google / Mistral / DeepSeek / Groq / OpenAI-compatible / Ollama.
RAG client lib/rag/** Qdrant + embeddings + the schema loader.
Safety + RBAC lib/validation/** SQL validator + AST-based access control (custom, ~600 LOC).
Auth auth.ts, lib/auth/** Auth.js v5 with email/password.
Domain knowledge lib/config/business-rules.ts, lib/config/field-guide.ts Plain TypeScript modules. Ported from the retired PHP. The load-bearing IP.
Container bootstrap scripts/init.ts One-shot Node script: Drizzle migrations, RAG corpus ingest, admin seed.

Where state lives

State Storage
Users + access scopes Postgres
Chat sessions + messages Postgres
Audit log (every query) Postgres
Conversation memory across turns Postgres (LangGraph checkpointer)
Schema + business rules + terminology, embedded Qdrant
Lab data (read-only) InteLIS MySQL — external

All four services run independently. Re-ingesting the corpus is a single command if the source MySQL schema changes; the init container does it automatically on first boot.

Deployment shape

One Docker image, four services in compose:

flowchart LR
  init["init<br/>(one-shot: migrations,<br/>corpus, admin seed)"]
  app["app<br/>(Next.js)"]
  pg[("postgres")]
  qd[("qdrant")]
  init --> pg
  init --> qd
  app --> pg
  app --> qd
  app --> mysql[("InteLIS MySQL<br/>external")]
  init -. waits .-> pg
  init -. waits .-> qd
  app -. waits .-> init

docker compose up -d brings up Postgres + Qdrant, runs init to completion, then starts app. See Getting started.

Why a single app, not a service mesh

We considered splitting the LLM workflow into a dedicated backend service and keeping Next.js as a thin UI. We chose not to, for three reasons:

  • One thing to operate. Ministry IT teams already run a lot. One Docker image, one process, one log stream — not a polyglot mesh of services to monitor.
  • One thing to hire for. End-to-end TypeScript. The same engineer who fixes the UI also fixes the workflow; no Python ↔ Node handoff, no shared-package build step.
  • No real scaling pressure. One deployment per country, single-digit concurrent analysts. Operator simplicity beats independent scaling every time at this size.

See also