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)];