LLM Platform
Self-hosted, RAG-enabled, per-use-case model selection.
Status: design (2026-05-14) → 20-loop implementation in progress
Owner: super-admin (operator config); platform/infra (runtime)
Scope: new services/llm Moleculer service + Ollama runtime + Supabase pgvector RAG + 4 super-admin pages + public REST API
1. Why
medOS-ultra needs a first-party LLM platform so the same model + prompt + retrieval setup works in every region (Thailand, Japan, Philippines, on-premise), without leaking PHI to third-party APIs and without per-feature ad-hoc OpenAI calls.
Constraints driving this design:
- PHI must not leave the hospital. On-premise deployments often have no outbound internet. The platform must run fully air-gapped.
- Per-region locale. Japan needs
ja, Philippines needsfil/en, Thailand needsth. The same use case must resolve to a model that speaks the right language. - Per-use-case model. A drug-interaction check needs a small/fast model with a curated drug corpus. A discharge-summary draft needs a bigger long-context model. Clinical-code lookup needs only RAG, no generation.
- Super-admin is the operator. Adding a new model, changing the system prompt for “lab panel suggest”, or rebuilding the ICD-10 corpus — all of these are config edits in the dashboard, not code deploys.
- Audit + rate-limit. Every call goes through a single audited gateway. No miniapp talks to Ollama directly.
2. Non-goals
- Training or fine-tuning models in the cluster (use external ops).
- Replacing the existing OpenAI-backed
services/ai/clinical-note flow on day one — that path keeps working; new use cases default to the self-hosted gateway. - Multi-modal (vision/audio) in v1 — schema reserves capability flags, but runtime only ships text.
- Streaming SSE in v1 —
/chatreturns the full message. Streaming added in a later loop if the playground needs it.
3. Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ Super-Admin Dashboard (web/) │
│ /admin/super-admin/llm-models /admin/super-admin/llm-use-cases │
│ /admin/super-admin/llm-corpora /admin/super-admin/llm-playground │
└────────────────────────┬─────────────────────────────────────────────────┘
│ Supabase (direct, RLS-protected) for config CRUD
│ REST (gateway → public-api) for chat/search
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ services/gateway (existing) → services/public-api (existing) │
│ exposes /api/llm/* and forwards to Moleculer action `llm.*` │
└────────────────────────┬─────────────────────────────────────────────────┘
│ NATS (Moleculer)
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ services/llm (NEW, Moleculer-native) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ ModelRegistry│ │UseCaseRegistry│ │CorpusRegistry│ │ ChatOrchestrator │ │
│ │ Mongo + │ │ Mongo + │ │ Mongo + │ │ retrieve→rerank │ │
│ │ Ollama pull │ │ Supabase │ │ ingestion │ │ →complete →log │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │OllamaProvider│ │ EmbeddingSvc │ │ Retriever │ │ AuditLogger │ │
│ │ HTTP client │ │ → pgvector │ │ cosine + MMR│ │ reuses aaa. │ │
│ │ │ │ │ │ │ │ auditLog │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────────┘ │
└──────┬────────────────────────┬─────────────────────────┬────────────────┘
│ HTTP │ pg (service_role) │ Moleculer events
▼ ▼ ▼
┌──────────────────┐ ┌────────────────────────┐ ┌─────────────────────────┐
│ Ollama │ │ Supabase (pgvector) │ │ MongoDB (medos-db) │
│ :11434 │ │ llm_embeddings, │ │ llm_models, │
│ models on disk │ │ llm_corpora, │ │ llm_use_cases, │
│ pulled on demand│ │ llm_conversations, │ │ llm_corpora (mirror), │
│ │ │ llm_audit_log │ │ audit_logs (shared) │
└──────────────────┘ └────────────────────────┘ └─────────────────────────┘
Why Ollama (not vLLM, llama.cpp, OpenAI-compat shim)
| Option | Pros | Cons | Decision |
|---|---|---|---|
| Ollama | Single container, OpenAI-compatible API at /v1/chat/completions, ollama pull from registry, runs CPU or GPU, native quantization (Q4/Q8), Modelfile for custom prompts |
Lower throughput than vLLM at scale (acceptable for hospital-scale traffic) | Chosen |
| vLLM | Best GPU throughput | GPU-required, heavier ops, no easy model registry | Reject for v1 |
| llama.cpp (server mode) | Tiny binary, CPU-friendly | One model per process, manual model management | Reject |
| LM Studio / GPT4All | UX-friendly | Desktop apps, not server-grade | Reject |
| External (OpenAI/Anthropic/etc) | Best quality | Sends PHI off-site, breaks air-gap | Already covered by existing services/ai/; this platform is the on-prem alternative |
Ollama can be swapped at the provider-layer if a region wants vLLM — llm_models.provider is an enum that the dispatcher reads. v1 only implements ollama; the seam is there.
4. Data model
4.1 MongoDB (write source-of-truth for config — collection per entity)
Schemas live in packages/platform-api-schema/src/llm/.
// LlmModel — what models we know about
{
_id: ObjectId,
provider: 'ollama' | 'vllm' | 'openai-compat',
modelName: string, // 'llama3.1:8b', 'qwen2.5:14b', 'nomic-embed-text'
displayName: string,
displayName_th?: string,
displayName_ja?: string,
capabilities: {
chat: boolean,
completion: boolean,
embedding: boolean,
vision: boolean,
functionCalling: boolean,
},
contextWindow: number, // 8192, 32768, 131072
parameterCount?: number,
status: 'registered' | 'pulling' | 'available' | 'error',
sizeBytes?: number,
endpointUrl: string, // 'http://ollama:11434'
defaultTemperature: number, // 0.3
active: boolean,
createdAt, updatedAt,
}
// LlmUseCase — mapping of feature/domain → model + system prompt + RAG
{
_id: ObjectId,
code: string, // 'medication.order_suggest', 'lab.panel_suggest',
// 'master.icd_lookup', 'clinical.discharge_summary'
displayName, displayName_th, displayName_ja,
description?: string,
primaryModel: ObjectId, // → LlmModel
fallbackModel?: ObjectId,
embeddingModel?: ObjectId, // for RAG; usually 'nomic-embed-text'
systemPrompt: string, // mustache-style {{patient.age}} {{encounter.id}}
rag: {
corpusIds: ObjectId[], // which corpora to query
topK: number, // default 5
minScore: number, // default 0.4 (cosine sim)
mmrLambda: number, // default 0.5 (diversity vs relevance)
},
generation: {
temperature: number,
maxTokens: number,
topP?: number,
stopSequences?: string[],
},
tools: any[], // OpenAI-format function defs (passthrough)
rateLimitPerMin: number, // 30
active: boolean,
createdAt, updatedAt,
}
// LlmCorpus — RAG corpus definitions
{
_id: ObjectId,
code: string, // 'icd10', 'drug-db', 'pathology-catalog',
// 'hospital-policies', 'clinical-pathways'
displayName, displayName_th, displayName_ja,
source: {
type: 'mongo-collection' | 'supabase-table' | 'static-jsonl' | 'manual',
collection?: string, // e.g. 'icd10_codes' (Mongo) or 'master_drug' (Supabase)
query?: string, // optional filter
textTemplate: string, // mustache: '{{code}} — {{description}} ({{description_th}})'
metadataKeys: string[], // which fields to copy into embedding metadata
idField: string, // primary key in source
},
chunking: {
strategy: 'whole' | 'fixed' | 'sentence',
chunkSize: number, // 512 chars for 'fixed'
chunkOverlap: number, // 64
},
embeddingModel: ObjectId,
totalChunks: number,
lastIndexedAt?: Date,
status: 'pending' | 'indexing' | 'ready' | 'error',
errorMessage?: string,
active: boolean,
createdAt, updatedAt,
}
4.2 Supabase (read model + vector store)
Migration: infrastructure/medbase/migrations/045_llm_platform.sql
CREATE EXTENSION IF NOT EXISTS vector;
-- 1. Config mirrors (lightweight read model so the UI doesn't hit Mongo)
CREATE TABLE public.llm_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_id TEXT UNIQUE, -- ObjectId hex of LlmModel
provider TEXT NOT NULL,
model_name TEXT NOT NULL,
display_name TEXT NOT NULL,
display_name_th TEXT,
display_name_ja TEXT,
capabilities JSONB NOT NULL DEFAULT '{}',
context_window INT NOT NULL DEFAULT 4096,
parameter_count BIGINT,
status TEXT NOT NULL DEFAULT 'registered',
size_bytes BIGINT,
endpoint_url TEXT NOT NULL,
default_temperature NUMERIC NOT NULL DEFAULT 0.3,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE public.llm_use_cases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_id TEXT UNIQUE,
code TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
display_name_th TEXT,
display_name_ja TEXT,
description TEXT,
primary_model_id UUID REFERENCES public.llm_models(id),
fallback_model_id UUID REFERENCES public.llm_models(id),
embedding_model_id UUID REFERENCES public.llm_models(id),
system_prompt TEXT NOT NULL,
rag_config JSONB NOT NULL DEFAULT '{}', -- {corpusIds, topK, minScore, mmrLambda}
generation_config JSONB NOT NULL DEFAULT '{}',
tools JSONB NOT NULL DEFAULT '[]',
rate_limit_per_min INT NOT NULL DEFAULT 30,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE public.llm_corpora (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_id TEXT UNIQUE,
code TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
display_name_th TEXT,
display_name_ja TEXT,
source JSONB NOT NULL, -- {type, collection, query, textTemplate, ...}
chunking JSONB NOT NULL DEFAULT '{}',
embedding_model_id UUID REFERENCES public.llm_models(id),
total_chunks INT NOT NULL DEFAULT 0,
last_indexed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Vector store (the actual RAG payload)
CREATE TABLE public.llm_embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
corpus_id UUID NOT NULL REFERENCES public.llm_corpora(id) ON DELETE CASCADE,
source_id TEXT NOT NULL, -- the source row's primary key
chunk_index INT NOT NULL DEFAULT 0,
content TEXT NOT NULL,
content_summary TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
embedding vector(768), -- nomic-embed-text default; 1024 for bge-large
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX llm_embeddings_hnsw ON public.llm_embeddings
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX llm_embeddings_corpus_idx ON public.llm_embeddings (corpus_id);
CREATE INDEX llm_embeddings_source_idx ON public.llm_embeddings (corpus_id, source_id);
-- 3. Conversations (multi-turn chat use cases)
CREATE TABLE public.llm_conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
use_case_code TEXT NOT NULL,
user_id TEXT NOT NULL,
patient_id TEXT,
encounter_id TEXT,
context JSONB NOT NULL DEFAULT '{}',
title TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active | archived
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE public.llm_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES public.llm_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- system | user | assistant | tool
content TEXT NOT NULL,
tool_calls JSONB,
tool_call_id TEXT,
model_id UUID REFERENCES public.llm_models(id),
prompt_tokens INT,
completion_tokens INT,
total_tokens INT,
retrieval_sources JSONB, -- [{corpus_code, source_id, score, snippet}]
latency_ms INT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 4. Audit log (every inference call, every config change)
CREATE TABLE public.llm_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
user_display TEXT,
use_case_code TEXT,
model_id UUID,
action TEXT NOT NULL, -- chat | complete | search | embed
-- | model.pull | model.delete
-- | use_case.upsert | corpus.reindex
request_payload JSONB,
response_summary TEXT,
prompt_tokens INT,
completion_tokens INT,
total_tokens INT,
latency_ms INT,
status TEXT NOT NULL DEFAULT 'ok', -- ok | error | rate_limited | blocked
error_message TEXT,
remote_address TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX llm_audit_user_idx ON public.llm_audit_log (user_id, created_at DESC);
CREATE INDEX llm_audit_use_case_idx ON public.llm_audit_log (use_case_code, created_at DESC);
-- 5. RLS — super-admin only for config + audit; user-scoped for conversations
ALTER TABLE public.llm_models ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_use_cases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_corpora ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.llm_audit_log ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.llm_is_super_admin() RETURNS boolean AS $$
SELECT coalesce(
(auth.jwt() -> 'app_metadata' -> 'roles' ? 'super_admin')
OR (auth.jwt() ->> 'role' = 'super_admin')
OR (auth.jwt() ->> 'role' = 'service_role'),
false
);
$$ LANGUAGE sql STABLE;
CREATE POLICY llm_models_super_admin ON public.llm_models FOR ALL USING (public.llm_is_super_admin());
CREATE POLICY llm_use_cases_super_admin ON public.llm_use_cases FOR ALL USING (public.llm_is_super_admin());
CREATE POLICY llm_corpora_super_admin ON public.llm_corpora FOR ALL USING (public.llm_is_super_admin());
CREATE POLICY llm_embeddings_super_admin ON public.llm_embeddings FOR ALL USING (public.llm_is_super_admin());
CREATE POLICY llm_audit_super_admin_select ON public.llm_audit_log FOR SELECT USING (public.llm_is_super_admin());
-- Conversations: super-admin OR owner
CREATE POLICY llm_conv_owner ON public.llm_conversations
FOR ALL USING (
public.llm_is_super_admin()
OR user_id = coalesce(auth.jwt() ->> 'sub', auth.jwt() ->> 'user_id')
);
CREATE POLICY llm_msg_owner ON public.llm_messages
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.llm_conversations c
WHERE c.id = llm_messages.conversation_id
AND (public.llm_is_super_admin()
OR c.user_id = coalesce(auth.jwt() ->> 'sub', auth.jwt() ->> 'user_id'))
)
);
-- 6. RPC helpers (called from the service via service_role key)
CREATE OR REPLACE FUNCTION public.llm_search_embeddings(
p_corpus_ids UUID[],
p_query_embedding vector(768),
p_top_k INT DEFAULT 5,
p_min_score NUMERIC DEFAULT 0.4
) RETURNS TABLE (
id UUID,
corpus_id UUID,
source_id TEXT,
content TEXT,
metadata JSONB,
score NUMERIC
) LANGUAGE sql STABLE AS $$
SELECT
e.id,
e.corpus_id,
e.source_id,
e.content,
e.metadata,
1 - (e.embedding <=> p_query_embedding) AS score
FROM public.llm_embeddings e
WHERE e.corpus_id = ANY(p_corpus_ids)
AND 1 - (e.embedding <=> p_query_embedding) >= p_min_score
ORDER BY e.embedding <=> p_query_embedding
LIMIT p_top_k;
$$;
4.3 Why two stores?
- Mongo is the write source-of-truth for config — matches the rest of medOS, lets
services/llmboot from Mongo without a Supabase dependency, and keeps the audit pattern uniform withaaa.auditLog. - Supabase is the read model + vector store — the super-admin UI talks to it directly (matches the cron-jobs / dispense-cycles pattern), realtime subscriptions work out of the box, and
pgvectoris already a dependency.
services/llm writes to Mongo, then projects the row into Supabase via service_role. Same pattern as the existing read-model edge functions; just shorter because both stores live next to each other.
5. API surface
5.1 REST (via services/public-api — exposed through gateway at /api/llm/*)
All routes are JWT-authenticated. Super-admin-only routes are tagged.
# Inference
POST /api/llm/chat
body: {
use_case: string, // 'medication.order_suggest'
messages: [{role, content}], // prior turns; first user msg required
context?: { // template substitutions
patient?: any,
encounter?: any,
orders?: any[],
...
},
conversation_id?: string, // continue existing thread
overrides?: { // optional admin overrides (super-admin only)
model_id?: string,
system_prompt?: string,
temperature?: number,
},
}
returns: {
conversation_id: string,
message: { role: 'assistant', content: string, tool_calls?: any[] },
sources: [{corpus, source_id, snippet, score}],
usage: {prompt_tokens, completion_tokens, total_tokens, latency_ms},
model: {id, display_name, provider},
}
POST /api/llm/complete
body: { use_case, prompt, context? }
returns: { text, sources, usage, model }
POST /api/llm/search
body: { corpus_codes: string[], query: string, top_k?: number }
returns: { results: [{corpus, source_id, content, metadata, score}] }
POST /api/llm/embed
body: { texts: string[], model_code?: string }
returns: { embeddings: number[][], model, usage }
GET /api/llm/models # list active models user can choose from
GET /api/llm/use-cases # list use cases the user has access to
# Pre-baked agents (thin wrappers over /chat with a fixed use_case)
POST /api/llm/agents/medication-suggest
POST /api/llm/agents/lab-panel-suggest
POST /api/llm/agents/master-data-lookup
POST /api/llm/agents/clinical-summary
# Super-admin
GET /api/admin/llm/models
POST /api/admin/llm/models # register
POST /api/admin/llm/models/:id/pull # trigger ollama pull
DELETE /api/admin/llm/models/:id
GET /api/admin/llm/use-cases
POST /api/admin/llm/use-cases
PATCH /api/admin/llm/use-cases/:id
DELETE /api/admin/llm/use-cases/:id
GET /api/admin/llm/corpora
POST /api/admin/llm/corpora
PATCH /api/admin/llm/corpora/:id
POST /api/admin/llm/corpora/:id/reindex # rebuild embeddings
DELETE /api/admin/llm/corpora/:id
GET /api/admin/llm/audit # paginated audit log
5.2 Moleculer actions (internal — other services call these)
llm.chat { useCase, messages, context } → {message, sources, usage}
llm.complete { useCase, prompt, context } → {text, sources, usage}
llm.search { corpusCodes, query, topK } → {results}
llm.embed { texts, modelCode } → {embeddings}
llm.models.list {} → [...]
llm.models.pull { id } → {status}
llm.useCases.list {} → [...]
llm.useCases.upsert { payload } → {...}
llm.corpora.list {} → [...]
llm.corpora.reindex { id } → {jobId}
6. RAG pipeline
1. Ingestion (background; runs on corpus.upsert and corpus.reindex)
┌─────────────────────────────────────────────────────────────────┐
│ a. Read source rows (Mongo collection / Supabase table / JSONL) │
│ b. For each row: │
│ text = render(textTemplate, row) // mustache │
│ chunks = chunk(text, chunking.strategy) │
│ for chunk in chunks: │
│ vec = embed(chunk, embeddingModel) │
│ upsert llm_embeddings (corpus_id, source_id, chunk_index, │
│ content, metadata, embedding=vec) │
│ c. Set corpus.totalChunks, lastIndexedAt, status='ready' │
└─────────────────────────────────────────────────────────────────┘
2. Retrieval (per /chat or /search call)
┌─────────────────────────────────────────────────────────────────┐
│ a. Build query string: last user msg + relevant context fields │
│ b. embed(query, useCase.embeddingModel) │
│ c. RPC llm_search_embeddings(corpus_ids, vec, topK*2, minScore) │
│ d. MMR rerank with mmrLambda → topK final results │
│ e. Build prompt: system + RAG-context-block + user messages │
└─────────────────────────────────────────────────────────────────┘
3. Generation
┌─────────────────────────────────────────────────────────────────┐
│ a. POST ollama:11434/v1/chat/completions │
│ {model, messages, temperature, max_tokens, tools} │
│ b. Parse response, persist conversation + message rows │
│ c. Log audit row with usage + latency │
│ d. Return response with retrieval_sources │
└─────────────────────────────────────────────────────────────────┘
MMR (Maximal Marginal Relevance) reranking is implemented in TS — small enough to keep in-process. The formula: pick the doc that maximizes λ·sim(d, q) - (1-λ)·max sim(d, picked).
7. Default seed (loop 19)
Default registry shipped with the platform — operators can edit/delete in the UI.
Models (llm_models)
| code | provider | size | purpose |
|---|---|---|---|
llama3.1:8b-instruct-q4_K_M |
ollama | ~5 GB | general chat, summary, fallback |
qwen2.5:14b-instruct-q4_K_M |
ollama | ~9 GB | longer-context clinical drafts |
qwen2.5:7b-instruct-q4_K_M |
ollama | ~5 GB | fast, multilingual (good at ja/th/fil) |
nomic-embed-text |
ollama | ~270 MB | embedding (768 dim) |
bge-m3 |
ollama | ~1.2 GB | multilingual embedding (1024 dim, optional) |
Use cases (llm_use_cases)
| code | model | corpus | purpose |
|---|---|---|---|
medication.order_suggest |
qwen2.5:7b | drug-db | propose Rx given diagnosis + patient ctx |
medication.interaction_check |
llama3.1:8b | drug-db | flag DDI / contraindication |
lab.panel_suggest |
qwen2.5:7b | pathology-catalog | suggest lab panel from CC/Dx |
lab.result_interpret |
llama3.1:8b | pathology-catalog | summarize abnormal result |
radiology.protocol_suggest |
qwen2.5:7b | radiology-catalog | propose imaging protocol |
master.icd_lookup |
qwen2.5:7b | icd10 | RAG-only ICD-10/9 lookup |
master.drug_lookup |
qwen2.5:7b | drug-db | drug ingredient/strength/route lookup |
clinical.encounter_summary |
qwen2.5:14b | clinical-policies | discharge / encounter summary draft |
clinical.handover_note |
qwen2.5:14b | clinical-policies | shift-handover summary |
billing.code_assist |
llama3.1:8b | icd10 + insurance-rules | suggest billing codes from notes |
nutrition.menu_advise |
qwen2.5:7b | nutrition-catalog | tailor menu to constraints |
policy.gate_explain |
qwen2.5:7b | hospital-policies | explain why a policy gate fires |
Corpora (llm_corpora)
| code | source | language |
|---|---|---|
icd10 |
mongo icd10_codes (template uses code, description, description_th, description_ja) |
multilingual |
drug-db |
mongo master_drug |
multilingual |
pathology-catalog |
mongo pathology_test |
multilingual |
radiology-catalog |
mongo radiology_procedure |
multilingual |
nutrition-catalog |
supabase nutrition_menu_items |
per-region |
clinical-policies |
manual (admin uploads JSONL) | per-tenant |
hospital-policies |
supabase policy_gates (rendered) |
per-tenant |
insurance-rules |
mongo insurance_rate + kaigo_rate + philhealth_rate |
per-region |
All seed data follows CLAUDE.md rule #7: bilingual (local language + English).
8. Super-admin UI (loops 13–18)
Routes registered in web/src/routes/AdminRoutes.tsx. All match the existing super-admin pattern: container wrapper with super-admin guard → page component using raw MUI + Supabase direct.
| Route | Files | Purpose |
|---|---|---|
/admin/super-admin/llm-models |
containers/super-admin-llm-models/page.tsx + common/components/super-admin/llm-models/LlmModelsPage.tsx + services/super-admin/llmModels.service.ts |
Register/pull/delete models, see status (registered/pulling/available/error), pull progress via realtime |
/admin/super-admin/llm-use-cases |
parallel layout | Mapping of code → model + system prompt + RAG; system-prompt editor with mustache preview against a sample context |
/admin/super-admin/llm-corpora |
parallel layout | Corpus list, reindex button (shows progress), source picker, text-template editor |
/admin/super-admin/llm-playground |
parallel layout | Pick a use-case, paste sample context, get response + see retrieved sources + token usage + latency; admin overrides for model/temperature |
Tile added to SuperAdminDashboard in loop 18.
9. Deployment
9.1 Docker compose changes (loop 12)
infrastructure/docker-compose.yml (dev) — add ollama service + api-llm service.
infrastructure/docker-compose-onpremise.yml (on-prem all-in-one) — same, plus volume for model cache.
# infrastructure/docker-compose-onpremise.yml (excerpt)
services:
ollama:
image: ollama/ollama:latest
restart: always
environment:
- OLLAMA_HOST=0.0.0.0:11434
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_NUM_PARALLEL=2
- OLLAMA_MAX_LOADED_MODELS=2
volumes:
- ollama-data:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
api-llm:
build:
context: ..
dockerfile: ./docker/api/Dockerfile
args:
SCOPE: ever-api-llm
image: medos-api
restart: always
environment:
- TZ=${TZ:-Asia/Bangkok}
- SCOPE=ever-api-llm
- TRANSPORTER=nats://nats:4222
- MONGO_URI=mongodb://mongo/medos-db?replicaSet=rs0
- OLLAMA_URL=http://ollama:11434
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
depends_on:
mongo: { condition: service_healthy }
nats: { condition: service_healthy }
ollama: { condition: service_started }
volumes:
ollama-data:
9.2 Per-region defaults
| Region | Default chat model | Default embed model | Locale field in templates |
|---|---|---|---|
| Thailand | qwen2.5:7b-instruct | bge-m3 | description_th |
| Japan | qwen2.5:14b-instruct | bge-m3 | description_ja |
| Philippines | llama3.1:8b-instruct | nomic-embed-text | description (English-first) |
Selected at seed time; super-admin can change later.
9.3 GPU vs CPU
Ollama runs CPU-only by default. To enable GPU (NVIDIA), add to ollama service:
deploy:
resources:
reservations:
devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]
This is documented in docs/llm-platform-deployment.md (created in loop 20).
10. Security & RBAC
| Layer | Mechanism |
|---|---|
| Config CRUD (UI) | Container guard + Supabase RLS (llm_is_super_admin()) |
/api/llm/chat, /search, /embed |
JWT required; rate limited per useCase.rateLimitPerMin |
/api/admin/llm/* |
JWT + super-admin role check in public-api controller |
| PHI in context | Pass-through to local Ollama only; never sent to external API; redaction hook in chat orchestrator (regex for ID numbers, configurable per region) |
| Audit | Every inference logs to llm_audit_log; super-admin can review |
| Tenant isolation | Use-case code is global; corpora can be scoped by adding tenant_id in metadata in v2 if needed |
11. Multi-region considerations
- Each region’s Supabase project gets the same migration (045) via
cross-region-policy-gates-deployment.mdpattern. - Seed data lives in
infrastructure/market-packs/{region}/seed-llm-defaults.sqlper region (loop 19). - Vercel projects don’t need changes —
/api/llm/*proxies via existingvercel.jsonrewrites to the same ALB. - On-prem deployments get Ollama on the same Docker network; models are pulled once and cached in the
ollama-datavolume.
12. Phased rollout (loop ordering)
| Loop | Deliverable |
|---|---|
| 1 | This design doc |
| 2 | Supabase migration 045_llm_platform.sql |
| 3 | packages/platform-api-schema/src/llm/ entities |
| 4 | services/llm/ skeleton |
| 5 | Ollama HTTP provider |
| 6 | Model registry actions |
| 7 | Use-case registry actions |
| 8 | Corpus registry + chunker |
| 9 | Embedding service + retriever |
| 10 | Chat orchestrator |
| 11 | Public-API route surface |
| 12 | Docker compose |
| 13 | Frontend service layer |
| 14 | Models admin page |
| 15 | Use-cases admin page |
| 16 | Corpora admin page |
| 17 | Playground |
| 18 | Route reg + dashboard tile |
| 19 | Default seed (bilingual, per region) |
| 20 | UAT doc + verification report |
13. Out of scope (post-20-loop)
- Streaming SSE responses
- Tool / function calling end-to-end (schema is reserved; orchestrator passes through but no built-in tool dispatcher)
- Image-input (vision) capability flag exists; runtime path skipped
- Auto-train / DPO loops
- Per-tenant fine-tuning
- vLLM provider (Ollama-only in v1)
- Cost accounting in currency units (token counts only)
These belong in a v2 design once the v1 platform is in operator hands.