From 5cafb17a110d5fdc0518a70c5434e953214b3a29 Mon Sep 17 00:00:00 2001 From: Miguel Fonseca <51347295+mfonseca10@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:52:19 -0600 Subject: [PATCH] =?UTF-8?q?fix(citas):=20agendar=20funciona=20end-to-end?= =?UTF-8?q?=20=E2=80=94=20Cal.com=20v2,=20eventTypeId=20del=20servidor=20y?= =?UTF-8?q?=20fecha=20real=20en=20el=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduleAppointment usa el cliente v2 de integrations/calcom.ts en vez de la llamada v1 incompleta: eventTypeId y timezone resueltos en el servidor (el modelo los alucinaba), modo de consulta de horarios libres, guardia date_in_past y logging de fallos para wrangler tail - el system prompt inyecta la fecha/hora actual del negocio (currentDateLine): sin ella el LLM propone fechas de su entrenamiento (pedía slots de 2023) - 6 pruebas del conector actualizadas/nuevas Co-Authored-By: Claude Fable 5 --- src/integrations/calcom.ts | 21 ++++++- src/system-prompt.ts | 31 ++++++++++ src/tools/scheduleAppointment.ts | 86 +++++++++++++++++++------- test/tools/scheduleAppointment.test.ts | 86 +++++++++++++++++++------- 4 files changed, 178 insertions(+), 46 deletions(-) diff --git a/src/integrations/calcom.ts b/src/integrations/calcom.ts index 439a0de..8f53d18 100644 --- a/src/integrations/calcom.ts +++ b/src/integrations/calcom.ts @@ -21,6 +21,16 @@ export function calcomTimeZone(env: Env): string { return (env.CALCOM_TIMEZONE || "").trim() || DEFAULT_TZ; } +/** YYYY-MM-DD de hoy en la zona dada (en-CA formatea ISO). */ +export function todayInTz(timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} + /** * Resuelve el eventTypeId para un servicio. Si hay un mapa CALCOM_EVENT_TYPES, * busca por coincidencia de palabra (case-insensitive); si no, usa el default. @@ -78,8 +88,12 @@ export async function getAvailableSlots( const res = await fetch(url, { headers: { Authorization: `Bearer ${env.CALCOM_API_KEY}`, "cal-api-version": SLOTS_VERSION }, }); - if (!res.ok) return { ok: false, reason: `http_${res.status}` }; + if (!res.ok) { + console.warn(`[calcom] slots http_${res.status}:`, (await res.text()).slice(0, 300)); + return { ok: false, reason: `http_${res.status}` }; + } const body = (await res.json()) as { data?: Record }; + console.log(`[calcom] slots ok eventType=${eventTypeId} date=${date} tz=${timeZone}:`, JSON.stringify(body.data ?? {}).slice(0, 300)); const byDate = body.data ?? {}; const slots = Object.values(byDate) .flat() @@ -127,7 +141,10 @@ export async function createBooking( ...(args.notes ? { bookingFieldsResponses: { notes: args.notes } } : {}), }), }); - if (!res.ok) return { ok: false, reason: `http_${res.status}` }; + if (!res.ok) { + console.warn(`[calcom] booking http_${res.status}:`, (await res.text()).slice(0, 300)); + return { ok: false, reason: `http_${res.status}` }; + } const body = (await res.json()) as { data?: { id: number | string; uid?: string; status?: string; start?: string } }; const d = body.data; if (!d?.id) return { ok: false, reason: "no_booking_id" }; diff --git a/src/system-prompt.ts b/src/system-prompt.ts index 34486e9..0117888 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -10,6 +10,7 @@ export interface SystemPromptInput { tone?: string; // owner-chosen tone (e.g. "cálido y cercano") extraEscalationKeywords?: string[]; // extra words that trigger a human handoff lessons?: string[]; // flywheel: rules distilled from owner takeovers + today?: string; // fecha/hora actual en la zona del negocio } const TEMPLATE = ` @@ -33,6 +34,8 @@ cliente con eficiencia y calidez, sin inventar nunca. Conoces este negocio. Si una pregunta no tiene respuesta en lo que sabes, escalas a un humano. +{{CONTEXTO_TEMPORAL}} + {{BUSINESS_CONTEXT}} @@ -122,7 +125,16 @@ ${lessons.map((l) => `- ${l}`).join("\n")} ` : ""; + const contextoTemporal = input.today + ? ` +Hoy es ${input.today}. Tu conocimiento de entrenamiento tiene OTRA fecha — ignórala. +Usa SIEMPRE esta fecha real para interpretar "hoy", "mañana", "el viernes", etc., +y para toda fecha que pases a las tools (citas, horarios). +` + : ""; + return TEMPLATE + .replaceAll("{{CONTEXTO_TEMPORAL}}", contextoTemporal) .replaceAll("{{LANGUAGE}}", input.language) .replaceAll("{{BOT_NAME}}", input.botName) .replaceAll("{{BUSINESS_NAME}}", input.businessName) @@ -141,6 +153,24 @@ export interface SystemPromptOverrides { lessons?: 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})`; +} + export function systemPromptFromEnv( env: Env, toolNames: string[], @@ -158,5 +188,6 @@ export function systemPromptFromEnv( tone: overrides?.tone, extraEscalationKeywords: overrides?.extraEscalationKeywords, lessons: overrides?.lessons, + today: currentDateLine((env.CALCOM_TIMEZONE || "").trim() || "America/Mexico_City"), }); } diff --git a/src/tools/scheduleAppointment.ts b/src/tools/scheduleAppointment.ts index 5241eae..4d37ea8 100644 --- a/src/tools/scheduleAppointment.ts +++ b/src/tools/scheduleAppointment.ts @@ -1,38 +1,78 @@ import { tool } from "ai"; import { z } from "zod"; import type { Env } from "../env"; +import { + calcomConfigured, + calcomTimeZone, + createBooking, + getAvailableSlots, + resolveEventTypeId, + todayInTz, +} from "../integrations/calcom"; -const CALCOM_API = "https://api.cal.com/v1"; - +// El eventTypeId y la zona horaria se resuelven SIEMPRE en el servidor +// (CALCOM_EVENT_TYPE_ID / CALCOM_EVENT_TYPES / CALCOM_TIMEZONE): el modelo no +// conoce esos ids y no debe inventarlos. export function scheduleAppointmentTool(env: Env, _getConversationId: () => string | null) { return tool({ description: - "Agenda una cita usando Cal.com. Necesitas eventTypeId (el dueño lo configura en Cal.com), fecha/hora, nombre y email del cliente.", + "Consulta horarios libres y agenda citas reales en el calendario del negocio (Cal.com). " + + "Para ver horarios disponibles de un día: pasa solo `date` (YYYY-MM-DD). " + + "Para reservar: pasa `startTime` (ISO con offset de la zona del negocio, ej. 2026-08-03T15:00:00-06:00), " + + "`attendeeName` y `attendeeEmail` — idealmente un `startTime` que venga de los horarios consultados. " + + "Si el negocio maneja varios tipos de cita, indica `service` con el nombre del servicio.", inputSchema: z.object({ - eventTypeId: z.number().int().describe("Cal.com event type ID"), - startTime: z.string().describe("ISO datetime, e.g. 2026-06-01T17:00:00Z"), - attendeeName: z.string(), - attendeeEmail: z.string().email(), + date: z.string().optional().describe("YYYY-MM-DD para consultar horarios libres de ese día"), + startTime: z.string().optional().describe("ISO datetime con offset para reservar, ej. 2026-08-03T15:00:00-06:00"), + attendeeName: z.string().optional(), + attendeeEmail: z.string().email().optional(), + service: z.string().optional().describe("nombre del servicio/tipo de cita solicitado"), notes: z.string().optional(), }), - execute: async ({ eventTypeId, startTime, attendeeName, attendeeEmail, notes }) => { - if (!env.CALCOM_API_KEY) return { error: "calcom_not_configured" as const }; - try { - const res = await fetch(`${CALCOM_API}/bookings?apiKey=${env.CALCOM_API_KEY}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - eventTypeId, - start: startTime, - responses: { name: attendeeName, email: attendeeEmail, notes: notes ?? "" }, - }), + execute: async ({ date, startTime, attendeeName, attendeeEmail, service, notes }) => { + if (!calcomConfigured(env)) return { error: "calcom_not_configured" as const }; + const eventTypeId = resolveEventTypeId(env, service); + if (eventTypeId == null) return { error: "calcom_not_configured" as const }; + const timeZone = calcomTimeZone(env); + + // Guardia anti-fecha-fantasma: los LLM no saben qué día es hoy y suelen + // proponer fechas de su época de entrenamiento. YYYY-MM-DD compara bien + // como string. + const today = todayInTz(timeZone); + const requestedDay = date ?? startTime?.slice(0, 10); + if (requestedDay && requestedDay < today) { + return { + error: "date_in_past" as const, + today, + hint: `Hoy es ${today}. Recalcula la fecha pedida por el cliente a partir de hoy y reintenta.`, + }; + } + + // Reservar: requiere hora exacta + datos del cliente. + if (startTime && attendeeName && attendeeEmail) { + const r = await createBooking(env, { + eventTypeId, + start: startTime, + name: attendeeName, + email: attendeeEmail, + timeZone, + notes, }); - if (!res.ok) return { error: "calcom_failed" as const, status: res.status }; - const body = (await res.json()) as any; - return { bookingId: body.id, status: body.status }; - } catch (e: any) { - return { error: "transient" as const, message: String(e?.message ?? e) }; + if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason }; + return { booked: true, bookingId: r.bookingId, status: r.status, start: r.start ?? startTime }; } + + // Consultar horarios libres de un día. + if (date) { + const r = await getAvailableSlots(env, eventTypeId, date, timeZone); + if (!r.ok) return { error: "calcom_failed" as const, reason: r.reason }; + return { date, timeZone, slots: r.slots.slice(0, 12) }; + } + + return { + error: "missing_params" as const, + hint: "Pasa `date` para ver horarios, o `startTime` + `attendeeName` + `attendeeEmail` para reservar.", + }; }, }); } diff --git a/test/tools/scheduleAppointment.test.ts b/test/tools/scheduleAppointment.test.ts index 4f1b858..0f1662a 100644 --- a/test/tools/scheduleAppointment.test.ts +++ b/test/tools/scheduleAppointment.test.ts @@ -1,53 +1,97 @@ import { describe, it, expect, vi } from "vitest"; import { scheduleAppointmentTool } from "../../src/tools/scheduleAppointment"; +// El tool ya no recibe eventTypeId del modelo: se resuelve del env +// (CALCOM_EVENT_TYPE_ID / CALCOM_EVENT_TYPES) y llama Cal.com API v2. describe("scheduleAppointmentTool", () => { - it("creates Cal.com booking via API", async () => { - global.fetch = vi.fn( + const baseEnv = { CALCOM_API_KEY: "fake", CALCOM_EVENT_TYPE_ID: "100", BOT_TIER: "pro" } as any; + + it("creates Cal.com booking via API v2 with server-resolved eventTypeId", async () => { + const fetchMock = vi.fn( async () => - new Response(JSON.stringify({ id: 12345, status: "ACCEPTED" }), { status: 201 }), - ) as any; - const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any; - const tool = scheduleAppointmentTool(env, () => "conv_x"); + new Response(JSON.stringify({ data: { id: 12345, status: "accepted" } }), { status: 201 }), + ); + global.fetch = fetchMock as any; + const tool = scheduleAppointmentTool(baseEnv, () => "conv_x"); const result = (await tool.execute!( { - eventTypeId: 100, - startTime: "2026-06-01T17:00:00Z", + startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com", }, {} as any, - )) as { bookingId: number; status: string }; + )) as { booked: boolean; bookingId: number }; + expect(result.booked).toBe(true); expect(result.bookingId).toBe(12345); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain("/v2/bookings"); + const body = JSON.parse(String(init.body)); + expect(body.eventTypeId).toBe(100); + expect(body.attendee.email).toBe("maria@x.com"); + expect((init.headers as Record).Authorization).toBe("Bearer fake"); + }); + + it("lists available slots when only date is given", async () => { + global.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ data: { "2027-06-01": [{ start: "2027-06-01T15:00:00-06:00" }] } }), + { status: 200 }, + ), + ) as any; + const tool = scheduleAppointmentTool(baseEnv, () => "conv_x"); + const result = (await tool.execute!({ date: "2027-06-01" }, {} as any)) as { + slots: string[]; + }; + expect(result.slots).toEqual(["2027-06-01T15:00:00-06:00"]); }); it("returns error when Cal.com fails", async () => { global.fetch = vi.fn(async () => new Response("err", { status: 400 })) as any; - const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any; - const tool = scheduleAppointmentTool(env, () => "conv_x"); + const tool = scheduleAppointmentTool(baseEnv, () => "conv_x"); const result = (await tool.execute!( { - eventTypeId: 100, - startTime: "2026-06-01T17:00:00Z", + startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com", }, {} as any, - )) as { error: string }; + )) as { error: string; reason: string }; expect(result.error).toBe("calcom_failed"); + expect(result.reason).toBe("http_400"); + }); + + it("rejects past dates without calling Cal.com", async () => { + global.fetch = vi.fn() as any; + const tool = scheduleAppointmentTool(baseEnv, () => "conv_x"); + const result = (await tool.execute!({ date: "2023-10-04" }, {} as any)) as { + error: string; + today: string; + }; + expect(result.error).toBe("date_in_past"); + expect(result.today > "2023-10-04").toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); }); it("returns calcom_not_configured when no API key", async () => { global.fetch = vi.fn() as any; - const env = { BOT_TIER: "pro" } as any; + const env = { CALCOM_EVENT_TYPE_ID: "100", BOT_TIER: "pro" } as any; const tool = scheduleAppointmentTool(env, () => "conv_x"); const result = (await tool.execute!( - { - eventTypeId: 100, - startTime: "2026-06-01T17:00:00Z", - attendeeName: "María", - attendeeEmail: "maria@x.com", - }, + { startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com" }, + {} as any, + )) as { error: string }; + expect(result.error).toBe("calcom_not_configured"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns calcom_not_configured when no event type is configured", async () => { + global.fetch = vi.fn() as any; + const env = { CALCOM_API_KEY: "fake", BOT_TIER: "pro" } as any; + const tool = scheduleAppointmentTool(env, () => "conv_x"); + const result = (await tool.execute!( + { startTime: "2027-06-01T17:00:00-06:00", attendeeName: "María", attendeeEmail: "maria@x.com" }, {} as any, )) as { error: string }; expect(result.error).toBe("calcom_not_configured");