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.
| Columna | Tipo | Notas |
|---|---|---|
id | TEXT PK | SHA-256 del complaint (lowercase, trim) → idempotente |
embedding | vector(1536) | HNSW cosine |
customer_complaint | TEXT | Queja original |
wrong_answer_bot | TEXT | Respuesta incorrecta del bot |
human_correction | TEXT | Corrección del supervisor |
supervisor_id | TEXT | Quién corrigió |
category | TEXT | feedback_wrong | feedback_approved | general |
call_id | TEXT | Llamada de origen |
created_at | TIMESTAMPTZ | — |
knowledge_base — manuales técnicos
Chunks de los manuales de equipos. Ver RAG → Ingesta.
| Columna | Tipo | Notas |
|---|---|---|
id | TEXT PK | SHA-256 de source::section::rowKey → idempotente |
source | TEXT | Ruta del .md dentro de documentation/manuals/ |
category | TEXT | Carpeta de primer nivel (ej. concentradores electricos, cap) |
title | TEXT | H1 del manual |
section | TEXT | H2 de la sección |
content | TEXT | Texto del chunk |
embedding | vector(1536) | HNSW cosine |
created_at | TIMESTAMPTZ | — |
call_sessions — sesiones persistidas
Se escribe al terminar la llamada. Fuente del historial y del teléfono en los logs.
| Columna | Tipo | Notas |
|---|---|---|
id | TEXT PK | call_id de Retell o FreeSWITCH |
caller_id | TEXT | Número o nombre del llamante |
agent_id | TEXT | Agente que atendió |
transcript | JSONB | { speaker, text, ts }[] (ts = epoch ms de inicio del turno) |
suggestions | JSONB | { 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ó") |
source | TEXT | retell | audio_fork |
started_at / ended_at | TIMESTAMPTZ | — |
reviewed_at | TIMESTAMPTZ | NULL = pendiente de revisión. Estado global de la llamada, no por usuario. Indexada (call_sessions_reviewed_at_idx) |
reviewed_by | TEXT | Email de quien la revisó primero (no se sobrescribe) |
patient_name | TEXT | Nombre y apellido que dio el paciente. NULL = nunca lo dijo |
patient_dni | TEXT | Documento, solo dígitos. También es la asociación con patients. Indexada (call_sessions_patient_dni_idx) |
patient_whatsapp | TEXT | Celular para el mensaje de WhatsApp, solo dígitos y sin +54 |
call_reason | TEXT | Motivo 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.
| Columna | Tipo | Notas |
|---|---|---|
id | TEXT PK | conv_0001kzrt… — el id de ElevenLabs, no un id nuestro |
channel | TEXT | Nombre del ChannelProfile: voice | whatsapp |
source | TEXT | El ConversationSource (elevenlabs, whatsapp). Es también el scope con el que se resuelven los ajustes del turno |
patient_dni | TEXT | Indexada (conversations_patient_dni_idx). La escribe el lookup de paciente del 3.7 |
patient_name | TEXT | Queda 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_extras | JSONB | Bloques de sistema extra (InboundTurn.systemExtras). El equipo del paciente no entra acá, ver abajo |
equipment | JSONB | Lista de modelos normalizados (["M50"]), no texto libre ni un prefijo armado. Es lo que el paciente nombró, y expira |
equipment_locked_at / equipment_locked_turn | TIMESTAMPTZ / INT | Cuándo y en qué turno se fijó el equipo. Son los insumos de la expiración |
patient_equipment | JSONB | Lo 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_tokens | INT | Acumulado de la conversación |
turn_count | INT | Turnos del paciente. Derivado del array entrante, nunca incrementado a ciegas |
started_at / last_turn_at | TIMESTAMPTZ | (channel, last_turn_at) indexado para el monitor en vivo |
ended_at | TIMESTAMPTZ | Nullable: 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ón | Regla |
|---|---|
| Inactividad | now - last_turn_at > rag.equipmentLockMinutes (default 30) |
| Cambio de equipo | El turno actual nombra otro modelo → gana el nuevo y rearma el lock |
| Tope de turnos | turn_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.
| Columna | Tipo | Notas |
|---|---|---|
id | BIGSERIAL PK | — |
conversation_id | TEXT FK | → conversations.id |
turn_index | INT | Posición en el array entrante, no un contador |
direction | TEXT | inbound | outbound |
role / content | TEXT | — |
rag_hit / rag_score | BOOL / FLOAT8 | En la fila del asistente, igual que los agrupa el payload suggestion_generated |
prompt_tokens / completion_tokens | INT | Ídem |
created_at | TIMESTAMPTZ | — |
UNIQUE(conversation_id, turn_index) es toda la idempotenciaturn_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.
| Columna | Tipo | Notas |
|---|---|---|
dni | TEXT PK | Solo dígitos. Es la clave natural y la primary key |
name | TEXT | Último nombre visto ('' si nunca se dijo) |
whatsapp | TEXT | Último celular visto ('' si nunca se dijo) |
first_seen_at / last_seen_at | TIMESTAMPTZ | Rango de actividad. Indexada last_seen_at |
equipment | JSONB | Caché de los equipos activos del paciente en Oxitesa, como modelos normalizados |
equipment_synced_at | TIMESTAMPTZ | Cuándo se trajo. Contra esto se mide patients.equipmentTtlMinutes |
equipment_source | TEXT | Qué adaptador lo escribió (oxitesa, mock), para poder auditar una fila sospechosa |
lookup_phone | TEXT | Telé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_dniya es la asociación. No haypatient_idque 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.
nameywhatsappsolo 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_atse queda en el mínimo ylast_seen_aten el máximo (least/greatesten el upsert), así que una llamada procesada fuera de orden no rompe el rango.- Sin contador de llamadas. Es un
count(*)sobrecall_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 conusers: 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_phonees una columna aparte dewhatsappa propósito.whatsappes "lo último que se le escuchó decir al paciente" y no lo validó nadie;lookup_phonees 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.
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.
| Columna | Tipo | Notas |
|---|---|---|
id | BIGSERIAL PK | — |
call_id / agent_id / suggestion_id | TEXT | suggestion_id = {call_id}_{turn} |
action | TEXT | used | correct | wrong | flagged_wrong | discarded |
suggestion_text / edited_text | TEXT | Original / corrección |
customer_complaint | TEXT | Reclamo que originó la lección (texto exacto embeddeado). Nullable |
lesson_id | TEXT | Id 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_reason | TEXT | Opcionales |
created_at | TIMESTAMPTZ | — |
La edición de una marca no escribe un
actionedited: se registra como eventofeedback_givenconpayload.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.
| Columna | Tipo | Notas |
|---|---|---|
id | UUID PK | — |
prompt_key | TEXT | Ej. agent_identity (permite N prompts sin cambiar schema) |
version | INT | Secuencia interna monotónica por prompt_key (max + 1), para orden/unicidad |
semver | TEXT | Versión que se muestra, ej. 1.2.3 (bump major/minor/patch al guardar). Nullable en filas legacy → se muestra {version}.0.0 |
content | TEXT | El prompt |
is_active | BOOLEAN | Exactamente una activa por key |
note | TEXT | Nota de cambio opcional |
created_by_id / created_by_email | TEXT | Quién guardó la versión |
created_at | TIMESTAMPTZ | — |
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
(scope → global → default del código). Ver Ajustes runtime.
| Columna | Tipo | Notas |
|---|---|---|
scope | TEXT PK | Canal al que aplica: global (default) o un ConversationSource (whatsapp, …) |
key | TEXT PK | Key del registry (ej. deepgram.minConfidence) |
value | JSONB | Valor overrideado (number / boolean / string) |
updated_by_id / updated_by_email | TEXT | Quién lo cambió (audit trail). Vacío en las filas sembradas al boot |
updated_at | TIMESTAMPTZ | — |
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.
db:generate no sabe generar solodrizzle-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
| Comando | Qué hace |
|---|---|
pnpm db:generate | Genera SQL de migración desde cambios del schema (no toca la DB) |
pnpm db:migrate | Aplica migraciones pendientes en la DB local (lee .env) |
pnpm db:migrate:prod | Igual pero contra producción (lee .env.prod) |
pnpm db:push | Push directo del schema sin migración (solo dev) |
pnpm db:studio | Drizzle Studio (explorador visual) |
ALTER TYPE ... ADD VALUEAgregar 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.
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.