Ajustes runtime
Las perillas de comportamiento del copiloto (umbrales de transcripción, debounce de
sugerencias, tamaños de buffer, timeouts de sesión, etc.) son editables en caliente por un
superadmin desde el dashboard, sin redeploy. Antes vivían como constantes/process.env
leídas al boot; ahora se resuelven en runtime desde un store en DB.
Es el mismo patrón que Prompts del agente, pero para configuración numérica/enum en vez de texto libre.
Solo se exponen perillas de tuning. Los secretos (API keys de Deepgram/LLM, credenciales
de la DB, AUDIO_FORK_SECRET) nunca están acá: siguen en las variables de entorno de la
plataforma (DigitalOcean) y no son visibles ni editables desde la UI.
Arquitectura: catálogo en código + overrides en DB
- El catálogo de settings (qué existe, tipo, rango, default, descripción, grupo) vive en
código:
src/settings/settings.registry.ts. Es tipado y validado — la UI no puede inventar keys ni valores fuera de rango. - La tabla
app_settingsguarda solo los valores overrideados. Una fila faltante = se cae al escalón siguiente de la cadena. Ver Base de datos.
Resolución en runtime: override del scope (DB) → override global (DB) → default (código).
No hay versionado por-setting (a diferencia de prompts); la auditoría se limita a las columnas
updated_by_id / updated_by_email / updated_at de la fila.
Scope: un valor por canal
La PK de app_settings es compuesta: (scope, key). El scope es el canal al que aplica el
valor: global (default) o una fuente de conversación (whatsapp, elevenlabs, retell,
audio_fork).
- Las filas que existían antes del scope quedaron en
global: la columna tieneDEFAULT 'global' NOT NULL, así que nada cambió de significado. getNumber(key)/getString(key)/getBoolean(key)son el scopeglobal. Un consumidor que no sabe de canales sigue leyendo lo mismo que siempre.- Un canal solo guarda las claves en las que difiere. Si no tiene fila, hereda la global.
Hoy las únicas perillas que se leen por canal son los tres umbrales de RAG (ver el grupo RAG más abajo y el Pipeline RAG).
La pantalla /anfibios/settings muestra y escribe el scope global. Para tocar un canal hay que
pasar ?scope= a mano:
curl -X PATCH 'localhost:3000/anfibios/settings/rag.knowledgeThreshold?scope=whatsapp' \
-H 'Content-Type: application/json' -d '{"value":0.65}'
curl 'localhost:3000/anfibios/settings?scope=whatsapp' # resuelto para ese canal
La UI de scope es un ticket de frontend aparte.
El seed al boot (mecanismo sutil, no se infiere del código)
Los tres umbrales de RAG antes se leían de variables de entorno (KNOWLEDGE_THRESHOLD,
SIMILARITY_THRESHOLD, APPROVED_THRESHOLD) en memory.config.ts. Ahora el dueño del valor es el
registry, y para que ningún entorno cambie de comportamiento el día del deploy,
SettingsService.onModuleInit() hace un seed de una sola vez:
Si no existe fila
scope='global'pararag.knowledgeThreshold/rag.similarityThreshold/rag.approvedThreshold, escribe una con el valor que hoy resuelvememory.config— o sea la ENV si está seteada, o su default.
Consecuencias que conviene tener presentes:
- No hace falta saber qué tiene producción. Lo que tenga queda sembrado en el primer arranque.
En local eso es
KNOWLEDGE_THRESHOLD=0.55. - Después de ese primer arranque las tres ENV quedan inertes: cambiarlas no hace nada, porque
ya existe la fila que las pisa. Siguen declaradas en
memory.config.tsa propósito — borrarlas es un cleanup aparte, no algo que valga la pena arriesgar en el mismo deploy. - El seed también escribe un override de canal:
conversation.idleMinutes = 180enscope='whatsapp', porque un hilo de WhatsApp callado tres horas sigue vivo y una llamada callada un cuarto de hora no. No hay override de umbral para WhatsApp: el diseño pedíarag.knowledgeThreshold = 0.65y la medición lo descartó (ver Pipeline RAG). - Es idempotente y no pisa nada: solo inserta donde el scope no tiene fila (
ON CONFLICT DO NOTHING). Un valor cambiado desde el panel sobrevive todos los reinicios. - Si la carga inicial de settings falla, el seed no corre: no se puede saber qué falta, y sembrar a ciegas sobre producción sería peor que quedarse con los defaults del código.
El servicio
SettingsService (src/settings/settings.service.ts):
| Método | Qué hace |
|---|---|
getNumber(key) / getString(key) / getBoolean(key) | Hot path, sincrónicos. Scope global. |
getNumberFor(scope, key) / getStringFor(...) / getBooleanFor(...) | Igual, pero resolviendo scope → global → default. |
getRaw(scope, key) | El valor guardado en ese scope, sin fallback. undefined = ese scope no dice nada. Es lo que distingue "no hay override" de "el override coincide con el default". |
getAll(scope?) | Catálogo + valores resueltos + isOverridden + metadata (para la pantalla). Default global. |
set(key, value, user, scope?) | Valida contra el registry, upsert en DB (target (scope, key)), actualiza cache, audita. |
reset(key, user, scope?) | Borra la fila de ese scope: el valor cae al escalón siguiente (un canal cae a global; global cae al default). |
- Preload en
onModuleInit: carga todas las filas a unMapen memoria keyeado`${scope}:${key}`. ElMaptiene solo filas de la DB — un miss significa "no hay override acá", y el default sale del registry al resolver. Por eso los getters son sincrónicos y baratos: los consumidores del hot path (por turno / por llamada) no pegan a la DB. - Validación en
set: por tipo (number conmin/max, enum contraoptions, boolean, string). Server-side y estricta; nunca confía en el cliente. - Es un módulo
@Global, así queAudioForkServiceyDeepgramServicelo inyectan sin importar el módulo.
Perillas disponibles
| Grupo | Key | Tipo | Default | Qué controla |
|---|---|---|---|---|
| Transcripción | deepgram.language | enum (es/es-419/multi) | es | Idioma del modelo STT |
| Transcripción | deepgram.minConfidence | number 0–1 | 0.6 | Umbral de confianza para aceptar un final |
| Transcripción | audioFork.bufferSeconds | number 10–600 | 90 | Ventana de audio pre-claim que se backfillea |
| Transcripción | audioFork.mergeGapMs | number 500–15000 | 1800 | Gap para fusionar turnos del mismo hablante. También es el piso del debounce de sugerencia (ver abajo) |
| Sugerencias | suggestion.debounceMs | number 200–5000 | 2000 | Silencio del paciente antes de disparar RAG+LLM. Nunca baja de mergeGapMs + 200ms |
| Sugerencias | suggestion.maxHistoryTurns | number 4–50 | 16 | Turnos previos enviados al LLM |
| Sugerencias | suggestion.maxTokens | number 20–500 | 80 | Largo máximo de la sugerencia |
| Sugerencias | suggestion.minWords | number 1–10 | 4 | Mínimo de palabras del turno para generar sugerencia |
| Sesiones | session.staleMs | number 30000–600000 | 120000 | Inactividad de audio para limpiar una sesión muerta |
| Sesiones | session.orphanClaimMs | number 5000–300000 | 30000 | Gracia antes de devolver a la cola el claim de un agente sin dashboard abierto |
| RAG | rag.knowledgeThreshold | number 0–1 | 0.60 | Umbral de similitud de los manuales. Se lee por canal, pero ningún canal tiene override: la medición descartó subir WhatsApp |
| RAG | rag.similarityThreshold | number 0–1 | 0.78 | Umbral de las correcciones de supervisor |
| RAG | rag.approvedThreshold | number 0–1 | 0.85 | Umbral de las respuestas ya validadas |
| RAG | rag.equipmentLockMinutes | number 1–1440 | 30 | Inactividad tras la cual se descarta el equipo pegajoso de la conversación |
| RAG | rag.equipmentLockMaxTurns | number 1–200 | 20 | Turnos del paciente tras los cuales se descarta el equipo pegajoso |
| Conversación | conversation.idleMinutes | number 1–1440 | 15 | Inactividad tras la cual una conversación del Custom LLM se da por terminada. whatsapp viene sembrado en 180 |
| Paciente | patients.equipmentTtlMinutes | number 0–10080 | 60 | Cuánto vale el equipo del paciente cacheado antes de volver a consultarlo a Oxitesa |
Las cinco del grupo RAG, conversation.idleMinutes y patients.equipmentTtlMinutes se resuelven
por scope (ProductionService, ConversationStateService, ConversationMonitorService y
PatientContextService usan el source del turno como scope). Las tres de umbral son además las
únicas con seed al boot desde las ENV viejas; las demás valen el default del registry hasta que
alguien las pise.
conversation.idleMinutes es la única forma en que una conversación del camino Custom LLM se
cierra: ElevenLabs no manda ningún webhook de fin. Es una inferencia nuestra, no un hecho — ver
Custom LLM → El fin de conversación es inferido.
debounceMs nunca puede ser menor que mergeGapMs
Un final de Deepgram que llega dentro de mergeGapMs se fusiona con el turno anterior en vez de
abrir uno nuevo. Si la sugerencia se dispara antes de que esa ventana cierre, se está contestando un
turno que todavía puede crecer: el segmento que llega después lo extiende y dispara una segunda
sugerencia sobre el mismo turno. Con los defaults viejos (4000 de fusión contra 1200 de debounce)
eso pasaba en cualquier frase con una pausa interna de entre 1.2s y 4s — medido en producción, 290
de 447 turnos se procesaron dos o más veces, y la primera pasada consultaba el manual con la frase
cortada a la mitad.
resolveSuggestionDelayMs (audio-fork.service.ts) fuerza el invariante en código —
max(debounceMs, mergeGapMs + 200ms) — así que un valor cargado desde la UI de ajustes no puede
volver a romperlo. El margen de 200ms existe porque la fusión es inclusiva (<= mergeGapMs). La
consecuencia práctica: subir la ventana de fusión sube la latencia de la sugerencia, aunque el
debounce quede igual.
Las dos del lock son un control clínico, no de caché: mientras el equipo esté pegado, todas
las consultas al manual salen prefijadas con ese modelo. Subirlas mucho hace que una conversación
que ya derivó a otro tema siga sesgando la búsqueda hacia un equipo que no es el que se está
consultando. Ver Base de datos → conversations.
patients.equipmentTtlMinutes es una perilla de latencia contra frescura, y las dos puntas
duelen. La consulta a Oxitesa corre antes del primer token de la respuesta, así que dentro de la
ventana del TTL la conversación arranca con el dato local y no la paga; pasada la ventana, vuelve a
preguntar. Pero los equipos cambian con entregas, retiros y recambios: un TTL largo hace que un
paciente al que le cambiaron el aparato arrastre el manual viejo hasta que venza. En 0 se consulta
siempre. Si Oxitesa no responde se usa el caché aunque esté vencido, y eso queda logueado con la
antigüedad del dato — degradar es preferible a quedarse sin contexto, porque un equipo viejo empeora
una búsqueda pero no inventa un hecho. Ver
Pipeline RAG → Filtro por equipo.
Detalle de cómo impacta cada una en el flujo: ver Ciclo de vida de la transcripción.
Edición desde el dashboard
Pantalla /anfibios/settings (dentro del hub /anfibios), solo superadmin global (ver
Guía de pantallas). Endpoints (todos @RequireGlobalRole('superadmin')):
| Método | Ruta | Qué hace |
|---|---|---|
GET | /anfibios/settings?scope= | Catálogo + valores resueltos para ese scope |
PATCH | /anfibios/settings/:key?scope= | Setea un valor ({ value }), validado contra el registry |
POST | /anfibios/settings/:key/reset?scope= | Borra el override de ese scope |
scope es opcional en los tres y default global: un cliente que no lo manda (aura-front hoy)
lee y escribe exactamente las filas que leía y escribía antes.
Aplicación en vivo
Los cambios se leen del Map en memoria → aplican sin redeploy:
mergeGapMs,debounceMs,minConfidence,maxTokens,maxHistoryTurns,minWords,staleMs: afectan del próximo turno.orphanClaimMs: se lee en cada barrido de sesiones, así que aplica desde el próximo barrido (corre cada 30 s). Es el piso de reacción: bajarla por debajo de eso no libera más rápido.bufferSecondsydeepgram.language: afectan llamadas nuevas (Deepgram se abre al reclamar, ver ciclo de vida).
Seguridad
- Todos los endpoints gateados con
@RequireGlobalRole('superadmin')(ver Ajolote y roles); en el front,RequireSuperadmin. - Cero secretos expuestos.
- Validación server-side contra el registry (rechaza tipos/rangos inválidos).
- El path
/anfibiosestá ofuscado como leve disuasivo; el gate real es el rol.
Agregar una perilla nueva
- Sumar la entrada al
SETTINGS_REGISTRY(y su key aSETTING_KEYS) ensettings.registry.ts. - Leerla donde corresponda con
this.settings.getNumber(SETTING_KEYS.xxx)(ogetString/getBoolean). Si la perilla tiene sentido por canal, usargetNumberFor(scope, key)con elsourcedel turno como scope.
No hace falta migración: la tabla app_settings es genérica ((scope, key)/value); solo cambia el
catálogo en código.
Conexiones externas
El grupo "Conexiones externas" configura la URL del sistema de pacientes (Oxitesa) sin redeploy, y trae el botón para probar la conexión.
| Ajuste | Dónde vive | Por qué |
|---|---|---|
patients.lookupUrl | Ajuste (panel) | Es el error más probable (barra final, http/https, puerto) y no es una credencial |
| Clave compartida | AURA_SHARED_SECRET, env var | No se configura acá. Ver abajo |
El motivo de mover la URL: en App Platform, cambiar una env var es un redeploy, y un redeploy reinicia el proceso y corta todos los WebSockets abiertos — los taps de audio de las llamadas en curso y los dashboards de los operadores con ellos. Un error de tipeo en la URL no debería costar una llamada. El adaptador lee la URL en cada consulta, así que el cambio aplica en la siguiente.
:::warning Después del primer arranque, la env var no manda
La URL se siembra de PATIENT_LOOKUP_URL una sola vez. A partir de ahí gana la fila: cambiar la
variable de entorno y redeployar no tiene efecto. Por eso el arranque loguea cuál es el valor
vivo, y avisa explícitamente cuando la env var dice otra cosa y está siendo ignorada.
:::
Por qué la clave NO está acá
Se evaluó y se decidió que no. Es una credencial, cambia prácticamente nunca (una rotación se coordina con el otro lado, o sea una ventana planificada donde un redeploy es aceptable), y moverla significaría sacarla de una env var —que el proveedor guarda encriptada— para ponerla en una columna en texto plano. El beneficio no paga ese costo.
Y hay que setearla en los dos lados: Oxitesa la usa para validar el header x-aura-secret que
recibe, Aura para mandarlo. Los dos valores tienen que ser idénticos.
:::note Y el chequeo de arranque la mira también
SecurityBootLogger evalúa la URL efectiva (el ajuste, y si no hay fila, la env var). Si mirara
sólo la variable de entorno, un deploy con SECURITY_STRICT=true abortaría el arranque por
PATIENT_LOOKUP_URL vacía aunque la URL esté bien cargada en el panel — que es justo donde
ahora vive. Corre en onApplicationBootstrap, o sea después de que el caché de ajustes se cargó.
:::
Probar conexión
El botón al pie del grupo (POST /anfibios/patient-lookup/check, superadmin) consulta un
documento que nadie puede tener (0000000, siete dígitos para pasar el filtro de plausibilidad,
todos ceros para no coincidir con nadie). No hace falta que el otro lado exponga un health check, y
no se leen datos de ningún paciente real.
Un not_found es la señal de éxito: que te contesten "no lo tengo" prueba de una sola vez que
la ruta existe, que la clave validó, que se llega por red y que la respuesta se entendió.
| Resultado | Qué se muestra | Dónde se arregla |
|---|---|---|
not_found / found | ✅ Conexión OK, con proveedor y latencia | — |
unauthorized | La clave no coincide | AURA_SHARED_SECRET, en alguno de los dos lados |
not_deployed | La URL responde pero la ruta no está ahí | El campo de arriba, o publicar la ruta |
timeout / network | No se llega, o tarda demasiado | URL, HTTPS, firewall |
disabled | No hay URL configurada | El campo de arriba |
El check no puede decir de qué lado está una clave mal: unauthorized sólo significa que las
dos no coinciden.
La latencia también se muestra, y arriba de 1,5 s avisa que está cerca del límite de 3 s del adaptador — una consulta lenta hoy es una que falla mañana.
:::note Multi-instancia Hoy Aura corre en una sola instancia, así que esto funciona como se espera.
set() actualiza la fila en la DB y el caché en memoria de la instancia que atendió el PATCH.
El día que haya más de una, las otras seguirían con el valor viejo hasta reiniciar, y habría que
resolverlo (releer por consulta, o TTL corto en el caché).
No es lo único que asume una sola instancia: el registro de llamadas de AudioForkService y los
sockets de CopilotGateway también viven en memoria. Escalar horizontalmente es un cambio más
grande que este caché.
:::