diff --git a/src/agent.ts b/src/agent.ts index 654abc19..5219423e 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -17,6 +17,7 @@ import { CustomerFactsRepo } from "./db/facts"; import { createModel } from "./llm/provider"; import { formatLlmError } from "./llm/errorDetail"; import { runLlmTurn } from "./llm/runTurn"; +import { llmFailureReply } from "./failureReply"; import { costOfUsage } from "./pricing"; import type { ChannelId } from "./channels/shared"; import { maskTelegramToken, unmaskTelegramToken } from "./telegramFiles"; @@ -413,8 +414,53 @@ export class SupportAgent extends Agent { } } + // Turno con FOTO: si ningún modelo pudo con el mensaje multimodal (la + // imagen no se pudo descargar, el modelo no tiene visión, el proveedor la + // rechazó), el turno sigue con PURO TEXTO. El cliente casi siempre + // escribió algo junto a la foto ("quiero info de esta propiedad"): con + // eso el bot puede conversar y preguntar qué muestra la foto, en vez de + // tumbar el turno completo por la imagen. if (!ok) { - assistantText = "Algo falló de mi lado, intenta de nuevo en un momento."; + const last: any = aiMessages[aiMessages.length - 1]; + if (Array.isArray(last?.content)) { + const textPart = last.content.find((p: any) => p?.type === "text"); + aiMessages[aiMessages.length - 1] = { + role: "user", + content: + `${textPart?.text ?? ""}\n[El cliente mandó una FOTO que no pudiste abrir. Sigue la conversación con naturalidad: ` + + `pídele que te cuente qué se ve en ella y ofrécele ayuda concreta. No menciones fallas técnicas.]`.trim(), + }; + try { + await attempt(model); + ok = true; + console.warn("[SupportAgent] turno con foto rescatado en modo solo-texto"); + } catch (eTxt: any) { + console.error("[SupportAgent] reintento solo-texto falló:", formatLlmError(eTxt)); + } + } + } + + if (!ok) { + assistantText = llmFailureReply(this.env.BOT_LANGUAGE); + // Aviso INMEDIATO al dueño: un cliente real se quedó sin respuesta. El + // watchdog solo alerta con 3+ fallos en 30 min, así que con 1 o 2 + // nadie se enteraba y el lead se enfriaba. + try { + const { notifyOwner } = await import("./tools/handoffHuman"); + const lastUser = (history[history.length - 1]?.content ?? "") + .replace(/\[IMAGE_URL: [^\]]+\]/g, "[foto]") + .replace(/\s+/g, " ") + .trim(); + await notifyOwner(this.env, { + reason: "el bot no pudo responder", + summary: + `Cliente ${this.state.channelUserId} (${this.state.channel}) se quedó esperando. ` + + `Escribió: "${lastUser.slice(0, 200)}". Contéstale tú desde el panel.`, + ticketId: `fallo-${convId}`, + }); + } catch (eN) { + console.error("[SupportAgent] aviso de fallo al dueño falló:", eN); + } } } diff --git a/src/failureReply.ts b/src/failureReply.ts new file mode 100644 index 00000000..161834bf --- /dev/null +++ b/src/failureReply.ts @@ -0,0 +1,21 @@ +/** + * Respuesta cuando el LLM falló del todo (primario + retries + fallback). + * + * Antes era "Algo falló de mi lado, intenta de nuevo en un momento.": delataba + * al bot y dejaba al cliente en seco, pidiéndole que él reintentara. Ahora + * suena a persona y promete lo que sí pasa: el dueño recibe aviso en ese mismo + * momento (agent.ts → notifyOwner) y contesta él. + * + * El watchdog cuenta los fallos por estos textos (y por el viejo, para que el + * historial previo al cambio siga contando). + */ +export const LLM_FAILURE_REPLIES = { + es: "Gracias por escribirnos. Te paso con una persona del equipo para ayudarte; te responde en breve por aquí.", + en: "Thanks for reaching out. I'm passing you to someone on our team; they'll reply here shortly.", +} as const; + +export const LLM_FAILURE_LEGACY_PREFIX = "Algo falló"; + +export function llmFailureReply(lang: string | undefined): string { + return (lang ?? "").toLowerCase().startsWith("en") ? LLM_FAILURE_REPLIES.en : LLM_FAILURE_REPLIES.es; +} diff --git a/src/index.ts b/src/index.ts index 45c61d68..e2db6397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,7 +258,7 @@ export default { const { runFollowups } = await import("./followup/run"); await runFollowups(env).catch((e) => console.error("followups:", e)); - // Watchdog: si el bot está fallando en cadena (3+ "Algo falló" en 30 min), + // Watchdog: si el bot está fallando en cadena (3+ respuestas de falla en 30 min, ver failureReply.ts), // avisa al dueño por su canal de handoff. Throttle 6h. Lo ÚNICO que debe // despertarlo en la noche. const { checkBotHealth } = await import("./watchdog"); diff --git a/src/watchdog.ts b/src/watchdog.ts index e7bb4e1b..8e61e490 100644 --- a/src/watchdog.ts +++ b/src/watchdog.ts @@ -11,6 +11,7 @@ import type { Env } from "./env"; import { Db } from "./db/client"; import { SettingsRepo } from "./db/settings"; import { notifyOwner } from "./tools/handoffHuman"; +import { LLM_FAILURE_REPLIES, LLM_FAILURE_LEGACY_PREFIX } from "./failureReply"; const WINDOW_MS = 30 * 60 * 1000; export const ALERT_THRESHOLD = 3; @@ -27,8 +28,8 @@ export async function checkBotHealth(env: Env, now = Date.now()): Promise( - "SELECT COUNT(*) as n FROM messages WHERE role = 'assistant' AND content LIKE 'Algo falló%' AND created_at > ?", - [now - WINDOW_MS], + "SELECT COUNT(*) as n FROM messages WHERE role = 'assistant' AND (content IN (?, ?) OR content LIKE ?) AND created_at > ?", + [LLM_FAILURE_REPLIES.es, LLM_FAILURE_REPLIES.en, `${LLM_FAILURE_LEGACY_PREFIX}%`, now - WINDOW_MS], ) )?.n ?? 0; diff --git a/test/agent.fallas.test.ts b/test/agent.fallas.test.ts new file mode 100644 index 00000000..02a1d673 --- /dev/null +++ b/test/agent.fallas.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Cuando la IA falla, el bot no deja al cliente en seco: +// 1. Turno con FOTO que ningún modelo pudo abrir → reintento con puro texto. +// 2. Falla total → respuesta con tono humano (no "Algo falló de mi lado") y +// aviso INMEDIATO al dueño con quién se quedó esperando. +// Mismo harness que test/agent.media.test.ts (agents/ai mockeados, sin red). + +vi.mock("agents", () => ({ + Agent: class { + ctx: any; + env: any; + state: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + setState(s: any) { + this.state = s; + } + sql(..._args: any[]) { + return undefined; + } + }, +})); + +const streamTextMock = vi.fn(); +const generateTextMock = vi.fn(); +vi.mock("ai", () => ({ + streamText: (...args: any[]) => streamTextMock(...args), + generateText: (...args: any[]) => generateTextMock(...args), + tool: (def: any) => def, +})); +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: () => (modelId: string) => ({ modelId }), +})); + +const notifyOwnerMock = vi.fn(async () => {}); +vi.mock("../src/tools/handoffHuman", async (importOriginal) => ({ + ...(await importOriginal()), + notifyOwner: (...args: any[]) => (notifyOwnerMock as any)(...args), +})); + +import { SupportAgent } from "../src/agent"; +import { ConversationsRepo } from "../src/db/conversations"; +import { MessagesRepo } from "../src/db/messages"; +import { SettingsRepo } from "../src/db/settings"; +import * as senderMod from "../src/replies/sender"; +import { LLM_FAILURE_REPLIES } from "../src/failureReply"; + +function okResult(text: string) { + async function* gen() { + yield text; + } + return { + textStream: gen(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, cachedInputTokens: 0 }), + steps: Promise.resolve([{ toolCalls: [] }]), + }; +} + +const lastIsImage = (args: any) => Array.isArray(args?.messages?.at(-1)?.content); + +function makeAgent() { + const env: any = { + DB: {}, + AI: { run: vi.fn(async () => ({ text: "" })) }, + ANTHROPIC_API_KEY: "sk-test", + BOT_TIER: "pro", + BOT_LANGUAGE: "es", + BUFFER_SECONDS: "8", + BOT_NAME: "TestBot", + BUSINESS_NAME: "TestCo", + }; + const agent: any = new (SupportAgent as any)({ storage: { setAlarm: vi.fn(), getAlarm: vi.fn() } }, env); + agent.setState({ + conversationId: "conv-1", + channel: "telegram", + channelUserId: "5215512345678", + pendingMessages: [], + lastAlarmAt: 0, + lastUserLang: "es", + toolCallsInLast2Turns: 0, + lastSearchKbScore: 1, + imageRetryCount: 0, + }); + return agent; +} + +function stubTurn(lastUserContent: string) { + const sendReply = vi.fn(async () => {}); + vi.spyOn(SettingsRepo.prototype, "all").mockResolvedValue({}); + vi.spyOn(MessagesRepo.prototype, "append").mockResolvedValue(undefined as any); + vi.spyOn(MessagesRepo.prototype, "lastN").mockResolvedValue([ + { role: "user", content: lastUserContent }, + ] as any); + vi.spyOn(ConversationsRepo.prototype, "touchLastMessage").mockResolvedValue(undefined as any); + vi.spyOn(senderMod, "pickAdapter").mockReturnValue({ sendReply } as any); + return sendReply; +} + +const sentText = (sendReply: any) => + ((sendReply.mock.calls.at(-1)?.at(0) as { chunks: string[] } | undefined)?.chunks ?? []).join(" ").replace(/\s+/g, " "); + +describe("el bot no deja al cliente en seco cuando la IA falla", () => { + beforeEach(() => { + vi.restoreAllMocks(); + streamTextMock.mockReset(); + generateTextMock.mockReset(); + notifyOwnerMock.mockClear(); + }); + + it("foto que ningún modelo pudo abrir → sigue la conversación con puro texto", async () => { + const agent = makeAgent(); + const sendReply = stubTurn( + "Me gustaría información de esta propiedad\n[IMAGE_URL: https://example.com/lona.jpg]", + ); + // Con la foto adjunta todo revienta (p. ej. la imagen no se pudo descargar); + // con puro texto el modelo contesta normal. + const fail = () => { + throw Object.assign(new Error("Error while downloading https://example.com/lona.jpg"), { + name: "AI_APICallError", + statusCode: 400, + }); + }; + streamTextMock.mockImplementation((args: any) => + lastIsImage(args) ? fail() : okResult("¡Claro! ¿Me dices dónde viste el anuncio?"), + ); + generateTextMock.mockImplementation(async (args: any) => + lastIsImage(args) ? fail() : { text: "¡Claro! ¿Me dices dónde viste el anuncio?", usage: {}, steps: [] }, + ); + + agent.state.pendingMessages = [{ text: "Me gustaría información", receivedAt: Date.now() }]; + await agent.processBuffer(); + + const reintento = [...streamTextMock.mock.calls, ...generateTextMock.mock.calls] + .map((c) => c[0]) + .find((a) => !lastIsImage(a)); + expect(reintento.messages.at(-1).content).toContain("Me gustaría información de esta propiedad"); + expect(reintento.messages.at(-1).content).toContain("FOTO que no pudiste abrir"); + expect(sentText(sendReply)).toContain("¿Me dices dónde viste el anuncio?"); + expect(notifyOwnerMock).not.toHaveBeenCalled(); + }, 20_000); + + it("falla total → respuesta humana + aviso inmediato al dueño", async () => { + const agent = makeAgent(); + const sendReply = stubTurn("Hola, ¿cuánto cuesta?"); + const boom = () => { + throw Object.assign(new Error("rate limit"), { name: "AI_APICallError", statusCode: 429 }); + }; + streamTextMock.mockImplementation(boom); + generateTextMock.mockImplementation(async () => boom()); + + agent.state.pendingMessages = [{ text: "Hola, ¿cuánto cuesta?", receivedAt: Date.now() }]; + await agent.processBuffer(); + + expect(sentText(sendReply)).toBe(LLM_FAILURE_REPLIES.es); + expect(sentText(sendReply)).not.toMatch(/Algo falló/); + expect(notifyOwnerMock).toHaveBeenCalledTimes(1); + const [, notice] = notifyOwnerMock.mock.calls[0] as unknown as [unknown, { reason: string; summary: string }]; + expect(notice.reason).toBe("el bot no pudo responder"); + expect(notice.summary).toContain("5215512345678"); + expect(notice.summary).toContain("Hola, ¿cuánto cuesta?"); + }, 20_000); +}); diff --git a/test/flywheel/autonomy.test.ts b/test/flywheel/autonomy.test.ts index 7e16b534..fa168c59 100644 --- a/test/flywheel/autonomy.test.ts +++ b/test/flywheel/autonomy.test.ts @@ -25,6 +25,7 @@ import { SettingsRepo, SETTING_KEYS } from "../../src/db/settings"; import { autoApplyPending } from "../../src/flywheel/apply"; import { getLessons } from "../../src/flywheel/detect"; import { checkBotHealth, ALERT_THRESHOLD } from "../../src/watchdog"; +import { LLM_FAILURE_REPLIES } from "../../src/failureReply"; import type { Env } from "../../src/env"; const PASSWORD = "secret123"; @@ -115,12 +116,12 @@ describe("autoApplyPending (copiloto)", () => { }); describe("watchdog (checkBotHealth)", () => { - async function seedFailures(n: number, at: number) { + async function seedFailures(n: number, at: number, text = "Algo falló de mi lado, ¿me repites tu mensaje?") { const convs = new ConversationsRepo(db); const msgs = new MessagesRepo(db); const conv = await convs.getOrCreate("twilio", "wd-user"); for (let i = 0; i < n; i++) { - await msgs.append(conv.id, "assistant", "Algo falló de mi lado, ¿me repites tu mensaje?"); + await msgs.append(conv.id, "assistant", text); } // Fuerza el created_at dentro/fuera de la ventana según el test. await db.run("UPDATE messages SET created_at = ? WHERE role = 'assistant'", [at]); @@ -138,6 +139,14 @@ describe("watchdog (checkBotHealth)", () => { expect(notice.summary).toContain("3 respuestas fallidas"); }); + it("cuenta también la respuesta de falla nueva (tono humano, es y en)", async () => { + const now = 1_800_000_000_000; + await seedFailures(2, now - 5 * 60_000, LLM_FAILURE_REPLIES.es); + await seedFailures(1, now - 5 * 60_000, LLM_FAILURE_REPLIES.en); + const r = await checkBotHealth(env, now); + expect(r).toEqual({ failures: 3, alerted: true }); + }); + it("no alerta bajo el umbral ni con fallos viejos", async () => { const now = 1_800_000_000_000; await seedFailures(2, now - 5 * 60_000);