Tenía una línea fija de negocio llena de llamadas comerciales y, cuando estaba trabajando, se me escapaban las de clientes. Así que monté una recepcionista con IA que contesta esa línea, todo en local y sin pagar nada por minuto:
Línea fija → Grandstream HT813 (adaptador FXO) → FreePBX 17 en una máquina virtual.
Asterisk AudioSocket: el dialplan manda el audio de la llamada por TCP a un servicio en Python con asyncio, en un PC con tarjeta gráfica.
Voz a texto: Parakeet TDT 0.6B v3 con whisper.cpp en una AMD RX 6800 por Vulkan, 50-200 ms por frase.
IA: qwen2.5:14b en Ollama, en streaming troceado por frases. La primera frase está lista en 0,6-1,1 s.
Voz: Piper, en CPU.
Avisos: resumen de cada llamada por Telegram.
Lo que hace: contesta, toma recados, cuelga a los comerciales y, en horario, me pasa la llamada al móvil por Tailscale (sin abrir puertos). Si no lo cojo, vuelve a atender.
Lo que más me costó, por si a alguien le ahorra tiempo:
No fiarse de la IA para las acciones. Los comerciales evidentes se filtran con expresiones regulares antes de que la IA vea nada ("¿hablo con el titular de la línea?" es la frase que más los delata). Para colgar o pasar la llamada, la IA dice una frase exacta y es Python quien actúa al detectarla en la respuesta del bot.
La caché del prompt importa más que el modelo. Con gemma3:12b había unos 3 s de silencio en cada turno porque releía todas las instrucciones; qwen2.5:14b reutiliza la caché de Ollama y contesta en menos de 1 s.
Intentos de manipularla. Hubo quien llamó para que "ignorara sus instrucciones". Las frases típicas se cortan con una respuesta fija sin pasar por la IA, y al tercer intento cuelga.
Dos modelos en la misma gráfica se pisan. Si otro servicio usa un modelo distinto, Ollama saca de la VRAM el del teléfono y la siguiente llamada espera unos 18 s. Solución: el mismo modelo y el mismo contexto para todo.
El eco de la línea. El bot oía su propio saludo y se contestaba a sí mismo. Ahora compara lo que oye con lo que está diciendo; si coinciden más del 60 % de las palabras, lo descarta:
Código
def es_eco(self, texto: str, duracion: float) -> bool: """¿Lo oído es el propio bot devuelto por la línea? Si la mayoría de sus palabras son las que el bot estaba diciendo a la vez, es eco.""" def palabras(t): t = unicodedata.normalize("NFD", t.lower()) t = "".join(c for c in t if unicodedata.category(c) != "Mn") # sin tildes return [p for p in re.findall(r"[a-zñ]+", t) if len(p) >= 3] oido = palabras(texto) if len(oido) < 2: return False desde = time.time() - duracion - 1.5 # cuándo empezó la frase oída dicho = set(palabras(self.sonando)) # lo que está sonando ahora for ini, fin, t in self.sonado: # y lo que sonó hace poco if fin >= desde - 0.5: dicho.update(palabras(t)) return sum(p in dicho for p in oido) / len(oido) >= 0.6
Con la transcripción "Hola, me llamamos a TV. Ahora estamos fuera de nuestro horario…" (que era su propio saludo mal transcrito) da 0,8 y se descarta. "Quería un presupuesto para un portero automático" da 0 y pasa.
Si alguien quiere probarla llamando, que me escriba por privado y le paso el número. Encantado de responder dudas sobre cualquier parte.





Autor


En línea
