feat(assistant): désambiguïsation client via options cliquables (un seul champ)
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 <noreply@anthropic.com>
This commit is contained in:
parent
a580a49eed
commit
69290d7b07
|
|
@ -23,6 +23,15 @@
|
|||
<q-icon v-if="m.role === 'assistant'" name="auto_awesome" size="14px" color="deep-purple-6" class="q-mr-xs" />{{ m.text }}
|
||||
</div>
|
||||
|
||||
<!-- Options cliquables proposées par l'assistant (désambiguïsation : quel client / entité ?) — remplace le 2e champ -->
|
||||
<q-list v-if="options.length" class="orch-options">
|
||||
<q-item v-for="(o, i) in options" :key="'o' + i" clickable v-ripple class="orch-option" @click="pickOption(o)">
|
||||
<q-item-section avatar><q-icon :name="o.icon || 'person'" size="18px" color="deep-purple-5" /></q-item-section>
|
||||
<q-item-section><q-item-label lines="1">{{ o.label }}</q-item-label><q-item-label caption lines="1">{{ o.caption }}</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Vérifications (lecture immédiate — pas de confirmation) -->
|
||||
<div v-if="diagnoses.length" class="q-mt-md">
|
||||
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
|
||||
|
|
@ -120,21 +129,9 @@
|
|||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
<!-- COMPOSER (bas) : écris ta demande / réponds à l'assistant — comme tout chat -->
|
||||
<!-- COMPOSER (bas) : UN SEUL champ — écris ta demande / réponds à l'assistant. La désambiguïsation
|
||||
(quel client ?) se fait via les options cliquables que l'assistant affiche dans le fil, pas un 2e champ. -->
|
||||
<div class="orch-composer">
|
||||
<q-select v-if="entitySearch" dense outlined use-input hide-dropdown-icon input-debounce="200"
|
||||
v-model="entityPick" :options="entityOptions" @filter="filterEntities" @update:model-value="onPickEntity"
|
||||
option-label="label" clearable class="q-mb-xs"
|
||||
label="+ Ajouter un client / une adresse (nom · adresse · tél. · courriel)">
|
||||
<template #prepend><q-icon name="person_search" color="deep-purple-5" size="18px" /></template>
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section avatar><q-icon :name="scope.opt.icon" size="18px" color="deep-purple-5" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label caption>{{ scope.opt.caption }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
<template #no-option><q-item><q-item-section class="text-grey-6">Tape au moins 2 lettres…</q-item-section></q-item></template>
|
||||
</q-select>
|
||||
<div class="row items-end no-wrap q-gutter-xs">
|
||||
<q-input v-model="text" type="textarea" autogrow :rows="1" outlined dense class="col orch-input" autofocus
|
||||
placeholder="Écris ta demande… (Entrée pour envoyer)" @keyup.enter.exact.prevent="plan" />
|
||||
|
|
@ -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; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@
|
|||
<!-- ⌘K langage naturel → copilote STAFF dispatch/planification (function-calling ; plan → confirmer → exécuter).
|
||||
MÊME composant réutilisé, branché sur le endpoint STAFF avec l'identité de l'agent (permissions serveur). -->
|
||||
<OrchestratorDialog v-if="can('view_clients')" v-model="staffCmdOpen" :initial-text="staffCmdText"
|
||||
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity entity-search />
|
||||
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity />
|
||||
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
||||
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
|
||||
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
|
||||
|
|
|
|||
|
|
@ -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"] } } },
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user