Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c6e032
doc: Add PRD for vital signs chart with openEHR transparency
claude Jan 3, 2026
9a3a19a
feat: Implement vital signs chart with openEHR transparency
claude Jan 4, 2026
fce9ddf
fix: Update pulse archetype CKM link to non-deprecated version
claude Jan 4, 2026
78af59d
fix: Correct encounter archetype CKM link
claude Jan 4, 2026
c994dc5
feat: Require encounter_id when recording vital signs
claude Jan 4, 2026
8e5db61
feat: Add openEHR template management infrastructure
claude Jan 4, 2026
6ab2b5c
feat: Use IDCR Vital Signs template from Ripple-openEHR
claude Jan 4, 2026
a46b042
docs: Update PRD and ADR with actual template implementation
claude Jan 4, 2026
0f05221
build: specify pnpm as the package manager in package.json
platzhersh Jan 4, 2026
0cbc32c
docs: add prisma migrate deploy command to API development instructions
platzhersh Jan 4, 2026
703c8e0
fix: Correct template upload Content-Type and Accept headers
claude Jan 4, 2026
82a1e93
feat: Improve template registration logging
claude Jan 4, 2026
1d3ec29
feat: add basic logging configuration
platzhersh Jan 4, 2026
7bf194d
fix: Update vital signs EHRBase composition structure, archetype vers…
platzhersh Jan 4, 2026
f5798c2
chore: Implement vital signs date validation and update EHRBase templ…
platzhersh Jan 4, 2026
4340783
fix: Add checks for `reading.id` before fetching compositions or swit…
platzhersh Jan 4, 2026
58792a6
chore: Add dedicated EHRbase client methods for formatted composition…
platzhersh Jan 4, 2026
378e145
refactor: Standardize datetime usage to be timezone-aware and add a c…
platzhersh Jan 4, 2026
deb0158
fix: Robustify vital signs input handling and openEHR metadata panel …
platzhersh Jan 4, 2026
2465817
feat: Add error handling and retry for encounter loading in Record Vi…
platzhersh Jan 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Query", "fastapi.Depends", "fastapi.Path"]

[tool.mypy]
python_version = "3.11"
# Enable important checks while being pragmatic about strictness
Expand Down
45 changes: 39 additions & 6 deletions api/src/ehrbase/queries.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
"""Common AQL queries for clinical data retrieval."""

# Get all vital signs for a patient
# Get all vital signs (blood pressure + pulse) for a patient
VITAL_SIGNS_QUERY = """
SELECT
c/uid/value as composition_id,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
o/data[at0001]/events[at0006]/time/value as time
c/context/start_time/value as recorded_at,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
pulse/data[at0002]/events[at0003]/data[at0001]/items[at0004]/value/magnitude as pulse_rate
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
CONTAINS (
OBSERVATION bp[openEHR-EHR-OBSERVATION.blood_pressure.v2] OR
OBSERVATION pulse[openEHR-EHR-OBSERVATION.pulse.v2]
)
WHERE e/ehr_id/value = $ehr_id
ORDER BY o/data[at0001]/events[at0006]/time/value DESC
ORDER BY c/context/start_time/value DESC
"""

# Get vital signs for a patient within a date range
VITAL_SIGNS_DATE_RANGE_QUERY = """
SELECT
c/uid/value as composition_id,
c/context/start_time/value as recorded_at,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic,
bp/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic,
pulse/data[at0002]/events[at0003]/data[at0001]/items[at0004]/value/magnitude as pulse_rate
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS (
OBSERVATION bp[openEHR-EHR-OBSERVATION.blood_pressure.v2] OR
OBSERVATION pulse[openEHR-EHR-OBSERVATION.pulse.v2]
)
WHERE e/ehr_id/value = $ehr_id
AND c/context/start_time/value >= $from_date
AND c/context/start_time/value <= $to_date
ORDER BY c/context/start_time/value DESC
"""

# Get all encounters for a patient
Expand All @@ -37,3 +61,12 @@
CONTAINS INSTRUCTION i[openEHR-EHR-INSTRUCTION.medication_order.v3]
WHERE e/ehr_id/value = $ehr_id
"""

# Count vital signs for a patient
VITAL_SIGNS_COUNT_QUERY = """
SELECT COUNT(c/uid/value) as count
FROM EHR e
CONTAINS COMPOSITION c
WHERE e/ehr_id/value = $ehr_id
AND c/archetype_details/template_id/value = 'open-cis.vital-signs.v1'
"""
228 changes: 219 additions & 9 deletions api/src/observations/router.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,232 @@
from fastapi import APIRouter, HTTPException
"""Router for vital signs observations with openEHR transparency."""

from src.observations.schemas import VitalSignsCreate, VitalSignsResponse
from datetime import datetime
from typing import Literal

from fastapi import APIRouter, HTTPException, Query

from src.ehrbase.client import ehrbase_client
from src.observations.schemas import (
RawCompositionResponse,
TemplateInfo,
TemplateListResponse,
VitalSignsCreate,
VitalSignsListResponse,
VitalSignsResponse,
)
from src.observations.service import observation_service

router = APIRouter()


# ============================================================================
# Vital Signs CRUD
# ============================================================================


@router.post("/vital-signs", response_model=VitalSignsResponse, status_code=201)
async def record_vital_signs(data: VitalSignsCreate):
"""Record vital signs for a patient."""
async def record_vital_signs(data: VitalSignsCreate) -> VitalSignsResponse:
"""Record vital signs for a patient.

Creates a composition in EHRBase with the vital signs data.
Returns the recorded data with openEHR metadata for transparency.
"""
try:
return await observation_service.record_vital_signs(data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e


@router.get("/vital-signs/patient/{patient_id}")
async def get_patient_vital_signs(patient_id: str):
"""Get all vital signs for a patient."""
vital_signs = await observation_service.get_vital_signs_for_patient(patient_id)
return {"vital_signs": vital_signs}
@router.get("/vital-signs", response_model=VitalSignsListResponse)
async def list_vital_signs(
patient_id: str = Query(..., description="Patient ID (required)"),
from_date: datetime | None = Query(None, description="Start of date range"),
to_date: datetime | None = Query(None, description="End of date range"),
skip: int = Query(0, ge=0, description="Pagination offset"),
limit: int = Query(100, ge=1, le=1000, description="Page size"),
) -> VitalSignsListResponse:
"""List vital signs for a patient.

Returns paginated list of vital signs readings with openEHR metadata.
"""
return await observation_service.get_vital_signs_for_patient(
patient_id=patient_id,
from_date=from_date,
to_date=to_date,
skip=skip,
limit=limit,
)


@router.get("/vital-signs/{composition_uid}", response_model=VitalSignsResponse)
async def get_vital_signs(
composition_uid: str,
patient_id: str = Query(..., description="Patient ID for EHR lookup"),
) -> VitalSignsResponse:
"""Get a single vital signs reading by composition UID."""
result = await observation_service.get_vital_signs(composition_uid, patient_id)
if not result:
raise HTTPException(status_code=404, detail="Vital signs not found")
return result


@router.delete("/vital-signs/{composition_uid}", status_code=204)
async def delete_vital_signs(
composition_uid: str,
patient_id: str = Query(..., description="Patient ID for EHR lookup"),
) -> None:
"""Delete a vital signs composition."""
success = await observation_service.delete_vital_signs(composition_uid, patient_id)
if not success:
raise HTTPException(status_code=404, detail="Vital signs not found or delete failed")


# ============================================================================
# openEHR Transparency Endpoints
# ============================================================================


@router.get("/openehr/templates", response_model=TemplateListResponse)
async def list_templates() -> TemplateListResponse:
"""List all available operational templates in EHRBase."""
try:
templates = await ehrbase_client.list_templates()
return TemplateListResponse(
templates=[
TemplateInfo(
template_id=t.get("template_id", ""),
concept=t.get("concept"),
archetype_id=t.get("archetype_id"),
)
for t in templates
]
)
except Exception as e:
raise HTTPException(
status_code=503, detail=f"Failed to fetch templates: {e}"
) from e


@router.get("/openehr/templates/{template_id}")
async def get_template_info(template_id: str) -> dict:
"""Get detailed information about a template.

Returns the template structure with example paths.
"""
try:
# Get template example to show structure
client = await ehrbase_client._get_client()
response = await client.get(
f"/openehr/v1/definition/template/adl1.4/{template_id}/example",
params={"format": "FLAT"},
)
if response.status_code == 200:
return {
"template_id": template_id,
"format": "FLAT",
"example": response.json(),
}
raise HTTPException(status_code=404, detail="Template not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=503, detail=f"Failed to fetch template: {e}"
) from e


@router.get("/openehr/compositions/{composition_uid}", response_model=RawCompositionResponse)
async def get_raw_composition(
composition_uid: str,
patient_id: str = Query(..., description="Patient ID for EHR lookup"),
format: Literal["FLAT", "STRUCTURED"] = Query("FLAT", description="Composition format"),
) -> RawCompositionResponse:
"""Get raw composition data for transparency.

Returns the full composition in the requested format (FLAT or STRUCTURED).
"""
result = await observation_service.get_raw_composition(
composition_uid, patient_id, format
)
if not result:
raise HTTPException(status_code=404, detail="Composition not found")
return RawCompositionResponse(**result)


@router.get("/openehr/compositions/{composition_uid}/paths")
async def get_composition_paths(
composition_uid: str,
patient_id: str = Query(..., description="Patient ID for EHR lookup"),
) -> dict:
"""Get all paths in a composition for transparency.

Returns a flattened list of all paths and values in the composition.
"""
result = await observation_service.get_raw_composition(
composition_uid, patient_id, "FLAT"
)
if not result:
raise HTTPException(status_code=404, detail="Composition not found")

composition = result.get("composition", {})
paths = []

for path, value in composition.items():
paths.append(
{
"path": path,
"value": value,
"type": type(value).__name__,
}
)

return {
"composition_uid": composition_uid,
"template_id": result.get("template_id"),
"paths": sorted(paths, key=lambda x: x["path"]),
}


@router.get("/openehr/archetypes/{archetype_id}")
async def get_archetype_info(archetype_id: str) -> dict:
"""Get information about an archetype.

Provides archetype details and link to Clinical Knowledge Manager (CKM).
"""
# Parse archetype ID to construct CKM URL
# Format: openEHR-EHR-OBSERVATION.blood_pressure.v2
parts = archetype_id.split(".")
if len(parts) >= 2:
concept = parts[-2] # e.g., "blood_pressure"
else:
concept = archetype_id

# Known archetype mappings
ckm_links = {
"openEHR-EHR-OBSERVATION.blood_pressure.v2": "https://ckm.openehr.org/ckm/archetypes/1013.1.3574",
"openEHR-EHR-OBSERVATION.pulse.v2": "https://ckm.openehr.org/ckm/archetypes/1013.1.170",
"openEHR-EHR-COMPOSITION.encounter.v1": "https://ckm.openehr.org/ckm/archetypes/1013.1.1366",
}

descriptions = {
"openEHR-EHR-OBSERVATION.blood_pressure.v2": (
"The local systemic arterial blood pressure which is a surrogate "
"for arterial pressure in the systemic circulation."
),
"openEHR-EHR-OBSERVATION.pulse.v2": (
"The rate and associated attributes for a pulse or heart beat."
),
"openEHR-EHR-COMPOSITION.encounter.v1": (
"Interaction, contact or care event between a subject of care "
"and healthcare provider(s)."
),
}

return {
"archetype_id": archetype_id,
"concept": concept,
"description": descriptions.get(archetype_id, "No description available"),
"ckm_url": ckm_links.get(archetype_id),
"reference_model": "EHR" if "EHR-" in archetype_id else "DEMOGRAPHIC",
"type": parts[0].split("-")[-1] if "-" in parts[0] else "UNKNOWN",
}
Loading