Vai al contenuto

Deep-dive · Health Layer + Medical Wearables

Phase: 4 (tracker) Versione: maggio 2026 Stack Python: httpx 0.28, pydantic 2.11, authlib 1.4, hvac 2.3, fhir-resources 8.0

1. Architettura del Medical Agent

Provider abstraction

# agents/medical-agent/providers/base.py
from abc import ABC, abstractmethod
from datetime import date
from pydantic import BaseModel


class SleepRecord(BaseModel):
    date: date
    total_sleep_minutes: int
    deep_sleep_minutes: int
    rem_sleep_minutes: int
    efficiency_pct: float
    latency_minutes: int
    source: str


class HRVRecord(BaseModel):
    timestamp: str
    rmssd_ms: float
    sdnn_ms: float | None = None
    source: str


class ActivityRecord(BaseModel):
    date: date
    steps: int
    active_calories: int
    distance_meters: float
    source: str


class BaseWearableProvider(ABC):
    provider_id: str
    display_name: str

    @abstractmethod
    async def fetch_sleep(self, days: int = 7) -> list[SleepRecord]: ...

    @abstractmethod
    async def fetch_hrv(self, days: int = 7) -> list[HRVRecord]: ...

    @abstractmethod
    async def fetch_activity(self, days: int = 7) -> list[ActivityRecord]: ...

    @abstractmethod
    async def refresh_token(self) -> None: ...

Token vault

Opzione A — HashiCorp Vault (produzione):

vault secrets enable -path=jarvis/medical kv-v2
vault kv put jarvis/medical/oura/user_alice \
  access_token="<tok>" \
  refresh_token="<ref>" \
  expires_at="2026-05-09T18:00:00Z"

Opzione B — age + sops (self-hosted):

age-keygen -o ~/.config/jarvis/age.key
sops --age $(cat ~/.config/jarvis/age.key.pub) \
     --encrypt secrets/medical_tokens.yaml > secrets/medical_tokens.enc.yaml

Polling vs webhook strategy

Provider Strategia Latenza
Oura v2 Polling 30 min ~15 min lag
Whoop v2 Webhook + polling fallback Near real-time
Polar AccessLink Polling on-demand Dopo sync watch
Garmin Health Push (partner program) Near real-time
Withings Webhook Near real-time
Dexcom CGM Polling 5 min 1-3h delay
Apple HealthKit Companion app push iOS background
Google Health Connect On-device SDK Locale

Conflict resolution

PROVIDER_PRIORITY = {
    "dexcom": 100,    # FDA-cleared
    "oura": 80,
    "whoop": 75,
    "polar": 70,
    "garmin": 65,
    "apple_healthkit": 60,
    "withings": 55,
}


def resolve_hr(readings: list[tuple[str, float]], strategy: str) -> float:
    if strategy == "highest_priority":
        return max(readings, key=lambda r: PROVIDER_PRIORITY.get(r[0], 0))[1]
    if strategy == "average":
        return sum(v for _, v in readings) / len(readings)
    if strategy == "most_recent":
        return readings[-1][1]
    raise ValueError(strategy)

2. Provider OAuth flows (esempi codice)

Oura Ring v2 (OAuth 2.0)

# Scope: email, personal, daily, heartrate, workout, spo2Daily
# Rate limit: 5000 req / 5 min · Token expiry: 1h

class OuraProvider(BaseWearableProvider):
    provider_id = "oura"

    def __init__(self, access_token, refresh_token, client_id, client_secret):
        self._access_token = access_token
        self._refresh_token = refresh_token
        self._client_id = client_id
        self._client_secret = client_secret

    async def refresh_token(self):
        async with httpx.AsyncClient() as c:
            r = await c.post(
                "https://api.ouraring.com/oauth/token",
                data={
                    "grant_type": "refresh_token",
                    "refresh_token": self._refresh_token,
                    "client_id": self._client_id,
                    "client_secret": self._client_secret,
                },
            )
            r.raise_for_status()
            p = r.json()
            self._access_token = p["access_token"]
            self._refresh_token = p["refresh_token"]

    async def fetch_sleep(self, days=7):
        start = (date.today() - timedelta(days=days)).isoformat()
        async with httpx.AsyncClient() as c:
            r = await c.get(
                "https://api.ouraring.com/v2/usercollection/daily_sleep",
                headers={"Authorization": f"Bearer {self._access_token}"},
                params={"start_date": start},
            )
            r.raise_for_status()
            return [
                SleepRecord(
                    date=item["day"],
                    total_sleep_minutes=item["contributors"]["total_sleep"] // 60,
                    deep_sleep_minutes=item["contributors"]["deep_sleep"] // 60,
                    rem_sleep_minutes=item["contributors"]["rem_sleep"] // 60,
                    efficiency_pct=item["contributors"]["efficiency"],
                    latency_minutes=item["contributors"]["latency"] // 60,
                    source="oura",
                )
                for item in r.json().get("data", [])
            ]

Whoop v2 (OAuth + Webhook HMAC)

class WhoopProvider(BaseWearableProvider):
    provider_id = "whoop"

    def validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
        expected = hmac.new(
            self._webhook_secret.encode(), raw_body, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, signature)

    async def fetch_sleep(self, days=7):
        start = (date.today() - timedelta(days=days)).isoformat()
        async with httpx.AsyncClient() as c:
            r = await c.get(
                "https://api.prod.whoop.com/developer/v1/activity/sleep",
                headers={"Authorization": f"Bearer {self._access_token}"},
                params={"start": f"{start}T00:00:00.000Z", "limit": 25},
            )
            r.raise_for_status()
            return [_to_sleep_record(item) for item in r.json().get("records", [])]


# Subscribe webhook (one-time)
async def subscribe_whoop_webhook(token: str, callback_url: str):
    async with httpx.AsyncClient() as c:
        await c.post(
            "https://api.prod.whoop.com/developer/v1/webhook",
            headers={"Authorization": f"Bearer {token}"},
            json={
                "url": callback_url,
                "event_types": ["recovery.updated", "sleep.updated", "workout.updated"],
            },
        )

Garmin Health API (OAuth 1.0a — attenzione sicurezza)

OAuth 1.0a usa HMAC-SHA1, niente refresh token, token a lunga scadenza. Richiede approvazione partner program. La libreria requests_oauthlib gestisce la firma.

Withings, Fitbit/Google Health, Dexcom

  • Withings: OAuth 2.0 web flow, sandbox available
  • Fitbit/Google Health: migrazione completa entro settembre 2026, re-consent obbligatorio
  • Dexcom CGM: OAuth 2.0, FDA-cleared. Limited Access (max 5 utenti) — Full Access via partnership

3. HAPI FHIR vault

Deploy con Docker

services:
  hapi-fhir:
    image: hapiproject/hapi:v7.4.0
    container_name: jarvis-fhir
    ports:
      - "8080:8080"
    environment:
      hapi.fhir.fhir_version: R4
      hapi.fhir.server_address: "https://fhir.jarvis.local/fhir"
      spring.datasource.url: "jdbc:postgresql://fhir-db:5432/hapi"
      spring.datasource.username: "${FHIR_DB_USER}"
      spring.datasource.password: "${FHIR_DB_PASS}"
    depends_on:
      - fhir-db
    networks:
      - jarvis-health-net   # rete isolata, non esposta

  fhir-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: hapi
      POSTGRES_USER: "${FHIR_DB_USER}"
      POSTGRES_PASSWORD: "${FHIR_DB_PASS}"
    volumes:
      - fhir_pgdata:/var/lib/postgresql/data
    networks:
      - jarvis-health-net

volumes:
  fhir_pgdata:

networks:
  jarvis-health-net:
    driver: bridge
    internal: true   # nessuna connettività esterna

FHIR Observation mapping (HR · HRV · Glucose · Sleep)

Heart rate (LOINC 8867-4):

{
  "resourceType": "Observation",
  "status": "final",
  "category": [{ "coding": [{ "code": "vital-signs" }] }],
  "code": { "coding": [{ "system": "http://loinc.org", "code": "8867-4", "display": "Heart rate" }] },
  "subject": { "reference": "Patient/jarvis-user-alice" },
  "effectiveDateTime": "2026-05-09T07:32:00+02:00",
  "valueQuantity": { "value": 52, "unit": "/min", "system": "http://unitsofmeasure.org" },
  "device": { "display": "Oura Ring v3" }
}

Glucose (LOINC 41653-7):

{
  "resourceType": "Observation",
  "code": { "coding": [{ "system": "http://loinc.org", "code": "41653-7" }] },
  "valueQuantity": { "value": 98, "unit": "mg/dL" },
  "interpretation": [{ "coding": [{ "code": "N", "display": "Normal" }] }],
  "device": { "display": "Dexcom G7 (FDA-cleared)" }
}

SMART on FHIR (condivisione con medico)

Scopes tipici:
patient/Observation.read
patient/Observation.write
patient/Patient.read
launch/patient
openid fhirUser

Backup cifrato

pg_dump -h localhost -U $FHIR_DB_USER hapi \
  | age --recipient $(cat ~/.config/jarvis/age.key.pub) \
  > /backups/fhir/$(date +%Y%m%d).sql.age

4. Open Wearables middleware

Open Wearables (MIT) unifica 10 provider in un'API self-hosted: Garmin, Whoop, Oura, Strava, Apple Health, Samsung Health, Google Health Connect, Polar, Suunto, Ultrahuman.

# Accesso unificato
async with httpx.AsyncClient(base_url="http://openwearables:3000") as c:
    sleep = await c.get("/api/sleep", params={"days": 7})
    hrv = await c.get("/api/hrv", params={"days": 7})

Raccomandazione Jarvis: Open Wearables come strato di acquisizione grezzo + layer FHIR + conflict resolution + coaching custom.

5. Apple HealthKit / Google Health Connect

  • HealthKit: on-device only, no cloud API. Pattern: companion app iOS con HKAnchoredObjectQuery → upload mTLS al server Jarvis.
  • Health Connect: SDK locale Android 14+. Letto on-device, sincronizzato via REST con server.

6. Coaching engine

class CoachingContext(BaseModel):
    user_id: str
    avg_hrv_7d: float
    hrv_trend: str
    avg_sleep_efficiency: float
    resting_hr_trend: str
    training_load_7d: str
    glucose_avg_mgdl: float | None = None


COACHING_PROMPT = """
Sei un coach della salute personale. Analizza i dati biometrici degli ultimi
7 giorni e fornisci raccomandazioni concrete. NON fare diagnosi mediche.
Output: 1 insight principale + 1 raccomandazione immediata + 1 settimanale.
DISCLAIMER: Non sostituisce il parere del medico.
"""

PH-LLM (Nature Medicine 2025): LLM fine-tuned su 15 giorni di dati supera esperti umani in sleep medicine (79% vs 76%).

7. Biometric alerting engine (YAML)

rules:
  - id: hrv_declining
    condition:
      metric: hrv_rmssd_7d_avg
      operator: lt
      threshold_factor: 0.80
      baseline_window_days: 30
    severity: warning
    cooldown_hours: 24
    dispatch:
      - channel: chat
      - channel: mobile_push
        message: "HRV in calo: considera un giorno di recupero."

  - id: glucose_high
    condition:
      metric: glucose_mgdl
      operator: gt
      value: 180
    severity: critical
    cooldown_hours: 1
    dispatch:
      - channel: watch_haptic
        pattern: "sos"
      - channel: mobile_push
        message: "Glicemia a {value} mg/dL. Controlla e agisci."

8. Privacy & compliance

GDPR Art. 9 — Categorie speciali

  • Base giuridica: consenso esplicito (Art. 9.2.a) o tutela salute (Art. 9.2.h)
  • DPIA obbligatoria prima del deploy
  • Data minimization: solo dati necessari
  • Retention policy definita

Audit log

class AuditEvent(BaseModel):
    timestamp: str
    user_id: str
    action: str  # read|write|delete|export
    resource_type: str
    resource_id: str
    provider: str
    outcome: str  # success|denied|error


def log_health_access(event: AuditEvent) -> None:
    log.info("health_data_access", **event.model_dump())
    # Scrivere anche FHIR AuditEvent resource

Right to be forgotten (Art. 17)

async def delete_user_health_data(user_id: str, fhir_base: str, token: str):
    async with httpx.AsyncClient() as c:
        # Cancella Observation
        search = await c.get(
            f"{fhir_base}/Observation",
            headers={"Authorization": f"Bearer {token}"},
            params={"subject": f"Patient/{user_id}"},
        )
        for entry in search.json().get("entry", []):
            await c.delete(
                f"{fhir_base}/Observation/{entry['resource']['id']}",
                headers={"Authorization": f"Bearer {token}"},
            )
        # Cancella Patient
        await c.delete(
            f"{fhir_base}/Patient/{user_id}",
            headers={"Authorization": f"Bearer {token}"},
        )
        # Revoca tutti i token OAuth dal vault (operazione separata)

Disclaimer medico (obbligatorio)

Le informazioni hanno finalità informative e di supporto al benessere. Non costituiscono diagnosi medica e non sostituiscono il consulto di un professionista sanitario abilitato. In caso di sintomi consultare sempre un medico.

Riferimenti