Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions mods/apiserver/src/services/emailAutopilot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildAutopilotContextLines,
emailAutopilotDecisionSchema,
type AiConfig,
type EmailAutopilot,
Expand All @@ -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,
Expand All @@ -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:",
Expand Down
8 changes: 4 additions & 4 deletions mods/apiserver/src/services/whatsAppAutopilot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildAutopilotContextLines,
emailAutopilotDecisionSchema,
type AiConfig,
type EmailAutopilot,
Expand All @@ -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,
Expand All @@ -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:",
Expand Down
1 change: 1 addition & 0 deletions mods/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
renderTemplate,
extractTemplateTokens,
buildOutreachContext,
buildAutopilotContextLines,
pickRandomNumber,
snakeToCamel,
renderWhatsAppTemplate
Expand Down
61 changes: 61 additions & 0 deletions mods/common/src/utils/outreach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
renderTemplate,
buildOutreachContext,
buildAutopilotContextLines,
pickRandomNumber,
snakeToCamel,
renderWhatsAppTemplate
Expand Down Expand Up @@ -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"];
Expand Down
59 changes: 59 additions & 0 deletions mods/common/src/utils/outreach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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)];
Expand Down
Loading