Skip to main content

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.

Ajustes ≠ secretos

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_settings guarda 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 tiene DEFAULT 'global' NOT NULL, así que nada cambió de significado.
  • getNumber(key) / getString(key) / getBoolean(key) son el scope global. 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).

Los valores por canal se editan por API, no por UI

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' para rag.knowledgeThreshold / rag.similarityThreshold / rag.approvedThreshold, escribe una con el valor que hoy resuelve memory.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.ts a 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 = 180 en scope='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ía rag.knowledgeThreshold = 0.65 y 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étodoQué hace
getNumber(key) / getString(key) / getBoolean(key)Hot path, sincrónicos. Scope global.
getNumberFor(scope, key) / getStringFor(...) / getBooleanFor(...)Igual, pero resolviendo scopeglobal → 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 un Map en memoria keyeado `${scope}:${key}`. El Map tiene 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 con min/max, enum contra options, boolean, string). Server-side y estricta; nunca confía en el cliente.
  • Es un módulo @Global, así que AudioForkService y DeepgramService lo inyectan sin importar el módulo.

Perillas disponibles

GrupoKeyTipoDefaultQué controla
Transcripcióndeepgram.languageenum (es/es-419/multi)esIdioma del modelo STT
Transcripcióndeepgram.minConfidencenumber 0–10.6Umbral de confianza para aceptar un final
TranscripciónaudioFork.bufferSecondsnumber 10–60090Ventana de audio pre-claim que se backfillea
TranscripciónaudioFork.mergeGapMsnumber 500–150001800Gap para fusionar turnos del mismo hablante. También es el piso del debounce de sugerencia (ver abajo)
Sugerenciassuggestion.debounceMsnumber 200–50002000Silencio del paciente antes de disparar RAG+LLM. Nunca baja de mergeGapMs + 200ms
Sugerenciassuggestion.maxHistoryTurnsnumber 4–5016Turnos previos enviados al LLM
Sugerenciassuggestion.maxTokensnumber 20–50080Largo máximo de la sugerencia
Sugerenciassuggestion.minWordsnumber 1–104Mínimo de palabras del turno para generar sugerencia
Sesionessession.staleMsnumber 30000–600000120000Inactividad de audio para limpiar una sesión muerta
Sesionessession.orphanClaimMsnumber 5000–30000030000Gracia antes de devolver a la cola el claim de un agente sin dashboard abierto
RAGrag.knowledgeThresholdnumber 0–10.60Umbral de similitud de los manuales. Se lee por canal, pero ningún canal tiene override: la medición descartó subir WhatsApp
RAGrag.similarityThresholdnumber 0–10.78Umbral de las correcciones de supervisor
RAGrag.approvedThresholdnumber 0–10.85Umbral de las respuestas ya validadas
RAGrag.equipmentLockMinutesnumber 1–144030Inactividad tras la cual se descarta el equipo pegajoso de la conversación
RAGrag.equipmentLockMaxTurnsnumber 1–20020Turnos del paciente tras los cuales se descarta el equipo pegajoso
Conversaciónconversation.idleMinutesnumber 1–144015Inactividad tras la cual una conversación del Custom LLM se da por terminada. whatsapp viene sembrado en 180
Pacientepatients.equipmentTtlMinutesnumber 0–1008060Cuá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étodoRutaQué 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.
  • bufferSeconds y deepgram.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 /anfibios está ofuscado como leve disuasivo; el gate real es el rol.

Agregar una perilla nueva

  1. Sumar la entrada al SETTINGS_REGISTRY (y su key a SETTING_KEYS) en settings.registry.ts.
  2. Leerla donde corresponda con this.settings.getNumber(SETTING_KEYS.xxx) (o getString / getBoolean). Si la perilla tiene sentido por canal, usar getNumberFor(scope, key) con el source del 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.

AjusteDónde vivePor qué
patients.lookupUrlAjuste (panel)Es el error más probable (barra final, http/https, puerto) y no es una credencial
Clave compartidaAURA_SHARED_SECRET, env varNo 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ó.

ResultadoQué se muestraDónde se arregla
not_found / found✅ Conexión OK, con proveedor y latencia
unauthorizedLa clave no coincideAURA_SHARED_SECRET, en alguno de los dos lados
not_deployedLa URL responde pero la ruta no está ahíEl campo de arriba, o publicar la ruta
timeout / networkNo se llega, o tarda demasiadoURL, HTTPS, firewall
disabledNo hay URL configuradaEl 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é. :::