Skip to content

Tobarrientos2/sofia

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sofia

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).

El loop (slice 5, capstone)

runConversation({ objective, view, opts }) integra todas las rebanadas:

  • Plan de fases (slice 1): si no hay plan, generatePlan arma uno y se fija; se expone via onPlan para que el usuario conozca las fases de antemano.
  • Mensaje + avance (slice 2): por turno, generateMessage produce el proximo mensaje de la fase actual y decide si la fase avanza.
  • Aislamiento por deudor (slice 3): trabaja SOLO sobre una DebtorView aislada (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: arbitro externo (MARCO_CMD)

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 dev

Si 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.

Uso

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:00

Mensajes reales: personalizacion + UF -> CLP

Sofia 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 generateMessage y lo chequea isIdentityVerification.
  • 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). findHallucinatedNumbers caza numeros inventados y generateMessage reintenta.
  • 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 env UF_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 con ufToClp(monto, UF_HOY).

Fuente de datos en vivo (Cobranza_IA)

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

Produccion: lectura autenticada (sin exponer PII)

La planilla tiene PII (nombres, emails, links de pago). En produccion Sofia la lee con una CREDENCIAL, nunca por una URL publica:

  1. Crear una service account en GCP (rol minimo; no necesita permisos extra).
  2. Compartir la planilla unificada con el email de esa service account (permiso de lector).
  3. 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) o SOFIA_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-001

Memoria de conversacion por cliente

Sofia 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 } }) o inMemoryStore() (dev/test).
  • Retomar: resumeInputs(memoria) entrega { priorTranscript, startPhaseIndex, memoryDigest }. Se los pasas a runConversation (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).

Entrega por Marco (aprobacion por Telegram antes de salir)

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/send con { to, channel:"whatsapp", body, dryRun:false, meta:{ modo:"borrador", approvalContext:{ deudor, negocio, caso, historial } } }.
  • Respuesta status:"held" + approvalId -> el turno guarda approvalId y se muestra "retenido"; la UI pollea GET /clientes/:id/aprobacion/:approvalId hasta el desenlace real y lo persiste en el turno (sent con providerId | rejected | failed).
  • Sin MARCO_SEND_URL la UI degrada limpio: /enviar responde 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_NUMBER el destino se fuerza a ese numero para CUALQUIER cliente (cero contacto al deudor real).

Prueba real e2e (la dispara el fundador, fuera de CI)

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:8080

Que se ve, paso a paso:

  1. En la UI: elegir un cliente -> "Redactar" -> "Enviar (via Marco)".
  2. 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.)
  3. Llega el aviso a Telegram al staff con el resumen y el link de aprobacion.
  4. El fundador aprueba: abre el link GET /approve/<id>?t=<token> (en localhost lo abre en su Mac, funciona sin webhook publico) o hace POST /approvals/<id>/approve.
  5. 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".
  6. 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.

Tests

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 typecheck

test: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.

About

kobra: AI collections agent (provisional name)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages