C3TMC

Architecture

A two-package monorepo, an async FastAPI backend, a React frontend.

The repository holds two deployable packages and the infrastructure that ships them. Everything else is configuration.

Repository layout

├── frontend/            React + TypeScript + Vite, served by nginx
├── backend/             FastAPI + Pydantic AI, served by uvicorn
├── infra/               on-premise deployment
├── .github/workflows/   CI, image build and scan, release
└── docker-compose.yml   local stack

The stack

Frontend

A single-page React application, built with Vite and served as static files by nginx.

LayerChoiceWhy this one
FrameworkReact + TypeScript + Vite, pnpmFast dev loop and optimal performance
Mapreact-map-gl (Mapbox GL JS)Vector tiles, and each marker is a React component
Layoutreact-resizable-panelsThe three panels are user-resizable
Server stateTanStack QueryTrial fetches are cached and shared across panels
Client statezustandSelection, theme and consent, persisted where it matters
StylingTailwind CSS + shadcn primitivesThe design tokens are the styling system

Backend

An async FastAPI service, managed by uv as dependency manager.

LayerChoiceWhy this one
FrameworkFastAPI, async throughout, uvFast and optimal performance
AgentPydantic AITyped tools and structured output, provider-agnostic
ModelOpenAIFast and performant models with many capabilities
DatabasePostgreSQL + SQLAlchemyOne denormalized read-optimized trial table
Vector searchpgvectorSame database, no second store to operate
EmbeddingsOpenAICheap and better embeddings results
SessionsRedisConversation history keyed by session ID, with a TTL
MigrationsAlembicSchema changes are reviewed like code

The request path

One chat turn touches every layer.

Nothing in it blocks

SQLAlchemy runs on asyncpg, the model is reached over an async HTTP client with retry and Retry-After handling, and the response is a StreamingResponse that yields as the model produces tokens.

Inside the backend

Layering

The backend keeps a strict one-way dependency order. Routes know about services, services know about repositories, and nothing below knows about anything above.

DirectoryHolds
routes/HTTP handlers only
services/orchestration over abstractions
repository/all data access, DB or otherwise
agents/one subpackage per agent: prompts, tools, guards, deps
core/config, database, LLM and embedding factories, dependency wiring
schemas/pydantic DTOs shared across the layers
models/SQLAlchemy ORM tables

The composition root

core/dependencies.py is the only place that knows which concrete repository backs which protocol. That is what lets the agent depend on a TrialSearch protocol it can be tested against without a database.

The trial model

Trials are stored denormalized and read-optimized.

Recruiting status is per site, not per trial, so a trial can be recruiting in one province and opening soon in another and the map shows both truthfully.

The indexes follow the same read pattern: GIN indexes on the array columns that get filtered (phases, treatment_type_names, intervention_names on the trial, cancer_type_names on the site), a plain index on per-site recruiting state, and city, province and the pair of them on locations.

Why the vectors are not indexed

The embedding columns carry no index, because the dataset is far too small for one. Scanning every row is quick at this size, and it is exact. An HNSW index becomes worth adding once the corpus is much larger.

Running it

Deployment

The system is deployed on-premise. Both packages ship as container images built and scanned in CI, and Ansible puts them on the VM. See Deployment for the pipeline and the environments.

Observability

Everything the system does is observed through Langfuse, self-hosted on a VM of its own. Each conversation is one trace. Prompts are versioned there, with a fallback in code, so a wording change can be published to one environment, tried, and promoted without a deploy. Feedback from the thumbs on a chat turn lands on the same trace as a score, which is what makes a bad answer findable later. See Observability.

Evaluation

An evaluation harness lives alongside the traces. The dataset is held in Langfuse, and a custom experiment can be triggered from the dashboard: it replays conversation items through the real agent and scores the answer with DeepEval metrics, next to deterministic precision and recall. See Evaluation.

Environments

ENVIRONMENT is the only switch, and all it decides is which features, endpoints and prompts are in play.

AffordanceDevelopmentProduction
/debug trial search pageonoff
OpenAPI docs and /redoconoff
Free-text feedback fieldsonoff
Patient text captured in Langfuseoncurrently on
Cloudflare Turnstile bot gateoffon

Last updated on

On this page