Verificando acceso interno...
Perfil persistente del cliente (cross-channel)
Capa de memoria persistente que comparten chat texto, voz por navegador y callback telefónico. Mismo cliente, mismo perfil, sin importar por dónde entre. El asistente recuerda nombre, teléfono, dirección, gustos/alergias y nivel de satisfacción en visitas posteriores aunque cambien de IP, de navegador (vía cookie) o de canal. Caso real: demo Vila Sen Vento. Patrón replicable para cualquier futuro demo/cliente.
1 · Por qué hace falta
Antes de esta capa, cada canal vivía aislado. Si Juan llamaba por teléfono, decía su dirección y colgaba, al volver al chat web el asistente saludaba como si no le conociera. Si rellenaba un perfil escribiendo, la próxima vez que entrara por voz tampoco recordaba nada. La memoria cross-channel que ya teníamos (getRecentContext) resumía conversaciones recientes, pero no consolidaba hechos confirmados sobre la persona — un nombre dicho hace una semana se perdía.
El perfil persistente resuelve eso: una fila por cliente y por proyecto, escrita desde tres puntos (tool del chat, webhook post-call de voz y lazy-fetch en handoff), leída en cada turno antes de generar respuesta.
2 · Identidad del cliente — customerKey
Toda la capa gira en torno a una clave opaca de 32 caracteres hex (SHA-256 truncado) que identifica al cliente independientemente del canal.
- Prioridad 1 · uid: UUID v4 generado en el navegador al cargar
cualquier página (
Base.astro, eager). Persistido enlocalStorage['vsv-uid']+ cookie del mismo nombre. Sobrevive a cambios de IP (NAT móvil, casa→bar), no colisiona cuando varias personas comparten una IP de oficina, y se puede borrar reseteando el navegador. - Prioridad 2 · IP hash: fallback cuando no hay uid (incognito, cookies
purgadas, primera petición pre-hidratación). Mismo formato (32 hex) para que la
columna
ip_hashsea comparable.
El nombre de columna ip_hash se mantiene por compatibilidad histórica;
semánticamente siempre es customerKey. La función
resolveCustomerKey({uid, ip, clientSlug}) en
functions/utils/demoContext.js es la única fuente. Cuatro endpoints la usan:
vilasenvento-chat-stream.js— chat texto (envíabody.uid).vilasenvento-voice-token.js— voz navegador (envíabody.uid).vilasenvento-voice-callback.js— phone callback (envíabody.uidenverify).webhooks/elevenlabs.js— webhook post-call (recupera el customerKey viaSELECT ip_hash FROM demo_voice_calls WHERE conversation_id = ?).
3 · Esquema D1
-- Migración 0169_demo_customer_profile.sql
CREATE TABLE demo_customer_profile (
customer_key TEXT NOT NULL,
client_slug TEXT NOT NULL,
name TEXT,
phone TEXT,
address TEXT,
preferences TEXT, -- JSON array (gustos, alergias, contexto)
satisfaction INTEGER, -- 1..5
satisfaction_note TEXT,
notes TEXT, -- libre, máx 600 chars
last_channel TEXT, -- 'chat' | 'voice'
source TEXT, -- 'tool' | 'voice_extract'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (customer_key, client_slug)
);
preferences se guarda como array JSON y se mergea (sin duplicar) en cada
upsertProfile; los demás campos hacen overwrite con lo último válido.
satisfaction solo se actualiza si el nuevo valor es entero 1..5.
4 · Tres caminos de escritura
4.1 · Tool update_customer_info (chat)
Declarada en functions/api/demos/_lib/vilasenvento-tools.js con
esquema JSON estándar de function calling. El system prompt instruye al modelo a
llamarla silenciosamente en cuanto aprenda algo de forma orgánica:
name,phone,address— datos de contacto.preference— gusto / alergia / contexto. Una llamada por dato.satisfaction+satisfactionNote— solo si hay expresión clara de satisfacción/queja.notes— libre, con moderación.
runUpdateCustomerInfo(env, args, ctx) recibe
ctx = { customerKey, clientSlug } inyectado por
chat-stream.js en cada runTool().
Si ctx no llega no rompe: devuelve ok:false con
error: 'missing_context' y el modelo continúa.
4.2 · Webhook post-call de ElevenLabs (voz)
Cuando termina una llamada (browser-voice o phone-callback), ElevenLabs entrega
el transcript en POST /api/webhooks/elevenlabs 30s-2min después.
maybeExtractProfileFromCall() hace:
-
SELECT ip_hash FROM demo_voice_calls WHERE conversation_id = ?— recupera el customerKey escrito por el frontend (token o callback). - Si
ip_hash IS NULL OR = 'webhook-only'→ bail (huérfana). -
extractProfileFromText(env, {summary, transcript})— pasa el transcript por DeepSeek con un prompt que extrae nombre/teléfono/dirección/preferencia/satisfacción en JSON estructurado. -
upsertProfile()conlastChannel: 'voice'ysource: 'voice_extract'.
4.3 · Lazy-fetch en handoff voz→chat
Si el cliente cuelga la llamada de voz y abre el chat en <30s, el webhook todavía
no ha llegado. El chat dispara fetchAndSaveLatestVoiceCall() que pide
el transcript directamente a la API de ElevenLabs, lo persiste, ejecuta
extractProfileFromText y hace upsertProfile. El primer turno
del chat ya tiene memoria y perfil actualizados.
5 · Lectura: inyección en el prompt
Cada turno (chat o voz) carga el perfil con loadProfile(db, customerKey, clientSlug)
y lo renderiza con renderProfileBlock(profile) en un bloque corto al final
del system prompt:
═══ PERFIL DEL CLIENTE ═══
Nombre: Juan
Teléfono: +34 612 34 56 78
Dirección: Rúa do Vilar 12, Santiago
Le gusta / lo que sabemos: sin gluten · prefiere Albariño · regalo para suegra
Última satisfacción: 5/5 — "encantado con la empanada"
Esto lo sabemos del cliente por interacciones previas. Úsalo con naturalidad
("¿te lo mando otra vez al mismo sitio?", "lo tuyo es lo gallego, ¿no?").
NO lo recites en bloque ni se lo confirmes a saco. Si algo no encaja con lo que
dice ahora, fluye con lo que diga AHORA y actualízalo.
En vilasenvento-voice-token.js el perfil también se mete como
personalization.profile en la respuesta, y el SDK del navegador lo
pasa al agente como dynamic variables para que el firstMessage pueda
ser concreto ("¿Qué tal Juan, te lo enviamos al mismo sitio?").
6 · Orden de bloques en el system prompt
El orden importa. chat-stream.js los compone así (cada uno hace
+= sobre systemPromptForRun):
- SYSTEM_PROMPT base — persona Vila Sen Vento.
- HORA ACTUAL Y ENVÍOS — corte de las 14h Europe/Madrid.
- MEMORIA DEL CLIENTE (si
getRecentContexttrae algo) o CLIENTE NUEVO (si no). - MEMORIA RECIÉN RESCATADA (HANDOFF) — solo cuando
handoff:truey el lazy-fetch trajo summary nuevo. Marca explícitamente que ignore el bloque "CLIENTE NUEVO" si lo había arriba. - HANDOFF DESDE VOZ — directiva de no saludar y retomar.
- PERFIL DEL CLIENTE — hechos confirmados (último, para que pesen).
⚠️ Bug histórico evitado: el lazy-fetch del handoff
append-ea, no sobreescribe. Si reasignas
systemPromptForRun = SYSTEM_PROMPT + ... pierdes el bloque de hora/cutoff
y el modelo vuelve a prometer "sale hoy si pides antes de las 14h" a las 17:00.
Fixed en commit 67cc7c28.
7 · Phone callback — el bug crítico (12 may 2026)
Síntoma: cliente pide callback, da datos al teléfono, cuelga → al volver por chat el asistente actuaba como si no le conociera. Las llamadas telefónicas nunca contribuían al perfil persistente.
Causa: vilasenvento-voice-callback.js no insertaba
fila previa en demo_voice_calls. Cuando el webhook post-call llegaba,
handlePostCallTranscription() entraba por la rama "no existe" e
insertaba una fila huérfana con ip_hash = 'webhook-only'.
maybeExtractProfileFromCall ve esa marca → bail sin extraer perfil.
getRecentContext tampoco la encuentra (busca por ip_hash = customerKey).
Fix (commit 21c34297):
-
Frontend
VoiceCallback.astroenvíauiden el body deaction: 'verify'. -
Backend
handleVerifytrasdialOK ejecutaresolveCustomerKeyy haceINSERT INTO demo_voice_calls (client_slug, ip_hash, conversation_id, agent_id, status='started'). -
El webhook ahora encuentra
existing(porconversation_id), entra en la rama UPDATE, ymaybeExtractProfileFromCallya tienecustomerKeyválido para hacerupsertProfile.
Lección: cualquier canal nuevo que arranque conversaciones de voz debe pre-insertar la fila placeholder con su customerKey ANTES de que llegue el webhook asíncrono.
8 · Mirroring del nombre al cliente
Para que la voz pre-llene "Te llamamos, Juan" sin necesidad de pedir el nombre en un
modal, el chat refleja el nombre del perfil al evento done:
// chat-stream.js, al cerrar el turno
send('done', { ..., customerName: profile.name || '' });
// ChatWidgetV2.astro
if (data.customerName) localStorage.setItem('vsv-name', data.customerName);
// VoiceCall.astro al abrir el modal
const name = localStorage.getItem('vsv-name') || '';
fetch('/api/demos/vilasenvento-voice-token', { body: JSON.stringify({ name, uid }) }); El modal de voz no tiene input de "tu nombre" — el agente lo pregunta de forma natural si hace falta. Resultado: cero fricción para empezar a hablar.
9 · Ciclo de vida — caso completo
- Lunes 10:00 — Juan llama al teléfono (callback). Agente le pregunta nombre + dirección. Cuelga 11:30.
- +90s — webhook post-call llega.
maybeExtractProfileFromCallextraename="Juan",address="Rúa do Vilar 12"y los persiste. - Martes 09:00 — Juan abre el chat web (mismo navegador).
customerKeycoincide vía uid.getRecentContextdevuelve summary de la llamada de ayer ([reciente]).loadProfiledevuelve nombre + dirección.- Asistente: "Buenas, Juan. ¿Te lo mando al mismo sitio?".
- Juan dice "es alérgico al gluten". Modelo llama
update_customer_info({preference: "sin gluten"})sin avisarle. - Una semana después — abre la voz desde el móvil (3G, IP distinta). uid sigue.
personalization.profiletrae todo.firstMessagedinámico: "¿Qué tal Juan, otro pedido sin gluten al mismo sitio?".
10 · Reutilización en futuros clientes
El esquema, los helpers (functions/utils/demoContext.js) y el contrato
del tool update_customer_info son genéricos: solo
cambia CLIENT_SLUG y los textos del prompt. Para enchufarlo en un cliente
nuevo:
- Aplicar migración
0169_demo_customer_profile.sql(idempotente, ya está). - Generar uid en el layout del storefront (copiar bloque de
Base.astrode VSV). - Enviar
body.uiddesde chat, voz y callback (los tres). - Inyectar
renderProfileBlock(profile)al final del system prompt. - Declarar la tool
update_customer_infoen el array de tools del cliente y enchufar el case enrunTool. - Comprobar que la fila placeholder en
demo_voice_callsse crea en TODOS los caminos que arranquen voz (token + callback).
11 · Limitaciones conocidas
- Sin UNIQUE en
conversation_idendemo_voice_calls. Si el webhook llegara antes que nuestro INSERT (microsegundos vs 30s+ habituales), habría doble fila. Migración futura: índice único parcial. - Edge case <30s en handoff phone→chat: si el cliente saca el móvil y abre el chat web antes de que el webhook llegue Y antes de que el lazy-fetch llegue a la API de EL, getRecentContext no tendrá summary. Poco realista en el flujo phone.
- uid borrable: incognito, limpieza de cookies y datos del sitio rompen la continuidad. Caemos a IP hash, que sigue funcionando dentro de la misma red doméstica pero no entre WiFi y 4G.
- Extracción LLM no es 100% fiable: si el cliente dice una dirección
confusa o un nombre raro, DeepSeek puede normalizarla mal. Por eso
upsertProfilenunca borra: solo overwrite con valor no-vacío.
12 · Ficheros clave
functions/utils/demoContext.js—resolveCustomerKey,loadProfile,upsertProfile,renderProfileBlock,fetchAndSaveLatestVoiceCall,extractProfileFromText.functions/api/demos/_lib/vilasenvento-tools.js— TOOL_SCHEMAS +runUpdateCustomerInfo.functions/api/demos/vilasenvento-chat-stream.js— orquesta inyección + ejecución de tool conctx.functions/api/demos/vilasenvento-voice-token.js— pre-INSERT con customerKey + personalization.profile.functions/api/demos/vilasenvento-voice-callback.js— pre-INSERT con customerKey tras dial OK.functions/api/webhooks/elevenlabs.js—maybeExtractProfileFromCall.storefronts/vilasenvento/src/layouts/Base.astro— generación + persistencia del uid.storefronts/vilasenvento/src/components/ChatWidgetV2.astro,VoiceCall.astro,VoiceCallback.astro— los tres envíanuid.migrations/0169_demo_customer_profile.sql— esquema.