Verificando acceso interno...
Demo cliente — Vila Sen Vento
Esta nota documenta el patrón "Demo Cliente" que estrenamos con Vila Sen Vento (28 abr 2026, refrescado en mayo): un mini-storefront propio + asistente IA RAG sobre el catálogo real del cliente, al que cualquier visitante puede añadir información en directo (URL, PDF o texto) para verlo responder con esa fuente al instante. Tres canales de conversación —chat texto, voz directa en navegador y callback al móvil— agrupados en un único LauncherDock flotante. Pensado como pitch comercial replicable por cliente. Demo en vivo: vilasenvento.pages.dev.
1 · Arquitectura
┌─ storefronts/vilasenvento/ ← Astro 4 SSG · Pages project "vilasenvento"
│ └─ index.astro
│ ├─ <ChatWidget/> → POST cadences.app/api/demos/vilasenvento-chat
│ └─ <IngestPanel/> → POST cadences.app/api/demos/vilasenvento-ingest
│
├─ functions/api/demos/ ← Pages Functions (root project "cadences")
│ ├─ vilasenvento-chat-stream.js · SSE + function calling (4 tools) → DeepSeek V4 (chat de texto, ACTIVO)
│ ├─ vilasenvento-chat.js · Endpoint legacy no-stream (fallback)
│ ├─ _lib/vilasenvento-tools.js · TOOL_SCHEMAS + runTool (search_knowledge, list_collections, browse_collection, get_shipping_info)
│ ├─ vilasenvento-search.js · POST tool-webhook (Bearer) que consume el agente de VOZ
│ ├─ vilasenvento-voice-token.js · GET → signed URL para SDK browser ElevenLabs
│ ├─ vilasenvento-voice-callback.js · Outbound call (Twilio Verify + ElevenLabs Twilio Outbound)
│ └─ vilasenvento-ingest.js · URL/PDF/Text → embed → Vectorize + D1 (rate-limit 15/IP/día)
│
├─ data/demos/vilasenvento/seed/ ← markdown scrapeado (21 docs, 138 chunks)
├─ scripts/scrape-vilasenvento.cjs · scraper específico cliente (sin headless)
├─ scripts/index-demo-content.cjs · indexer GENÉRICO multi-cliente
└─ migrations/0161_demo_rag.sql · esquema GENÉRICO multi-cliente
2 · Pipeline RAG
- Embeddings:
@cf/baai/bge-m3(Workers AI, 1024 dims, multilingüe, FREE) - Vector search: índice Cloudflare Vectorize
codex-knowledge(compartido), namespacedemo_vilasenvento(aislamiento por cliente) - Retrieval:
topK=6,MIN_SCORE=0.35, fallback top-1 si≥ 0.25 - D1: tabla
demo_chunksfiltrada porclient_slug='vilasenvento'+status='approved' - LLM:
deepseek-chat(V4 Flash) → fallback Workers AIllama-3.1-8b - Latencia típica: ~3-4s end-to-end (embed 30ms + Vectorize 5ms + D1 5ms + DeepSeek ~3s)
3 · Esquema D1 (migración 0161)
demo_chunks
id PK · client_slug · doc_slug · chunk_index · chunk_text · title · category
source ('seed'|'ingest_url'|'ingest_pdf'|'ingest_text') · source_url
ip_hash · status ('approved'|'pending'|'rejected') · created_at
demo_chat_rate_limits PK(ip_hash, client_slug, date) · default 50/día
demo_ingest_rate_limits PK(ip_hash, client_slug, date) · default 15/día
demo_ingest_log audit · status · error_msg
4 · Endpoints públicos
POST /api/demos/vilasenvento-chat-stream (activo, SSE + tools)
Body: { message, history?, lang? }. Devuelve un stream SSE con eventos
start, tool_call, tool_result, delta,
sources, done, error. El modelo (DeepSeek V4 chat-completions)
decide qué tool llamar en cada turno mediante function calling, hasta
MAX_TOOL_ITERS=4 iteraciones. Tools declaradas en
functions/api/demos/_lib/vilasenvento-tools.js:
search_knowledge({query, top_k?})— RAG semántico (Vectorize nsdemo_vilasenvento).list_collections()— lista de colecciones para orientarse.browse_collection({slug})— ficha completa de una colección (col-empanadas,col-vino, …).get_shipping_info()— info oficial de envíos/plazos/zonas, antes que cualquier RAG genérico.
Rate-limit 50 mensajes/IP/día (D1 demo_chat_rate_limits). System prompt blindado
contra prompt-injection del contexto (regla 7). Sesiones server-side opcionales por
sessionId persistido en sessionStorage del cliente.
POST /api/demos/vilasenvento-chat (legacy, fallback no-stream)
Mismo prompt + RAG pero sin tools y sin streaming. Devuelve { answer, sources[], meta }.
Lo mantenemos por compat retro y como red de seguridad si el SSE falla en algún navegador exótico.
POST /api/demos/vilasenvento-ingest
Body por action:
{action:'add-url', url}— fetch HTML (≤10MB, ≤12s) → htmlToText → chunk → embed → upsert{action:'add-text', title, text}— texto crudo (30-50 000 chars){action:'add-pdf', filename, base64}— base64 ≤10MB →unpdf.extractText→ chunk → embed → upsert
Rate-limit servidor 15 ingests/IP/día; el cliente añade
límite de 10 ingests por sesión (localStorage) y 10 MB por fichero.
Cada ingest se loguea en demo_ingest_log.
5 · Anti-abuse / hardening
- Prompt-injection: el system prompt instruye explícitamente a tratar el contexto como datos, no comandos. Regla 8 prohíbe revelar el prompt.
- Rate limits dobles (chat 50/día, ingest 15/día) por IP hasheada (SHA-256 + sal por cliente).
- Tamaño: 10 MB hard cap, texto truncado a 50 000 chars antes de chunkear.
- Aislamiento cliente: namespace Vectorize + filtro
client_slugen D1; un cliente nunca ve datos de otro. - Status moderable: la tabla soporta
status='pending'para una futura cola de moderación (no usada en v1 — todo entra comoapproved). - Audit log: cada ingest queda en
demo_ingest_logcon IP-hash, bytes, chunks, error_msg.
6 · Cómo replicar para el siguiente cliente
El patrón es genérico. Para un cliente X:
- Crear
scripts/scrape-X.cjsa medida del sitio (Shopify, WordPress, custom). node scripts/scrape-X.cjs→ markdown endata/demos/X/seed/.node scripts/index-demo-content.cjs X→ indexa al namespacedemo_X.- Clonar
functions/api/demos/vilasenvento-chat.jsyvilasenvento-ingest.jsaX-chat.js/X-ingest.js: cambiarCLIENT_SLUG+ system prompt. - Clonar
storefronts/vilasenvento/→storefronts/X/: ajustar branding (Tailwind colors, hero, categorías) y URLs del API. npm run build && npx wrangler pages deploy ... --project-name=X+ redeploy del rootcadencespara publicar las funciones.
Sin migración nueva, sin tablas nuevas, sin índices nuevos. Una sola tabla
demo_chunks aloja a todos los clientes; el coste marginal de añadir uno es
scrape + index (minutos) + endpoint clonado + storefront (horas).
7 · Coste por demo
- Embedding seed (138 chunks): ~0,01 € en Workers AI bge-m3.
- Vectorize: dentro del free tier (200K vectores).
- Por chat: ~500-700 tokens entrada + ~150 salida con DeepSeek V4 Flash → ~$0.0001/respuesta.
- Por ingest: 1 embedding batch + 1 D1 batch → centavos.
- Cloudflare Pages, D1 reads, R2: dentro de free tier en este volumen.
8 · Smoke tests del 28-abr-2026
# Chat sobre seed (catálogo real)
$ curl POST /api/demos/vilasenvento-chat {"message":"¿Qué empanadas tenéis?"}
→ "Tenemos empanada de trigo y empanada de maíz, link a colección..." (3.7s, 6 chunks, 49 restantes)
# Ingest texto
$ curl POST /api/demos/vilasenvento-ingest {"action":"add-text","title":"Premio...","text":"..."}
→ {success:true, docSlug:"ingest-text-7e56c5fe", chunks:1, remaining:14}
# Verifica ingest
$ curl POST /api/demos/vilasenvento-chat {"message":"¿Es verdad que ganasteis el premio..."}
→ "Sí, en 2024 recibimos el premio... cebolla pochada 3h... taller de Brión." (score 0.65 sobre el ingest)
9 · Voz en directo (ElevenLabs Conversational AI)
La demo monta también un canal de voz en directo en el navegador con ElevenLabs Conversational AI. No usa Twilio (eso es para teléfono real); es WebSocket browser ⇄ ElevenLabs con signed URL emitida por nosotros.
9.1 · Piezas en el código
┌─ functions/api/demos/
│ ├─ vilasenvento-voice-token.js · GET → signed URL (rate-limit 10 sesiones/IP/día, máx 15 min/sesión)
│ └─ vilasenvento-search.js · POST tool-webhook (Bearer auth) → top-k chunks de demo_vilasenvento
├─ storefronts/vilasenvento/src/components/VoiceCall.astro
│ · Botón flotante + modal + SDK @elevenlabs/client cargado por CDN
└─ migrations/0162_demo_voice.sql · demo_voice_rate_limits + demo_voice_calls
9.2 · Pasos exactos en el panel ElevenLabs
Ir a elevenlabs.io/app/conversational-ai → Agents → New agent:
- Name:
Vila Sen Vento — Demo. - Voice: elegir voz stock española/gallega (ej. Sarah, Carlos, Mateo). Dejar pendiente la voz clonada de Javier hasta que tengamos audios.
- Language: Spanish (Spain). Activar también Galician.
- LLM: GPT-4o-mini (latencia + coste). En clientes premium, GPT-4o.
- First message: fallback corto del panel (el real se inyecta por overrides):
Hola, soy de Vila Sen Vento, ¿qué tal? Cuéntame. - System prompt: pegar el bloque "Prompt mínimo para el panel" de
storefronts/vilasenvento/docs/ELEVENLABS_AGENT_PROMPT.md. El prompt completo (4 bloques A/B/C/D, regla anti-prompt-injection, palabriñas en galego, cierre demo a 3 min) se envía comoconversation_config_override.agent.prompt.promptdesde el código en cada llamada (Opción B — ver §12). Requiere tener marcado Security → Agent overrides → System prompt en el panel. - Tools → Add tool → Webhook:
- Name:
search_knowledge - Description: "Busca en la base de conocimiento de Vila Sen Vento (catálogo, envíos, políticas) y devuelve fragmentos relevantes. Llamar antes de responder a cualquier pregunta concreta."
- URL:
https://cadences.app/api/demos/vilasenvento-search - Method: POST
- Authentication: Bearer → pegar el valor del secret
ELEVENLABS_VOICE_TOOL_TOKEN(lo creas tú, ver 9.3) - Body schema (JSON):
{ "type": "object", "properties": { "query": { "type": "string", "description": "La pregunta del usuario" }, "top_k": { "type": "integer", "description": "Cuántos fragmentos devolver (1-8)", "default": 5 } }, "required": ["query"] }
- Name:
- Workspace → Webhooks: apuntar a
https://cadences.app/api/webhooks/elevenlabs(ya existe). Pegar el signing secret enELEVENLABS_SIGNING_SECRETsi no lo está. - Copiar el Agent ID y guardarlo como secret en el Pages project
cadencescon nombreELEVENLABS_AGENT_ID_VSV.
9.3 · Secrets a añadir en Cloudflare Pages (proyecto cadences)
npx wrangler pages secret put ELEVENLABS_AGENT_ID_VSV --project-name=cadences
# Pegar agent_id del paso 8
# Generar un token aleatorio para el tool webhook (Bearer)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
npx wrangler pages secret put ELEVENLABS_VOICE_TOOL_TOKEN --project-name=cadences
# Pegar el mismo token también en el panel ElevenLabs (tool → Authentication → Bearer) 9.4 · Flujo end-to-end
VoiceCall.astro (botón "Llamar a la voz")
↓ click
GET /api/demos/vilasenvento-voice-token
↓ check rate-limit 3/IP/día → fetch ElevenLabs signed URL → INSERT demo_voice_calls(status='started')
WebSocket(signedUrl) — SDK @elevenlabs/client
↓ user audio → ElevenLabs ASR + LLM
↓ LLM decide llamar tool
POST /api/demos/vilasenvento-search (Bearer ELEVENLABS_VOICE_TOOL_TOKEN)
↓ embed query → Vectorize ns demo_vilasenvento → D1 demo_chunks
↑ JSON { ok, answer_context, sources }
↑ ElevenLabs TTS lee la respuesta al usuario
↓ conversation_end_metadata
POST /api/webhooks/elevenlabs (firma xi-signature) → UPDATE demo_voice_calls (duration, cost) 9.5 · Coste y límites
- ElevenLabs Convai con GPT-4o-mini: ~$0.08/min.
- Hard cap: 15 min/sesión · 10 sesiones/IP/día = máx ~$12/IP/día (margen para hacer demos en directo sin quedarse corto).
- Auditable en
demo_voice_calls(filtrar porcreated_at > date('now','-1 day')).
9.6 · Superficie de tools por canal
Asimetría deliberada entre voz (1 tool) y chat (4 tools). Mismo Vectorize namespace + misma D1, así que el contenido es consistente.
| Canal | Tools | Dónde se configuran |
|---|---|---|
| Voz (panel ElevenLabs Convai · navegador SDK + Twilio outbound) | search_knowledge | Panel ElevenLabs → Tools → Add server tool. Auth Bearer = ELEVENLABS_VOICE_TOOL_TOKEN. Endpoint: POST /api/demos/vilasenvento-search. |
| Chat texto (ChatWidgetV2 + SSE) | search_knowledge, list_collections, browse_collection, get_shipping_info | Backend: functions/api/demos/_lib/vilasenvento-tools.js. Function calling DeepSeek V4 (máx 4 iteraciones/turno). Nada que tocar en el panel ElevenLabs. |
Por qué la voz solo tiene una: cada tool extra suma ~600-900 ms de TTS+ASR perceptible.
Mejor un único search_knowledge con top_k generoso y dejar al agente filtrar.
En chat la latencia tolera iteración y respuestas estructuradas (lista de colecciones, ficha,
info de envíos) producen mejores respuestas que un RAG genérico.
Detalle completo: storefronts/vilasenvento/docs/ELEVENLABS_AGENT_PROMPT.md.
10 · Memoria cross-channel (voz ↔ chat)
El usuario puede colgar la llamada de voz, abrir el chat de texto y seguir la conversación
sin tener que repetir nada. Implementado en mayo 2026.
Fuente única: functions/utils/demoContext.js (helper getRecentContext),
consumido por vilasenvento-chat-stream.js (SSE, activo) y vilasenvento-chat.js (legacy).
10.1 · Cómo se persiste cada llamada
- Inicio —
VoiceCall.astro→GET /api/demos/vilasenvento-voice-token. El backend haceINSERT demo_voice_calls (ip_hash=hashClientIP(ip,'vilasenvento'), status='started')y devuelvesessionRef. - Conexión SDK — en
onConnect, el componente hacePOST /api/demos/vilasenvento-voice-link {sessionRef, conversationId}para enlazar la fila inicial con elconversation_idreal de ElevenLabs. - Post-call — ElevenLabs dispara
post_call_transcription→functions/api/webhooks/elevenlabs.jsverifica firma HMAC y haceUPDATE demo_voice_calls SET summary, transcript, status='completed' WHERE conversation_id=?. Sivoice-linkno llegó a ejecutarse, el webhook inserta una fila nueva conip_hash='webhook-only', que no será visible al chat porque no coincide con ninguna IP real.
10.2 · Qué inyecta getRecentContext en el system prompt del chat
Devuelve { hasContext, lastChannel, narrative, voiceSummary, chatExcerpt }. Defaults:
| Parámetro | Default | Significado |
|---|---|---|
withinMinutes | 7 × 24 × 60 (7 días) | Ventana total de búsqueda. |
voiceLimit | 5 | Máx llamadas de voz a incluir. |
sameContextMinutes | 12 × 60 (12 h) | Umbral "misma conversación". |
El narrative lleva etiquetas de antigüedad por llamada para que el modelo entienda qué retomar y qué tratar como histórico:
| Tag | Edad | Sentido |
|---|---|---|
[sigue en curso] | ≤ 12 h | Misma conversación. Retomar con naturalidad. |
[reciente] | 12-48 h | Ayer/anteayer. Probable que siga en el mismo proceso. |
[antecedente] | > 48 h | Histórico del cliente. Contexto, no continuidad. |
Ejemplo del bloque inyectado:
═══ MEMORIA DEL CLIENTE ═══
Hemos hablado 3 veces por voz:
• [antecedente · el otro día] Pregunta por packs de regalo, pidió …
• [reciente · hoy mismo] Comparó albariños, le interesó el Adega …
• [sigue en curso · hace un momento] Confirmó envío a Madrid, …
Etiquetas: [sigue en curso] = misma conversación (≤12h, retómala). … 10.3 · Cuándo se inyecta
El bloque se añade al system prompt si:
effectiveHistory.length === 0(chat sin historial), Octx.lastChannel === 'voice'(la llamada de voz es más reciente que cualquier mensaje del chat).
Esta segunda rama es lo que cubre el caso "cuelgo el teléfono y abro el chat para seguir": antes solo se inyectaba en chats fríos, así que sesiones de chat preexistentes nunca veían la llamada nueva (bug detectado y corregido en mayo 2026).
10.4 · Identificación cross-channel
- Hash IP único por cliente:
SHA-256(ip + '-demo-' + clientSlug).slice(0, 32). - Helper canónico
hashClientIP(ip, clientSlug)endemoContext.js. Los endpoints de chat tienen una función localhashIP(ip)con el mismo algoritmo (no es mismatch). - Limitación: si el usuario cambia de red (wifi → 4G), el hash cambia y se pierde la memoria.
10.5 · Anti-patterns
- ❌ Dejar de llamar a
/api/demos/vilasenvento-voice-linkenonConnect— sin eso el webhook guardaip_hash='webhook-only'y el chat nunca encuentra esa llamada. - ❌ Cambiar la sal del hash en un solo endpoint (rompe el matching cross-channel).
- ❌ Subir
voiceLimitpor encima de ~5 sin acotarsummary— el system prompt crece y degrada respuestas.
11 · Callback telefónico ("Llámame")
Tercer canal: storefronts/vilasenvento/src/components/VoiceCallback.astro + endpoint
functions/api/demos/vilasenvento-voice-callback.js. Modal de 4 pasos:
- step-warn — Aviso explícito: la llamada sale desde un número USA (+1) mientras no portemos el número español de Twilio. Si el usuario intenta devolverla, también responde el número USA. Botones "Sí, llámame desde USA" / "Mejor no".
- step-phone — Móvil ES (+34, 6XX/7XX) + Cloudflare Turnstile (anti-bot).
- step-code — Código SMS vía Twilio Verify.
- step-done — Llamada saliente vía ElevenLabs Twilio Outbound. Máx 3 min, 3 llamadas/móvil/día, 5 SMS/móvil/día.
Fuente de verdad del prompt de voz: constante VSV_VOICE_PERSONA en
vilasenvento-voice-callback.js. Se inyecta en cada llamada por
conversation_config_override.agent.{first_message, prompt} (Opción B — ver §12).
12 · LauncherDock unificado y estrategia de prompt (Opción B)
El storefront monta tres componentes (ChatWidgetV2.astro, VoiceCall.astro,
VoiceCallback.astro) más un LauncherDock.astro que dibuja una única píldora flotante
con los 3 botones agrupados (chat coral · voz teal · callback discreto cream + borde). Los modales
siguen viviendo en cada componente, pero su lanzador propio queda oculto vía CSS desde el dock.
En móvil la píldora se centra abajo con env(safe-area-inset-bottom).
Prompt strategy = Opción B (overrides desde código). En el panel ElevenLabs
vive solo un prompt mínimo de fallback; el prompt completo (4 bloques A/B/C/D, regla
anti-prompt-injection, palabriñas en galego, cierre demo a 3 min) se envía como override en cada
llamada. Detalle y rationale: storefronts/vilasenvento/docs/ELEVENLABS_AGENT_PROMPT.md.
Requisito panel: marcar Security → Agent overrides para
First message, System prompt y Language; sin esto los overrides se ignoran silenciosamente.
Secrets necesarios en Pages project cadences:
ELEVENLABS_AGENT_ID_VSV— agent id del agente Vila Sen Vento.ELEVENLABS_VOICE_TOOL_TOKEN— Bearer del toolsearch_knowledge.ELEVENLABS_PHONE_NUMBER_ID— id del número Twilio comprado en ElevenLabs (USA hoy, ES cuando se porte).TWILIO_VERIFY_SERVICE_SID— Verify service para SMS de validación.ELEVENLABS_SIGNING_SECRET— verificación de webhook post-call.ELEVENLABS_API_KEY— auth para signed URL y outbound call.
13 · Próximos pasos
- Portar número ES de Twilio para retirar el aviso del
step-warn. - Refactor: extraer
VSV_VOICE_PERSONAa un módulo compartido entreVoiceCall.astro(overrides browser) yvilasenvento-voice-callback.js(overrides outbound) — hoy están duplicados a mano. - Página
/adminprotegida para que el cliente vea sudemo_ingest_log+demo_voice_callsy pueda moderar. - Whitelist de dominios para
add-urlsi crece el ratio de spam. - Voz clonada de Javier en cuanto recibamos los 5-10 min de audio limpio.
- Filtrar el webhook de ElevenLabs por
agent_id == ELEVENLABS_AGENT_ID_VSVpara escribir endemo_voice_calls(hoy escribe siempre envoice_calls). - Reusar para Restaurant23, GoServiHogar, etc. — el patrón ya está estable.