From e3a6017ee120a296d0d18ac2415ffdd465bda0db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 18:44:50 +0000 Subject: [PATCH] fix: correo de Cal.com, textos pegados y hora local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sin CALCOM_API_KEY la tool de citas no se registra, así el bot no pide un correo para Cal.com. El texto de varios pasos se une con una línea en blanco, y el prompt incluye la hora actual en BOT_TIMEZONE. Ticket #394AA0 Co-authored-by: santmun --- src/env.ts | 6 +++- src/llm/runTurn.ts | 51 ++++++++++++++++++++++++++++++-- src/system-prompt.ts | 26 +++++------------ src/time/dateAnchor.ts | 49 +++++++++++++++++++++++++++++++ src/tools/index.ts | 14 +++++---- test/admin/agente.test.ts | 11 +++++++ test/llm/runTurn.test.ts | 56 +++++++++++++++++++++++++++++++++++- test/system-prompt.test.ts | 35 ++++++++++++++++++++++ test/time/dateAnchor.test.ts | 42 +++++++++++++++++++++++++++ test/tools/index.test.ts | 40 ++++++++++++++++++++------ 10 files changed, 294 insertions(+), 36 deletions(-) create mode 100644 src/time/dateAnchor.ts create mode 100644 test/time/dateAnchor.test.ts diff --git a/src/env.ts b/src/env.ts index f67c4678..e963701d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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. @@ -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 diff --git a/src/llm/runTurn.ts b/src/llm/runTurn.ts index 78933644..47647e48 100644 --- a/src/llm/runTurn.ts +++ b/src/llm/runTurn.ts @@ -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 { @@ -59,13 +106,13 @@ async function streamTurn(args: LlmTurnArgs): Promise { } 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 { const result = await generateText(callArgs(args)); return { - text: result.text ?? "", + text: joinStepTexts(result.steps) ?? result.text ?? "", ...fromUsage(result.usage), ...fromSteps(result.steps), }; diff --git a/src/system-prompt.ts b/src/system-prompt.ts index 0d1d6468..022b8961 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -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; @@ -151,7 +151,8 @@ ${instructions} const contextoTemporal = input.today ? ` 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. @@ -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( @@ -217,6 +205,6 @@ export function systemPromptFromEnv( extraEscalationKeywords: overrides?.extraEscalationKeywords, lessons: overrides?.lessons, customInstructions: overrides?.customInstructions, - today: currentDateLine(businessTimeZone(env)), + today: currentDateLine(anchorTimeZone(env)), }); } diff --git a/src/time/dateAnchor.ts b/src/time/dateAnchor.ts new file mode 100644 index 00000000..2ca5b8ba --- /dev/null +++ b/src/time/dateAnchor.ts @@ -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}`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index d71558dc..36e77508 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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 = { 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); diff --git a/test/admin/agente.test.ts b/test/admin/agente.test.ts index ec8d0fca..07be0820 100644 --- a/test/admin/agente.test.ts +++ b/test/admin/agente.test.ts @@ -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)); }); @@ -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"); diff --git a/test/llm/runTurn.test.ts b/test/llm/runTurn.test.ts index a5ac448f..192d0ea6 100644 --- a/test/llm/runTurn.test.ts +++ b/test/llm/runTurn.test.ts @@ -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() { @@ -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 }); @@ -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(); + }); +}); diff --git a/test/system-prompt.test.ts b/test/system-prompt.test.ts index fb40b422..6c8eceda 100644 --- a/test/system-prompt.test.ts +++ b/test/system-prompt.test.ts @@ -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", @@ -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", diff --git a/test/time/dateAnchor.test.ts b/test/time/dateAnchor.test.ts new file mode 100644 index 00000000..498f18ca --- /dev/null +++ b/test/time/dateAnchor.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { anchorTimeZone, currentDateAnchor, formatLocalTime } from "../../src/time/dateAnchor"; + +afterEach(() => vi.useRealTimers()); + +describe("formatLocalTime", () => { + it("da la hora en 24 h de la zona pedida", () => { + const afternoon = new Date("2026-08-27T22:30:00.000Z"); + expect(formatLocalTime("America/Mexico_City", afternoon)).toBe("16:30"); + expect(formatLocalTime("America/Bogota", afternoon)).toBe("17:30"); + expect(formatLocalTime("Europe/Madrid", new Date("2026-08-27T15:00:00.000Z"))).toBe("17:00"); + }); +}); + +describe("currentDateAnchor", () => { + it("pone la fecha y la frase de la hora juntas", () => { + const line = currentDateAnchor("America/Mexico_City", new Date("2026-08-27T22:30:00.000Z")); + expect(line).toContain("2026-08-27"); + expect(line.toLowerCase()).toContain("jueves"); + expect(line).toContain("La hora actual es 16:30"); + expect(line).toContain("America/Mexico_City"); + }); +}); + +describe("anchorTimeZone", () => { + it("BOT_TIMEZONE gana sobre CALCOM_TIMEZONE", () => { + expect( + anchorTimeZone({ BOT_TIMEZONE: "America/Bogota", CALCOM_TIMEZONE: "Europe/Madrid" }), + ).toBe("America/Bogota"); + }); + + it("sin BOT_TIMEZONE usa la zona del negocio", () => { + expect(anchorTimeZone({ CALCOM_TIMEZONE: "Europe/Madrid" })).toBe("Europe/Madrid"); + expect(anchorTimeZone({})).toBe("America/Mexico_City"); + }); + + it("ignora BOT_TIMEZONE vacío", () => { + expect(anchorTimeZone({ BOT_TIMEZONE: " ", CALCOM_TIMEZONE: "Europe/Madrid" })).toBe( + "Europe/Madrid", + ); + }); +}); diff --git a/test/tools/index.test.ts b/test/tools/index.test.ts index 530953a2..ecc83c05 100644 --- a/test/tools/index.test.ts +++ b/test/tools/index.test.ts @@ -14,10 +14,29 @@ function makeCtx(tier: "free" | "pro", niche?: string): ToolContext { return { env, getConversationId: () => "conv-1" }; } +function withCalcom(ctx: ToolContext, key = "cal_test"): ToolContext { + ctx.env.CALCOM_API_KEY = key; + ctx.env.CALCOM_EVENT_TYPE_ID = "12"; + return ctx; +} + describe("buildTools", () => { - it("registers the 6 free-tier tools (incluye captureLead y scheduleAppointment)", () => { - const tools = buildTools(makeCtx("free")); - expect(Object.keys(tools).sort()).toEqual([ + it("sin CALCOM_API_KEY no registra scheduleAppointment (no pide correo de Cal.com)", () => { + for (const tier of ["free", "pro"] as const) { + const tools = buildTools(makeCtx(tier)); + expect(tools.scheduleAppointment).toBeUndefined(); + expect(Object.keys(tools)).not.toContain("scheduleAppointment"); + } + }); + + it("una llave en blanco tampoco registra la tool", () => { + const tools = buildTools(withCalcom(makeCtx("pro"), " ")); + expect(tools.scheduleAppointment).toBeUndefined(); + }); + + it("con CALCOM_API_KEY registra scheduleAppointment igual que antes (free y pro)", () => { + const free = buildTools(withCalcom(makeCtx("free"))); + expect(Object.keys(free).sort()).toEqual([ "captureLead", "handoffHuman", "pauseBot", @@ -25,27 +44,32 @@ describe("buildTools", () => { "searchKb", "snoozeUser", ]); + expect(free.scheduleAppointment.description).toContain("Cal.com"); + expect(free.scheduleAppointment.description).toContain("attendeeEmail"); + expect(free.catalogQuery).toBeUndefined(); + + const pro = buildTools(withCalcom(makeCtx("pro"))); + expect(pro.scheduleAppointment).toBeDefined(); + expect(pro.catalogQuery).toBeDefined(); + expect(pro.scheduleAppointment.description).toContain("attendeeEmail"); }); - it("free tier captura leads y agenda citas, pero excluye las Pro-only (catálogo)", () => { + it("free tier captura leads, pero excluye las Pro-only (catálogo)", () => { const tools = buildTools(makeCtx("free")); expect(tools.captureLead).toBeDefined(); - expect(tools.scheduleAppointment).toBeDefined(); expect(tools.catalogQuery).toBeUndefined(); }); - it("pro tier has the 6 base tools plus catalogQuery (Pro)", () => { + it("pro tier has the base tools plus catalogQuery (Pro)", () => { const tools = buildTools(makeCtx("pro")); expect(Object.keys(tools).sort()).toEqual([ "captureLead", "catalogQuery", "handoffHuman", "pauseBot", - "scheduleAppointment", "searchKb", "snoozeUser", ]); - expect(tools.scheduleAppointment).toBeDefined(); expect(tools.catalogQuery).toBeDefined(); });