Cómo está hecho
No es un chat sobre una persona. Es un agente de voz de dominio cerrado con recuperación trazable, evals en CI y control de costos; la persona es el primer tenant. Esta página es para quien llega desde el CV y quiere ver las decisiones, no el demo.
Flujo
- El navegador pide un token efímero de Gemini. El servidor lo emite con la configuración del tenant bloqueada dentro del token (system prompt, tools, voz): el cliente no puede cambiarla y la API key nunca sale del servidor.
- El audio va directo del navegador a Gemini Live. Speech-to-speech nativo: ~300 ms al primer audio, interrupción incluida.
- Cuando el modelo necesita un dato, emite un
toolCall. El navegador solo lo transporta: el tool corre en el servidor, contra el corpus. El corpus nunca toca el cliente. - El resultado vuelve al modelo, que responde citando los documentos que lo respaldan.
Speech-to-speech, no pipeline
Un pipeline STT → LLM → TTS suma tres latencias en serie (alrededor de un segundo) y la interrupción hay que construirla a mano. Gemini Live devuelve audio a ~300 ms y trae la interrupción de fábrica. La reproducción usa un AudioWorklet con flush() en lugar de <audio>: interrumpir exige vaciar lo que aún no sonó. El plan original proponía un relay WebSocket propio; Vercel serverless no lo mantiene, y un token efímero con configuración bloqueada cumple el objetivo (la key no sale del servidor) sin un segundo servicio.
BM25, no vectores
El corpus son unos 25 documentos Markdown con frontmatter; cabe entero en el contexto. Un índice vectorial añadiría un servicio, un modelo de embeddings y un coste para un problema que un BM25 de 80 líneas resuelve con un test determinista: “esta consulta debe recuperar este documento”. Las coincidencias con tags, tecnologías o empresa refuerzan la puntuación; no filtran (un filtro excluyente dejaba fuera al documento correcto cuando un token casual era tag de otro). La interfaz Retriever.search(query, limit) no cambia: migrar a vectores es cambiar el cuerpo de una clase.
Los tools corren en el servidor
El navegador ve nombres y argumentos, nunca la implementación ni los datos. Los argumentos se validan con Zod, y el mismo schema genera la declaración que ve el modelo: una sola fuente de verdad. Argumentos inválidos vuelven como ok: false con el detalle, para que el modelo corrija, no como un 500.
Un solo tool con efecto
dejar_mensaje es el único tool que escribe algo. Sin tools destructivos, el radio de daño de un jailbreak es cero: lo peor que puede hacer un visitante hostil es dejar un mensaje, y ese tool tiene rate limit por IP, sanitización e idempotencia (el modelo a veces vuelve a llamarlo tras un “sí, guárdalo”). La contención es quitar capacidades, no acumular filtros.
Anti-alucinación
Datos y evals, no solo prompt. Capa 0: una ficha de ~2k tokens fija al inicio del prompt. Capa 1: el corpus, con un documento por cada pregunta predecible sin respuesta obvia (renta, por qué dejó cada trabajo, tecnologías que no ha usado): una respuesta redactada vale más que cualquier guardrail. Y una suite de evals que corre contra la ruta de texto con reglas deterministas y un modelo distinto como juez, con umbral: si un rechazo falla, el build falla.
- Casos
- 76/76
- último run 2026-09-20 23:41 UTC
- Rechazos correctos
- 18/18
- umbral: 100 %
- Juez
- gemini-3.1-flash-lite
- distinto al modelo que responde
Métricas de producción
- Sesiones
- 14
- últimos 30 días
- Turnos
- 53
- Tasa de rechazo
- 6 %
- turnos en que dijo que no tenía el dato
- Primer audio p50
- 7105 ms
- desde el click hasta oír al agente
- Primer audio p95
- 70513 ms
- Duración media
- 0 s
- mediana por sesión
Contención y privacidad
Turnstile invisible antes de emitir tokens de voz; rate limit por IP y día; presupuesto diario por tenant que cierra el canal solo y avisa por correo al 50 % y al 100 %; kill-switch desde el panel, sin redeploy. Cascada de degradación: Gemini Live → voz del navegador (con aviso) → texto; nunca un error. Los turnos se guardan sin audio y con correos y teléfonos redactados, 30 días. El micrófono solo se activa tras consentimiento explícito.
Multi-tenant
Nada de la persona vive fuera de tenants/<id>/: configuración, ficha y corpus. El motor programa contra las interfaces Retriever, Tool y VoiceProvider. Un segundo tenant es una carpeta, no un fork.
Código
Todo es público: el repositorio, con el plan de trabajo, siete registros de decisión (ADR) que explican dónde y por qué la implementación se apartó del plan, y un historial de commits que cuenta el porqué de cada cambio.