Technical manual
Bulk canonical terminology store for the PDHC platform. Read-only over
HTTP; mutations happen only through the import CLIs. This document
describes the service as it is deployed today (read API + seven
importers, FHIR conformance hardened under rollups #347 / #374).
termbank.pdhc imports upstream vocabularies as-is and serves them
read-only. It owns the canonical URI namespace
https://termbank.pdhc.se/CodeSystem/<system>/<code> (Rule 27). No
other service mints canonical URIs: plan.pdhc mirrors the subset it
curates into its working set, rosetta.pdhc stores mappings that point
at these URIs, and downstream data services (cdr.pdhc, forms.pdhc,
1177.pdhc, request.pdhc) reference them.
Imported vocabularies (seven importers, one system each):
system |
Importer script | Source |
|---|---|---|
loinc |
scripts/import_loinc.py |
LOINC release table |
snomed |
scripts/import_snomed.py |
SNOMED CT Managed Service SE (RF2) |
icd10 |
scripts/import_icd10.py |
ICD-10-SE |
atc |
scripts/import_atc.py |
ATC (Läkemedelsverket / SeNSL) |
kva |
scripts/import_kva.py |
KVA medical (KMA) + surgical (KKA) procedures |
socialstyrelsen |
scripts/import_sosstyr.py |
Socialstyrelsen termbank (full XML) |
icf |
scripts/import_icf.py |
ICF |
There is no ICD-11 importer.
Deliberately narrow (Rule 15): termbank implements CodeSystem
$lookup plus a /fhir/metadata CapabilityStatement, and nothing
else FHIR-shaped. ValueSet + $expand / $validate-code, ConceptMap +
$translate, and TerminologyCapabilities are out of scope by design
— that curated terminology layer lives in plan.pdhc. See
docs/decisions/ADR-001-scope-rule15.md.
Five tables (app/models/__init__.py):
concepts — primary canonical row. Primary key iscanonical_uri itself (no numeric surrogate leaks out; Rule 27 /system, code (unique together viauq_concept_system_code), display, provenance (source,source_url, version), lifecycle (status ∈active|retired|experimental, imported_at, retired_at),parent_uri (hierarchy, null for flat vocabularies), and animport_run_id FK.concept_designations — localised displays. (concept_uri,
language, value) unique; use_code (e.g. preferred, fsn,synonym) drives displayLanguage selection.concept_properties — source-specific attributes. key / valuevalue_type ∈ string|code|integer|decimal|boolean|dateTime;value_type maps to the right FHIR value[x] field on output.import_runs — per-import bookkeeping (source, version,status ∈ running|completed|failed|cancelled,concepts_imported / concepts_failed, operator, notes).audit_log — Rule 24 operation log (actor, action, target,ConceptDesignation and ConceptProperty cascade-delete with their
parent concept.
Routes live in app/api/terminology.py; the app factory and read-only
guard are in app/__init__.py.
GET /fhir/metadataFHIR R5 CapabilityStatement. Advertises CodeSystem $lookup as the
headline operation, plus the shortcut/auxiliary routes and the
termbank-search OperationDefinition. CapabilityStatement.date is
derived from the module file's mtime (not datetime.now()) so it is
identical across gunicorn workers and only advances on an image rebuild
(finding §1.4 / #352).
GET|POST /fhir/CodeSystem/$lookup (and /fhir/CodeSystem/%24lookup)The primary operation. Parameters:
- system (required), code (required)
- displayLanguage (optional)
On GET these are query params; on POST they come from a FHIR
Parameters request body (_extract_from_parameters_body, accepting
valueUri / valueUrl / valueCode / valueString). The %24
(URL-escaped $) path variant is registered for clients that emit it.
Missing system/code → 400 OperationOutcome; no match → 404.
Success → a FHIR Parameters resource.
GET /CodeSystem/<system>/<code>Convenience path equivalent to $lookup. <code> is captured with a
<path:...> converter so codes containing / work. Supports
displayLanguage. Same Parameters response / 404 behaviour.
GET /Concept?uri=<canonical_uri>Lookup by canonical URI. The URI is a query parameter (?uri=...),
not a path segment. Missing uri → 400; no match → 404. Supports
displayLanguage. Same Parameters response.
GET /search?q=&system=&limit=&displayLanguage=Fuzzy, ranked search across Concept.display and designation values in
a single query. q required (empty → empty result set). system
repeatable or comma-separated. limit default 20, clamped to 1..200.
Rank tiers: exact code/display/designation match (1) → display prefix
(2) → preferred-designation prefix (3) → display/designation contains
(4) → designation contains (5); ties broken by active status, then
display length, then canonical URI. Response is plain JSON
({query, systems, system, count, results[]}), not a FHIR Bundle.
GET /healthDB reachability plus a per-source version/staleness summary. Returns
200 / status: "ok" when SELECT 1 succeeds, 503 /
status: "degraded" otherwise (CLAUDE.md §10 shape). Each source row
carries version, concept_count, the pinned <SOURCE>_VERSION env
value, and a stale flag when the pin and the loaded version differ.
version reports TERMBANK_GIT_SHA (default dev).
$lookup / /CodeSystem/<system>/<code> / /Concept → FHIR R5Parameters: name (= system), version, display, thenproperty parameters for code, status, optional parent /deprecationDate, and termbank-scoped canonical-uri, source,source-url, followed by designation and per-property parameters.property parameters, not a_meta envelope (#351).OperationOutcome.A before_request hook returns 405 (OperationOutcome, code
not-supported) for any POST/PUT/PATCH/DELETE, except the
two $lookup POST endpoints (/fhir/CodeSystem/$lookup and
/fhir/CodeSystem/%24lookup), which are reads with no side effects.
GET/HEAD/OPTIONS always pass.
Each importer parses upstream, builds concepts + designations +
properties, and writes them through the shared importer_base.py
upsert. Every importer requires --source-url and --version;
--operator and --batch-size (default 2000) are optional everywhere.
File-input flags differ by source:
| Importer | Required file flag(s) |
|---|---|
import_loinc.py |
--local-file |
import_icd10.py |
--local-file |
import_atc.py |
--local-file |
import_icf.py |
--local-file |
import_snomed.py |
--release-dir (RF2 release folder) |
import_kva.py |
--kma-file and/or --kka-file (at least one) |
import_sosstyr.py |
--local-file optional (downloads from --source-url otherwise) |
There are no --release-csv / --release-tsv flags. Idempotency
(Rule 27): re-running with an already-imported --version is a no-op; a
new --version updates concepts in place while preserving each
canonical_uri.
Example invocations are in docs/user_manual.md.
127.0.0.1:9011) · 9012 Flask app · 9013 reserved.docker-compose.yml): termbank_pdhc_dbpostgres:16, volume termbank_pdhc_pgdata) and termbank_pdhc_appDockerfile). Pin COMPOSE_PROJECT_NAME=termbank_pdhc soapp/config.py, .env.example): DATABASE_URLpostgresql+psycopg2://termbank:changeme@localhost:9011/termbank_pdhc_db),CANONICAL_URI_BASE (default https://termbank.pdhc.se), SECRET_KEY,<SOURCE>_VERSION pins consumed by /health.AUTH_MODE=off — the read API is public (Rule 24), so thereSECRET_KEY in production; pytest is exempt via TESTING=True.migrations/ (flask db upgrade).app/tests/ covers the read API, health, models, per-importer parsing,
and a test_capability_truth.py conformance check that keeps the
CapabilityStatement honest against the routes actually registered. The
Postgres-only search ranking (func.least) is deselected on SQLite via
make test (-k "not search"); make test-all runs everything against
Postgres. make corpus / make conformance emit and validate $lookup
+ CapabilityStatement samples against the HL7 R5 validator.
All ports bind to 127.0.0.1 (loopback only); external traffic arrives
via the reverse proxy.
| Port | Service |
|---|---|
| 9012 | Flask application (Gunicorn) |
| 9011 | PostgreSQL database |