From 69290d7b079402c21c1b898546942fcf3ae20f98 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 20 Jul 2026 08:54:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20d=C3=A9sambigu=C3=AFsation?= =?UTF-8?q?=20client=20via=20options=20cliquables=20(un=20seul=20champ)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le 2e champ « rechercher un client » du chat par une liste d'options que l'ASSISTANT propose DANS le fil (comme un menu de désambiguïsation). Plus naturel, plus simple : on écrit une seule chose, et si le client est ambigu l'assistant liste les candidats probables — un clic les envoie comme tour suivant. Backend (staff-agent.js + agent-tools.json) : - lecteur find_customer(query) → CANDIDATS via /collab/customer-search (réutilise l'endpoint de l'ancien typeahead). 1 candidat → l'assistant prend l'id ; >1 → il demande « lequel ? » sans réénumérer (l'UI affiche la liste). - execToolPlan capte les candidats (find_customer + check_service ambigu) dans ctx.options ; plan() renvoie { options } ; prompt : règle « identifier le client ». Frontend (OrchestratorDialog.vue + MainLayout.vue) : - retire le q-select entitySearch (+ la prop et le binding MainLayout) ; composer = un seul champ. Rend d.options en q-list cliquable ; pickOption envoie « label (id) » comme tour suivant ; options effacées à l'envoi/au reset. Vérifié live (dev→hub prod) : « redémarre le modem du client Tremblay » → 1 champ, 8 options cliquables + « lequel ? » ; clic « Tremblay (Filtr-Aqua) Sylvain » → tour « … (C-SYLVT…) » envoyé, options effacées, l'assistant continue ; 0 erreur console. Co-Authored-By: Claude Opus 4.8 --- .../components/shared/OrchestratorDialog.vue | 71 +++++++------------ apps/ops/src/layouts/MainLayout.vue | 2 +- services/targo-hub/lib/agent-tools.json | 1 + services/targo-hub/lib/staff-agent.js | 39 ++++++++-- 4 files changed, 61 insertions(+), 52 deletions(-) diff --git a/apps/ops/src/components/shared/OrchestratorDialog.vue b/apps/ops/src/components/shared/OrchestratorDialog.vue index 61315c1..941f2c9 100644 --- a/apps/ops/src/components/shared/OrchestratorDialog.vue +++ b/apps/ops/src/components/shared/OrchestratorDialog.vue @@ -23,6 +23,15 @@ {{ m.text }} + + + + + {{ o.label }}{{ o.caption }} + + + +
@@ -120,21 +129,9 @@ - +
- - - - -
@@ -166,40 +163,19 @@ const props = defineProps({ planEndpoint: { type: String, default: '/collab/orchestrate' }, runEndpoint: { type: String, default: '/collab/orchestrate/run' }, sendIdentity: { type: Boolean, default: false }, // joindre X-Authentik-Email (routes STAFF : agissent au nom de l'agent connecté) - entitySearch: { type: Boolean, default: false }, // typeahead client/adresse → injecte l'entité résolue dans la demande (ex. « Allouer un technicien ») }) const emit = defineEmits(['update:modelValue', 'done']) const $q = useQuasar() const open = ref(false) const text = ref('') -// Typeahead entité (client/adresse) — recherche floue hub (nom/adresse/téléphone/courriel) ; le choix INJECTE l'entité -// RÉSOLUE (id + nom + adresse) dans la demande → l'assistant Gemini en extrait l'ID pour agir (proposer_rdv_client, etc.). -const entityPick = ref(null) -const entityOptions = ref([]) -function filterEntities (val, update) { - const q = (val || '').trim() - if (q.length < 2) { update(() => { entityOptions.value = [] }); return } - update(async () => { - try { - const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`, { headers: reqHeaders() }) - const matches = r.ok ? ((await r.json()).matches || []) : [] - entityOptions.value = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({ - id: m.name, name: m.customer_name || m.name, address: m.address || '', - icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person', - label: m.customer_name || m.name, - caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name, - })) - } catch { entityOptions.value = [] } - }) -} -function onPickEntity (opt) { - if (!opt) return - const frag = `client ${opt.id} (${opt.name}${opt.address ? ' — ' + opt.address : ''})` - const t = text.value.trim() - const sep = !t ? '' : (/[:,–-]$/.test(t) ? ' ' : ' · ') // pas de « · » après un « : » ou une virgule - text.value = (t ? t + sep : '') + frag + ' ' - nextTick(() => { entityPick.value = null; entityOptions.value = [] }) +// Options cliquables (désambiguïsation) proposées par l'assistant DANS le fil : « quel client / entité ? ». Un clic +// envoie le choix comme tour suivant → l'assistant continue avec l'id retenu. Remplace l'ancien 2e champ de recherche. +const options = ref([]) +function pickOption (o) { + options.value = [] + text.value = `${o.label} (${o.id})` + plan() } const actions = ref([]) const results = ref([]) @@ -230,9 +206,9 @@ async function pickDiag (a, m) { } watch(() => props.modelValue, v => { open.value = v; if (v) { reset(); if (props.initialText) { text.value = props.initialText; nextTick(() => plan()) } } }) -watch(() => thread.value.length + actions.value.length + results.value.length, scrollDown) // défile vers le bas à chaque nouveau contenu (comme un chat) +watch(() => thread.value.length + actions.value.length + results.value.length + options.value.length, scrollDown) // défile vers le bas à chaque nouveau contenu (comme un chat) watch(open, v => emit('update:modelValue', v)) -function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; thread.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false } +function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; thread.value = []; options.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false } const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2', create_job: 'add_task', assign_tech: 'engineering', add_assistant: 'group_add', set_job_status: 'flag', reschedule_job: 'event_repeat', create_recurring_shift: 'event_available', follow_doc: 'notifications_active' }[t] || 'bolt') const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7', create_job: 'indigo-6', assign_tech: 'indigo-6', add_assistant: 'blue-grey-6', set_job_status: 'deep-orange-6', reschedule_job: 'deep-purple-5', create_recurring_shift: 'teal-6', follow_doc: 'blue-6' }[t] || 'grey-7') @@ -260,7 +236,7 @@ function toggleMic () { async function plan () { const userText = text.value.trim() if (!userText || planning.value) return - planning.value = true; results.value = [] + planning.value = true; results.value = []; options.value = [] // nouveau tour → efface les options du tour précédent thread.value.push({ role: 'user', text: userText }) const history = thread.value.slice(0, -1).map(m => ({ role: m.role, content: m.text })) // tours précédents = contexte text.value = '' // vide le champ → prêt à RÉPONDRE à une question de l'assistant (chat) @@ -269,6 +245,7 @@ async function plan () { const d = await r.json() if (d.ok) { actions.value = d.actions || [] // plan du DERNIER tour (remplace le précédent) + options.value = d.options || [] // candidats cliquables (désambiguïsation client/entité) proposés par l'assistant reply.value = d.reply || '' if (d.reply) thread.value.push({ role: 'assistant', text: d.reply }) // réponse/question de l'assistant → dans le fil for (const a of actions.value) if (a.type === 'create_ticket' && a.resolved) onResolved(a) // pré-charge les adresses de service @@ -313,4 +290,8 @@ async function run () { .orch-msg { padding: 8px 12px; border-radius: 12px; font-size: 0.92rem; white-space: pre-wrap; max-width: 86%; line-height: 1.45; box-shadow: 0 1px 1px rgba(24,32,46,.05); } .orch-msg-user { align-self: flex-end; background: #7c6cf6; color: #fff; border-bottom-right-radius: 3px; } .orch-msg-assistant { align-self: flex-start; background: #fff; border: 1px solid #ece9fb; color: #3a3a4a; border-bottom-left-radius: 3px; } +/* Options cliquables (désambiguïsation) — menu sous la question de l'assistant, côté gauche comme une bulle assistant */ +.orch-options { align-self: flex-start; max-width: 92%; background: #fff; border: 1px solid #ece9fb; border-radius: 12px; overflow: hidden; padding: 2px 0; box-shadow: 0 1px 1px rgba(24,32,46,.05); } +.orch-option { min-height: 44px; } +.orch-option + .orch-option { border-top: 1px solid #f2f0fb; } diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index b8d2f30..9e38363 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -170,7 +170,7 @@ + title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity /> diff --git a/services/targo-hub/lib/agent-tools.json b/services/targo-hub/lib/agent-tools.json index c318c1a..86acfcf 100644 --- a/services/targo-hub/lib/agent-tools.json +++ b/services/targo-hub/lib/agent-tools.json @@ -20,6 +20,7 @@ { "audience": "staff", "mode": "read", "title": "Trouver un créneau", "type": "function", "function": { "name": "find_slot", "description": "STAFF/dispatch. Propose les meilleurs créneaux disponibles (technicien + date + heure) selon la durée et la compétence requise. LECTURE — ne réserve rien ; présente les options à l'utilisateur.", "parameters": { "type": "object", "properties": { "duration_h": { "type": "number", "description": "Durée en heures (défaut 1)" }, "skill": { "type": "string", "description": "Compétence requise (optionnel)" }, "after_date": { "type": "string", "description": "Chercher à partir de cette date AAAA-MM-JJ (défaut aujourd'hui)" } }, "required": [] } } }, { "audience": "staff", "mode": "read", "title": "Ressources disponibles", "type": "function", "function": { "name": "resource_availability", "description": "STAFF/dispatch. DIAGNOSTIC : combien de techniciens QUALIFIÉS pour une compétence sont DISPONIBLES sur une période, et combien d'heures libres. LECTURE. À appeler après resolve_skill pour répondre « a-t-on la ressource ? » avant de proposer ; find_slot donnera ensuite les créneaux précis.", "parameters": { "type": "object", "properties": { "skill": { "type": "string", "description": "Compétence requise (issue de resolve_skill), ex. réparation, installation" }, "after_date": { "type": "string", "description": "Dès cette date AAAA-MM-JJ (défaut aujourd'hui)" }, "days": { "type": "number", "description": "Fenêtre en jours (défaut 7)" } }, "required": [] } } }, { "audience": "staff", "mode": "read", "title": "État du service (modem/signal)", "type": "function", "function": { "name": "check_service", "description": "STAFF/dispatch. DIAGNOSTIC À DISTANCE : état LIVE du service d'un client — modem/ONU en ligne ou hors ligne, puissance du signal (Rx dBm), abonnement. LECTURE. À utiliser AVANT de proposer un déplacement pour un problème de connexion : si l'équipement est EN LIGNE, propose d'abord un correctif à distance (redémarrage) ; s'il est HORS LIGNE avec signal faible/nul, un déplacement (réparation) est justifié. Fournis customer (id C-xxxx) ou query (nom/adresse/téléphone/courriel).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client (C-xxxx) si connu" }, "query": { "type": "string", "description": "Sinon : nom / adresse / téléphone / courriel du client" } }, "required": [] } } }, + { "audience": "staff", "mode": "read", "title": "Trouver un client", "type": "function", "function": { "name": "find_customer", "description": "STAFF. Recherche un client par nom / adresse / téléphone / courriel et renvoie les CANDIDATS probables. LECTURE. À appeler dès qu'un client n'est PAS déjà identifié par son id (C-xxxx) : UN seul candidat → prends son id et continue ; PLUSIEURS → NE DEVINE PAS, demande brièvement « lequel ? » — l'interface affiche la liste cliquable des candidats, ne les réénumère donc pas tous en texte. Enchaîne l'action avec l'id choisi.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Nom, adresse, téléphone ou courriel (même partiel)" } }, "required": ["query"] } } }, { "audience": "staff", "mode": "write", "permission": "create_jobs", "title": "Créer un job", "type": "function", "function": { "name": "create_job", "description": "STAFF/dispatch. Crée un Dispatch Job (intervention). ÉCRITURE — proposée puis confirmée par l'utilisateur avant exécution. auto_assign=true assigne au meilleur tech disponible.", "parameters": { "type": "object", "properties": { "subject": { "type": "string", "description": "Description du travail" }, "customer_id": { "type": "string", "description": "ID client (optionnel)" }, "service_location": { "type": "string", "description": "Nom du lieu de service (optionnel)" }, "address": { "type": "string", "description": "Adresse civique de l'intervention (texte libre, ex. « 2338 rue Ste-Clotilde ») — si aucun lieu de service lié" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Priorité (défaut high pour urgence, medium sinon)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type de travail (défaut Dépannage)" }, "notes": { "type": "string", "description": "Contexte additionnel" }, "auto_assign": { "type": "boolean", "description": "Assigner automatiquement au meilleur tech dispo (défaut true)" } }, "required": ["subject"] } } }, { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Assigner un technicien", "type": "function", "function": { "name": "assign_tech", "description": "STAFF/dispatch. Assigne OU réassigne un Dispatch Job à un technicien (pose aussi l'heure au premier trou libre du quart). ÉCRITURE — confirmée avant exécution. Résous d'abord le tech via list_technicians pour obtenir tech_id.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "date": { "type": "string", "description": "Date AAAA-MM-JJ (optionnel)" }, "start": { "type": "string", "description": "Heure HH:MM (optionnel)" } }, "required": ["job", "tech_id"] } } }, { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Ajouter un assistant", "type": "function", "function": { "name": "add_assistant", "description": "STAFF/dispatch. Ajoute un technicien en renfort (assistant) sur un job — le tech assigné (lead) reste inchangé. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien assistant (TECH-xxxx)" }, "tech_name": { "type": "string", "description": "Nom du technicien (optionnel, pour l'affichage)" } }, "required": ["job", "tech_id"] } } }, diff --git a/services/targo-hub/lib/staff-agent.js b/services/targo-hub/lib/staff-agent.js index 50b7e53..73b3657 100644 --- a/services/targo-hub/lib/staff-agent.js +++ b/services/targo-hub/lib/staff-agent.js @@ -130,7 +130,10 @@ async function read_check_service ({ customer, query } = {}) { try { const r = await require('./ticket-collab').serviceStatus({ customer: customer || '', q: query || '' }) if (!r || r.ok === false) return { error: (r && r.error) || 'statut de service indisponible' } - if (r.found === false) return { found: false, ambiguous: !!r.ambiguous, matches: (r.matches || []).slice(0, 6).map(m => ({ id: m.name, name: m.customer_name || m.name })), note: r.ambiguous ? 'Plusieurs clients correspondent — précise lequel (customer=id).' : 'Client/service introuvable.' } + if (r.found === false) { + const cands = (r.matches || []).slice(0, 6).map(m => ({ id: m.name, label: m.customer_name || m.name, caption: m.address || m.phone || m.email || '', icon: 'person' })) + return { found: false, ambiguous: !!r.ambiguous, candidates: cands, note: r.ambiguous ? "Plusieurs clients correspondent — présente-les (l'interface affiche des boutons cliquables) et demande lequel ; ne devine pas." : 'Client/service introuvable.' } + } const s = r.summary || {} let advice if (s.online === true) advice = 'Équipement EN LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + " → tente d'abord un correctif À DISTANCE (redémarrage / reprovision) ; un déplacement n'est probablement PAS nécessaire." @@ -139,7 +142,25 @@ async function read_check_service ({ customer, query } = {}) { return { found: true, customer: r.customer && r.customer.name, customer_name: r.customer && r.customer.customer_name, online: s.online, rx_power_dbm: s.rxPower != null ? s.rxPower : null, signal: s.signal || null, status_label: s.label || '', source: s.source || '', subscriptions: (r.subscriptions || []).map(x => (x.plan || '?') + ' · ' + (x.status || '?')), advice } } catch (e) { return { error: e.message } } } -const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service } +// Recherche floue d'un client → CANDIDATS (l'UI les rend cliquables dans le fil). Réutilise /collab/customer-search +// (même endpoint que l'ancien typeahead) MAIS c'est l'ASSISTANT qui liste les options — plus besoin d'un 2e champ. +async function read_find_customer ({ query } = {}) { + const q = String(query || '').trim() + if (q.length < 2) return { error: 'requête trop courte (2 lettres min)' } + try { + const r = await hubCall('GET', `/collab/customer-search?q=${encodeURIComponent(q)}&team=1`) + const matches = (r.data && r.data.matches) || [] + const candidates = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({ + id: m.name, label: m.customer_name || m.name, + caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name, + icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person', + })) + if (!candidates.length) return { count: 0, candidates: [], note: `Aucun client pour « ${q} ».` } + if (candidates.length === 1) return { count: 1, candidates, note: `Un seul client : ${candidates[0].label} (${candidates[0].id}) — utilise cet id et continue.` } + return { count: candidates.length, candidates, note: `${candidates.length} clients possibles — demande « lequel ? » ; l'interface affiche la liste cliquable (ne les réénumère pas en texte).` } + } catch (e) { return { error: e.message } } +} +const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service, find_customer: read_find_customer } // ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ── const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] @@ -342,7 +363,8 @@ function systemPrompt (email) { Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}. Tu transformes une commande en langage naturel en APPELS D'OUTILS. RÈGLES : -- Utilise d'abord les outils de LECTURE (list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir. +- Utilise d'abord les outils de LECTURE (find_customer, list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir. +- IDENTIFIER LE CLIENT : si le client n'est pas donné par son id (C-xxxx), appelle find_customer avec ce que tu as (nom, adresse, téléphone, courriel). UN seul candidat → prends son id et continue. PLUSIEURS → NE DEVINE PAS : demande brièvement « lequel ? » ; l'interface affiche la liste cliquable des candidats, donc NE les réénumère PAS tous en texte. Utilise ensuite l'id choisi pour l'action. - PROBLÈME DE CONNEXION (« pas d'internet », « lent », « signal », « hors ligne », « coupé ») : appelle check_service AVANT de proposer un déplacement. Si le modem est EN LIGNE → propose d'abord un correctif À DISTANCE via device_action(reboot) ; s'il est HORS LIGNE avec signal faible/nul → un déplacement (réparation) est justifié. Dis à l'utilisateur ce que tu as constaté (en ligne/hors ligne · Rx dBm) avant de recommander. - CORRECTIF À DISTANCE (Do Stuff) : device_action agit sur l'ONU du client via l'OLT — reboot (redémarrer), speed (changer le forfait), suspend/unsuspend (couper/rétablir l'internet), activate/replace/remove (provisionnement). Fournis serial OU customer_id (on résout l'ONU). Utilise-le pour éviter un déplacement quand c'est réparable à distance. ÉCRITURE RÉSEAU conséquente → toujours PROPOSÉE puis CONFIRMÉE ; n'affirme jamais que c'est fait avant confirmation. - INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie. @@ -357,7 +379,12 @@ async function execToolPlan (name, args, ctx) { const meta = META[name] if (!meta) return { error: 'outil inconnu : ' + name } try { - if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } } + if (meta.mode === 'read') { + const fn = READERS[name]; if (!fn) return { error: 'lecteur manquant : ' + name } + const out = await fn(args || {}) + if (out && Array.isArray(out.candidates) && out.candidates.length > 1) ctx.options = out.candidates // client ambigu → l'UI affiche des options cliquables + return out + } const w = WRITES[name] if (!w) return { error: 'exécuteur manquant : ' + name } const staged = await w.build(args || {}, ctx.email) @@ -371,7 +398,7 @@ async function execToolPlan (name, args, ctx) { async function plan (text, email, history) { if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." } const planned = [] - const ctx = { email, planned } + const ctx = { email, planned, options: [] } // options = candidats cliquables (désambiguïsation client/entité) à afficher dans le fil // Chat MULTI-TOURS : les tours précédents (user/assistant) donnent le contexte pour répondre à une question de // clarification (« oui, crée l'intervention »). Bornage : 10 derniers tours, 4000 car. chacun. const hist = Array.isArray(history) ? history.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).slice(-10).map(m => ({ role: m.role, content: String(m.content).slice(0, 4000) })) : [] @@ -394,7 +421,7 @@ async function plan (text, email, history) { try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } } const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.') - return { ok: true, reply, actions: planned, transcript: String(text || '') } + return { ok: true, reply, actions: planned, options: ctx.options, transcript: String(text || '') } } // RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.