Skip to main content

Base de datos

PostgreSQL con la extensión pgvector. ORM y migraciones con Drizzle. Los schemas viven junto a cada módulo (src/**/*.schema.ts) y se re-exportan desde el barrel src/database/db.schema.ts.

Tablas

memory_embeddings — correcciones (flywheel)

Lecciones validadas por supervisores; se recuperan por RAG en llamadas futuras.

ColumnaTipoNotas
idTEXT PKSHA-256 del complaint (lowercase, trim) → idempotente
embeddingvector(1536)HNSW cosine
customer_complaintTEXTQueja original
wrong_answer_botTEXTRespuesta incorrecta del bot
human_correctionTEXTCorrección del supervisor
supervisor_idTEXTQuién corrigió
categoryTEXTfeedback_wrong | feedback_approved | general
call_idTEXTLlamada de origen
created_atTIMESTAMPTZ

knowledge_base — manuales técnicos

Chunks de los manuales de equipos. Ver RAG → Ingesta.

ColumnaTipoNotas
idTEXT PKSHA-256 de source::section::rowKey → idempotente
sourceTEXTRuta del .md dentro de documentation/manuals/
categoryTEXTCarpeta de primer nivel (ej. concentradores electricos, cap)
titleTEXTH1 del manual
sectionTEXTH2 de la sección
contentTEXTTexto del chunk
embeddingvector(1536)HNSW cosine
created_atTIMESTAMPTZ

call_sessions — sesiones persistidas

Se escribe al terminar la llamada. Fuente del historial y del teléfono en los logs.

ColumnaTipoNotas
idTEXT PKcall_id de Retell o FreeSWITCH
caller_idTEXTNúmero o nombre del llamante
agent_idTEXTAgente que atendió
transcriptJSONB{ speaker, text, ts }[] (ts = epoch ms de inicio del turno)
suggestionsJSONB{ id, turnIndex, text, createdAt, ragHit?, ragScore? }[]. El score viaja con la sugerencia para que la revisión post-llamada y /history muestren de dónde salió; falta en las llamadas anteriores a que se registrara, y ausente ≠ ragScore: null (que significa "no consultó")
sourceTEXTretell | audio_fork
started_at / ended_atTIMESTAMPTZ
reviewed_atTIMESTAMPTZNULL = pendiente de revisión. Estado global de la llamada, no por usuario. Indexada (call_sessions_reviewed_at_idx)
reviewed_byTEXTEmail de quien la revisó primero (no se sobrescribe)
patient_nameTEXTNombre y apellido que dio el paciente. NULL = nunca lo dijo
patient_dniTEXTDocumento, solo dígitos. También es la asociación con patients. Indexada (call_sessions_patient_dni_idx)
patient_whatsappTEXTCelular para el mensaje de WhatsApp, solo dígitos y sin +54
call_reasonTEXTMotivo de la llamada en una frase

Las últimas cuatro las completa el copiloto durante la llamada (ver Visión general); en llamadas viejas y en el flujo de Retell quedan en NULL. Son el snapshot de lo que se dijo en esa llamada, y quien la revisa las puede corregir con PATCH /audio-fork/sessions/:id/patient-data (cualquier usuario autenticado: el operador que atendió es el que sabe qué se dijo). Vaciar un campo lo deja en NULL — un DNI mal transcripto es peor que un NULL. Cada corrección escribe un evento patient_data_updated con qué campos se tocaron, no con los valores.

conversations — estado cross-turn de una conversación

El camino Custom LLM (ElevenLabs, voz y WhatsApp) es HTTP stateless: todo el estado que el gateway de Retell guardaba en nueve Map de memoria no existe ahí, y tampoco sobrevivía un redeploy ni una segunda instancia. Esta tabla lo reemplaza. Se escribe en el primer turno, con la conversación todavía abierta.

La PK es el id que manda el canal: para ElevenLabs, el header x-conversation-id (ver Custom LLM). Sin ese header cada turno es una conversación nueva.

ColumnaTipoNotas
idTEXT PKconv_0001kzrt… — el id de ElevenLabs, no un id nuestro
channelTEXTNombre del ChannelProfile: voice | whatsapp
sourceTEXTEl ConversationSource (elevenlabs, whatsapp). Es también el scope con el que se resuelven los ajustes del turno
patient_dniTEXTIndexada (conversations_patient_dni_idx). La escribe el lookup de paciente del 3.7
patient_nameTEXTQueda en NULL a propósito, también después del 3.7: un nombre en el estado es un nombre que el prompt puede decir, y divulgarlo exige la confirmación de identidad del 3.8
system_extrasJSONBBloques de sistema extra (InboundTurn.systemExtras). El equipo del paciente no entra acá, ver abajo
equipmentJSONBLista de modelos normalizados (["M50"]), no texto libre ni un prefijo armado. Es lo que el paciente nombró, y expira
equipment_locked_at / equipment_locked_turnTIMESTAMPTZ / INTCuándo y en qué turno se fijó el equipo. Son los insumos de la expiración
patient_equipmentJSONBLo que el CRM dice que el paciente tiene, resuelto una vez al abrir la conversación (3.7). No expira dentro de la conversación y no se mezcla con equipment: uno es lo que dijo, el otro es quién es
prompt_tokens / cached_tokens / completion_tokensINTAcumulado de la conversación
turn_countINTTurnos del paciente. Derivado del array entrante, nunca incrementado a ciegas
started_at / last_turn_atTIMESTAMPTZ(channel, last_turn_at) indexado para el monitor en vivo
ended_atTIMESTAMPTZNullable: nadie nos avisa cuándo termina una conversación, se infiere por inactividad

No se reusó call_sessions porque sus started_at/ended_at son NOT NULL: describe una sesión que ya terminó. call_sessions y call_meta quedan intactas para el legado de voz.

El equipo pegajoso expira

Una vez que el paciente dice "M50", ese modelo se prefija a todas las consultas al manual: en el turno 5 nadie repite el modelo pero la pregunta sigue siendo sobre ese equipo. Medido el 11-ago-2026 con el mismo último turno (y ahora me tira el error H08): sin el prefijo, MISS; con el prefijo pegajoso, HIT score=0.582 en m50_sysmedm50.md, sección "Tabla de Errores".

El bug del legado era que ese lock nunca expiraba (solo se limpiaba al cerrar el WebSocket, y en el camino de Conversation Flow ni eso). Un lock permanente le inyecta el manual equivocado a un paciente con oxígeno: es riesgo clínico. Por eso se resuelve en cada lectura, y se descarta si:

CondiciónRegla
Inactividadnow - last_turn_at > rag.equipmentLockMinutes (default 30)
Cambio de equipoEl turno actual nombra otro modelo → gana el nuevo y rearma el lock
Tope de turnosturn_count - equipment_locked_turn > rag.equipmentLockMaxTurns (default 20)

Ambos ajustes se resuelven por canal (WhatsApp es asincrónico y tolera más que una llamada). Al expirar, equipment vuelve a NULL y el prefijo sale vacío ese turno. Nunca es permanente.

patient_equipment no participa de esta expiración, y no es una excepción sino la consecuencia de que sea otra cosa: el lock es una memoria de la conversación y por eso caduca, mientras que los equipos del CRM son un hecho sobre la persona y valen mientras dure la conversación. Su frescura la gobierna patients.equipmentTtlMinutes, contra el momento en que se consultó a Oxitesa, no contra el último turno. Los dos se unen para armar el filtro del RAG (ver Pipeline RAG).

messages — turnos de la conversación

Escritura derivada, no la fuente de verdad del historial: ElevenLabs manda el array messages completo en cada request, así que el motor nunca lee esta tabla. Existe para analítica, para el monitor en vivo y para recalcular el feedback.

ColumnaTipoNotas
idBIGSERIAL PK
conversation_idTEXT FKconversations.id
turn_indexINTPosición en el array entrante, no un contador
directionTEXTinbound | outbound
role / contentTEXT
rag_hit / rag_scoreBOOL / FLOAT8En la fila del asistente, igual que los agrupa el payload suggestion_generated
prompt_tokens / completion_tokensINTÍdem
created_atTIMESTAMPTZ
UNIQUE(conversation_id, turn_index) es toda la idempotencia

turn_index sale de la posición del mensaje en el array entrante, que es estable entre reintentos porque ElevenLabs reenvía el array completo. Un reintento del mismo turno colisiona en el mismo índice y el onConflictDoNothing no escribe nada — y el insert que no escribió es además cómo se detecta el reintento: si no se escribió la fila del asistente, tampoco se mueven los contadores de conversations.

Derivar turn_index de un contador de la DB rompe esto en silencio: el contador avanza en el reintento, esquiva la constraint y duplica el turno.

El ticket original pedía un provider_message_id UNIQUE (el wamid de Meta), que nunca vamos a recibir: la cuenta de WhatsApp es de ElevenLabs, no nuestra.

patients — pacientes

Roster de pacientes, armado con lo que las llamadas revelan.

ColumnaTipoNotas
dniTEXT PKSolo dígitos. Es la clave natural y la primary key
nameTEXTÚltimo nombre visto ('' si nunca se dijo)
whatsappTEXTÚltimo celular visto ('' si nunca se dijo)
first_seen_at / last_seen_atTIMESTAMPTZRango de actividad. Indexada last_seen_at
equipmentJSONBCaché de los equipos activos del paciente en Oxitesa, como modelos normalizados
equipment_synced_atTIMESTAMPTZCuándo se trajo. Contra esto se mide patients.equipmentTtlMinutes
equipment_sourceTEXTQué adaptador lo escribió (oxitesa, mock), para poder auditar una fila sospechosa
lookup_phoneTEXTTeléfono normalizado con el que resolvió el lookup, y la clave por la que se lee el caché. Indexada

Decisiones que conviene no revertir sin pensarlo:

  • El DNI es la PK, así que call_sessions.patient_dni ya es la asociación. No hay patient_id que mantener en sincronía, y corregir el DNI en la pantalla de revisión reagrupa la llamada por construcción.
  • Sin DNI no se crea paciente. Agrupar por teléfono metería a toda una familia detrás de una línea compartida. Esas llamadas guardan su snapshot y se asocian después, si alguien completa el DNI al revisarlas.
  • name y whatsapp solo se sobrescriben cuando el dato nuevo existe, así una llamada donde el paciente solo dio el DNI no borra lo que se aprendió antes. first_seen_at se queda en el mínimo y last_seen_at en el máximo (least/greatest en el upsert), así que una llamada procesada fuera de orden no rompe el rango.
  • Sin contador de llamadas. Es un count(*) sobre call_sessions.patient_dni (que está indexada); un contador desnormalizado se desincronizaría en cada corrección que reasocia.
  • Sin FK desde call_sessions. Igual que con users: la asociación es eventual y una llamada se persiste exista o no el paciente.
  • Las cuatro columnas de equipo son caché de un dato ajeno, no un registro nuestro. El equipo vive en Oxitesa y cambia ahí con cada entrega, retiro y recambio; acá están para que una conversación siga funcionando cuando ese sistema no responde. Que se pueda cachear es una propiedad del dato: un equipo viejo empeora una búsqueda, no produce una respuesta factual incorrecta. Nada de lo que traiga el 3.8 (visitas, entregas, nombre) se puede cachear con ese criterio.
  • lookup_phone es una columna aparte de whatsapp a propósito. whatsapp es "lo último que se le escuchó decir al paciente" y no lo validó nadie; lookup_phone es el número con el que un lookup efectivamente resolvió, normalizado. Usar el primero como clave de identidad sería tratar un dictado como una credencial.
Métricas por paciente: pendiente

La tabla se escribe desde ya para que las métricas se construyan sobre datos que ya existen, sin backfill. Todavía no hay endpoints ni pantalla: no se expone "este DNI llamó 3 veces", ni listado/búsqueda de pacientes, ni las llamadas anteriores en la revisión. Todo eso es un count(*) / select sobre call_sessions.patient_dni cuando se decida encararlo.

feedback — acciones del supervisor

Una fila por marca sobre una sugerencia. Alimenta /feedback/stats y el flywheel.

ColumnaTipoNotas
idBIGSERIAL PK
call_id / agent_id / suggestion_idTEXTsuggestion_id = {call_id}_{turn}
actionTEXTused | correct | wrong | flagged_wrong | discarded
suggestion_text / edited_textTEXTOriginal / corrección
customer_complaintTEXTReclamo que originó la lección (texto exacto embeddeado). Nullable
lesson_idTEXTId de la lección en memory_embeddings (= sha256(customer_complaint)). Vincula la marca con su lección para editarla/borrarla sin reconstruir el reclamo. Nullable
flag_mode / flag_reasonTEXTOpcionales
created_atTIMESTAMPTZ

La edición de una marca no escribe un action edited: se registra como evento feedback_given con payload.op = 'edited' (ver Observabilidad).

event_log — logging estructurado + auditoría

Store append-only de todos los eventos operativos y de usuario. Ver Observabilidad.

prompt_versions — prompts editables del agente

Historial append-only del prompt base editable por superadmin. Una fila is_active=true por prompt_key. Ver Prompts del agente.

ColumnaTipoNotas
idUUID PK
prompt_keyTEXTEj. agent_identity (permite N prompts sin cambiar schema)
versionINTSecuencia interna monotónica por prompt_key (max + 1), para orden/unicidad
semverTEXTVersión que se muestra, ej. 1.2.3 (bump major/minor/patch al guardar). Nullable en filas legacy → se muestra {version}.0.0
contentTEXTEl prompt
is_activeBOOLEANExactamente una activa por key
noteTEXTNota de cambio opcional
created_by_id / created_by_emailTEXTQuién guardó la versión
created_atTIMESTAMPTZ

app_settings — ajustes del copiloto en runtime

Overrides de las perillas tuneables (transcripción, sugerencias, sesiones, RAG). El catálogo (qué existe, tipo, rango, default, descripción) vive en código (settings.registry.ts); esta tabla guarda solo los valores overrideados. Una fila faltante = se cae al escalón siguiente (scopeglobal → default del código). Ver Ajustes runtime.

ColumnaTipoNotas
scopeTEXT PKCanal al que aplica: global (default) o un ConversationSource (whatsapp, …)
keyTEXT PKKey del registry (ej. deepgram.minConfidence)
valueJSONBValor overrideado (number / boolean / string)
updated_by_id / updated_by_emailTEXTQuién lo cambió (audit trail). Vacío en las filas sembradas al boot
updated_atTIMESTAMPTZ

La PK es compuesta (scope, key). Era solo key: la migración que la cambió agrega la columna con DEFAULT 'global' NOT NULL y recién después reemplaza la constraint, así que las filas preexistentes quedan en scope='global' sin perder nada.

Cambiar una PK es lo único que db:generate no sabe generar solo

drizzle-kit no puede resolver el nombre de la constraint vieja: emite el ADD CONSTRAINT pero deja el DROP CONSTRAINT comentado, con un placeholder. Tal cual sale, la migración falla (multiple primary keys for table). Es el único caso en que hay que intervenir el .sql generado, y hay que hacerlo con el nombre real leído de la DB:

SELECT constraint_name FROM information_schema.table_constraints
WHERE table_schema = 'public' AND table_name = 'app_settings' AND constraint_type = 'PRIMARY KEY';

Además conviene generar el cambio en dos pasos (una migración que agrega la columna, otra que cambia la PK): en una sola, drizzle-kit emite el ADD CONSTRAINT antes del ADD COLUMN y falla por columna inexistente.

Migraciones (Drizzle)

Regla de oro: nunca editar a mano los .sql ni drizzle/meta/_journal.json. Siempre generar con db:generate, que fija el when con el timestamp actual.

# 1. Modificar el schema en src/**/*.schema.ts
# 2. Generar la migración (crea el .sql y actualiza _journal.json)
pnpm db:generate
# 3. Revisar el SQL en drizzle/XXXX_nombre.sql
# 4. Aplicar en local
pnpm db:migrate
# 5. Commitear drizzle/ junto con el cambio de schema
ComandoQué hace
pnpm db:generateGenera SQL de migración desde cambios del schema (no toca la DB)
pnpm db:migrateAplica migraciones pendientes en la DB local (lee .env)
pnpm db:migrate:prodIgual pero contra producción (lee .env.prod)
pnpm db:pushPush directo del schema sin migración (solo dev)
pnpm db:studioDrizzle Studio (explorador visual)
Enums: ALTER TYPE ... ADD VALUE

Agregar un valor a un enum de Postgres (ej. un nuevo tipo de evento) va en su propia migración y no puede usarse en la misma transacción en que se crea. Drizzle lo separa con breakpoints; si db:migrate se queja de "unsafe use of new value", dividir el ADD VALUE en una migración aparte de las columnas que lo usen.

Timestamps de migraciones

Drizzle solo corre migraciones cuyo when sea mayor al último created_at en __drizzle_migrations. Si una migración se saltea en prod, verificar su when en _journal.json contra SELECT MAX(created_at) FROM __drizzle_migrations.

En producción (DigitalOcean) las migraciones corren como job pre-deploy automáticamente; ver Operaciones → Despliegue.