Pistes ?

 @Faniry 

pistes sur certains points dont j'ai encore du mal a comprendre (notamment sur le flux d'identification du patient + , l'attribution des payeurs, et l'orchestration entre le front et n8n et ODOO).


  • l’identification du patient
  • 2) l’attribution des payeurs, 3
  • ) l’orchestration Front ↔ n8n ↔ Odoo.


Je reste strictement dans votre stack (Odoo Online + n8n + Supabase RAG + OpenAI Realtime + Stripe).

1) Identification fiable du patient (qui commande ? pour qui ?)

.

A. Stratégie “multi-ancrages” (cumuler 2 facteurs contextuels)

  1. Contexte agenda (le plus simple)
    • L’infirmière ouvre la fiche visite du jour (depuis Odoo ou la WebApp).
    • L’écran E.19 reçoit patient_id, visit_id, address, care_plan_id.
    • Pas de recherche vocale du patient réduction des erreurs.
  2. QR/NFC patient (fallback terrain)
    • Carte NFC/QR chez le senior → scan depuis l’écran E.19.
    • Charge le dossier (res.partner) et verrouille le contexte pendant X minutes.
  3. NER vocal (complément) > C'est notre cas pour un HELLO WOLD EFFET YOUAH 
    • Si l’infirmière dit “Pour Mme Martin, 3 boîtes…”, un module NER (LLM) extrait “Mme Martin”
      recherche floue dans Odoo (nom, année de naissance, téléphone) limitée à la tournée du jour.
    • > C'est notre démo à faire ...@faniry
  4. Garde-fous
    • Si >1 match : l’UI demande une confirmation visuelle (photo + adresse + date naissance masquée).
    • Si 0 match : proposition de créer un brouillon de patient non rattaché + alerte call center.

B. Schéma minimal côté Odoo (Odoo Studio)

  • res.partner (patient)
    • x_is_senior (bool), x_ssid_masked (char), x_default_payor (selection), x_insurer_id (m2o), x_mutuelle_id (m2o).
  • x_care_visit (modèle visite)
    • patient_id (m2o), nurse_id (m2o), scheduled_at, address.
  • E.19 consomme patient_id via l’URL ou un paramètre SSO.

C. Flux d’identification (texte)


[Infirmière ouvre Visite] → Front reçoit patient_id ↓ (option NFC/QR si pas de contexte) [Record vocal] → transcript → parse JSON ↓ Si JSON contient "patient_hint" → NER + fuzzy match limité à la tournée ↓ Si ambigu → écran de confirmation patient (photo + adresse) → OK

2) Attribution des payeurs (assurance, mutuelle, patient)

A. Principe : moteur de règles simple + override

On évite une usine à gaz : un tableau de décision basé sur (1) le produit, (2) le patient, (3) la préscription.

1) Données d’entrée

  • Produit (Odoo product.template) :
    • x_lpp_code (code LPP), x_reimbursable_by (selection: assurance_maladie, mutuelle, non_remboursable),
    • x_convention_rate (optionnel %), x_requires_prescription (bool), x_pack_unit.
  • Patient (res.partner) :
    • x_insurer_id, x_mutuelle_id, x_rights_level (ALD/100%, 70%, etc.), x_default_payor.
  • Contexte de la commande :
    • prescription_id (si photo/scan), care_plan_id (parcours HAD, APA, etc.).

2) Matrice de décision (exemple)

ConditionPayeur principalReste à charge
Produit assurance_maladie + patient ALD 100% + prescriptionAssurance Maladie0
Produit assurance_maladie + patient couverture 70% + prescriptionAssurance Maladie 70%Mutuelle 30% (si présente) sinon Patient
Produit mutuelleMutuellePatient si plafond atteint
Produit non_remboursablePatient

Règle d’or : le LLM ne décide pas du payeur, il proposen8n applique la règle et calcule.

3) Overrides (contrôle humain)

  • L’infirmière peut forcer x_payor sur une ligne si elle a une info terrain (ex : ordonnance oubliée, mutuelle inactive).
  • Toute modification est journalisée (qui/quand/pourquoi).

B. Preuves & pièces (pour remboursement)

  • Préscription : capture photo/scan dans l’écran E.19 → envoi vers Odoo Documents, lien sur sale.order.
  • Justificatifs (ALD, DMP, etc.) : Odoo Documents liés au patient.
  • Données remboursables : export CSV/EDI pour votre opérateur/tiers payant (n8n planifié).

.

3) Orchestration Front ↔ n8n ↔ Odoo (avec RAG Supabase + Realtime)

A. Happy path (10 étapes)

  1. E.19 reçoit patient_id (agenda/QR) + contexte visite.
  2. Record → OpenAI Realtime (STT live) → transcript final.
  3. Front envoie à Webhook n8n : {patient_id, nurse_id, raw_text}.
  4. n8n → appelle Tool “search_product” (Supabase RAG) pour chaque entité détectée.
  5. n8n → construit JSON lignes normalisées : product_id, qty, hints (taille, pack, etc.).
  6. n8nmoteur de règles payeur : détermine x_payor par ligne (Assurance/Mutuelle/Patient).
  7. n8nOdoo : crée un sale.order + sale.order.line (avec x_payor, x_is_voice_order).
  8. Odoo calcule prix/remises/taxes → total et parts.
  9. n8n retourne au Front : quote_id, quote_url, lines[], totals_by_payor.
  10. Front affiche récap → actions : Payer le reste à charge (Stripe) / Valider pour remboursement.


B. Schémas de payloads (exemples)

1) Front → n8n (webhook)

{ "patient_id": 2345, "nurse_id": 9981, "raw_text": "Pour Mme Martin, 3 boîtes de compresses stériles 10x10..." }

2) n8n → Supabase (RAG) (RPC “search_products”)

{ "query": "compresses stériles 10x10", "top_k": 5, "lang": "fr" }


3) n8n → Odoo (create sale.order)

{ "partner_id": 2345, "x_is_voice_order": true, "x_nurse_id": 9981, "order_line": [ {"product_id": 123, "product_uom_qty": 3, "x_payor": "assurance_maladie"}, {"product_id": 234, "product_uom_qty": 2, "x_payor": "mutuelle"}, {"product_id": 345, "product_uom_qty": 1, "x_payor": "patient"} ]

4) n8n → Front (réponse)

{ "quote_id": 77101, "quote_url": "https://your-odoo/....", "totals": { "assurance_maladie": 21.00, "mutuelle": 8.40, "patient": 5.60 }, "items": [ {"name": "Compresses 10x10", "qty": 3, "unit_price": 7.0, "payor": "assurance_maladie"}, {"name": "Bande cohésive 8cm", "qty": 2, "unit_price": 4.2, "payor": "mutuelle"}, {"name": "Gants nitrile M", "qty": 1, "unit_price": 5.6, "payor": "patient"} ] }

C. États UI & erreurs (robuste, terrain)

  • État 1 – Enregistrement : waveform + compteur.
  • État 2 – Transcription : affiche texte + bouton “Corriger”.
  • État 3 – Parsing & Matching : spinner + messages “On vérifie tailles et conditionnements…”.
  • État 4 – Récap : cartes lignes (modifier quantité/payer).
  • État 5 – Échec :
    • STT indispo → proposer saisie texte.
    • RAG incertain → “2 produits possibles : choisissez”.
    • Odoo timeout → file d’attente (n8n) + badge “devis en cours”.
  • Journal : afficher mini-journal (qui a modifié quoi).

D. Sécurité & conformité

  • JWT courte durée entre Front ↔ n8n (API Gateway).
  • Scopes par rôle (infirmier vs call center).
  • Anonymisation des transcripts après création du devis (ou purge J+90).
  • Consentement patient affiché une fois/visite (bannière RGPD).
  • Audit : n8n log des appels (horodatage, hash transcript, patient_id pseudonymisé).

4) Détails utiles (pense-bête d’implémentation)

A. RAG Supabase (performant)

  • Table medical_products: product_id, name, synonyms[], lpp_code, reimbursable_by, price, uom, embedding.
  • pgvector pour la similarité.
  • Moteur “search_products(query, top_k)” qui renvoie aussi un score (pour décider si confirmation UI).

B. LLM parsing (prompt court et durci)

  • “Retourne STRICTEMENT ce JSON {items:[{name, qty, notes}]…} sans texte hors JSON.”
  • Si manque d’info (taille, pack) → needs_confirmation: true.

C. Stripe (reste à charge)

  • Session Checkout uniquement sur la part patient.
  • Lien Stripe collé dans la réponse n8n → bouton “Payer maintenant”.
  • Webhook Stripe → n8n → sale.order / account.payment (reconcil).

D. Documents (ordonnance)

  • Upload image → Odoo Documents → lien attachment_id sur sale.order.
  • Si requires_prescription=true et pas de doc joint → drapeau “En attente de prescription”.

5) Mini “quick-start” (en 10 tâches)


  1. Odoo Studio : ajouter champs produits/payeurs + champs devis.
  2. Exporter produits (n8n CRON) → Supabase (upsert + embeddings).
  3. Front E.19 : bouton Record (WebRTC) + affichage transcript.
  4. Webhook n8n : reçoit {patient_id, nurse_id, raw_text}.
  5. n8n : LLM parse + RAG match → JSON normalisé items[].
  6. n8n : moteur règles payeur → x_payor par ligne.
  7. n8n → Odoo : création sale.order + order_line.
  8. n8n → Front : récap + quote_url + totaux par payeur.
  9. Stripe (option) : Checkout part patient → webhook → Odoo paiement.
  10. Purge & Audit : règles RGPD, journaux n8n, rapport hebdo.


En deux phrases


  • Identification patient : privilégiez le contexte de visite + QR/NFC en fallback ; utilisez le vocal uniquement comme indice, confirmé à l’écran.


  • Payeurs : mettez la décision dans n8n (moteur de règles), pas dans le LLM ; le LLM ne fait que structurer la commande.


​ANNEXE

schémas de tables Supabase (avec pgvector),

1) Supabase — schémas SQL (RAG + ordres vocaux)

-- Extensions create extension if not exists vector; create extension if not exists pgcrypto; -- Locataire (multi-établissements / organisations) create table public.orgs ( id uuid primary key default gen_random_uuid(), name text not null ); -- Produits médicaux indexés pour le RAG create table public.medical_products ( id bigserial primary key, org_id uuid not null references public.orgs(id) on delete cascade, product_id bigint not null, -- id Odoo (product.product) name text not null, synonyms text[], -- ["compresses","pansement 10x10",...] lpp_code text, -- code remboursement reimbursable_by text check (reimbursable_by in ('assurance_maladie','mutuelle','non_remboursable')), unit text, -- "boîte","paquet" price_eur numeric(12,2), description text, embedding vector(1536), -- pgvector updated_at timestamptz default now(), unique (org_id, product_id) ); -- Index sémantique create index on public.medical_products using ivfflat (embedding vector_cosine_ops) with (lists=100); create index on public.medical_products (org_id); -- Visites / contexte create table public.care_visits ( id bigserial primary key, org_id uuid not null references public.orgs(id) on delete cascade, patient_id bigint not null, -- res.partner (Odoo) nurse_id bigint not null, -- res.users/res.partner scheduled_at timestamptz, address text, created_at timestamptz default now() ); -- Commandes vocales (journal + traçabilité) create table public.voice_orders ( id bigserial primary key, org_id uuid not null references public.orgs(id) on delete cascade, visit_id bigint references public.care_visits(id), patient_id bigint not null, nurse_id bigint not null, raw_text text, -- transcription brute (purgeable) quote_id bigint, -- sale.order Odoo quote_url text, status text default 'draft' check (status in ('draft','quoted','paid','sent','error')), created_by uuid default auth.uid(), created_at timestamptz default now() ); create table public.voice_order_items ( id bigserial primary key, org_id uuid not null references public.orgs(id) on delete cascade, voice_order_id bigint not null references public.voice_orders(id) on delete cascade, product_id bigint not null, -- Odoo product_id product_name text not null, quantity numeric(12,2) not null, payor text check (payor in ('assurance_maladie','mutuelle','patient')), unit_price numeric(12,2), total numeric(12,2), meta jsonb default '{}'::jsonb ); create index on public.voice_orders (org_id, patient_id, nurse_id); create index on public.voice_order_items (org_id, voice_order_id); -- RPC de recherche produits pour le LLM/n8n create or replace function public.search_products( p_org_id uuid, p_query text, p_top_k int default 5 ) returns table( product_id bigint, name text, lpp_code text, reimbursable_by text, unit text, price_eur numeric, similarity float ) language plpgsql as $$ declare q_emb vector(1536); begin -- Embedding calculé côté n8n/LLM → passer un vecteur si vous préférez. -- Variante simple : utiliser un “lexical LIKE” en fallback (si pas de vecteur fourni). raise exception 'This version expects embeddings computed client-side'; end $$;

Astuce : si vous voulez faire l’embedding serveur-side, créez une table tmp_query_embeddings et remplacez la fonction par une version SQL calculant 1 - (embedding <=> q_emb).

1.1 RLS (multi-tenant + rôles)

-- Activer RLS alter table public.medical_products enable row level security; alter table public.care_visits enable row level security; alter table public.voice_orders enable row level security; alter table public.voice_order_items enable row level security; -- Profil: vous avez typiquement 3 rôles côté Supabase: -- 1) service_role (n8n) : bypass RLS (clé secrète) -- 2) authenticated (front infirmière) -- 3) anon (disabled ici) -- Policies: lecture infirmière limitée à son org create policy "read_products_same_org" on public.medical_products for select to authenticated using (org_id = (select org_id from public.user_profiles where user_id = auth.uid())); create policy "read_visits_same_org" on public.care_visits for select to authenticated using (org_id = (select org_id from public.user_profiles where user_id = auth.uid())); create policy "read_write_voice_orders_same_org" on public.voice_orders for all to authenticated using (org_id = (select org_id from public.user_profiles where user_id = auth.uid())) with check (org_id = (select org_id from public.user_profiles where user_id = auth.uid())); create policy "read_write_voice_order_items_same_org" on public.voice_order_items for all to authenticated using (org_id = (select org_id from public.user_profiles where user_id = auth.uid())) with check (org_id = (select org_id from public.user_profiles where user_id = auth.uid()));

Créez une table public.user_profiles(user_id uuid primary key, org_id uuid not null, role text) et synchronisez-la depuis Odoo ou Keycloak/SSO.

n8n utilisera la service key (bypass RLS) pour upsert produits & écrire les ordres.

2) Odoo — champs Studio (minimum utile)

product.template

  • x_lpp_code (Char)
  • x_reimbursable_by (Selection: assurance_maladie, mutuelle, non_remboursable)
  • x_requires_prescription (Boolean)
  • x_convention_rate (Float, optionnel)

sale.order

  • x_is_voice_order (Boolean, default True),
  • x_nurse_id (Many2one → res.partner),
  • x_visit_id (Many2one → care.visit si vous l’avez).

sale.order.line

  • x_payor (Selection: assurance_maladie, mutuelle, patient)

res.partner (patient)

  • x_is_senior (Boolean)
  • x_insurer_id (Many2one), x_mutuelle_id (Many2one)
  • x_rights_level (Selection: ALD100, 70, 0)
  • x_default_payor (Selection)

Piste pour faire ? 

Je vais vous le faire en mode “clic par clic dans Odoo Online + Studio”, modèle par modèle, avec les bons types de champs et, si possible, les noms techniques.

Je pars du principe :

  • Odoo Online (>= v16/v17),
  • vous avez les droits administrateur et Odoo Studio activé.


Découvrir plus


3) n8n — Flow (JSON skeleton à importer)

⚠️ Remplacez <CREDENTIALS_…> par vos identifiants dans n8n.

Flow : Webhook ← Front E.19 → LLM ParseSupabase RPC search_productsRules PayorCreate Odoo QuoteStripe (option)Respond

{ "name": "Synergia - Voice Order → Quote Odoo", "nodes": [ { "parameters": { "path": "voice-order", "methods": ["POST"], "responseMode": "onReceived" }, "name": "Webhook In", "type": "n8n-nodes-base.webhook", "typeVersion": 1 }, { "parameters": { "model": "gpt-4o-mini", "systemMessage": "Tu es un assistant qui extrait des lignes de commande de consommables médicaux en JSON STRICT.", "prompt": "TRANSCRIPT:\\n{{$json.raw_text}}\\n\\nRetourne STRICTEMENT le JSON: {\"items\":[{\"name\":\"\",\"qty\":1,\"notes\":\"\"}], \"patient_hint\":\"\"}", "responsePropertyName": "llm" }, "name": "LLM Parse", "type": "n8n-nodes-base.openAi", "typeVersion": 3, "credentials": { "openAIApi": "<CREDENTIALS_OPENAI>" } }, { "parameters": { "functionCode": "const items = $json.llm?.choices?.[0]?.message?.content ? JSON.parse($json.llm.choices[0].message.content).items : [];\nreturn items.map(i => ({ json: i }));" }, "name": "Split Items", "type": "n8n-nodes-base.function", "typeVersion": 2 }, { "parameters": { "authentication": "predefinedCredentialType", "url": "https://<SUPABASE_URL>/rest/v1/rpc/search_products", "options": { "query": { "select": "*" } }, "sendBody": true, "jsonParameters": true, "bodyParametersJson": "={ \"p_org_id\": \"{{$json.org_id}}\", \"p_query\": \"{{$json.name}}\", \"p_top_k\": 5 }" }, "name": "RAG Search (Supabase RPC)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 2, "credentials": { "httpHeaderAuth": "<CREDENTIALS_SUPABASE_SERVICE>" } }, { "parameters": { "functionCode": "const rag = items[0].json; // top match\nconst qty = $json.qty || 1;\n// Exemple de règle payeur (simplifiée) :\nlet payor = 'patient';\nif (rag.reimbursable_by === 'assurance_maladie') payor = 'assurance_maladie';\nelse if (rag.reimbursable_by === 'mutuelle') payor = 'mutuelle';\nreturn [{ json: { product_id: rag.product_id, product_name: rag.name, qty, payor } }];" }, "name": "Rules Payor", "type": "n8n-nodes-base.function", "typeVersion": 2 }, { "parameters": { "functionCode": "const lines = items.map(i => ({product_id: i.json.product_id, product_uom_qty: i.json.qty, x_payor: i.json.payor}));\nreturn [{ json: { partner_id: $json.patient_id, x_is_voice_order: true, x_nurse_id: $json.nurse_id, order_line: lines } }];" }, "name": "Build Odoo Quote Payload", "type": "n8n-nodes-base.function", "typeVersion": 2 }, { "parameters": { "authentication": "predefinedCredentialType", "requestMethod": "POST", "url": "https://<ODOO_BASE>/jsonrpc", "jsonParameters": true, "options": {}, "bodyParametersJson": "={\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":{\"service\":\"object\",\"method\":\"execute_kw\",\"args\":[\"<DB>\", <UID>, \"<API_KEY>\", \"sale.order\", \"create\", [{{$json}}]]}}" }, "name": "Create Quote (Odoo)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 2, "credentials": { "httpHeaderAuth": "<CREDENTIALS_ODOO>" } }, { "parameters": { "functionCode": "return [{ json: { quote_id: $json.result, quote_url: `https://<ODOO_BASE>/web#id=${$json.result}&model=sale.order&view_type=form` } }];" }, "name": "Quote Response", "type": "n8n-nodes-base.function", "typeVersion": 2 }, { "parameters": { "responseBody": "={{$json}}", "responseCode": 200 }, "name": "Respond", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1 } ], "pinData": {}, "connections": { "Webhook In": { "main": [ [ { "node": "LLM Parse", "type": "main", "index": 0 } ] ] }, "LLM Parse": { "main": [ [ { "node": "Split Items", "type": "main", "index": 0 } ] ] }, "Split Items": { "main": [ [ { "node": "RAG Search (Supabase RPC)", "type": "main", "index": 0 } ] ] }, "RAG Search (Supabase RPC)": { "main": [ [ { "node": "Rules Payor", "type": "main", "index": 0 } ] ] }, "Rules Payor": { "main": [ [ { "node": "Build Odoo Quote Payload", "type": "main", "index": 0 } ] ] }, "Build Odoo Quote Payload": { "main": [ [ { "node": "Create Quote (Odoo)", "type": "main", "index": 0 } ] ] }, "Create Quote (Odoo)": { "main": [ [ { "node": "Quote Response", "type": "main", "index": 0 } ] ] }, "Quote Response": { "main": [ [ { "node": "Respond", "type": "main", "index": 0 } ] ] } } }

Variante Stripe : insérer un nœud “Create Checkout Session (Stripe)” entre Create Quote et Respond, sur le reste à charge patient (somme des lignes x_payor='patient'), puis gérer le webhook Stripe pour marquer le devis payé dans Odoo.

4) Prompts LLM (parser robuste + confirmation)

System

Tu es un assistant de parsing. Objectif : transformer une commande orale en JSON STRICT.

Si une information est ambiguë (taille, conditionnement…), mets \"needs_confirmation\": true au niveau de la ligne.

User

TRANSCRIPT: "Pour Mme Martin, il me faut 3 boîtes de compresses stériles 10x10, 2 bandes cohésives, et des gants taille M." Réponds STRICTEMENT en JSON: { "patient_hint": "<nom entendu ou vide>", "items": [ {"name":"", "qty":1, "notes":""} ] }

Réponse attendue (ex.)

{ "patient_hint": "Mme Martin", "items": [ {"name":"compresses stériles 10x10", "qty":3, "notes":"boîtes"}, {"name":"bande cohésive", "qty":2, "notes":""}, {"name":"gants nitrile taille M", "qty":1, "notes":""} ] }

5) Contrats Front ↔ n8n (rappel)

POST /hooks/voice-order

{ "org_id": "c0f5-…-9b", "patient_id": 2345, // ou vide si identification par NER "nurse_id": 9981, "visit_id": 771, "raw_text": "…transcription finale…" }

200

{ "quote_id": 120045, "quote_url": "https://odoo/web#id=120045&model=sale.order&view_type=form", "totals": {"assurance_maladie": 21.00, "mutuelle": 8.40, "patient": 5.60}, "items": [ {"product_id":123,"name":"Compresses 10x10","qty":3,"payor":"assurance_maladie","unit_price":7.0}, {"product_id":234,"name":"Bande cohésive 8cm","qty":2,"payor":"mutuelle","unit_price":4.2}, {"product_id":345,"name":"Gants nitrile M","qty":1,"payor":"patient","unit_price":5.6} ] }

6) Sécurité • RGPD • Observabilité (résumé)

  • Auth : JWT court côté Front → API Gateway n8n (scopes nurse, callcenter).
  • RLS : lecture/écriture limitée à org_id ; n8n en service role.
  • Purge : raw_text dans voice_orders supprimé J+90 (job n8n CRON).
  • Traçabilité : stocker hash_sha256(raw_text) pour audit sans conserver le texte.
  • Observabilité : logs latences (STT, RAG, Odoo), taux d’ambiguïtés (needs_confirmation=true).
  • Éthique : validation manuelle si un produit exige x_requires_prescription=true sans pièce jointe.

En bref

  • Patient : verrouillez le contexte via Visite (id + QR/NFC), NER vocal seulement en indice.
  • Payeurs : décidez dans n8n (moteur de règles), pas dans le LLM.
  • Orchestration : Front → Webhook n8n → RAG Supabase → Odoo (Stripe option), avec journaux et garde-fous.

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

  1. un fichier .sql prêt à exécuter (création tables + policies), et
  2. un export .json du flow n8n (nettoyé, variables d’env),
    pour les déposer dans votre dépôt projet ?
Vous avez dit :

2

ChatGPT a dit :

Parfait, on fait le flow n8n propre, “copier-coller → importer”.

Voici un export JSON complet de workflow n8n pour le scénario :

Front E.19 → Webhook → LLM parse → RAG Supabase → règles payeur → création devis Odoo → réponse au front

Je l’ai nettoyé pour qu’il utilise des variables d’environnement plutôt que des valeurs en dur.

🔧 Comment l’utiliser

  1. Copier tout le JSON ci-dessous dans un fichier :
    synergia_voice_order_quote.json
  2. Dans n8n → Import → “Import from file” → choisir ce fichier.
  3. Dans n8n, créer les credentials :
    • OpenAI (Synergia)
    • Supabase Service (Synergia)
    • Odoo JSON-RPC (Synergia)
  4. Adapter les variables d’environnement dans n8n (Settings → Environment):
    • ODOO_BASE
    • ODOO_DB
    • ODOO_UID
    • ODOO_API_KEY
    • SUPABASE_BASE_URL
    • ORG_ID_DEFAULT 


📦 Flow n8n JSON (à importer tel quel)

{ "name": "Synergia - Voice Order → Odoo Quote", "nodes": [ { "id": "WebhookIn", "name": "Webhook In", "type": "n8n-nodes-base.webhook", "typeVersion": 1, "position": [260, 300], "parameters": { "httpMethod": "POST", "path": "synergia/voice-order", "responseMode": "onReceived", "options": { "responseContentType": "application/json" } } }, { "id": "LLMParse", "name": "LLM Parse (Items JSON)", "type": "n8n-nodes-base.openAi", "typeVersion": 3, "position": [560, 300], "parameters": { "resource": "chat", "model": "gpt-4o-mini", "responsePropertyName": "llm", "options": { "responseFormat": "json_object" }, "messages": { "values": [ { "text": "Tu es un assistant de parsing. Objectif : transformer une commande orale de consommables médicaux en JSON STRICT.\n\nTRANSCRIPT:\n{{$json[\"raw_text\"]}}\n\nRéponds STRICTEMENT au format JSON suivant, sans texte en dehors du JSON :\n{\n \"patient_hint\": \"<nom entendu ou vide>\",\n \"items\": [\n {\"name\": \"\", \"qty\": 1, \"notes\": \"\"}\n ]\n}", "type": "user" } ] } }, "credentials": { "openAIApi": { "id": "OpenAI (Synergia)", "name": "OpenAI (Synergia)" } } }, { "id": "ExtractItems", "name": "Extract Items", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [820, 300], "parameters": { "functionCode": "const raw = $json.llm?.choices?.[0]?.message?.content;\nif (!raw) {\n return [{ json: { error: 'NO_LLM_RESPONSE' } }];\n}\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (e) {\n return [{ json: { error: 'INVALID_JSON_FROM_LLM', raw } }];\n}\nconst items = parsed.items || [];\nconst patient_hint = parsed.patient_hint || null;\n// On garde patient_hint dans le contexte global (item 0)\nreturn items.map((i, index) => ({\n json: {\n name: i.name,\n qty: i.qty || 1,\n notes: i.notes || \"\",\n patient_hint,\n index\n }\n}));" } }, { "id": "RAGSearch", "name": "RAG Search (Supabase RPC)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 2, "position": [1080, 300], "parameters": { "authentication": "headerAuth", "requestMethod": "POST", "url": "={{$env[\"SUPABASE_BASE_URL\"]}}/rest/v1/rpc/search_products", "jsonParameters": true, "options": {}, "bodyParametersJson": "={\n \"p_org_id\": $json.org_id || $env[\"ORG_ID_DEFAULT\"],\n \"p_query\": $json.name,\n \"p_top_k\": 5\n}" }, "credentials": { "httpHeaderAuth": { "id": "Supabase Service (Synergia)", "name": "Supabase Service (Synergia)" } } }, { "id": "SelectTopMatchAndPayor", "name": "Select Top Match + Payor Rules", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [1340, 300], "parameters": { "functionCode": "const ragResults = items[0].json; // Supabase RPC renvoie un tableau\nif (!Array.isArray(ragResults) || ragResults.length === 0) {\n // Aucun produit trouvé : on renvoie une ligne marquée 'needs_confirmation'\n return [{\n json: {\n product_id: null,\n product_name: $json.name,\n qty: $json.qty,\n payor: 'patient',\n needs_confirmation: true,\n reason: 'NO_RAG_MATCH'\n }\n }];\n}\nconst best = ragResults[0];\n// Règle payeur simplifiée : à enrichir plus tard côté n8n si besoin\nlet payor = 'patient';\nif (best.reimbursable_by === 'assurance_maladie') payor = 'assurance_maladie';\nelse if (best.reimbursable_by === 'mutuelle') payor = 'mutuelle';\n\nreturn [{\n json: {\n product_id: best.product_id,\n product_name: best.name,\n qty: $json.qty,\n payor,\n needs_confirmation: false\n }\n}];" } }, { "id": "AggregateLines", "name": "Aggregate Lines", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [1600, 300], "parameters": { "functionCode": "const items = $items();\n// On récupère patient_id et nurse_id du premier item (venant du Webhook)\nconst root = $items(0, 'Webhook In')[0].json;\nconst patient_id = root.patient_id;\nconst nurse_id = root.nurse_id;\n\nconst order_lines = items.map(i => ({\n product_id: i.json.product_id,\n product_uom_qty: i.json.qty,\n x_payor: i.json.payor\n}));\n\nreturn [{\n json: {\n partner_id: patient_id,\n x_is_voice_order: true,\n x_nurse_id: nurse_id,\n order_line: order_lines\n }\n}];" }, "notesInFlow": true, "notes": "Construit le payload Odoo sale.order à partir des lignes agrégées.\nOn suppose que patient_id et nurse_id viennent du Webhook initial." }, { "id": "CreateQuoteOdoo", "name": "Create Quote (Odoo JSON-RPC)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 2, "position": [1860, 300], "parameters": { "authentication": "headerAuth", "requestMethod": "POST", "url": "={{$env[\"ODOO_BASE\"]}}/jsonrpc", "jsonParameters": true, "options": {}, "bodyParametersJson": "={\n \"jsonrpc\": \"2.0\",\n \"method\": \"call\",\n \"params\": {\n \"service\": \"object\",\n \"method\": \"execute_kw\",\n \"args\": [\n $env[\"ODOO_DB\"],\n Number($env[\"ODOO_UID\"]),\n $env[\"ODOO_API_KEY\"],\n \"sale.order\",\n \"create\",\n [{{$json}}]\n ]\n }\n}" }, "credentials": { "httpHeaderAuth": { "id": "Odoo JSON-RPC (Synergia)", "name": "Odoo JSON-RPC (Synergia)" } } }, { "id": "BuildResponse", "name": "Build Response", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [2120, 300], "parameters": { "functionCode": "const quote_id = $json.result;\nconst base = $env[\"ODOO_BASE\"];\nreturn [{\n json: {\n quote_id,\n quote_url: `${base}/web#id=${quote_id}&model=sale.order&view_type=form`\n }\n}];" } }, { "id": "Respond", "name": "Respond to Front", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [2380, 300], "parameters": { "responseBody": "={{$json}}", "responseCode": 200 } } ], "connections": { "Webhook In": { "main": [ [ { "node": "LLM Parse (Items JSON)", "type": "main", "index": 0 } ] ] }, "LLM Parse (Items JSON)": { "main": [ [ { "node": "Extract Items", "type": "main", "index": 0 } ] ] }, "Extract Items": { "main": [ [ { "node": "RAG Search (Supabase RPC)", "type": "main", "index": 0 } ] ] }, "RAG Search (Supabase RPC)": { "main": [ [ { "node": "Select Top Match + Payor Rules", "type": "main", "index": 0 } ] ] }, "Select Top Match + Payor Rules": { "main": [ [ { "node": "Aggregate Lines", "type": "main", "index": 0 } ] ] }, "Aggregate Lines": { "main": [ [ { "node": "Create Quote (Odoo JSON-RPC)", "type": "main", "index": 0 } ] ] }, "Create Quote (Odoo JSON-RPC)": { "main": [ [ { "node": "Build Response", "type": "main", "index": 0 } ] ] }, "Build Response": { "main": [ [ { "node": "Respond to Front", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }, "tags": [ { "name": "Synergia" }, { "name": "Voice Order" } ] }

Si vous voulez, à la prochaine étape je peux :

  • ajouter le calcul des totaux par payeur dans le flow,

Mapping RAG  à partir de la transcription vocale.

Écrivez un ou deux paragraphes décrivant votre produit ou vos services. Pour réussir, votre contenu doit être utile à vos lecteurs.

Commencez par le client: trouvez ce qu'il veut et donner-le lui. Mapping RAG (structures JSON à indexer dans Supabase) pour que l’agent LLM puisse générer ces create/write à partir de la transcription vocale.


Découvrir plus

un exemple de prompt système pour l’agent LLM,<> ODOO 

un exemple de prompt système pour l’agent LLM, lui expliquant exactement : “Tu as accès à un outil de recherche RAG Supabase qui renvoie ces JSON, et tu dois produire un JSON intermédiaire ‘order + lines’ conforme à Odoo.”

[SYSTEM PROMPT – AGENT COMMANDE VOCALE → ODOO]

Découvrir plus