ODOO MDD projet Synergia Withings > x_health_patient

voici une architecture complète, pragmatique et « prête à câbler » pour (1) ingérer les données Withings dans Odoo 

  1. en respectant le consentement et
  2.  exposer ces données à un agent conversationnel OpenAI.

1) Collecte & stockage des données dans Odoo

1.1 Schéma fonctionnel (workflow, texte)


Objets Withings → App Withings (utilisateur) → Cloud Withings (OAuth2 + Webhooks) → Ingestion/ETL (FastAPI/Node) → Contrôles (consentement, normalisation, pseudonymisation) → Odoo (modèles personnalisés + sécurité) → Dashboards/BI (Odoo / export)



1.2 Composants (rôles et points d’attention)

  • Withings Public API + Webhooks (OAuth2, opt-in)
    • Scopes : user.metrics (poids, tension, SPO₂, FC…), user.activity, user.sleepevents.
    • Webhook unique (subscription) ⇒ événement → ETL récupère le détail par API pull.


  • Micro-service Ingestion/ETL (FastAPI ou Node)
    • Reçoit les notifications (id utilisateur Withings + type d’événement).
    • Vérifie le consentement actif dans Odoo avant tout appel API.
    • Récupère les mesures via l’API Withings (fenêtre temporelle ciblée).
    • Normalise (unités SI, horodatage UTC, mapping types).
    • Pseudonymise si demandé (hash salé de l’ID Withings).
    • Pousse dans Odoo (RPC/JSON-RPC) en lots idempotents (upsert par external_uid).


  • Odoo (Online ou On-prem)
    • Modèles personnalisés pour patients, consentements et mesures (agrégées + historisées).
    • Sécurité : groupes “Santé”, règles d’enregistrement (record rules), journal d’audit, politique de rétention.
    • Dashboards : vues pivot/graph (tendance poids, TA, sommeil), rapports PDF.


1.3 Modèle de données Odoo (proposition)

A) Table patient & consentement

x_health_patient - partner_id (m2o res.partner) -> patient - withings_userid (char, index) -> identifiant côté Withings - oauth_status (selection: disconnected|connected|revoked|error) - consent_datetime (datetime, index) - consent_scope (char/json) -> scopes acceptés - consent_expires_on (date) -> optional - pseudonymized (boolean) -> si true, on stocke hashed ids - notes (text) - active (boolean, default=True)

x_health_consent_log - patient_id (m2o x_health_patient, index) - event (selection: grant|refresh|revoke|expire|scope_update) - event_at (datetime, index) - scope (char/json) - actor (char) -> “user”, “system”, “admin” - metadata (text/json)

B) Mesures historisées (schéma générique + spécifiques)

Schéma générique (souple et extensible)

x_health_measure - patient_id (m2o x_health_patient, index) - measure_type (selection: weight|systolic|diastolic|hr|spo2|bmi|fat_pct| sleep_score|sleep_duration|min_hr|steps|calories|... ) - value (float) -> valeur principale - unit (char) -> "kg", "mmHg", "bpm", "%" - measured_at (datetime, index) -> horodatage origine (UTC) - source (char) -> "withings" - external_uid (char, unique) -> idempotence (ex: hash(userId+ts+type)) - raw_payload (json, groups: Santé) -> trace brute minimale

Tables spécialisées (optionnel si vous voulez des champs dédiés)

x_health_bp (systolic, diastolic, hr, measured_at, posture, context) x_health_weight (weight_kg, bmi, fat_pct, muscle_pct, water_pct, bone_mass_kg, measured_at) x_health_sleep (sleep_score, duration_min, deep_min, rem_min, light_min, awake_min, measured_at) x_health_activity (steps, distance_m, calories, measured_at)

Index recommandés

  • (patient_id, measured_at) sur toutes les tables.
  • (patient_id, measure_type, measured_at) sur x_health_measure.
  • Unique (external_uid) pour éviter les doublons.



1.4 Mapping (Withings → Odoo)

DomaineWithings (ex.)Odoo x_health_measure.measure_typeUnité
Poidsweightweightkg
IMCbmibmi
Masse grassefat_ratiofat_pct%
Tensionsystolic, diastolicsystolic, diastolicmmHg
Fréq. cardiaquehrhrbpm
SPO₂spo2spo2%
Sommeilsleep_score, duréessleep_score, sleep_duration– / min
Activitésteps, distance, caloriessteps, calories, etc.pas / kcal


1.5 Sécurité, confidentialité, conformité (RGPD)

  • Consentement explicite (horodaté, scope, révocable).
  • Principe de minimisation : ne stocker que le nécessaire au cas d’usage.
  • Pseudonymisation optionnelle (hash salé pour withings_userid + séparation des secrets).
  • Journal d’audit : création/màj/suppression de mesures + accès utilisateur.
  • Rétention : règle configurable, p. ex. purge auto des bruts au-delà de 13 mois.
  • Groupes & ACL : seuls les membres du groupe “Santé” voient les données santé.
  • Droits du sujet : export/effacement par patient (wizard Odoo dédié).



1.6 Endpoints (ETL) — contrat d’interface

  • POST /webhook/withings
    Body minimal : { "userid": "...", "category": "...", "timestamp": ... }
    Action : vérifie consentement → programme un pull ciblé (fenêtre ±24h).
  • POST /sync/withings/pull (interne, worker/cron)
    Body : { "userid": "...", "from": "...", "to": "..." }
    Action : appelle l’API Withings, normalise, upsert vers Odoo.
  • POST /odoo/upsert
    Body : { "model": "x_health_measure", "records": [ ... ] }
    Action : JSON-RPC Odoo, upsert par external_uid.

Idempotence & robustesse

  • external_uid = sha256(withings_userid|measured_at|type)
  • Retry exponentiel, circuit-breaker, DLQ (Dead Letter Queue) pour payloads en échec.
  • Rate-limit côté ETL pour respecter l’API Withings.



1.7 Exemples de code (extraits)

A) Odoo — module minimal (Python, modèle générique)

# models/x_health_measure.py from odoo import models, fields, api class HealthMeasure(models.Model): _name = "x_health_measure" _description = "Health Measure" _rec_name = "measure_type" _order = "measured_at desc" patient_id = fields.Many2one("x_health_patient", index=True, required=True) measure_type = fields.Selection([ ("weight","Weight"), ("bmi","BMI"), ("fat_pct","Fat %"), ("systolic","Systolic"), ("diastolic","Diastolic"), ("hr","Heart Rate"), ("spo2","SPO2"), ("sleep_score","Sleep Score"), ("sleep_duration","Sleep Duration"), ("steps","Steps"), ("calories","Calories"), ], required=True, index=True) value = fields.Float(required=True) unit = fields.Char() measured_at = fields.Datetime(index=True, required=True) source = fields.Char(default="withings") external_uid = fields.Char(index=True) raw_payload = fields.Json(string="Raw", groups="module_health.group_health") _sql_constraints = [ ("external_uid_uniq", "unique(external_uid)", "Duplicate measure (external_uid).") ]

B) FastAPI — webhook + push Odoo (pseudo-code)

from fastapi import FastAPI, Request, HTTPException import httpx, hashlib, json, os app = FastAPI() ODOO_URL = os.getenv("ODOO_URL") ODOO_DB = os.getenv("ODOO_DB") ODOO_USER = os.getenv("ODOO_USER") ODOO_PWD = os.getenv("ODOO_PWD") def ext_uid(uid, ts, t): return hashlib.sha256(f"{uid}|{ts}|{t}".encode()).hexdigest() @app.post("/webhook/withings") async def withings_hook(evt: dict): uid = evt.get("userid") # 1) Vérifier consentement dans Odoo ok = await check_consent_in_odoo(uid) if not ok: raise HTTPException(status_code=403, detail="No active consent") # 2) Planifier un pull (par tâche/worker) sur la fenêtre impactée await enqueue_pull(uid, evt.get("timestamp")) return {"status":"queued"} async def upsert_measures_in_odoo(measures): async with httpx.AsyncClient() as c: payload = { "jsonrpc":"2.0","method":"call","id":1, "params":{ "service":"object","method":"execute_kw","args":[ ODOO_DB, await get_uid(c), ODOO_PWD, "x_health_measure","create_or_update_bulk", [measures] ] } } await c.post(f"{ODOO_URL}/jsonrpc", json=payload)

(Côté Odoo, exposez un @api.model qui fait l’upsert par external_uid.)



1.8 Dashboards/BI (Odoo natif)

  • Graph : poids & IMC (rolling 30 j), TA (médian/semaine), sommeil (score & durée).
  • Pivot : agrégations par patient / période / type de mesure.
  • KPIs (champs calculés ou vues SQL) :
    • Variation 30 j : Δweight_kg ; TA contrôlée (seuils) ; % nuits > 7h ; moyenne FC repos.
  • Export : CSV/Excel anonymisé (si pseudonymisation active).

2) Agent conversationnel OpenAI (exploiter les données Odoo)

2.1 Architecture logique

Chat UI (WhatsApp/Web/Voice) → Gateway API → Tooling (fonctions outillées) → Odoo Data Fetch → Calculs/trends → LLM (réponses guidées + garde-fous) → Journalisation (audit, opt-in)

2.2 Tools (fonctions) à exposer au LLM

  • get_latest_metrics(patient_ref, metrics, window_days) → retourne séries (timestamp, value, unit).
  • get_trends(patient_ref, metric, horizon_days) → moyenne mobile, pente, min/max, anomalies.
  • get_compliance(patient_ref, metric, target_range) → % jours dans la cible.
  • list_measure_dates(patient_ref, metric) → datation pour vérification de fraîcheur.

Spécification d’un tool (exemple JSON)

{ "name": "get_latest_metrics", "description": "Récupère les mesures Odoo pour un patient", "parameters": { "type": "object", "properties": { "patient_ref": {"type":"string", "description":"ID patient Odoo ou email"}, "metrics": {"type":"array", "items":{"type":"string"}}, "window_days": {"type":"integer", "default": 30} }, "required": ["patient_ref","metrics"] } }

2.3 Logique de réponse (prompt système résumé)

  • Rôle : coach bien-être informatif (pas de diagnostic médical).
  • Style : clair, factuel, personnalisable (FR/EN).
  • Sécurité :
    • Si données trop anciennes ⇒ signaler « Dernière mesure le JJ/MM/AAAA ».
    • Si hors plage/alerte ⇒ proposer des messages génériques non médicaux + inviter à consulter un pro de santé.
    • Jamais d’interprétation clinique ni d’ajustement de traitement.

2.4 Exemples d’outputs LLM (comportement attendu)

  • « Sur 30 jours, votre poids a varié de –1,4 kg (pente ≈ –0,05 kg/j). Dernière mesure le 12/11/2025. »
  • « Votre moyenne SPO₂ nocturne est 96 % (≥ 95 % 90 % du temps). »

2.5 Gouvernance & conformité (agent)

  • Opt-in conversationnel : stockage des transcripts désactivé par défaut, ou anonymisés.
  • Scopes d’accès : un patient ne peut consulter que ses propres données (liage compte↔patient).
  • Traçabilité : journal des appels de tools (qui/quoi/quand).
  • TTL du contexte : purger les vecteurs/transcripts après X jours si non requis.

3) Déploiement — check-list rapide

  • ETL
    • Secrets (OAuth, Odoo creds) en vault (env/kms).
    • Files d’attente (Redis/Rabbit) pour lisser les pics webhook.
    • Jobs planifiés pour backfill (ex. J-30 initial).
    • Observabilité : métriques (latence, erreurs API, taux de doublons).
  • Odoo
    • Module dédié health_withings (modèles, vues, ACL, menus).
    • Groupes module_health.group_health + règles de sécurité.
    • Vues graph/pivot prédéfinies, filtres temporels.
    • Wizard RGPD (export/erase patient).
  • Agent
    • Tools REST signés (JWT), timeouts défensifs.
    • Tests unitaires (plages, unités, timezones).
    • Prompt système versionné + canary releases.

4) Étapes de mise en œuvre (S-prints suggérés)

  1. S1 – Base Odoo : modèles x_health_patient, x_health_measure, ACL + vues.
  2. S2 – ETL : Webhook+Pull, mapping poids/TA/SPO₂, idempotence.
  3. S3 – Dashboards : graph/pivot, KPIs standards.
  4. S4 – Agent : tools read-only (30 jours), prompts & garde-fous.
  5. S5 – Élargissement : sommeil/activité, pseudonymisation, rétention/purge.
  6. S6 – Durcissement : tests charge, observabilité, audits RGPD.

Souhaitez-vous que je vous génère :

  • le module Odoo squelette (manifeste, modèles, menus, groupes) ;
  • un ETL FastAPI minimal fonctionnel (webhook, pull, upsert) ;
  • et le contrat des tools pour l’agent (JSON + exemples d’appels) ?

le module Odoo squelette (manifeste, modèles, menus, groupes) ; Oui

# ─────────────────────────────────────────────────────────────────────────────

# health_withings/ (racine du module)

# ─────────────────────────────────────────────────────────────────────────────

# Arborescence suggérée :

# health_withings/

# ├── __init__.py

# ├── __manifest__.py

# ├── models/

# │   ├── __init__.py

# │   ├── patient.py

# │   ├── measure.py

# │   └── consent_log.py

# ├── security/

# │   ├── ir.model.access.csv

# │   └── security.xml

# ├── views/

# │   ├── menu.xml

# │   ├── patient_views.xml

# │   ├── measure_views.xml

# │   └── consent_log_views.xml

# └── data/

#     └── groups.xml (optionnel si non défini dans security.xml)

#

# Version Odoo cible : 17.0 (fonctionnera aussi en 16.0 avec peu/aucune modif.)

# Namespace technique : health_withings

# ─────────────────────────────────────────────────────────────────────────────


# ===================== __manifest__.py =====================

{

    "name": "Health Withings Integration (Skeleton)",

    "version": "17.0.1.0.0",

    "summary": "Squelette d'intégration Withings: patients, consentements, mesures",

    "description": """

Base de données santé (patients, consentements, mesures) pour ingestion via ETL.

- Modèles: x_health_patient, x_health_consent_log, x_health_measure

- Sécurité: groupe Santé, règles d'accès / enregistrement

- Vues: listes, formulaires, graphiques, pivots

- Menus: Santé > Patients, Mesures, Consentements

    """,

    "author": "Votre Organisation",

    "license": "LGPL-3",

    "website": "https://example.com",

    "depends": ["base", "mail"],

    "data": [

        "security/security.xml",

        "security/ir.model.access.csv",

        "views/menu.xml",

        "views/patient_views.xml",

        "views/measure_views.xml",

        "views/consent_log_views.xml",

    ],

    "application": True,

    "installable": True,

}


# ===================== __init__.py =====================

from . import models


# ===================== models/__init__.py =====================

from . import patient

from . import measure

from . import consent_log


# ===================== models/patient.py =====================

from odoo import api, fields, models, _


class HealthPatient(models.Model):

    _name = "x_health_patient"

    _description = "Health Patient"

    _inherit = ["mail.thread", "mail.activity.mixin"]

    _rec_name = "display_name"


    partner_id = fields.Many2one(

        "res.partner",

        string="Contact",

        required=True,

        index=True,

        tracking=True,

        domain=[("type", "!=", "delivery")],

    )

    display_name = fields.Char(

        string="Nom affiché",

        compute="_compute_display_name",

        store=True,

    )

    withings_userid = fields.Char(string="Withings User ID", index=True)

    oauth_status = fields.Selection(

        [

            ("disconnected", "Déconnecté"),

            ("connected", "Connecté"),

            ("revoked", "Révoqué"),

            ("error", "Erreur"),

        ],

        default="disconnected",

        tracking=True,

        index=True,

    )

    consent_datetime = fields.Datetime(string="Consentement le", index=True)

    consent_scope = fields.Char(string="Scopes consentis")

    consent_expires_on = fields.Date(string="Expiration du consentement")

    pseudonymized = fields.Boolean(string="Pseudonymisé ?", default=False)

    notes = fields.Text(string="Notes")

    active = fields.Boolean(default=True)


    measure_count = fields.Integer(

        string="# Mesures",

        compute="_compute_measure_count",

        store=False,

    )


    _sql_constraints = [

        ("uniq_withings_userid", "unique(withings_userid)", "Withings User ID déjà présent."),

    ]


    @api.depends("partner_id")

    def _compute_display_name(self):

        for rec in self:

            rec.display_name = rec.partner_id.display_name or _("Patient #%s") % rec.id


    def action_open_measures(self):

        self.ensure_one()

        return {

            "type": "ir.actions.act_window",

            "name": _("Mesures"),

            "res_model": "x_health_measure",

            "view_mode": "tree,graph,form,pivot",

            "domain": [("patient_id", "=", self.id)],

            "context": {"default_patient_id": self.id},

        }


    def action_open_consent_logs(self):

        self.ensure_one()

        return {

            "type": "ir.actions.act_window",

            "name": _("Journal de consentement"),

            "res_model": "x_health_consent_log",

            "view_mode": "tree,form",

            "domain": [("patient_id", "=", self.id)],

            "context": {"default_patient_id": self.id},

        }


# ===================== models/measure.py =====================

from odoo import api, fields, models, _


class HealthMeasure(models.Model):

    _name = "x_health_measure"

    _description = "Health Measure"

    _order = "measured_at desc, id desc"


    patient_id = fields.Many2one("x_health_patient", string="Patient", required=True, index=True)

    measure_type = fields.Selection([

        ("weight", "Poids"),

        ("bmi", "IMC"),

        ("fat_pct", "% Masse grasse"),

        ("systolic", "Systolique"),

        ("diastolic", "Diastolique"),

        ("hr", "Fréquence cardiaque"),

        ("spo2", "SPO2"),

        ("sleep_score", "Score sommeil"),

        ("sleep_duration", "Durée sommeil (min)"),

        ("steps", "Pas"),

        ("calories", "Calories"),

    ], required=True, index=True)

    value = fields.Float(string="Valeur", required=True, digits=(16, 3))

    unit = fields.Char(string="Unité")

    measured_at = fields.Datetime(string="Mesuré le", required=True, index=True)

    source = fields.Char(string="Source", default="withings")

    external_uid = fields.Char(string="UID externe", index=True)

    raw_payload = fields.Json(string="Payload brut", help="Trace minimale pour audit.")


    _sql_constraints = [

        ("external_uid_uniq", "unique(external_uid)", "Mesure déjà importée (external_uid)."),

    ]


    @api.model

    def create_or_update_bulk(self, records):

        """

        Upsert list de dicts (clé: external_uid). Exemple record:

        {

          'patient_id': 12,

          'measure_type': 'weight',

          'value': 72.3,

          'unit': 'kg',

          'measured_at': '2025-11-12 08:15:02',

          'source': 'withings',

          'external_uid': 'sha256...'

        }

        """

        if not isinstance(records, list):

            return False

        created, updated = 0, 0

        External = {r.get("external_uid"): r for r in records if r.get("external_uid")}

        if not External:

            return {"created": 0, "updated": 0}

        existing = self.search([("external_uid", "in", list(External.keys()))])

        existing_map = {m.external_uid: m for m in existing}

        to_create = []

        for uid, vals in External.items():

            if uid in existing_map:

                existing_map[uid].write(vals)

                updated += 1

            else:

                to_create.append(vals)

        if to_create:

            self.create(to_create)

            created = len(to_create)

        return {"created": created, "updated": updated}


# ===================== models/consent_log.py =====================

from odoo import fields, models


class HealthConsentLog(models.Model):

    _name = "x_health_consent_log"

    _description = "Health Consent Log"

    _order = "event_at desc, id desc"


    patient_id = fields.Many2one("x_health_patient", string="Patient", required=True, index=True)

    event = fields.Selection([

        ("grant", "Autorisé"),

        ("refresh", "Refresh"),

        ("revoke", "Révoqué"),

        ("expire", "Expiré"),

        ("scope_update", "Mise à jour de scope"),

    ], required=True, index=True)

    event_at = fields.Datetime(string="Horodatage", required=True, index=True)

    scope = fields.Char(string="Scope")

    actor = fields.Char(string="Acteur", help="user/system/admin")

    metadata = fields.Text(string="Métadonnées")


# ===================== security/security.xml =====================

<?xml version="1.0" encoding="UTF-8"?>

<odoo>

  <!-- Groupe Santé -->

  <record id="group_health" model="res.groups">

    <field name="name">Santé</field>

    <field name="category_id" ref="base.module_category_human_resources"/>

  </record>


  <!-- Règles d'enregistrement (record rules) -->

  <record id="rule_health_patient_read" model="ir.rule">

    <field name="name">Patients – lecture groupe Santé</field>

    <field name="model_id" ref="model_x_health_patient"/>

    <field name="groups" eval="[(4, ref('health_withings.group_health'))]"/>

    <field name="domain_force">[(1,'=',1)]</field>

    <field name="perm_read" eval="True"/>

    <field name="perm_write" eval="True"/>

    <field name="perm_create" eval="True"/>

    <field name="perm_unlink" eval="False"/>

  </record>


  <record id="rule_health_measure_read" model="ir.rule">

    <field name="name">Mesures – accès groupe Santé</field>

    <field name="model_id" ref="model_x_health_measure"/>

    <field name="groups" eval="[(4, ref('health_withings.group_health'))]"/>

    <field name="domain_force">[(1,'=',1)]</field>

    <field name="perm_read" eval="True"/>

    <field name="perm_write" eval="True"/>

    <field name="perm_create" eval="True"/>

    <field name="perm_unlink" eval="False"/>

  </record>


  <record id="rule_health_consent_log_read" model="ir.rule">

    <field name="name">Consent Logs – accès groupe Santé</field>

    <field name="model_id" ref="model_x_health_consent_log"/>

    <field name="groups" eval="[(4, ref('health_withings.group_health'))]"/>

    <field name="domain_force">[(1,'=',1)]</field>

    <field name="perm_read" eval="True"/>

    <field name="perm_write" eval="True"/>

    <field name="perm_create" eval="True"/>

    <field name="perm_unlink" eval="False"/>

  </record>

</odoo>


# ===================== security/ir.model.access.csv =====================

# Attention: les ACL ci-dessous limitent l'accès aux seuls membres du groupe Santé.

# Vous pouvez compléter par des ACL en lecture seule pour d'autres groupes si besoin.

"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"

"access_health_patient","access_health_patient","model_x_health_patient","health_withings.group_health",1,1,1,0

"access_health_measure","access_health_measure","model_x_health_measure","health_withings.group_health",1,1,1,0

"access_health_consent_log","access_health_consent_log","model_x_health_consent_log","health_withings.group_health",1,1,1,0


# ===================== views/menu.xml =====================

<?xml version="1.0" encoding="UTF-8"?>

<odoo>

  <menuitem id="menu_health_root" name="Santé" sequence="20"/>


  <menuitem id="menu_health_patients" name="Patients" parent="menu_health_root" action="action_health_patients"/>

  <menuitem id="menu_health_measures" name="Mesures" parent="menu_health_root" action="action_health_measures"/>

  <menuitem id="menu_health_consents" name="Consentements" parent="menu_health_root" action="action_health_consents"/>

</odoo>


# ===================== views/patient_views.xml =====================

<?xml version="1.0" encoding="UTF-8"?>

<odoo>

  <record id="view_health_patient_tree" model="ir.ui.view">

    <field name="name">x.health.patient.tree</field>

    <field name="model">x_health_patient</field>

    <field name="arch" type="xml">

      <tree>

        <field name="display_name"/>

        <field name="partner_id"/>

        <field name="withings_userid"/>

        <field name="oauth_status"/>

        <field name="consent_datetime"/>

        <field name="pseudonymized"/>

        <field name="measure_count"/>

        <field name="active"/>

      </tree>

    </field>

  </record>


  <record id="view_health_patient_form" model="ir.ui.view">

    <field name="name">x.health.patient.form</field>

    <field name="model">x_health_patient</field>

    <field name="arch" type="xml">

      <form string="Patient">

        <sheet>

          <div class="oe_button_box" name="button_box">

            <button type="object" class="oe_stat_button" name="action_open_measures" icon="fa-line-chart" string="Mesures"/>

            <button type="object" class="oe_stat_button" name="action_open_consent_logs" icon="fa-shield" string="Consentements"/>

          </div>

          <group>

            <group>

              <field name="partner_id"/>

              <field name="display_name" readonly="1"/>

              <field name="active"/>

            </group>

            <group>

              <field name="withings_userid"/>

              <field name="oauth_status"/>

              <field name="consent_datetime"/>

              <field name="consent_expires_on"/>

              <field name="consent_scope"/>

              <field name="pseudonymized"/>

            </group>

          </group>

          <notebook>

            <page string="Notes">

              <field name="notes" nolabel="1"/>

            </page>

          </notebook>

        </sheet>

        <chatter>

          <field name="message_follower_ids"/>

          <field name="activity_ids"/>

          <field name="message_ids"/>

        </chatter>

      </form>

    </field>

  </record>


  <record id="action_health_patients" model="ir.actions.act_window">

    <field name="name">Patients</field>

    <field name="res_model">x_health_patient</field>

    <field name="view_mode">tree,form</field>

    <field name="context">{"search_default_active":1}</field>

  </record>

</odoo>


# ===================== views/measure_views.xml =====================

<?xml version="1.0" encoding="UTF-8"?>

<odoo>

  <record id="view_health_measure_tree" model="ir.ui.view">

    <field name="name">x.health.measure.tree</field>

    <field name="model">x_health_measure</field>

    <field name="arch" type="xml">

      <tree>

        <field name="patient_id"/>

        <field name="measure_type"/>

        <field name="value"/>

        <field name="unit"/>

        <field name="measured_at"/>

        <field name="source"/>

      </tree>

    </field>

  </record>


  <record id="view_health_measure_form" model="ir.ui.view">

    <field name="name">x.health.measure.form</field>

    <field name="model">x_health_measure</field>

    <field name="arch" type="xml">

      <form string="Mesure">

        <sheet>

          <group>

            <group>

              <field name="patient_id"/>

              <field name="measure_type"/>

              <field name="value"/>

              <field name="unit"/>

            </group>

            <group>

              <field name="measured_at"/>

              <field name="source"/>

              <field name="external_uid" readonly="1"/>

            </group>

          </group>

          <notebook>

            <page string="Payload brut">

              <field name="raw_payload" nolabel="1"/>

            </page>

          </notebook>

        </sheet>

      </form>

    </field>

  </record>


  <record id="view_health_measure_graph" model="ir.ui.view">

    <field name="name">x.health.measure.graph</field>

    <field name="model">x_health_measure</field>

    <field name="arch" type="xml">

      <graph string="Mesures" type="line">

        <field name="measured_at" type="row" interval="day"/>

        <field name="value" type="measure"/>

        <field name="measure_type" type="col"/>

      </graph>

    </field>

  </record>


  <record id="view_health_measure_pivot" model="ir.ui.view">

    <field name="name">x.health.measure.pivot</field>

    <field name="model">x_health_measure</field>

    <field name="arch" type="xml">

      <pivot string="Mesures">

        <field name="patient_id" type="row"/>

        <field name="measure_type" type="row"/>

        <field name="measured_at" interval="month" type="col"/>

        <field name="value" type="measure"/>

      </pivot>

    </field>

  </record>


  <record id="action_health_measures" model="ir.actions.act_window">

    <field name="name">Mesures</field>

    <field name="res_model">x_health_measure</field>

    <field name="view_mode">tree,graph,form,pivot</field>

    <field name="context">{"search_default_groupby_patient_id":1}</field>

  </record>

</odoo>


# ===================== views/consent_log_views.xml =====================

<?xml version="1.0" encoding="UTF-8"?>

<odoo>

  <record id="view_health_consent_log_tree" model="ir.ui.view">

    <field name="name">x.health.consent.log.tree</field>

    <field name="model">x_health_consent_log</field>

    <field name="arch" type="xml">

      <tree>

        <field name="patient_id"/>

        <field name="event"/>

        <field name="event_at"/>

        <field name="scope"/>

        <field name="actor"/>

      </tree>

    </field>

  </record>


  <record id="view_health_consent_log_form" model="ir.ui.view">

    <field name="name">x.health.consent.log.form</field>

    <field name="model">x_health_consent_log</field>

    <field name="arch" type="xml">

      <form string="Journal de consentement">

        <sheet>

          <group>

            <field name="patient_id"/>

            <field name="event"/>

            <field name="event_at"/>

            <field name="scope"/>

            <field name="actor"/>

            <field name="metadata"/>

          </group>

        </sheet>

      </form>

    </field>

  </record>


  <record id="action_health_consents" model="ir.actions.act_window">

    <field name="name">Consentements</field>

    <field name="res_model">x_health_consent_log</field>

    <field name="view_mode">tree,form</field>

  </record>

</odoo>


un ETL FastAPI minimal fonctionnel (webhook, pull, upsert)

Agent Tools — Contrat JSON (Withings → Odoo)

Ce document décrit les tools que l’agent (OpenAI) peut appeler pour lire les données santé stockées dans Odoo. Les tools sont exposés via une API Gateway interne et renvoient des objets JSON strictement typés.

0) En-têtes & Auth

  • Auth : Authorization: Bearer <JWT> (portant sub = user_id et patient_id autorisé)
  • Content-Type : application/json
  • Horodatage : toutes les dates sont en UTC ISO 8601 ou epoch secondes (précisé par champ).

Erreurs génériques (HTTP):

{"error": {"code":"FORBIDDEN","message":"No consent or scope denied"}}

1) Tool: get_latest_metrics

But: Récupérer les dernières mesures (fenêtre temporelle) pour un patient et une liste de métriques.

Déclaration (OpenAI function)

{
  "type": "function",
  "function": {
    "name": "get_latest_metrics",
    "description": "Récupère les séries de mesures pour un patient sur une fenêtre glissante (par défaut 30 jours).",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string", "description": "ID patient Odoo ou email"},
        "metrics": {
          "type": "array",
          "items": {"type": "string", "enum": [
            "weight","bmi","fat_pct","systolic","diastolic","hr","spo2","sleep_score","sleep_duration","steps","calories"
          ]},
          "minItems": 1
        },
        "window_days": {"type": "integer", "default": 30, "minimum": 1, "maximum": 365}
      },
      "required": ["patient_ref","metrics"]
    }
  }
}

Endpoint REST (interne)

POST /agent/tools/get_latest_metrics

Request

{
  "patient_ref": "patient:42",
  "metrics": ["weight","systolic","diastolic"],
  "window_days": 30
}

Response

{
  "patient_id": 42,
  "series": {
    "weight": [
      {"ts": "2025-11-10T07:12:00Z", "value": 72.8, "unit": "kg"},
      {"ts": "2025-11-12T07:10:00Z", "value": 72.3, "unit": "kg"}
    ],
    "systolic": [
      {"ts": "2025-11-12T06:50:00Z", "value": 122, "unit": "mmHg"}
    ],
    "diastolic": [
      {"ts": "2025-11-12T06:50:00Z", "value": 78, "unit": "mmHg"}
    ]
  },
  "meta": {"window_days": 30, "timezone": "UTC"}
}

Exemple d’appel (tools)

{"tool_name":"get_latest_metrics","arguments":{"patient_ref":"patient:42","metrics":["weight","hr"],"window_days":30}}

2) Tool: get_trends

But: Calculer des tendances simples sur une métrique (moyenne mobile, pente, min/max).

Déclaration

{
  "type": "function",
  "function": {
    "name": "get_trends",
    "description": "Calcule les tendances (pente/jour, moyenne, min/max) pour une métrique sur un horizon donné.",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string"},
        "metric": {"type": "string", "enum": [
          "weight","bmi","fat_pct","systolic","diastolic","hr","spo2","sleep_score","sleep_duration","steps","calories"
        ]},
        "horizon_days": {"type": "integer", "default": 30, "minimum": 7, "maximum": 365},
        "smoothing_days": {"type": "integer", "default": 7, "minimum": 1, "maximum": 30}
      },
      "required": ["patient_ref","metric"]
    }
  }
}

Endpoint

POST /agent/tools/get_trends

Response

{
  "patient_id": 42,
  "metric": "weight",
  "stats": {
    "avg": 72.5,
    "min": {"value": 71.9, "ts": "2025-11-05T07:10:00Z"},
    "max": {"value": 73.2, "ts": "2025-11-01T07:12:00Z"},
    "slope_per_day": -0.05,
    "last": {"value": 72.3, "ts": "2025-11-12T07:10:00Z"}
  },
  "window": {"horizon_days": 30, "smoothing_days": 7}
}

Exemple d’appel

{"tool_name":"get_trends","arguments":{"patient_ref":"patient:42","metric":"weight","horizon_days":30}}

3) Tool: get_compliance

But: Évaluer la part du temps passée dans une plage cible pour une métrique.

Déclaration

{
  "type": "function",
  "function": {
    "name": "get_compliance",
    "description": "Calcule le pourcentage d'échantillons dans la plage cible (target_low/target_high) sur un horizon.",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string"},
        "metric": {"type": "string", "enum": ["weight","systolic","diastolic","hr","spo2","sleep_score","sleep_duration","steps","calories","bmi","fat_pct"]},
        "target_low": {"type": "number"},
        "target_high": {"type": "number"},
        "horizon_days": {"type": "integer", "default": 30}
      },
      "required": ["patient_ref","metric","target_low","target_high"]
    }
  }
}

Endpoint

POST /agent/tools/get_compliance

Response

{
  "patient_id": 42,
  "metric": "spo2",
  "target": {"low": 95, "high": 100, "unit": "%"},
  "horizon_days": 30,
  "samples": 28,
  "in_range_pct": 89.3,
  "last": {"value": 96, "ts": "2025-11-12T06:45:00Z"}
}

Exemple d’appel

{"tool_name":"get_compliance","arguments":{"patient_ref":"patient:42","metric":"spo2","target_low":95,"target_high":100,"horizon_days":30}}

4) Tool: list_measure_dates

But: Lister les dates de disponibilité (fraîcheur des données) pour une métrique.

Déclaration

{
  "type": "function",
  "function": {
    "name": "list_measure_dates",
    "description": "Retourne les timestamps des mesures disponibles pour estimer la fraîcheur et les trous de collecte.",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string"},
        "metric": {"type": "string"},
        "horizon_days": {"type": "integer", "default": 90}
      },
      "required": ["patient_ref","metric"]
    }
  }
}

Endpoint

POST /agent/tools/list_measure_dates

Response

{
  "patient_id": 42,
  "metric": "weight",
  "dates": ["2025-11-01","2025-11-05","2025-11-10","2025-11-12"],
  "last_ts": "2025-11-12T07:10:00Z"
}

Exemple d’appel

{"tool_name":"list_measure_dates","arguments":{"patient_ref":"patient:42","metric":"weight","horizon_days":60}}

5) Tool: list_metrics_available

But: Découvrir dynamiquement quelles métriques existent pour ce patient.

Déclaration

{
  "type": "function",
  "function": {
    "name": "list_metrics_available",
    "description": "Liste les types de mesures existantes pour ce patient, avec la dernière date disponible.",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string"}
      },
      "required": ["patient_ref"]
    }
  }
}

Endpoint

POST /agent/tools/list_metrics_available

Response

{
  "patient_id": 42,
  "metrics": [
    {"type": "weight", "unit": "kg", "last_ts": "2025-11-12T07:10:00Z"},
    {"type": "systolic", "unit": "mmHg", "last_ts": "2025-11-12T06:50:00Z"},
    {"type": "diastolic", "unit": "mmHg", "last_ts": "2025-11-12T06:50:00Z"}
  ]
}

Exemple d’appel

{"tool_name":"list_metrics_available","arguments":{"patient_ref":"patient:42"}}

6) Tool: get_patient_summary

But: Récapitulatif non médical (dernières valeurs clés, fraîcheur, tendances succinctes).

Déclaration

{
  "type": "function",
  "function": {
    "name": "get_patient_summary",
    "description": "Résumé non médical des mesures récentes et tendances de haut niveau.",
    "parameters": {
      "type": "object",
      "properties": {
        "patient_ref": {"type": "string"},
        "include_metrics": {
          "type": "array",
          "items": {"type": "string"},
          "default": ["weight","systolic","diastolic","hr","spo2","sleep_score"]
        },
        "horizon_days": {"type": "integer", "default": 30}
      },
      "required": ["patient_ref"]
    }
  }
}

Endpoint

POST /agent/tools/get_patient_summary

Response

{
  "patient_id": 42,
  "horizon_days": 30,
  "summary": [
    {"metric":"weight","last":72.3,"unit":"kg","trend":"down","delta":-1.4},
    {"metric":"systolic","last":122,"unit":"mmHg","trend":"stable"},
    {"metric":"spo2","last":96,"unit":"%","trend":"stable"}
  ],
  "last_updated": "2025-11-12T07:10:00Z"
}

Exemple d’appel

{"tool_name":"get_patient_summary","arguments":{"patient_ref":"patient:42","horizon_days":30}}

7) Bonnes pratiques d’utilisation côté LLM

  • Toujours vérifier la fraîcheur: si last_ts > 14 j, avertir « données anciennes ».
  • Aucune recommandation clinique: formuler des messages de bien-être génériques.
  • Confidentialité: ne jamais afficher d’IDs techniques; utiliser les unités renvoyées par l’API.

Patron de réponse (exemple LLM)

Sur les 30 derniers jours: votre poids moyen est 72,5 kg, avec une tendance légèrement baissière (~−0,05 kg/j). Dernière mesure le 12/11/2025. Pour un suivi personnalisé, pensez à rester hydraté, dormir régulièrement et consulter un professionnel de santé si besoin.

8) Mapping types (rappel)

weight, bmi, fat_pct, systolic, diastolic, hr, spo2, sleep_score, sleep_duration, steps, calories

9) Exemples cURL

curl -X POST https://api.internal/agent/tools/get_latest_metrics \
 -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
 -d '{"patient_ref":"patient:42","metrics":["weight","hr"],"window_days":30}'

curl -X POST https://api.internal/agent/tools/get_trends \
 -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
 -d '{"patient_ref":"patient:42","metric":"weight","horizon_days":30}'

10) Codes d’erreur spécifiques

  • NO_DATA: aucune mesure dans la fenêtre demandée.
  • STALE_DATA: dernière mesure trop ancienne pour analyse robuste.
  • CONSENT_REVOKED: patient révoqué.
  • INVALID_METRIC: métrique non supportée.
  • RATE_LIMITED: réessayez plus tard.

11) Notes d’implémentation

  • Ces tools s’appuient sur les modèles du module health_withings (Odoo) et peuvent être implémentés par un micro-service (Python/FastAPI) qui requête Odoo (search_read) puis calcule les stats (pente, moyenne, etc.).
  • Pour la pente, utilisez une régression linéaire simple value ~ time sur les points de la fenêtre.
  • Retournez toujours les unités.