Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 20 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ uv add --dev <package> # Add dev dependency

## 4. Coding Standards

- **Python**: 3.10-3.11, prefer sync for legacy EHR compatibility; async available for modern systems but use only when explicitly needed
- **Python**: 3.10-3.13, prefer sync for legacy EHR compatibility; async available for modern systems but use only when explicitly needed
- **Dependencies**: Pydantic v2 (<2.11.0), NumPy <2.0.0 (spaCy compatibility)
- **Environment**: Use `uv` to manage dependencies and run commands (`uv run <command>`)
- **Formatting**: `ruff` enforces project style
Expand Down Expand Up @@ -140,9 +140,26 @@ When responding to user instructions, follow this process:
- Prefer existing abstractions over new ones
- Run: `uv run ruff check . --fix && uv run ruff format .`
- If stuck, return to step 3 to re-plan
5. **Review**: Summarize files changed, key design decisions, and any follow-ups or TODOs
5. **Review**: Summarize files changed, key design decisions, and any follow-ups or TODOs. Always check:
- **Tests**: Do any existing tests need updating? Are there gaps worth flagging?
- **Docs**: Do any doc pages, docstrings, or examples need updating?
- **Cookbooks**: Do any cookbook examples need updating to reflect the change?
6. **Session Boundaries**: If request isn't related to current context, suggest starting fresh to avoid confusion

### Committing Changes

When the developer is ready to commit, AI should:
1. Run the review checklist from step 5 above — flag anything outstanding
2. Run `git status` and `git diff` to identify what's changed
3. Group changes into logical commits if needed (e.g. don't mix a feature with a test infra change)
4. Suggest which files to stage for each commit
5. Propose a commit message following [Conventional Commits](https://www.conventionalcommits.org/) style
6. Let the developer review and run the actual `git commit` themselves — AI never runs `git commit` or `git push`

### Writing Cookbook Examples

Follow the principles in [CONTRIBUTING.md — Writing Cookbooks](../CONTRIBUTING.md#writing-cookbooks). Key rule: reduce time-to-running above all else — pre-bake demo data, collapse advanced setup, and lead with the problem the cookbook solves.

### Adding New FHIR Resource Utilities

1. Check for existing utilities in @healthchain/fhir/
Expand All @@ -164,4 +181,4 @@ When responding to user instructions, follow this process:

---

**Last updated**: 2025-12-17
**Last updated**: 2026-04-20
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ When writing docs:
- Include code snippets or configuration examples where helpful.
- Call out assumptions, limitations, and safety‑relevant behaviour explicitly.

### Writing Cookbooks

Cookbooks are often the first thing a developer runs. These principles keep them effective:

- **Reduce time-to-running**: Every prerequisite you can eliminate or defer is a developer you don't lose. Pre-bake demo data and models where possible; collapse advanced setup into `??? details` blocks.
- **Lead with the problem**: The intro should say what pain it solves — "you trained a model on CSVs, now you need to deploy against FHIR data" — not just what the code does.
- **Show HealthChain's unique value**: Each cookbook should have a moment that would be 50+ lines of custom code without HealthChain (`Dataset.from_fhir_bundle()`, `merge_bundles()`, `FHIRAuthConfig.from_env()`). Don't bury it.
- **Complement, don't replace**: Anywhere an existing tool (LangChain, FastAPI, sklearn) appears alongside HealthChain, say explicitly that they work together. Reduces the "should I switch?" anxiety.
- **Be honest about the roadmap**: Compliance, eval, and audit features are in progress — reference them as such. Developers trust you more for it.
- **Collapse advanced paths, don't omit them**: The `??? details` pattern keeps the main path clean without losing information for power users.

## 💻 Writing Code

>**New to HealthChain?** Look for [`good first issue`](https://github.com/dotimplement/HealthChain/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and [`help wanted`](https://github.com/dotimplement/HealthChain/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) labels.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,4 @@ This project builds on [fhir.resources](https://github.com/nazrulworld/fhir.reso
[build]: https://github.com/dotimplement/HealthChain/actions?query=branch%3Amain
[discord]: https://discord.gg/UQC6uAepUz
[substack]: https://jenniferjiangkells.substack.com/
[claude-md]: CLAUDE.MD
[claude-md]: CLAUDE.md
1 change: 0 additions & 1 deletion cookbook/cds_discharge_summarizer_hf_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ def discharge_summarizer(request: CDSRequest) -> CDSResponse:
app = HealthChainAPI(
title="Discharge Note Summarizer",
description="AI-powered discharge note summarization service",
port=8000,
service_type="cds-hooks",
)
app.register_service(cds, path="/cds")
Expand Down
1 change: 0 additions & 1 deletion cookbook/cds_discharge_summarizer_hf_trf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ def discharge_summarizer(request: CDSRequest) -> CDSResponse:
app = HealthChainAPI(
title="Discharge Note Summarizer",
description="AI-powered discharge note summarization service",
port=8000,
service_type="cds-hooks",
)
app.register_service(cds, path="/cds")
Expand Down
148 changes: 148 additions & 0 deletions cookbook/data/medplum_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Seed Medplum with a synthetic patient for FHIR Q&A demos.

Setup:
1. Sign up at https://app.medplum.com and create a client application
(Settings → Security → Client Applications → New Client)
2. Add to .env:
MEDPLUM_CLIENT_ID=your_client_id
MEDPLUM_CLIENT_SECRET=your_client_secret
MEDPLUM_BASE_URL=https://api.medplum.com/fhir/R4
MEDPLUM_TOKEN_URL=https://api.medplum.com/oauth2/token

Run:
uv run python cookbook/data/medplum_seed.py

Output:
Prints the created patient ID — add it to .env as DEMO_PATIENT_ID.
"""

import httpx
from dotenv import load_dotenv
from urllib.parse import urlparse, urlunparse

from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
from healthchain.gateway.clients.auth import OAuth2TokenManager

load_dotenv()

config = FHIRAuthConfig.from_env("MEDPLUM")
_token_manager = OAuth2TokenManager(config.to_oauth2_config())


def _auth_headers() -> dict:
return {
"Authorization": f"Bearer {_token_manager.get_access_token()}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
}


def _transaction_url(raw_base_url: str) -> str:
"""Normalize base URL so httpx always receives an HTTP(S) endpoint."""
parsed = urlparse(raw_base_url)

if parsed.scheme == "fhir":
# Support connection-string style env values:
# fhir://host/path?client_id=...&token_url=...
return urlunparse(("https", parsed.netloc, parsed.path, "", "", "")).rstrip("/")

if parsed.scheme in {"http", "https"}:
# Drop query params if someone copied a connection-string-like URL into BASE_URL.
return urlunparse(
(parsed.scheme, parsed.netloc, parsed.path, "", "", "")
).rstrip("/")

raise ValueError(
f"Unsupported MEDPLUM_BASE_URL scheme '{parsed.scheme or '<missing>'}'. "
"Use https://... or fhir://..."
)


PATIENT = {
"resourceType": "Patient",
"name": [{"use": "official", "given": ["Sarah"], "family": "Johnson"}],
"gender": "female",
"birthDate": "1979-03-15",
}


def _resources(patient_id: str) -> list:
ref = f"Patient/{patient_id}"
return [
{
"resourceType": "Condition",
"subject": {"reference": ref},
"code": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/icd-10",
"code": "C53.9",
"display": "Malignant neoplasm of cervix uteri, unspecified",
}
],
"text": "Cervical cancer",
},
"clinicalStatus": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "active",
}
]
},
"onsetDateTime": "2025-11-01",
},
{
"resourceType": "Appointment",
"status": "booked",
"description": "Colposcopy follow-up",
"start": "2026-04-10T10:00:00Z",
"end": "2026-04-10T10:30:00Z",
"participant": [
{
"actor": {"reference": ref},
"status": "accepted",
}
],
},
{
"resourceType": "CarePlan",
"status": "active",
"intent": "plan",
"subject": {"reference": ref},
"title": "Cervical Cancer Treatment Plan",
"description": (
"Stage IB cervical cancer. Treatment: radical hysterectomy followed by "
"adjuvant chemoradiation if margins involved. Monthly monitoring for 2 years. "
"Clinical nurse specialist available Mon-Fri 09:00-17:00, ext. 4821."
),
},
]


def main():
base_url = _transaction_url(config.base_url)
with httpx.Client(timeout=30) as client:
# Step 1: create patient, get real ID
r = client.post(f"{base_url}/Patient", headers=_auth_headers(), json=PATIENT)
r.raise_for_status()
patient_id = r.json()["id"]

# Step 2: create remaining resources referencing the real patient ID
for resource in _resources(patient_id):
r = client.post(
f"{base_url}/{resource['resourceType']}",
headers=_auth_headers(),
json=resource,
)
r.raise_for_status()

print("Seeded patient: Sarah Johnson (cervical cancer, stage IB)")
print(f"Patient ID: {patient_id}")
print(f"\nAdd to .env:\n DEMO_PATIENT_ID={patient_id}")


if __name__ == "__main__":
main()
151 changes: 151 additions & 0 deletions cookbook/fhir_context_llm_qa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
FHIR-Grounded Patient Q&A

Pulls patient data from a FHIR store, formats it as context, and serves
a Q&A endpoint powered by any LangChain-compatible LLM.

Requirements:
pip install healthchain langchain-core langchain-anthropic python-dotenv

Setup:
1. Run: python cookbook/data/medplum_seed.py
2. Add to .env:
MEDPLUM_CLIENT_ID=your_client_id
MEDPLUM_CLIENT_SECRET=your_client_secret
MEDPLUM_BASE_URL=https://api.medplum.com/fhir/R4
MEDPLUM_TOKEN_URL=https://api.medplum.com/oauth2/token
ANTHROPIC_API_KEY=your_api_key # or OPENAI_API_KEY, etc.

Run:
python cookbook/fhir_context_llm_qa.py
# POST /qa {"patient_id": "...", "question": "..."}
# Docs at: http://localhost:8888/docs
"""

from dotenv import load_dotenv
from pydantic import BaseModel

from langchain_core.language_models import BaseChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_anthropic import ChatAnthropic

from healthchain.fhir.r4b import Condition, Appointment, CarePlan

from healthchain.gateway import FHIRGateway, HealthChainAPI
from healthchain.gateway.clients import FHIRAuthConfig
from healthchain.pipeline import Pipeline
from healthchain.io.containers import Document
from healthchain.fhir import merge_bundles


load_dotenv()


class PatientQuestion(BaseModel):
patient_id: str
question: str


class PatientAnswer(BaseModel):
patient_id: str
question: str
answer: str


def create_pipeline() -> Pipeline[Document]:
"""Format a FHIR patient bundle into a structured LLM context string."""
pipeline = Pipeline[Document]()

@pipeline.add_node
def format_context(doc: Document) -> Document:
conditions = doc.fhir.get_resources("Condition")
appointments = doc.fhir.get_resources("Appointment")
careplans = doc.fhir.get_resources("CarePlan")

lines = ["PATIENT CLINICAL CONTEXT"]
if conditions:
lines.append("\nDiagnoses:")
for c in conditions:
onset = c.onsetDateTime
lines.append(
f" - {c.code.text}" + (f" (since {onset})" if onset else "")
)
if appointments:
lines.append("\nUpcoming Appointments:")
for a in appointments:
lines.append(f" - {a.description}: {a.start}")
if careplans:
lines.append("\nCare Plan:")
for cp in careplans:
lines.append(f" {cp.description}")

doc.text = "\n".join(lines)
return doc

return pipeline


def create_chain(llm: BaseChatModel):
"""Q&A chain: patient context + question → grounded answer."""
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a patient information assistant at a hospital. "
"Use the patient's clinical context to give accurate, personalised responses. "
"Do not provide medical advice or diagnoses. "
"Refer clinical questions to the care team.",
),
("human", "{context}\n\nPatient question: {question}"),
]
)
return prompt | llm | StrOutputParser()


def create_app(llm: BaseChatModel) -> HealthChainAPI:
fhir_config = FHIRAuthConfig.from_env("MEDPLUM")

gateway = FHIRGateway()
gateway.add_source("medplum", fhir_config.to_connection_string())

pipeline = create_pipeline()
chain = create_chain(llm)

app = HealthChainAPI(
title="FHIR-Grounded Patient Q&A",
description="Answers patient questions using live FHIR data as context",
service_type="fhir-gateway",
)

@app.post("/qa")
def answer_question(request: PatientQuestion) -> PatientAnswer:
bundles = []
for resource_type in [Condition, Appointment, CarePlan]:
try:
bundle = gateway.search(
resource_type, {"patient": request.patient_id}, "medplum"
)
bundles.append(bundle)
except Exception as e:
print(f"Warning: Could not fetch {resource_type.__name__}: {e}")

doc = Document(data=merge_bundles(bundles))

doc = pipeline(doc)

answer = chain.invoke({"context": doc.text, "question": request.question})
return PatientAnswer(
patient_id=request.patient_id,
question=request.question,
answer=answer,
)

return app


if __name__ == "__main__":
llm = ChatAnthropic(model="claude-opus-4-6", max_tokens=512)
app = create_app(llm)
app.run(port=8888)
3 changes: 1 addition & 2 deletions cookbook/multi_ehr_data_aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:
app = HealthChainAPI(
title="Multi-EHR Data Aggregation",
description="Aggregate patient data from multiple FHIR sources",
port=8888,
service_type="fhir-gateway",
)
app.register_gateway(gateway, path="/fhir")
Expand All @@ -112,4 +111,4 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:


if __name__ == "__main__":
create_app().run()
create_app().run(port=8888)
Loading
Loading