From e35621c4a9b93e1ed2fa01bd299bef98a01fbbb3 Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Thu, 16 Jul 2026 06:41:23 -0400 Subject: [PATCH] feat(apiserver): give the WhatsApp/Email autopilot enough loan context to answer basic questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full account/loan record was already loaded into EmailAutopilotRequest.context (buildOutreachContext pulls every field), but buildPrompt() in both autopilots only ever forwarded two fields into the actual prompt text: outstandingBalance, and a customerName key that buildOutreachContext never sets (it's firstName/fullName) — so that line silently never rendered. In practice the model had the balance and nothing else: no due date, terms, missed-payment count, or last-payment info, so it couldn't answer basic loan questions even with that data sitting in the DB. Added buildAutopilotContextLines (mods/common/src/utils/outreach.ts), shared by both whatsAppAutopilot.ts and emailAutopilot.ts instead of each hand-rolling its own two-line context array — the customerName/firstName mismatch is exactly the kind of drift that duplicating this logic invites. Surfaces principalAmount, termsAmount/termsFrequency/termsLength, daysPastDue, missedInstallments, and lastPaymentDate/lastPaymentAmount, each only when meaningful for the account. Deliberately excludes negotiationOptions (internal, freeform admin notes on negotiation flexibility) — not safe for the model to repeat verbatim to a customer. Added a prompt instruction telling the model it may use the Contexto to answer basic loan questions, and not to invent data outside it. Co-Authored-By: Claude Sonnet 5 --- mods/apiserver/src/services/emailAutopilot.ts | 8 +-- .../src/services/whatsAppAutopilot.ts | 8 +-- mods/common/src/utils/index.ts | 1 + mods/common/src/utils/outreach.test.ts | 61 +++++++++++++++++++ mods/common/src/utils/outreach.ts | 59 ++++++++++++++++++ 5 files changed, 129 insertions(+), 8 deletions(-) diff --git a/mods/apiserver/src/services/emailAutopilot.ts b/mods/apiserver/src/services/emailAutopilot.ts index fe7f766..ded9fcc 100644 --- a/mods/apiserver/src/services/emailAutopilot.ts +++ b/mods/apiserver/src/services/emailAutopilot.ts @@ -1,4 +1,5 @@ import { + buildAutopilotContextLines, emailAutopilotDecisionSchema, type AiConfig, type EmailAutopilot, @@ -11,10 +12,7 @@ function buildPrompt(req: EmailAutopilotRequest): string { const lines = req.thread .map((m) => `${m.direction === "outbound" ? "Agente" : "Cliente"}: ${m.body}`) .join("\n"); - const ctx: string[] = []; - if (req.context?.customerName) ctx.push(`Cliente: ${req.context.customerName}`); - if (typeof req.context?.outstandingBalance === "number") - ctx.push(`Saldo pendiente: ${req.context.outstandingBalance}`); + const ctx = buildAutopilotContextLines(req.context); const language = req.language || "es"; return [ req.systemPrompt, @@ -29,6 +27,8 @@ function buildPrompt(req: EmailAutopilotRequest): string { 'relativas ("mañana", "el viernes", "la próxima semana") a esa fecha usando la fecha de hoy. ' + "Si el cliente no indica una fecha concreta, omite dueDate.", "Si el asunto no corresponde / está resuelto, usa resolve. Si requiere intervención humana, escalate.", + "Usa el Contexto para responder preguntas básicas del cliente sobre su préstamo (saldo, cuota, " + + "plazo, atraso, último pago). No inventes datos que no estén en el Contexto.", `Idioma de la respuesta: ${language}.`, ctx.length ? `Contexto — ${ctx.join(" · ")}` : "", "Hilo:", diff --git a/mods/apiserver/src/services/whatsAppAutopilot.ts b/mods/apiserver/src/services/whatsAppAutopilot.ts index f88383b..958f557 100644 --- a/mods/apiserver/src/services/whatsAppAutopilot.ts +++ b/mods/apiserver/src/services/whatsAppAutopilot.ts @@ -1,4 +1,5 @@ import { + buildAutopilotContextLines, emailAutopilotDecisionSchema, type AiConfig, type EmailAutopilot, @@ -11,10 +12,7 @@ function buildPrompt(req: EmailAutopilotRequest): string { const lines = req.thread .map((m) => `${m.direction === "outbound" ? "Agente" : "Cliente"}: ${m.body}`) .join("\n"); - const ctx: string[] = []; - if (req.context?.customerName) ctx.push(`Cliente: ${req.context.customerName}`); - if (typeof req.context?.outstandingBalance === "number") - ctx.push(`Saldo pendiente: ${req.context.outstandingBalance}`); + const ctx = buildAutopilotContextLines(req.context); const language = req.language || "es"; return [ req.systemPrompt, @@ -26,6 +24,8 @@ function buildPrompt(req: EmailAutopilotRequest): string { "Si el cliente promete pagar, usa outcome = PAYMENT_PROMISE y objective { type, amount, dueDate }.", "Si el cliente pide que no le contacten (opt-out), usa outcome = OPT_OUT y action = resolve.", "Si el asunto no corresponde / está resuelto, usa resolve. Si requiere intervención humana, escalate.", + "Usa el Contexto para responder preguntas básicas del cliente sobre su préstamo (saldo, cuota, " + + "plazo, atraso, último pago). No inventes datos que no estén en el Contexto.", `Idioma de la respuesta: ${language}.`, ctx.length ? `Contexto — ${ctx.join(" · ")}` : "", "Hilo:", diff --git a/mods/common/src/utils/index.ts b/mods/common/src/utils/index.ts index d1a8533..ec309a4 100644 --- a/mods/common/src/utils/index.ts +++ b/mods/common/src/utils/index.ts @@ -3,6 +3,7 @@ export { renderTemplate, extractTemplateTokens, buildOutreachContext, + buildAutopilotContextLines, pickRandomNumber, snakeToCamel, renderWhatsAppTemplate diff --git a/mods/common/src/utils/outreach.test.ts b/mods/common/src/utils/outreach.test.ts index 0156bfa..4d42176 100644 --- a/mods/common/src/utils/outreach.test.ts +++ b/mods/common/src/utils/outreach.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { renderTemplate, buildOutreachContext, + buildAutopilotContextLines, pickRandomNumber, snakeToCamel, renderWhatsAppTemplate @@ -144,6 +145,66 @@ describe("renderTemplate + buildOutreachContext", () => { }); }); +describe("buildAutopilotContextLines", () => { + it("returns an empty array for undefined context", () => { + assert.deepEqual(buildAutopilotContextLines(undefined), []); + }); + + it("surfaces balance, terms, due status, and payment history for a past-due account", () => { + const ctx = buildOutreachContext( + makeAccount({ + fullName: "Juan Pérez", + outstandingBalance: 9500, + principalAmount: 10000, + termsAmount: 500, + termsFrequency: "quincenal", + termsLength: 20, + daysPastDue: 15, + missedInstallments: 2, + lastPaymentDate: new Date("2026-06-01T00:00:00Z"), + lastPaymentAmount: 500 + }), + { currency: "DOP" } + ); + const lines = buildAutopilotContextLines(ctx); + assert.deepEqual(lines, [ + "Cliente: Juan", + "Saldo pendiente: 9500 DOP", + "Monto original del préstamo: 10000 DOP", + "Cuota: 500 DOP (quincenal)", + "Plazo: 20 cuotas", + "Días de atraso: 15", + "Cuotas incumplidas: 2", + "Último pago de 500 DOP: 2026-06-01" + ]); + }); + + it("omits due-status and payment-history lines when they aren't meaningful", () => { + const ctx = buildOutreachContext( + makeAccount({ + outstandingBalance: 0, + principalAmount: 0, + termsAmount: 0, + daysPastDue: 0, + missedInstallments: 0, + lastPaymentDate: null + }), + { currency: "USD" } + ); + const lines = buildAutopilotContextLines(ctx); + assert.deepEqual(lines, ["Cliente: María", "Saldo pendiente: 0 USD"]); + }); + + it("never includes negotiationOptions, even when set on the account", () => { + const ctx = buildOutreachContext( + makeAccount({ negotiationOptions: "Puede ofrecer 20% de descuento si insiste" }), + { currency: "CRC" } + ); + const lines = buildAutopilotContextLines(ctx); + assert.ok(lines.every((l) => !l.includes("20%") && !l.includes("descuento"))); + }); +}); + describe("pickRandomNumber", () => { it("returns a number from the pool", () => { const pool = ["+1", "+2", "+3"]; diff --git a/mods/common/src/utils/outreach.ts b/mods/common/src/utils/outreach.ts index 8eb2748..041a81e 100644 --- a/mods/common/src/utils/outreach.ts +++ b/mods/common/src/utils/outreach.ts @@ -122,6 +122,65 @@ export function buildOutreachContext( }; } +/** + * Builds the "Contexto — ..." bullet lines an autopilot prompt (WhatsApp/Email) shows the + * model, from a {@link buildOutreachContext} result. Only factual account/loan data the + * customer already has a right to see — never `negotiationOptions` (internal, freeform + * admin notes on negotiation flexibility, not safe for the model to repeat verbatim). + * + * Skips fields that aren't meaningful for the current account (e.g. `daysPastDue` when the + * account isn't past due) so the model isn't fed noise, but always includes the ones that + * let it answer basic loan questions (balance, terms, due status, last payment) instead of + * just the outstanding balance — the model otherwise has nothing to work with beyond the + * balance and can't answer "how much do I owe in total" or "when's my next payment due". + */ +export function buildAutopilotContextLines(context: Record | undefined): string[] { + if (!context) return []; + const lines: string[] = []; + const currency = typeof context.currency === "string" ? ` ${context.currency}` : ""; + + if (typeof context.firstName === "string" && context.firstName) { + lines.push(`Cliente: ${context.firstName}`); + } + if (typeof context.outstandingBalance === "number") { + lines.push(`Saldo pendiente: ${context.outstandingBalance}${currency}`); + } + if (typeof context.principalAmount === "number" && context.principalAmount > 0) { + lines.push(`Monto original del préstamo: ${context.principalAmount}${currency}`); + } + if (typeof context.termsAmount === "number" && context.termsAmount > 0) { + const freq = + typeof context.termsFrequency === "string" && context.termsFrequency + ? ` (${context.termsFrequency})` + : ""; + lines.push(`Cuota: ${context.termsAmount}${currency}${freq}`); + } + if (typeof context.termsLength === "number" && context.termsLength > 0) { + lines.push(`Plazo: ${context.termsLength} cuotas`); + } + if (typeof context.daysPastDue === "number" && context.daysPastDue > 0) { + lines.push(`Días de atraso: ${context.daysPastDue}`); + } + if (typeof context.missedInstallments === "number" && context.missedInstallments > 0) { + lines.push(`Cuotas incumplidas: ${context.missedInstallments}`); + } + if (context.lastPaymentDate) { + const date = + context.lastPaymentDate instanceof Date + ? context.lastPaymentDate + : new Date(context.lastPaymentDate as string); + if (!Number.isNaN(date.getTime())) { + const amount = + typeof context.lastPaymentAmount === "number" + ? ` de ${context.lastPaymentAmount}${currency}` + : ""; + lines.push(`Último pago${amount}: ${date.toISOString().slice(0, 10)}`); + } + } + + return lines; +} + /** Default number selector: a uniform random pick from the pool. */ export const pickRandomNumber: NumberSelector = (numbers) => numbers[Math.floor(Math.random() * numbers.length)];