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)
-
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.
-
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.
-
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
-
Si l’infirmière dit “Pour Mme Martin, 3 boîtes…”, un module NER (LLM) extrait “Mme Martin” →
-
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)
| Condition | Payeur principal | Reste à charge |
|---|---|---|
| Produit assurance_maladie + patient ALD 100% + prescription | Assurance Maladie | 0 |
| Produit assurance_maladie + patient couverture 70% + prescription | Assurance Maladie 70% | Mutuelle 30% (si présente) sinon Patient |
| Produit mutuelle | Mutuelle | Patient si plafond atteint |
| Produit non_remboursable | Patient | — |
Règle d’or : le LLM ne décide pas du payeur, il propose → n8n 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)
- E.19 reçoit patient_id (agenda/QR) + contexte visite.
- Record → OpenAI Realtime (STT live) → transcript final.
- Front envoie à Webhook n8n : {patient_id, nurse_id, raw_text}.
- n8n → appelle Tool “search_product” (Supabase RAG) pour chaque entité détectée.
- n8n → construit JSON lignes normalisées : product_id, qty, hints (taille, pack, etc.).
- n8n → moteur de règles payeur : détermine x_payor par ligne (Assurance/Mutuelle/Patient).
- n8n → Odoo : crée un sale.order + sale.order.line (avec x_payor, x_is_voice_order).
- Odoo calcule prix/remises/taxes → total et parts.
- n8n retourne au Front : quote_id, quote_url, lines[], totals_by_payor.
- 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)
- Odoo Studio : ajouter champs produits/payeurs + champs devis.
- Exporter produits (n8n CRON) → Supabase (upsert + embeddings).
- Front E.19 : bouton Record (WebRTC) + affichage transcript.
- Webhook n8n : reçoit {patient_id, nurse_id, raw_text}.
- n8n : LLM parse + RAG match → JSON normalisé items[].
- n8n : moteur règles payeur → x_payor par ligne.
- n8n → Odoo : création sale.order + order_line.
- n8n → Front : récap + quote_url + totaux par payeur.
- Stripe (option) : Checkout part patient → webhook → Odoo paiement.
- 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é.
3) n8n — Flow (JSON skeleton à importer)
⚠️ Remplacez <CREDENTIALS_…> par vos identifiants dans n8n.
Flow : Webhook ← Front E.19 → LLM Parse → Supabase RPC search_products → Rules Payor → Create Odoo Quote → Stripe (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 :
- un fichier .sql prêt à exécuter (création tables + policies), et
-
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
-
Copier tout le JSON ci-dessous dans un fichier :
synergia_voice_order_quote.json - Dans n8n → Import → “Import from file” → choisir ce fichier.
-
Dans n8n, créer les credentials :
- OpenAI (Synergia)
- Supabase Service (Synergia)
- Odoo JSON-RPC (Synergia)
-
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.
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]