Sofia genera conversacion en base a un PROPOSITO y no para hasta conseguirlo, ACOTADA por Marco (un arbitro EXTERNO que le pone reglas para que no se contamine persiguiendo su objetivo).
runConversation({ objective, view, opts }) integra todas las rebanadas:
- Plan de fases (slice 1): si no hay plan,
generatePlanarma uno y se fija; se expone viaonPlanpara que el usuario conozca las fases de antemano. - Mensaje + avance (slice 2): por turno,
generateMessageproduce el proximo mensaje de la fase actual y decide si la fase avanza. - Aislamiento por deudor (slice 3): trabaja SOLO sobre una
DebtorViewaislada (openSession(store, id)); nunca alcanza a otro deudor. - Fase de canal de pago (slice 4): para objetivos de cobranza, el plan incluye una fase obligatoria de entrega de medio de pago.
- Arbitraje EXTERNO (Marco): antes de emitir cada mensaje, la accion se valida
contra Marco como proceso aparte (no
import). Si Marco veta, el mensaje no se emite. Sofia no conoce las reglas: solo recibe{ ok, violations }.
Termina con status: "achieved" | "blocked" | "exhausted" y devuelve el
transcript, las fases alcanzadas, las invocaciones a Marco y los bloqueos.
Marco vive en otro repo y se invoca como subproceso con un contrato JSON por stdio:
- entrada (stdin):
{ plan, context, action: { kind: "send_message", at? } } - salida (stdout):
{ ok, violations }
Apunta MARCO_CMD al CLI de Marco (se pasa SOFIA_NOW por env para sus reglas
de ventana/reloj):
export MARCO_CMD="node /ruta/al/repo/marco/dist/cli.js" # o src/cli.js en devSi MARCO_CMD no esta seteado (y no se pasa opts.marcoCmd), el loop falla con
un error claro: Marco es obligatorio, Sofia no valida reglas por su cuenta.
npm install
# Conversacion para un deudor aislado (LLM real + Marco externo):
export MARCO_CMD="node /ruta/al/repo/marco/dist/cli.js"
npm run converse -- --objective "cobrar la cuota vencida" \
--debtor DEU-001 --store test/fixtures/debtors.json \
--now 2026-06-22T12:00:00
# Una accion fuera de la ventana de Marco queda bloqueada (mensaje NO emitido):
npm run converse -- --objective "cobrar la cuota vencida" \
--debtor DEU-001 --store test/fixtures/debtors.json \
--now 2026-06-22T22:00:00Sofia abre y conversa con mensajes REALES y personalizados con los datos del PROPIO interlocutor (su nombre, la empresa acreedora, el item, montos y fechas):
- 1er mensaje: SIEMPRE verifica identidad por nombre ("Hola, ¿hablo con
{nombre}?"). Lo exige
generateMessagey lo chequeaisIdentityVerification. - Personalizacion permitida: usar los datos del propio deudor es el punto del
producto (se invirtio el anti-fuga de slice 2). El aislamiento lo sigue
garantizando la
DebtorView(slice 3): por codigo no hay datos de OTRO deudor alcanzables. - Anti-alucinacion: toda cifra/monto/fecha del mensaje DEBE derivar del
contexto del deudor (o ser el CLP calculado).
findHallucinatedNumberscaza numeros inventados ygenerateMessagereintenta. - UF -> CLP (
src/uf.ts):ufToClp(montoUF, ufHoy)+ formato es-CL (formatClp:$1.370.410). El valor de la UF de hoy es INYECTABLE por envUF_HOY(en prod viene de fuente oficial; el fetch real es otra slice). El CLP lo calcula el codigo y se le entrega hecho al LLM, asi el monto impreso coincide SIEMPRE conufToClp(monto, UF_HOY).
Sofia trae la fila del cliente con un accesor POR-CLIENTE de la planilla
unificada (pestaña Cobranza_IA, que el sync regenera con la lista protegida
contra doble-cobro y el link de pago real). La fuente se elige por entorno, en
orden de preferencia (lo PRIMERO que aplique gana):
| Variable | Fuente | Uso |
|---|---|---|
SOFIA_PLANILLA_SHEET_ID |
Sheets API v4 autenticado (service account) | Produccion |
SOFIA_PLANILLA_PATH |
CSV local | dev/test |
--store <json> |
fixture JSON | dev/test |
La planilla tiene PII (nombres, emails, links de pago). En produccion Sofia la lee con una CREDENCIAL, nunca por una URL publica:
- Crear una service account en GCP (rol minimo; no necesita permisos extra).
- Compartir la planilla unificada con el email de esa service account (permiso de lector).
- Setear en el entorno de Sofia (Cloud Run):
SOFIA_PLANILLA_SHEET_ID= id de la planilla (el de su URL).SOFIA_PLANILLA_SHEET_TAB=Cobranza_IA(default si se omite).- La credencial:
GOOGLE_APPLICATION_CREDENTIALS(ruta al JSON de la SA) oSOFIA_GOOGLE_SA_JSON(el JSON inline). SOFIA_PLANILLA_MAP(opcional, JSON) para overridear el mapeo de columnas.
Sofia firma un JWT RS256 con la clave de la SA, lo intercambia por un
access_token (scope spreadsheets.readonly) y llama a la Sheets API con
Authorization: Bearer. Lee SOLO la pestaña indicada y devuelve SOLO la fila del
cliente pedido (clon aislado). Si SOFIA_PLANILLA_SHEET_ID esta seteada pero
falta la credencial, falla con un error claro: no cae a ninguna fuente
publica como fallback.
export SOFIA_PLANILLA_SHEET_ID="1irx...thw8"
export SOFIA_PLANILLA_SHEET_TAB="Cobranza_IA"
export GOOGLE_APPLICATION_CREDENTIALS="/secrets/sa.json"
npm run converse -- --objective "cobrar la cuota vencida" --debtor CLI-001Sofia a veces inicia, pero a veces le contestan mas tarde y debe RECORDAR lo ya
hablado. src/memory.ts resuelve eso con tres piezas (codigo PURO, sin LLM ni
reloj salvo el store de archivo; el resumidor por defecto es deterministico):
- Persistir:
recordRun({ store, clientId, result })guarda el transcript del cliente. El store es inyectable:fileMemoryStore(path)(un JSON{ conversations: { <id>: memoria } }) oinMemoryStore()(dev/test). - Retomar:
resumeInputs(memoria)entrega{ priorTranscript, startPhaseIndex, memoryDigest }. Se los pasas arunConversation(opciones nuevas del loop) y Sofia retoma con memoria: no se vuelve a presentar ni repite lo acordado, y arranca por la fase ya alcanzada. - Resumen rodante: para historiales largos,
compactMemory(mem, { keepRecent })mueve los turnos viejos a un resumen para caber en contexto. Los HECHOS CLAVE (compromisos de pago, escalaciones, link entregado, montos, identidad) se extraen por codigo ANTES de compactar y se acumulan de forma monotona: el resumen en prosa puede ser lossy, pero los hechos nunca se pierden. El digest (resumen + hechos) se le inyecta al generador de mensaje como MEMORIA PREVIA, y sus cifras cuentan como contexto permitido (no se marcan como invento).
El boton "Enviar (via Marco)" de la UI NO manda el WhatsApp directo: entrega por el motor de entrega (Marco) en modo borrador. Marco RETIENE el mensaje como aprobacion, AVISA al staff por Telegram y recien cuando el staff aprueba manda el WhatsApp real. La UI muestra el estado REAL (retenido -> aprobado/enviado, o rechazado/fallo), nunca un "enviado" falso.
- La UI postea a
MARCO_SEND_URL/sendcon{ to, channel:"whatsapp", body, dryRun:false, meta:{ modo:"borrador", approvalContext:{ deudor, negocio, caso, historial } } }. - Respuesta
status:"held"+approvalId-> el turno guardaapprovalIdy se muestra "retenido"; la UI polleaGET /clientes/:id/aprobacion/:approvalIdhasta el desenlace real y lo persiste en el turno (sentconproviderId|rejected|failed). - Sin
MARCO_SEND_URLla UI degrada limpio:/enviarresponde 503 (no se inventa un envio). El transporte propio de Marco queda deprecado en la UI; Marco sigue como ARBITRO (validar/escalar/anti-alucinacion), no como transporte. - Candado de prueba: con
SOFIA_UI_TEST_NUMBERel destino se fuerza a ese numero para CUALQUIER cliente (cero contacto al deudor real).
Requiere Marco corriendo con .env real. Cero contacto al deudor real: el candado
fuerza el destino al numero de prueba.
Marco y la UI de Sofia usan ambos el puerto 8080 por defecto: hay que darle a
Marco un puerto distinto (PORT) para que no choquen.
# 1. Levantar Marco (motor de entrega) en modo borrador, con su .env real
# (WASENDER_API_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_STAFF_CHAT_ID, ...).
cd <repo-marco> && PORT=3000 MARCO_MODO_BORRADOR=1 npm run serve # http://localhost:3000
# 2. Levantar Sofia apuntando al motor + candado al numero de prueba.
MARCO_SEND_URL=http://localhost:3000 \
SOFIA_UI_TEST_NUMBER=+56942949490 \
npm run ui # http://localhost:8080Que se ve, paso a paso:
- En la UI: elegir un cliente -> "Redactar" -> "Enviar (via Marco)".
- La UI muestra "Retenido para +56942949490 (prueba) - aprobacion apr-XXXX. Avisando al staff por Telegram". El chat marca el turno "Retenido" (NO "enviado"). NO llega WhatsApp todavia. (El poll confirma luego el aviso a Telegram, o advierte si fallo.)
- Llega el aviso a Telegram al staff con el resumen y el link de aprobacion.
- El fundador aprueba: abre el link
GET /approve/<id>?t=<token>(en localhost lo abre en su Mac, funciona sin webhook publico) o hacePOST /approvals/<id>/approve. - Marco manda el WhatsApp REAL: llega un WhatsApp a +56942949490. La UI (poll) pasa a "Aprobado y enviado (id )" y el chat marca el turno "Aprobado y enviado".
- Camino de rechazo: en vez de aprobar,
POST /approvals/<id>/reject-> NO llega WhatsApp y la UI marca "Rechazado - no se envio".
Abrir a numeros reales = cambio EXPLICITO de env del fundador (quitar
SOFIA_UI_TEST_NUMBER), NO es parte de este flujo.
npm test # mensajes reales: UF->CLP, anti-alucinacion, identidad, personalizacion (+ e2e LLM)
npm run test:memory # memoria por cliente: persistir, retomar, resumen rodante (sin LLM, store fijo)
npm run test:sheets # lectura autenticada Cobranza_IA: JWT, token source, provider Sheets (sin red)
npm run test:bridge # puente de datos: planilla unificada -> getCliente por-cliente
npm run test:msg # slice 2: generador de mensaje (reglas de personalizacion)
npm run test:loop # capstone: e2e deterministico (Marco como proceso externo, stub)
npm run test:iso # slice 3: aislamiento por deudor
npm run test:profiles # slice 4: fase obligatoria de canal de pago
npm run test:plan # slice 1: generador de plan
npm run typechecktest:loop es 100% determinista: inyecta un plan fijo + generador de mensaje
scripteado + simulador de contraparte, y usa test/fixtures/marco-stub.mjs como
Marco externo real (subproceso con el mismo contrato JSON). El stub registra cada
invocacion (spy) para verificar que Sofia llama a Marco como proceso aparte.