Aliendreamer/AIDoctorAssistant

C#

0

91 commits

updated Sep 26, 2026

See the code

README

MedAssist — AI Doctor Assistant

A bilingual (EN/BG) RAG-based medical knowledge assistant for physicians. Queries indexed medical books using hybrid dense + sparse (BM25) vector search, powered by a local LLM via Ollama.


Monitoring GPU utilization

watch -n2 nvidia-smi

Architecture

MedAssist.Shared   — models, interfaces, constants (referenced by all projects)
MedAssist.Data     — EF Core 9 + PostgreSQL, entities, migrations, repositories
MedAssist.AI       — embedder, reranker, sparse vectorizer, Qdrant store,
                     ingestion pipeline, SK plugins, kernel factory
MedAssist.Web      — FastEndpoints REST API + Blazor Server UI
MedAssist.Tests    — xUnit unit tests

Key technologies

ConcernTechnology
Framework.NET 10
UIBlazor Server
APIFastEndpoints 8
AI orchestrationSemantic Kernel
LLM inferenceOllama (gemma2:9b by default)
Dense embeddingsmultilingual-e5-large (ONNX, auto-downloaded)
Rerankerms-marco-MiniLM-L-6-v2 cross-encoder (ONNX, auto-downloaded)
Sparse embeddingsBM25 (in-process)
Vector storeQdrant — hybrid named-vector collection
Metadata storePostgreSQL via EF Core 9
PDF → MarkdownMarker (runs as HTTP service, GPU-accelerated)
ObservabilityOpenTelemetry → Prometheus → Grafana + Tempo
LoggingSerilog (compact JSON)
ContainerDocker Compose

Query flow

Browser → Blazor → QueryService
                       ↓
                   RagPluginBase
                   ├─ MedicalDictionary.ExpandQuery()   (ICD-10 synonym expansion)
                   ├─ Embedder.EmbedQueryAsync()         (dense vector, 1024-dim)
                   └─ SparseVectorizer.VectorizeQuery()  (BM25 sparse vector)
                           ↓
                   QdrantVectorStore.SearchAsync()
                   ├─ dense prefetch  → "dense" named vector (cosine)
                   ├─ sparse prefetch → "sparse" named vector (BM25 index)
                   └─ RRF fusion (Reciprocal Rank Fusion)
                           ↓
                   CrossEncoderReranker (ms-marco-MiniLM)
                           ↓
                   Ollama LLM  →  answer + citations

Prerequisites


Repository encryption (git-crypt)

The config/ and books/ directories are encrypted at rest. After cloning, unlock them:

# Symmetric key (shared secret)
git-crypt unlock /path/to/medassist.key

# Or GPG-based (if your key was added by a team member)
git-crypt unlock

CI/CD: store the base64-encoded key as a secret and run echo "$GIT_CRYPT_KEY" | base64 -d | git-crypt unlock - before building images.


Shared infrastructure (PersonalCommandCenter)

MedAssist does not run its own database, vector store, LLM, metasearch, or observability stack. Those are provided by the sibling PersonalCommandCenter (PCC) stack, and this repo's docker-compose.yml only builds two services: web (the app) and marker (GPU OCR).

The app reaches the shared services by container name over the external personalcommandcenter_default Docker network (declared here as pcc-net):

Shared serviceReached asProvided by
PostgreSQLpostgres:5432PCC
Qdrant (gRPC)qdrant:6334PCC
Ollamaollama:11434PCC
SearXNGsearxng:8080PCC
OTEL collectorotel-collector:4317PCC

Ordering matters. pcc-net is an external network, so the PCC stack must be started before MedAssist — otherwise docker compose up fails because the network doesn't exist yet. The app also can't depends_on cross-stack services, so PCC must be healthy before the app runs. The medassist database is created automatically on first run by EF Core migrations.

Shared ONNX model cache

The ONNX models (embedder multilingual-e5-large ~2.2 GB, cross-encoder reranker ms-marco-MiniLM-L-6-v2 ~90 MB) are downloaded from HuggingFace on first run into the external Docker volume shared_onnx_models, mounted at /models. The sibling DndMcpAICsharpFun stack mounts the same volume, so the reranker (identical in both projects) is fetched once and reused. Layout is one subdirectory per model:

shared_onnx_models/
  multilingual-e5-large/      # MedAssist embedder only
  ms-marco-MiniLM-L-6-v2/     # shared reranker (model.onnx + vocab.txt)

Because it's an external volume, compose does not auto-create it — create and seed it once, in this order (seed before the first up, or MedAssist re-downloads the 2.2 GB embedder over the MTU-constrained link):

# 1. Create the shared volume
docker volume create shared_onnx_models

# 2. (Migration only) Seed from the old per-project volume that already holds the models.
#    NOTE: Compose prefixes volume names with the project name, so the populated volume is
#    `aidoctorassistant_medassist-models` (NOT the bare `medassist-models`). It already uses
#    the same subdir layout. Stop the web container first so it isn't mid-download, and clear
#    any partial download in the shared volume before copying:
docker compose stop web
docker run --rm -v aidoctorassistant_medassist-models:/src -v shared_onnx_models:/dst \
  alpine sh -c "rm -rf /dst/* && cp -a /src/. /dst/"
docker compose up -d web

On a clean machine with no prior volume, skip step 2 — both stacks download what they need on first run and populate the shared cache. DnD points at the reranker subdir via Reranker:ModelPath.

Quick start (Docker)

# 1. Clone and unlock
git clone <repo-url> && cd AIDoctorAssistant
git-crypt unlock /path/to/medassist.key

# 2. Start the shared PCC stack FIRST (separate repo)
cd ../PersonalCommandCenter && docker compose up -d && cd -

# 3. Pull the LLM into the shared Ollama (runs in the PCC stack)
docker exec -it $(docker ps -qf name=ollama) ollama pull gemma2:9b

# 4. Start MedAssist (web + marker)
docker compose up -d

# 5. Open the web app
open http://localhost:8081

Service URLs

MedAssist publishes only the web app and the Marker OCR service. Everything else (Grafana, Prometheus, pgAdmin, Qdrant REST, SearXNG UI) is owned by PCC and reached via its Traefik router at *.pcc.localhost.


UI

The web UI is at http://localhost:8081. All pages require login — navigate there and you will be redirected to the login screen automatically.

Pages

PathRoleDescription
/loginPublicSign in with username + password
/queryDoctor, AdminAsk medical questions — select language, query type, and optionally filter by book
/admin/booksAdminList all books, trigger re-indexing per book
/admin/books/uploadAdminUpload a new PDF book
/admin/usersAdminList user accounts, delete users
/admin/users/createAdminCreate a new Doctor or Admin account

Default credentials

On first start the app seeds a single Admin account from config/appsettings.shared.json:

UsernamePasswordRole
adminmedassist123Admin

The doctor user from the old config is not auto-seeded. Create doctor accounts through the UI at /admin/users/create after logging in as admin.

After the first run, credentials live in the PostgreSQL users table (PBKDF2-hashed). Manage them entirely from the admin UI — the config list is only used for the first-run seed.


Authentication

The REST API uses JWT bearer tokens. Obtain one via:

curl -s -X POST http://localhost:8081/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"medassist123"}' | jq .token

Book ingestion

Books are scanned PDFs (not digital-born). The ingestion pipeline is:

Admin uploads PDF
      ↓
POST /api/admin/books/upload   — saves PDF to /books/raw/, registers in DB (status: pending)
      ↓
POST /api/admin/books/{bookId}/index   — triggers indexing in background
      ↓
Marker (OCR)         — PDF → Markdown
      ↓
MarkdownChunker      — splits into semantic chunks (≤ 512 tokens)
      ↓
ChunkEnricher        — tags each chunk with ICD-10 codes from the medical dictionary
      ↓
MultilingualE5Embedder  — dense vector per chunk (1024-dim)
SparseVectorizer        — BM25 sparse vector per chunk
      ↓
Qdrant               — upserts named vectors (dense + sparse)
PostgreSQL           — updates book status → indexed, saves checkpoints

Step 1 — Upload

POST /api/admin/books/upload (Admin role required)

Multipart form fields:

FieldRequiredDescription
FileyesThe scanned PDF
BookIdyesUnique identifier, e.g. harrison-21
TitleyesDisplay title
AuthoryesAuthor(s)
Languageyesen or bg
EditionnoEdition string

Step 2 — Trigger indexing

POST /api/admin/books/{bookId}/index (Admin role required)

Returns 202 Accepted immediately. Indexing runs in the background — check book status via GET /api/books.

Indexing is resumable: if interrupted, re-triggering picks up from the last checkpoint.

Check status

GET /api/books (Admin or Doctor role required) — returns all indexed books.


Local development

The default dev flow is fully containerized — the app joins the shared network and reaches the PCC services by container name:

# 1. Start the shared infra (PCC stack: postgres, qdrant, ollama, searxng, otel-collector)
cd ../PersonalCommandCenter && docker compose up -d && cd -

# 2. Start MedAssist (web + marker) on the shared network
docker compose up -d --build

Running the app on the host (dotnet run --project MedAssist.Web) is not wired up out of the box: the PCC stack doesn't publish postgres/qdrant/ollama on host ports, and Docker's container-name DNS only resolves inside the network. To do host-based dev you'd need to override the endpoints in config/appsettings.shared.json (or via __-separated env vars) to host-reachable addresses. The container flow above is the supported path.

The app auto-downloads ONNX models on first start (~1.2 GB total for embedder + reranker).


Configuration reference

Settings priority (highest wins):

  1. Environment variables (__ as separator, e.g. Database__ConnectionString)
  2. config/appsettings.shared.json
KeyDefaultDescription
Database:ConnectionString—PostgreSQL connection string
Models:PathmodelsDirectory for ONNX model files
Models:RerankerPathmodels/ms-marco-MiniLM-L-6-v2Reranker model directory
Books:RawPath/books/rawDirectory where uploaded PDFs are stored
Marker:Endpointhttp://localhost:5002Marker HTTP service URL
VectorStore:Qdrant:Endpointhttp://localhost:6334Qdrant gRPC endpoint
AI:ModelProviderollamaLLM provider
AI:Ollama:Endpointhttp://localhost:11434Ollama base URL
AI:Ollama:ModelNamegemma2:9bModel tag

Project structure

AIDoctorAssistant/
├── MedAssist.Shared/
│   ├── Constants/          OnnxConstants, IngestionStatus, LanguageCodes, VectorStoreConstants
│   ├── Interfaces/         IVectorStore, IEmbedder, ISparseVectorizer,
│   │                       IBM25VocabStore, IMedicalDictionary, ICrossEncoderReranker
│   └── Models/             MedicalChunk, BookInfo, SparseVector, BM25VocabSnapshot, …
├── MedAssist.Data/
│   ├── Entities/           BookEntity, IngestionCheckpointEntity, Bm25VocabEntity, …
│   ├── Migrations/
│   ├── Repositories/       BookRepository, CheckpointRepository
│   └── MedAssistDbContext.cs
├── MedAssist.AI/
│   ├── Dictionary/         MedicalDictionaryService, BM25VocabService
│   ├── Embedding/          MultilingualE5Embedder, SparseVectorizer, ModelInitializer
│   ├── Ingestion/          BookIndexer, MarkdownChunker, ChunkEnricher,
│   │                       VocabularyBuilder, MarkerClient
│   ├── Kernel/             KernelFactory
│   ├── Plugins/            RagPluginBase, SymptomsPlugin, DiseasePlugin,
│   │                       TreatmentPlugin, WebSearchPlugin
│   ├── Reranker/           CrossEncoderReranker
│   └── VectorStore/        QdrantVectorStore
├── MedAssist.Web/
│   ├── Components/
│   │   ├── Layout/         MainLayout, AdminLayout, NavMenu
│   │   ├── Pages/          Login, Home (redirect), Query
│   │   │   └── Admin/      Books, UploadBook, Users, CreateUser
│   │   └── Shared/         BookSourceCitation, WebSourceCitation
│   ├── Data/               UserRepository
│   ├── Endpoints/
│   │   ├── Auth/           LoginEndpoint, LogoutEndpoint
│   │   ├── Books/          ListBooksEndpoint, UploadBookEndpoint, TriggerIndexEndpoint
│   │   ├── Dictionary/     GetByIcdEndpoint, SearchDictionaryEndpoint
│   │   ├── Query/          QueryEndpoint
│   │   └── Users/          ListUsersEndpoint, CreateUserEndpoint, DeleteUserEndpoint
│   ├── Extensions/         ServiceCollectionExtensions, WebApplicationExtensions
│   ├── Services/           BookCatalogService, QueryService,
│   │                       AdminApiClient, AdminBookService, AdminUserService
│   ├── Startup/            UserSeeder
│   └── Program.cs
├── MedAssist.Tests/
├── config/
│   └── appsettings.shared.json
├── books/
│   └── raw/                Uploaded PDFs (git-crypt encrypted)
├── docker/
│   └── marker/             Marker OCR service (Dockerfile + app.py) — the only infra MedAssist builds
├── requests/
│   └── medassist.yaak.json  Yaak/Insomnia v4 collection
├── docker-compose.yml
└── MedAssist.slnx

Build and test

dotnet build MedAssist.slnx
dotnet test MedAssist.Tests

Aliendreamer/AIDoctorAssistant

C#

0

91 commits

updated Sep 26, 2026

See the code

README

MedAssist — AI Doctor Assistant

A bilingual (EN/BG) RAG-based medical knowledge assistant for physicians. Queries indexed medical books using hybrid dense + sparse (BM25) vector search, powered by a local LLM via Ollama.


Monitoring GPU utilization

watch -n2 nvidia-smi

Architecture

MedAssist.Shared   — models, interfaces, constants (referenced by all projects)
MedAssist.Data     — EF Core 9 + PostgreSQL, entities, migrations, repositories
MedAssist.AI       — embedder, reranker, sparse vectorizer, Qdrant store,
                     ingestion pipeline, SK plugins, kernel factory
MedAssist.Web      — FastEndpoints REST API + Blazor Server UI
MedAssist.Tests    — xUnit unit tests

Key technologies

ConcernTechnology
Framework.NET 10
UIBlazor Server
APIFastEndpoints 8
AI orchestrationSemantic Kernel
LLM inferenceOllama (gemma2:9b by default)
Dense embeddingsmultilingual-e5-large (ONNX, auto-downloaded)
Rerankerms-marco-MiniLM-L-6-v2 cross-encoder (ONNX, auto-downloaded)
Sparse embeddingsBM25 (in-process)
Vector storeQdrant — hybrid named-vector collection
Metadata storePostgreSQL via EF Core 9
PDF → MarkdownMarker (runs as HTTP service, GPU-accelerated)
ObservabilityOpenTelemetry → Prometheus → Grafana + Tempo
LoggingSerilog (compact JSON)
ContainerDocker Compose

Query flow

Browser → Blazor → QueryService
                       ↓
                   RagPluginBase
                   ├─ MedicalDictionary.ExpandQuery()   (ICD-10 synonym expansion)
                   ├─ Embedder.EmbedQueryAsync()         (dense vector, 1024-dim)
                   └─ SparseVectorizer.VectorizeQuery()  (BM25 sparse vector)
                           ↓
                   QdrantVectorStore.SearchAsync()
                   ├─ dense prefetch  → "dense" named vector (cosine)
                   ├─ sparse prefetch → "sparse" named vector (BM25 index)
                   └─ RRF fusion (Reciprocal Rank Fusion)
                           ↓
                   CrossEncoderReranker (ms-marco-MiniLM)
                           ↓
                   Ollama LLM  →  answer + citations

Prerequisites


Repository encryption (git-crypt)

The config/ and books/ directories are encrypted at rest. After cloning, unlock them:

# Symmetric key (shared secret)
git-crypt unlock /path/to/medassist.key

# Or GPG-based (if your key was added by a team member)
git-crypt unlock

CI/CD: store the base64-encoded key as a secret and run echo "$GIT_CRYPT_KEY" | base64 -d | git-crypt unlock - before building images.


Shared infrastructure (PersonalCommandCenter)

MedAssist does not run its own database, vector store, LLM, metasearch, or observability stack. Those are provided by the sibling PersonalCommandCenter (PCC) stack, and this repo's docker-compose.yml only builds two services: web (the app) and marker (GPU OCR).

The app reaches the shared services by container name over the external personalcommandcenter_default Docker network (declared here as pcc-net):

Shared serviceReached asProvided by
PostgreSQLpostgres:5432PCC
Qdrant (gRPC)qdrant:6334PCC
Ollamaollama:11434PCC
SearXNGsearxng:8080PCC
OTEL collectorotel-collector:4317PCC

Ordering matters. pcc-net is an external network, so the PCC stack must be started before MedAssist — otherwise docker compose up fails because the network doesn't exist yet. The app also can't depends_on cross-stack services, so PCC must be healthy before the app runs. The medassist database is created automatically on first run by EF Core migrations.

Shared ONNX model cache

The ONNX models (embedder multilingual-e5-large ~2.2 GB, cross-encoder reranker ms-marco-MiniLM-L-6-v2 ~90 MB) are downloaded from HuggingFace on first run into the external Docker volume shared_onnx_models, mounted at /models. The sibling DndMcpAICsharpFun stack mounts the same volume, so the reranker (identical in both projects) is fetched once and reused. Layout is one subdirectory per model:

shared_onnx_models/
  multilingual-e5-large/      # MedAssist embedder only
  ms-marco-MiniLM-L-6-v2/     # shared reranker (model.onnx + vocab.txt)

Because it's an external volume, compose does not auto-create it — create and seed it once, in this order (seed before the first up, or MedAssist re-downloads the 2.2 GB embedder over the MTU-constrained link):

# 1. Create the shared volume
docker volume create shared_onnx_models

# 2. (Migration only) Seed from the old per-project volume that already holds the models.
#    NOTE: Compose prefixes volume names with the project name, so the populated volume is
#    `aidoctorassistant_medassist-models` (NOT the bare `medassist-models`). It already uses
#    the same subdir layout. Stop the web container first so it isn't mid-download, and clear
#    any partial download in the shared volume before copying:
docker compose stop web
docker run --rm -v aidoctorassistant_medassist-models:/src -v shared_onnx_models:/dst \
  alpine sh -c "rm -rf /dst/* && cp -a /src/. /dst/"
docker compose up -d web

On a clean machine with no prior volume, skip step 2 — both stacks download what they need on first run and populate the shared cache. DnD points at the reranker subdir via Reranker:ModelPath.

Quick start (Docker)

# 1. Clone and unlock
git clone <repo-url> && cd AIDoctorAssistant
git-crypt unlock /path/to/medassist.key

# 2. Start the shared PCC stack FIRST (separate repo)
cd ../PersonalCommandCenter && docker compose up -d && cd -

# 3. Pull the LLM into the shared Ollama (runs in the PCC stack)
docker exec -it $(docker ps -qf name=ollama) ollama pull gemma2:9b

# 4. Start MedAssist (web + marker)
docker compose up -d

# 5. Open the web app
open http://localhost:8081

Service URLs

MedAssist publishes only the web app and the Marker OCR service. Everything else (Grafana, Prometheus, pgAdmin, Qdrant REST, SearXNG UI) is owned by PCC and reached via its Traefik router at *.pcc.localhost.


UI

The web UI is at http://localhost:8081. All pages require login — navigate there and you will be redirected to the login screen automatically.

Pages

PathRoleDescription
/loginPublicSign in with username + password
/queryDoctor, AdminAsk medical questions — select language, query type, and optionally filter by book
/admin/booksAdminList all books, trigger re-indexing per book
/admin/books/uploadAdminUpload a new PDF book
/admin/usersAdminList user accounts, delete users
/admin/users/createAdminCreate a new Doctor or Admin account

Default credentials

On first start the app seeds a single Admin account from config/appsettings.shared.json:

UsernamePasswordRole
adminmedassist123Admin

The doctor user from the old config is not auto-seeded. Create doctor accounts through the UI at /admin/users/create after logging in as admin.

After the first run, credentials live in the PostgreSQL users table (PBKDF2-hashed). Manage them entirely from the admin UI — the config list is only used for the first-run seed.


Authentication

The REST API uses JWT bearer tokens. Obtain one via:

curl -s -X POST http://localhost:8081/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"medassist123"}' | jq .token

Book ingestion

Books are scanned PDFs (not digital-born). The ingestion pipeline is:

Admin uploads PDF
      ↓
POST /api/admin/books/upload   — saves PDF to /books/raw/, registers in DB (status: pending)
      ↓
POST /api/admin/books/{bookId}/index   — triggers indexing in background
      ↓
Marker (OCR)         — PDF → Markdown
      ↓
MarkdownChunker      — splits into semantic chunks (≤ 512 tokens)
      ↓
ChunkEnricher        — tags each chunk with ICD-10 codes from the medical dictionary
      ↓
MultilingualE5Embedder  — dense vector per chunk (1024-dim)
SparseVectorizer        — BM25 sparse vector per chunk
      ↓
Qdrant               — upserts named vectors (dense + sparse)
PostgreSQL           — updates book status → indexed, saves checkpoints

Step 1 — Upload

POST /api/admin/books/upload (Admin role required)

Multipart form fields:

FieldRequiredDescription
FileyesThe scanned PDF
BookIdyesUnique identifier, e.g. harrison-21
TitleyesDisplay title
AuthoryesAuthor(s)
Languageyesen or bg
EditionnoEdition string

Step 2 — Trigger indexing

POST /api/admin/books/{bookId}/index (Admin role required)

Returns 202 Accepted immediately. Indexing runs in the background — check book status via GET /api/books.

Indexing is resumable: if interrupted, re-triggering picks up from the last checkpoint.

Check status

GET /api/books (Admin or Doctor role required) — returns all indexed books.


Local development

The default dev flow is fully containerized — the app joins the shared network and reaches the PCC services by container name:

# 1. Start the shared infra (PCC stack: postgres, qdrant, ollama, searxng, otel-collector)
cd ../PersonalCommandCenter && docker compose up -d && cd -

# 2. Start MedAssist (web + marker) on the shared network
docker compose up -d --build

Running the app on the host (dotnet run --project MedAssist.Web) is not wired up out of the box: the PCC stack doesn't publish postgres/qdrant/ollama on host ports, and Docker's container-name DNS only resolves inside the network. To do host-based dev you'd need to override the endpoints in config/appsettings.shared.json (or via __-separated env vars) to host-reachable addresses. The container flow above is the supported path.

The app auto-downloads ONNX models on first start (~1.2 GB total for embedder + reranker).


Configuration reference

Settings priority (highest wins):

  1. Environment variables (__ as separator, e.g. Database__ConnectionString)
  2. config/appsettings.shared.json
KeyDefaultDescription
Database:ConnectionString—PostgreSQL connection string
Models:PathmodelsDirectory for ONNX model files
Models:RerankerPathmodels/ms-marco-MiniLM-L-6-v2Reranker model directory
Books:RawPath/books/rawDirectory where uploaded PDFs are stored
Marker:Endpointhttp://localhost:5002Marker HTTP service URL
VectorStore:Qdrant:Endpointhttp://localhost:6334Qdrant gRPC endpoint
AI:ModelProviderollamaLLM provider
AI:Ollama:Endpointhttp://localhost:11434Ollama base URL
AI:Ollama:ModelNamegemma2:9bModel tag

Project structure

AIDoctorAssistant/
├── MedAssist.Shared/
│   ├── Constants/          OnnxConstants, IngestionStatus, LanguageCodes, VectorStoreConstants
│   ├── Interfaces/         IVectorStore, IEmbedder, ISparseVectorizer,
│   │                       IBM25VocabStore, IMedicalDictionary, ICrossEncoderReranker
│   └── Models/             MedicalChunk, BookInfo, SparseVector, BM25VocabSnapshot, …
├── MedAssist.Data/
│   ├── Entities/           BookEntity, IngestionCheckpointEntity, Bm25VocabEntity, …
│   ├── Migrations/
│   ├── Repositories/       BookRepository, CheckpointRepository
│   └── MedAssistDbContext.cs
├── MedAssist.AI/
│   ├── Dictionary/         MedicalDictionaryService, BM25VocabService
│   ├── Embedding/          MultilingualE5Embedder, SparseVectorizer, ModelInitializer
│   ├── Ingestion/          BookIndexer, MarkdownChunker, ChunkEnricher,
│   │                       VocabularyBuilder, MarkerClient
│   ├── Kernel/             KernelFactory
│   ├── Plugins/            RagPluginBase, SymptomsPlugin, DiseasePlugin,
│   │                       TreatmentPlugin, WebSearchPlugin
│   ├── Reranker/           CrossEncoderReranker
│   └── VectorStore/        QdrantVectorStore
├── MedAssist.Web/
│   ├── Components/
│   │   ├── Layout/         MainLayout, AdminLayout, NavMenu
│   │   ├── Pages/          Login, Home (redirect), Query
│   │   │   └── Admin/      Books, UploadBook, Users, CreateUser
│   │   └── Shared/         BookSourceCitation, WebSourceCitation
│   ├── Data/               UserRepository
│   ├── Endpoints/
│   │   ├── Auth/           LoginEndpoint, LogoutEndpoint
│   │   ├── Books/          ListBooksEndpoint, UploadBookEndpoint, TriggerIndexEndpoint
│   │   ├── Dictionary/     GetByIcdEndpoint, SearchDictionaryEndpoint
│   │   ├── Query/          QueryEndpoint
│   │   └── Users/          ListUsersEndpoint, CreateUserEndpoint, DeleteUserEndpoint
│   ├── Extensions/         ServiceCollectionExtensions, WebApplicationExtensions
│   ├── Services/           BookCatalogService, QueryService,
│   │                       AdminApiClient, AdminBookService, AdminUserService
│   ├── Startup/            UserSeeder
│   └── Program.cs
├── MedAssist.Tests/
├── config/
│   └── appsettings.shared.json
├── books/
│   └── raw/                Uploaded PDFs (git-crypt encrypted)
├── docker/
│   └── marker/             Marker OCR service (Dockerfile + app.py) — the only infra MedAssist builds
├── requests/
│   └── medassist.yaak.json  Yaak/Insomnia v4 collection
├── docker-compose.yml
└── MedAssist.slnx

Build and test

dotnet build MedAssist.slnx
dotnet test MedAssist.Tests