Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface Env {
WA_DAILY_TEMPLATE_CAP?: string; // tope diario de plantillas HSM (default 250 — tier 1 de Meta)
BUSINESS_NAME: string;
BOT_LANGUAGE: string;
// Reloj que ve el modelo (saludos, "qué hora es", anticipación en horas).
// Si falta, se usa CALCOM_TIMEZONE y, si tampoco está, member/config.local.ts.
BOT_TIMEZONE?: string;
BOT_TIER: "free" | "pro";
// Nicho del bot (restaurante, inmobiliaria…). Selecciona el "niche pack" que
// re-etiqueta el dashboard, aporta el playbook del giro y sus columnas.
Expand Down Expand Up @@ -81,7 +84,8 @@ export interface Env {

// ── Cal.com (agenda real para scheduleAppointment) ───────────────────────
// Con estas vars, el bot consulta disponibilidad real y reserva en Cal.com.
// Sin ellas, scheduleAppointment solo registra la cita para que el dueño la confirme.
// Sin CALCOM_API_KEY, scheduleAppointment no se registra: el bot no pide
// un correo "para Cal.com" cuando las citas se agendan por otro lado.
CALCOM_API_KEY?: string; // secret: API key de Cal.com (cal_...)
CALCOM_EVENT_TYPE_ID?: string; // event type por defecto (numérico, como string)
CALCOM_EVENT_TYPES?: string; // opcional: JSON {"corte":123,"barba":456} servicio→eventTypeId
Expand Down
51 changes: 49 additions & 2 deletions src/llm/runTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,53 @@ function fromUsage(usage: any) {
};
}

function plainStepText(step: any): string {
if (typeof step?.text === "string") return step.text;
if (!Array.isArray(step?.content)) return "";
return step.content
.filter((p: any) => p?.type === "text" && typeof p.text === "string")
.map((p: any) => p.text as string)
.join("");
}

/**
* El stream junta el texto de cada paso (antes y después de una tool) sin
* separador: "…el nuevo horario 💛Ya quedó anotado". Con dos o más trozos,
* los une con una línea en blanco. Un solo trozo regresa null para dejar
* el texto original intacto.
*/
export function joinStepTexts(steps: any[] | undefined): string | null {
if (!steps?.length) return null;
const parts: string[] = [];
const push = (raw: string) => {
const trimmed = raw.trim();
if (trimmed) parts.push(trimmed);
};

for (const step of steps) {
const content = Array.isArray(step?.content) ? step.content : null;
const splitByTool = content?.some(
(p: any) => p?.type === "tool-call" || p?.type === "tool-result",
);
if (content && splitByTool) {
let buf = "";
for (const part of content) {
if (part?.type === "text" && typeof part.text === "string") buf += part.text;
else if (part?.type === "tool-call" || part?.type === "tool-result") {
push(buf);
buf = "";
}
}
push(buf);
} else {
push(plainStepText(step));
}
}

if (parts.length < 2) return null;
return parts.join("\n\n");
}

function fromSteps(steps: any[] | undefined) {
const list = steps ?? [];
return {
Expand All @@ -59,13 +106,13 @@ async function streamTurn(args: LlmTurnArgs): Promise<LlmTurnResult> {
}
const usage = await result.usage;
const steps = await result.steps;
return { text, ...fromUsage(usage), ...fromSteps(steps) };
return { text: joinStepTexts(steps) ?? text, ...fromUsage(usage), ...fromSteps(steps) };
}

async function generateTurn(args: LlmTurnArgs): Promise<LlmTurnResult> {
const result = await generateText(callArgs(args));
return {
text: result.text ?? "",
text: joinStepTexts(result.steps) ?? result.text ?? "",
...fromUsage(result.usage),
...fromSteps(result.steps),
};
Expand Down
26 changes: 7 additions & 19 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Env } from "./env";
import { businessTimeZone } from "./time/resolveDate";
import { anchorTimeZone, currentDateAnchor } from "./time/dateAnchor";

export interface SystemPromptInput {
botName: string;
Expand Down Expand Up @@ -151,7 +151,8 @@ ${instructions}
const contextoTemporal = input.today
? `<contexto_temporal>
Hoy es ${input.today}. Tu conocimiento de entrenamiento tiene OTRA fecha — ignórala.
Usa SIEMPRE esta fecha real para hablar de "hoy" o "mañana" con el cliente.
Usa SIEMPRE esta fecha y la hora actual para hablar de "hoy", "mañana" o la hora con el cliente
(saludo de mañana, tarde o noche, y reglas como agendar con 24 h de anticipación).
Cuando llames una tool de citas/horarios con una fecha relativa ("el viernes",
"el próximo martes", "mañana"), pasa las PALABRAS del cliente, no un YYYY-MM-DD
que hayas calculado tú. El sistema resuelve la fecha exacta y el día de la semana.
Expand Down Expand Up @@ -181,22 +182,9 @@ export interface SystemPromptOverrides {
customInstructions?: string;
}

/** Fecha/hora actual legible + ISO en la zona del negocio (ancla "hoy"/"mañana"). */
export function currentDateLine(timeZone: string): string {
const now = new Date();
const legible = new Intl.DateTimeFormat("es-MX", {
timeZone,
dateStyle: "full",
timeStyle: "short",
}).format(now);
// en-CA formatea YYYY-MM-DD, útil como fecha ISO para las tools.
const iso = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
return `${legible} (fecha ISO: ${iso}, zona horaria: ${timeZone})`;
/** Fecha y hora actual en la zona del negocio (ancla "hoy"/"mañana" y la hora). */
export function currentDateLine(timeZone: string, now: Date = new Date()): string {
return currentDateAnchor(timeZone, now);
}

export function systemPromptFromEnv(
Expand All @@ -217,6 +205,6 @@ export function systemPromptFromEnv(
extraEscalationKeywords: overrides?.extraEscalationKeywords,
lessons: overrides?.lessons,
customInstructions: overrides?.customInstructions,
today: currentDateLine(businessTimeZone(env)),
today: currentDateLine(anchorTimeZone(env)),
});
}
49 changes: 49 additions & 0 deletions src/time/dateAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { businessTimeZone } from "./resolveDate";

/**
* Zona del reloj que ve el modelo.
* BOT_TIMEZONE gana (negocios que agendan sin Cal.com); si no, la zona de
* Cal.com o la de member/config.
*/
export function anchorTimeZone(env: {
BOT_TIMEZONE?: string;
CALCOM_TIMEZONE?: string;
}): string {
const bot = (env.BOT_TIMEZONE || "").trim();
if (bot) return bot;
return businessTimeZone(env);
}

/** Hora local HH:MM (24 h) en la zona dada. */
export function formatLocalTime(timeZone: string, now: Date = new Date()): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(now);
let hour = parts.find((p) => p.type === "hour")?.value ?? "00";
const minute = parts.find((p) => p.type === "minute")?.value ?? "00";
if (hour === "24") hour = "00";
return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
}

/**
* Fecha legible + ISO y la hora local. La frase "La hora actual es HH:MM"
* va aparte: el modelo usa esa hora para saludos y reglas de anticipación.
*/
export function currentDateAnchor(timeZone: string, now: Date = new Date()): string {
const legible = new Intl.DateTimeFormat("es-MX", {
timeZone,
dateStyle: "full",
timeStyle: "short",
}).format(now);
const iso = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
const hhmm = formatLocalTime(timeZone, now);
return `${legible} (fecha ISO: ${iso}, zona horaria: ${timeZone}).\nLa hora actual es ${hhmm}`;
}
14 changes: 9 additions & 5 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@ export interface ToolContext {
}

export function buildTools(ctx: ToolContext) {
// Free tier base set. captureLead y scheduleAppointment van aquí a propósito: el bot
// Starter (free) captura prospectos Y agenda citas — Cal.com lo pone el dueño con su
// propia cuenta/llave, sin costo para Forja, así que es valor central sin gate. Lo Pro
// es consultar catálogo/inventario y las tools avanzadas por nicho.
// Free tier base set. captureLead va aquí a propósito: el bot Starter (free)
// captura prospectos. scheduleAppointment también es gratis (la llave de
// Cal.com es del dueño), pero solo se registra si hay CALCOM_API_KEY: su
// esquema pide attendeeEmail y, sin Cal.com, el bot le pide al cliente un
// correo "para Cal.com" aunque las citas se agenden por otro lado.
const tools: Record<string, any> = {
searchKb: searchKbTool(ctx.env),
handoffHuman: handoffHumanTool(ctx.env, ctx.getConversationId),
pauseBot: pauseBotTool(ctx.env, ctx.getConversationId),
snoozeUser: snoozeUserTool(ctx.env, ctx.getConversationId),
captureLead: captureLeadTool(ctx.env, ctx.getConversationId),
scheduleAppointment: scheduleAppointmentTool(ctx.env, ctx.getConversationId),
};

if ((ctx.env.CALCOM_API_KEY || "").trim()) {
tools.scheduleAppointment = scheduleAppointmentTool(ctx.env, ctx.getConversationId);
}

// Pro tier additions
if (isPro(ctx.env)) {
tools.catalogQuery = catalogQueryTool(ctx.env);
Expand Down
11 changes: 11 additions & 0 deletions test/admin/agente.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ beforeEach(async () => {
BOT_TIER: "pro",
BUFFER_SECONDS: "8",
DASHBOARD_PASSWORD: PASSWORD,
CALCOM_API_KEY: "cal_test",
CALCOM_EVENT_TYPE_ID: "12",
} as unknown as Env;
settings = new SettingsRepo(new Db(d1));
});
Expand All @@ -61,6 +63,15 @@ describe("Mi Agente — page and canvas", () => {
expect(html).toContain("8 s");
});

it("sin CALCOM_API_KEY no muestra scheduleAppointment en el canvas", async () => {
const bare = { ...env, CALCOM_API_KEY: undefined, CALCOM_EVENT_TYPE_ID: undefined };
const res = await adminApp.request("/agente", { headers: AUTH }, bare);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain("scheduleAppointment");
expect(html).toContain("captureLead");
});

it("shows real channels from the conversations table", async () => {
const convs = new ConversationsRepo(new Db(env.DB));
await convs.getOrCreate("telegram", "u1");
Expand Down
56 changes: 55 additions & 1 deletion test/llm/runTurn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ vi.mock("ai", () => ({
generateText: (...args: unknown[]) => generateTextMock(...args),
}));

import { runLlmTurn } from "../../src/llm/runTurn";
import { joinStepTexts, runLlmTurn } from "../../src/llm/runTurn";

function streamOk(text: string) {
async function* gen() {
Expand Down Expand Up @@ -70,6 +70,36 @@ describe("runLlmTurn", () => {
expect(generateTextMock.mock.calls[0][0].messages).toEqual(ARGS.messages);
});

it("separa con una línea en blanco el texto de antes y después de una tool", async () => {
async function* gen() {
yield "…el nuevo horario 💛";
yield "Ya quedó anotado";
}
streamTextMock.mockImplementation(() => ({
textStream: gen(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 4, cachedInputTokens: 0 }),
steps: Promise.resolve([
{ text: "…el nuevo horario 💛", toolCalls: [{ toolName: "captureLead", input: {} }] },
{ text: "Ya quedó anotado", toolCalls: [] },
]),
}));
const r = await runLlmTurn(ARGS);
expect(r.text).toBe("…el nuevo horario 💛\n\nYa quedó anotado");
expect(r.text).not.toContain("💛Ya");
});

it("un solo paso de texto se deja igual", async () => {
streamTextMock.mockImplementation(() => ({
textStream: (async function* () {
yield "buen día";
})(),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1, cachedInputTokens: 0 }),
steps: Promise.resolve([{ text: "buen día", toolCalls: [] }]),
}));
const r = await runLlmTurn(ARGS);
expect(r.text).toBe("buen día");
});

it("no usa generateText en un 429 — eso es failover de proveedor", async () => {
streamTextMock.mockImplementation(() => {
throw Object.assign(new Error("Too Many Requests"), { statusCode: 429 });
Expand All @@ -78,3 +108,27 @@ describe("runLlmTurn", () => {
expect(generateTextMock).not.toHaveBeenCalled();
});
});

describe("joinStepTexts", () => {
it("separa texto que quedó a los dos lados de una tool en el mismo paso", () => {
const joined = joinStepTexts([
{
content: [
{ type: "text", text: "…el nuevo horario 💛" },
{ type: "tool-call", toolName: "captureLead" },
{ type: "text", text: "Ya quedó anotado" },
],
},
]);
expect(joined).toBe("…el nuevo horario 💛\n\nYa quedó anotado");
});

it("ignora pasos que solo ejecutaron una tool", () => {
expect(
joinStepTexts([
{ text: "listo" },
{ text: " ", toolCalls: [{ toolName: "searchKb" }] },
]),
).toBeNull();
});
});
35 changes: 35 additions & 0 deletions test/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ describe("currentDateLine", () => {
expect(line).toContain("2026-08-27");
expect(line.toLowerCase()).toContain("jueves");
expect(line).toContain("Europe/Madrid");
expect(line).toContain("La hora actual es 17:00");
});
});

describe("systemPromptFromEnv", () => {
afterEach(() => vi.useRealTimers());

it("asks the model to pass relative date words, not a self-computed YYYY-MM-DD", () => {
const env = {
BOT_NAME: "Bot",
Expand All @@ -109,6 +112,38 @@ describe("systemPromptFromEnv", () => {
expect(prompt).not.toContain("y para toda fecha que pases a las tools");
});

it("incluye la hora local de BOT_TIMEZONE junto a la fecha", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-27T22:30:00.000Z"));
const env = {
BOT_NAME: "Bot",
BUSINESS_NAME: "Acme",
BOT_LANGUAGE: "es",
BOT_TIMEZONE: "America/Bogota",
CALCOM_TIMEZONE: "Europe/Madrid",
} as any;
const prompt = systemPromptFromEnv(env, ["searchKb"], "ctx");
expect(prompt).toContain("La hora actual es 17:30");
expect(prompt).toContain("America/Bogota");
expect(prompt).toContain("2026-08-27");
expect(prompt).toContain("24 h de anticipación");
expect(prompt).not.toContain("Europe/Madrid");
});

it("sin BOT_TIMEZONE la hora sigue la zona de Cal.com", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-27T15:00:00.000Z"));
const env = {
BOT_NAME: "Bot",
BUSINESS_NAME: "Acme",
BOT_LANGUAGE: "es",
CALCOM_TIMEZONE: "Europe/Madrid",
} as any;
const prompt = systemPromptFromEnv(env, ["scheduleAppointment"], "ctx");
expect(prompt).toContain("La hora actual es 17:00");
expect(prompt).toContain("Europe/Madrid");
});

it("pulls botName/businessName/language from env", () => {
const env = {
BOT_NAME: "Bot",
Expand Down
Loading
Loading