Skip to content

Commit e1ec874

Browse files
committed
feat: FHIR R4 conformance, integration push resilience, OCR + worker fixes
Squash merge of fix/fhir-r4-conformance (49 commits). FHIR R4 facade conformance & honesty (F2-F18): - Advertise R4 4.0.1 (was R4B 4.3.0); honest CapabilityStatement (drop unimplemented batch/transaction/history/validate, dynamic date) - Honest versioning (no-version) + If-Match optimistic locking (412) - Spec-compliant pagination links (page=N), _elements projection, _summary=count, _total=none, _format=xml explicit reject - Token/category search via JSONB @> containment; date precision semantics; sort column corrections; search.mode on every entry FHIR import pipeline (G6-G11, I4-I7): - Import all 15 resource types (was 7); honor entry.request.method (PUT/POST/DELETE/ifNoneExist) for transaction bundles - Record Provenance per entry; cross-tenant collision warnings - Reference routing for bare urn:uuid via FIELD_HINT_TO_TYPE - Observation.component + canonical interpretation JSONB columns - validate_and_filter_observations no longer mutates caller's list - Patient schema 0..* cardinality for name/address/telecom Tenant bridge & auth (F19, G10): - Service-account JWT (POST /auth/service-account, password-less) - X-Tenant header override for SYSTEM_ADMIN - Provenance.agent.who resolves to real Practitioner/Device (F12) - DocumentReference.author resolves to Practitioner (F11) Integration sync & push (H1-H8): - Shared run_sync pipeline (worker + manual endpoint) - Push resilience: per-row 401 retry, cursor integrity, batch isolation - Remote Provenance POST after push; insufficient_scope detection - OAuth hygiene: atomic state consume, 429 classification, token revocation (RFC 7009), DCR guard, OperationOutcome diagnostics Worker & notification stability (A7, C11-C13): - Worker-scoped NullPool async engine singleton (fixes loop-affinity crash) - Inject worker session into notification trigger task - Deactivate dead push subscriptions on HTTP 410/404 - Skip LLM call when all OCR docs produce empty text OCR pipeline fixes (C7-C9): - Compute relative_score in OCR path (was always NULL) - Correct normalized_value direction (raw -> preferred unit) - Case-insensitive exact match for catalog dedup (was ilike wildcard bug) Hygiene & config (B11, B14, B15, J9): - VAPID keys required in production (prod-guard validator) - setup_env.py auto-generates VAPID key pair + prompts contact email - check_observation_access defensive against missing reference key - Remove print()/traceback wrapper from doctors endpoint - Lifespan startup fail-fatal in production Security: redact Web Push endpoints in logs; use fake Fernet key in tests. DB migrations required: f11a2b3c4d5e, f19a2b3c4d5e, i6a7b8c9d0e1, i7b8c9d0e1f2
1 parent 19315a4 commit e1ec874

94 files changed

Lines changed: 8679 additions & 967 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ JWT_EXPIRATION_HOURS=24
9292
# SMTP_PASSWORD=your_app_password
9393
# SMTP_FROM=noreply@health-assistant.local
9494

95-
# ─── Web Push (VAPID) — required for browser notifications ────────────────
96-
# Generate with: npx web-push generate-vapid-keys
95+
# ─── Web Push (VAPID) — required in production for browser notifications ───
96+
# REQUIRED in production — boot refuses to start without these when
97+
# APP_ENV != "development" (see config.py prod-guard).
98+
# Generate either with: scripts/setup_env.py (auto-fills these), OR manually
99+
# with: npx web-push generate-vapid-keys
97100
# VAPID_PUBLIC_KEY=
98101
# VAPID_PRIVATE_KEY=
99102
# VAPID_ADMIN_EMAIL=admin@example.com

CHANGELOG.md

Lines changed: 48 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Add practitioner_id to documents for FHIR DocumentReference.author (F11)
2+
3+
Revision ID: f11a2b3c4d5e
4+
Revises: d2e3f4a5b6c7
5+
Create Date: 2026-06-30 00:00:00.000000
6+
7+
Closes audit F11: ``DocumentReference.author`` previously emitted
8+
``Practitioner/<owner_id>`` but ``documents.owner_id`` is a ``ForeignKey``
9+
to ``users.id``, not ``doctors.id`` — so external clients resolving the
10+
reference got 404. This migration adds a nullable ``practitioner_id``
11+
column (FK to ``doctors.id``, ON DELETE SET NULL), backfills it from the
12+
existing owner→doctor mapping, and adds an index for search.
13+
14+
The application layer (``DocumentModel.to_fhir_dict``) emits
15+
``Practitioner/<practitioner_id>`` when the column is set, and omits the
16+
``author`` element otherwise (rather than emitting a wrong reference).
17+
"""
18+
from typing import Sequence, Union
19+
20+
from alembic import op
21+
import sqlalchemy as sa
22+
from sqlalchemy.dialects import postgresql
23+
24+
25+
revision: str = "f11a2b3c4d5e"
26+
down_revision: Union[str, Sequence[str], None] = "d2e3f4a5b6c7"
27+
branch_labels: Union[str, Sequence[str], None] = None
28+
depends_on: Union[str, Sequence[str], None] = None
29+
30+
31+
def upgrade() -> None:
32+
# 1. Add the nullable column. Type matches doctors.id (UUID).
33+
op.add_column(
34+
"documents",
35+
sa.Column(
36+
"practitioner_id",
37+
postgresql.UUID(as_uuid=True),
38+
nullable=True,
39+
comment="Resolved Practitioner (DoctorModel) id for FHIR "
40+
"DocumentReference.author. Backfilled from owner_id at migration "
41+
"time; set on new uploads via owner→doctor lookup.",
42+
),
43+
)
44+
45+
# 2. Backfill from the existing owner→doctor mapping. Each user may have
46+
# at most one doctor row per tenant; pick the first match deterministically.
47+
op.execute(
48+
"""
49+
UPDATE documents d
50+
SET practitioner_id = sub.id
51+
FROM (
52+
SELECT DISTINCT ON (d2.tenant_id, d2.owner_id)
53+
d2.id, d2.tenant_id, d2.owner_id
54+
FROM documents d2
55+
JOIN doctors doc ON doc.user_id = d2.owner_id
56+
AND doc.tenant_id = d2.tenant_id
57+
WHERE d2.owner_id IS NOT NULL
58+
ORDER BY d2.tenant_id, d2.owner_id, doc.id
59+
) AS sub
60+
WHERE d.tenant_id = sub.tenant_id
61+
AND d.owner_id = sub.owner_id
62+
"""
63+
)
64+
65+
# 3. Add the FK constraint + index.
66+
op.create_foreign_key(
67+
"fk_documents_practitioner_id_doctors",
68+
"documents",
69+
"doctors",
70+
["practitioner_id"],
71+
["id"],
72+
ondelete="SET NULL",
73+
)
74+
op.create_index(
75+
"ix_documents_practitioner_id",
76+
"documents",
77+
["practitioner_id"],
78+
)
79+
80+
81+
def downgrade() -> None:
82+
op.drop_index("ix_documents_practitioner_id", table_name="documents")
83+
op.drop_constraint(
84+
"fk_documents_practitioner_id_doctors",
85+
"documents",
86+
type_="foreignkey",
87+
)
88+
op.drop_column("documents", "practitioner_id")
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Service-account flag on users (F19)
2+
3+
Revision ID: f19a2b3c4d5e
4+
Revises: i7b8c9d0e1f2
5+
Create Date: 2026-07-01
6+
7+
Adds ``users.is_service_account`` (BOOLEAN NOT NULL DEFAULT false) and relaxes
8+
``users.hashed_password`` to nullable so password-less machine accounts can be
9+
created. Service accounts are minted by admins via ``POST /auth/service-account``
10+
and carry a long-lived JWT with ``is_service_account=True`` — they authenticate
11+
against the FHIR facade and REST API as a bearer token without a password.
12+
"""
13+
from typing import Sequence, Union
14+
15+
from alembic import op
16+
import sqlalchemy as sa
17+
18+
revision: str = "f19a2b3c4d5e"
19+
down_revision: Union[str, Sequence[str], None] = "i7b8c9d0e1f2"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def upgrade() -> None:
25+
op.execute(
26+
"ALTER TABLE users "
27+
"ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN DEFAULT false"
28+
)
29+
op.execute(
30+
"UPDATE users SET is_service_account = false WHERE is_service_account IS NULL"
31+
)
32+
op.alter_column(
33+
"users", "is_service_account",
34+
existing_type=sa.Boolean(),
35+
nullable=False,
36+
server_default="false",
37+
)
38+
# Relax hashed_password so service accounts (which have no password) can be created.
39+
op.alter_column(
40+
"users", "hashed_password",
41+
existing_type=sa.String(255),
42+
nullable=True,
43+
)
44+
op.create_index(
45+
"ix_users_is_service_account",
46+
"users",
47+
["is_service_account"],
48+
postgresql_where=sa.text("is_service_account = true"),
49+
)
50+
51+
52+
def downgrade() -> None:
53+
op.drop_index("ix_users_is_service_account", table_name="users")
54+
# Re-set NULL passwords to a dummy hash before re-tightening.
55+
op.execute(
56+
"UPDATE users SET hashed_password = '!' WHERE hashed_password IS NULL"
57+
)
58+
op.alter_column(
59+
"users", "hashed_password",
60+
existing_type=sa.String(255),
61+
nullable=False,
62+
)
63+
op.alter_column(
64+
"users", "is_service_account",
65+
existing_type=sa.Boolean(),
66+
nullable=True,
67+
server_default=None,
68+
)
69+
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS is_service_account")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Observation.interpretation: String -> JSONB (I6)
2+
3+
Revision ID: i6a7b8c9d0e1
4+
Revises: f11a2b3c4d5e
5+
Create Date: 2026-06-30
6+
7+
Stores the canonical FHIR R4 interpretation shape (``0..* CodeableConcept``,
8+
a list) instead of a flattened display string. The backfill wraps legacy
9+
plain-string rows in ``[{"text": <value>}]`` so the round-trip
10+
import → export preserves the LOINC/OBSINT coding instead of collapsing
11+
to a lossy display string.
12+
13+
The reverse (downgrade) flattens the list back to a display string using
14+
the same precedence as ``_flatten_interpretation`` (coding.display →
15+
coding.code → text).
16+
"""
17+
from typing import Sequence, Union
18+
19+
from alembic import op
20+
21+
revision: str = "i6a7b8c9d0e1"
22+
down_revision: Union[str, Sequence[str], None] = "f11a2b3c4d5e"
23+
branch_labels: Union[str, Sequence[str], None] = None
24+
depends_on: Union[str, Sequence[str], None] = None
25+
26+
27+
def upgrade() -> None:
28+
op.execute(
29+
"""
30+
ALTER TABLE fhir_observations
31+
ALTER COLUMN interpretation TYPE JSONB
32+
USING CASE
33+
WHEN interpretation IS NULL THEN NULL
34+
WHEN interpretation ~ '^\\s*(\\[|\\{)' THEN interpretation::jsonb
35+
ELSE jsonb_build_array(jsonb_build_object('text', interpretation))
36+
END
37+
"""
38+
)
39+
40+
41+
def downgrade() -> None:
42+
op.execute(
43+
"""
44+
ALTER TABLE fhir_observations
45+
ALTER COLUMN interpretation TYPE TEXT
46+
USING CASE
47+
WHEN interpretation IS NULL THEN NULL
48+
WHEN jsonb_typeof(interpretation) = 'array'
49+
AND jsonb_array_length(interpretation) > 0
50+
THEN COALESCE(
51+
interpretation->0->'coding'->0->>'display',
52+
interpretation->0->'coding'->0->>'code',
53+
interpretation->0->>'text',
54+
''
55+
)
56+
ELSE interpretation::text
57+
END
58+
"""
59+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Observation.component JSONB column (prereq for H2)
2+
3+
Revision ID: i7b8c9d0e1f2
4+
Revises: i6a7b8c9d0e1
5+
Create Date: 2026-06-30
6+
7+
Adds the canonical FHIR R4 ``Observation.component`` (``0..*``) column as
8+
JSONB so multi-component observations (blood pressure, panels like BMP/CBC)
9+
can be stored and round-tripped. Without this column, the push path (H2)
10+
cannot emit ``component[]`` even after the SDK mapper is fixed, because push
11+
reads ``obs.to_fhir_dict()`` which projects from ORM columns.
12+
13+
A GIN index supports future ``component-code-value-*`` search params.
14+
"""
15+
from typing import Sequence, Union
16+
17+
from alembic import op
18+
import sqlalchemy as sa
19+
from sqlalchemy.dialects import postgresql
20+
21+
revision: str = "i7b8c9d0e1f2"
22+
down_revision: Union[str, Sequence[str], None] = "i6a7b8c9d0e1"
23+
branch_labels: Union[str, Sequence[str], None] = None
24+
depends_on: Union[str, Sequence[str], None] = None
25+
26+
27+
def upgrade() -> None:
28+
op.add_column(
29+
"fhir_observations",
30+
sa.Column("component", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
31+
)
32+
op.create_index(
33+
"ix_fhir_observations_component_gin",
34+
"fhir_observations",
35+
["component"],
36+
postgresql_using="gin",
37+
)
38+
39+
40+
def downgrade() -> None:
41+
op.drop_index("ix_fhir_observations_component_gin", table_name="fhir_observations")
42+
op.drop_column("fhir_observations", "component")

backend/app/ai/pipeline/ontology.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import logging
1111
from typing import Any, Dict
1212

13-
from sqlalchemy import select
13+
from sqlalchemy import select, func
1414
from sqlalchemy.ext.asyncio import AsyncSession
1515

1616
from app.models.biomarker_model import BiomarkerDefinition, Unit
@@ -81,7 +81,7 @@ async def process_unknown_medications(
8181
name_map[def_data.raw_name_match] = def_data.name
8282
existing = await db.execute(
8383
select(MedicationCatalog).where(
84-
MedicationCatalog.name.ilike(def_data.name)
84+
func.lower(MedicationCatalog.name) == func.lower(def_data.name)
8585
)
8686
)
8787
if not existing.scalar_one_or_none():

backend/app/ai/pipeline/persistence.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from app.models.fhir.medication import Medication, MedicationCatalog, MedicationStatus
3030
from app.models.fhir.patient import Observation
3131
from app.models.document_model import DocumentModel
32-
from app.services.fhir_helpers import FhirSerializationError, assert_valid_fhir
32+
from app.services.fhir_helpers import FhirSerializationError, _normalize_interpretation, assert_valid_fhir
3333

3434
logger = logging.getLogger(__name__)
3535

@@ -196,19 +196,55 @@ async def save_observation(
196196
biomarker_id = target_bio.id if target_bio else None
197197
unit_symbol = b.unit_symbol
198198
raw_unit_id = None
199+
# Default normalized_value to the raw value; refined below if we have
200+
# enough unit information to convert. The previous code only normalized
201+
# when the biomarker had a preferred unit *different* from the matched
202+
# unit, leaving auto-created biomarkers (no preferred unit) with mixed-
203+
# unit trends across labs. Now we always normalize at least to the
204+
# base SI unit so trends are consistent.
199205
normalized_val = val_float
200206

207+
# --- relative_score ---------------------------------------------------
208+
# Mirror ObservationBuilder.build() (integrations path): the value's
209+
# position within the reference range as a [0.0, 1.0] float. The
210+
# previous OCR path never set relative_score, leaving the biomarker
211+
# engine's flagship scoring feature silently disabled for OCR data.
212+
relative_score: Optional[float] = None
213+
ref_min = b.reference_range_min
214+
ref_max = b.reference_range_max
215+
if val_float is not None and ref_min is not None and ref_max is not None and ref_max > ref_min:
216+
relative_score = (val_float - ref_min) / (ref_max - ref_min)
217+
relative_score = max(0.0, min(1.0, relative_score))
218+
elif ref_min is not None or ref_max is not None:
219+
# Incomplete range — middle score, matches ObservationBuilder.
220+
relative_score = 0.5
221+
201222
if unit_symbol:
202223
unit_lower = unit_symbol.lower()
203224
if unit_lower in units_by_symbol:
204225
matched_unit = units_by_symbol[unit_lower]
205226
raw_unit_id = matched_unit.id
206-
if (
207-
target_bio
208-
and target_bio.preferred_unit_id
209-
and str(target_bio.preferred_unit_id) != str(matched_unit.id)
210-
):
211-
normalized_val = val_float * matched_unit.conversion_multiplier
227+
# Convert raw value -> base SI unit. matched_unit.conversion_multiplier
228+
# is "multiply this unit's value by this number to get the base unit".
229+
base_value = val_float * matched_unit.conversion_multiplier
230+
# Express normalized_value in the biomarker's preferred unit
231+
# (fall back to base SI when no preferred unit is set, so trends
232+
# are at least consistent across labs reporting in different raw
233+
# units). The previous code skipped normalization entirely when
234+
# no preferred_unit_id was set, AND used the wrong direction
235+
# (raw * raw_mult = base, then labeled it as-preferred).
236+
preferred_unit: Optional[Unit] = None
237+
if target_bio and target_bio.preferred_unit_id:
238+
# Look up the preferred Unit object. units_by_symbol is the
239+
# only Unit collection passed in; scan it once (small set).
240+
for u in units_by_symbol.values():
241+
if str(u.id) == str(target_bio.preferred_unit_id):
242+
preferred_unit = u
243+
break
244+
if preferred_unit is not None and preferred_unit.conversion_multiplier:
245+
normalized_val = base_value / preferred_unit.conversion_multiplier
246+
else:
247+
normalized_val = base_value
212248
else:
213249
new_unit = Unit(
214250
symbol=unit_symbol,
@@ -247,9 +283,10 @@ async def save_observation(
247283
raw_value=val_float,
248284
raw_unit_id=raw_unit_id,
249285
normalized_value=normalized_val,
286+
relative_score=relative_score,
250287
lab_reference_range=lab_ref_range,
251288
method=b.method,
252-
interpretation=b.interpretation_flag,
289+
interpretation=_normalize_interpretation(b.interpretation_flag),
253290
category=_fhir_observation_category(
254291
target_bio.category if target_bio else None
255292
),

0 commit comments

Comments
 (0)