From 8b2151d92fb739ed3036bf76555d76ee8e313b0a Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 7 Jul 2026 22:48:11 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20fast-path=20de=20bienvenida=20+=20instr?= =?UTF-8?q?umentaci=C3=B3n=20de=20latencia=20(fase=200=20+=201.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 0 (instrumentar): el log de turno ahora incluye 'tools' (nº de tool-calls del agente) y 'fastPath', para ver qué turnos son model-bound vs tool-bound. Fase 1.1 (fast-path de saludos): si un usuario NUEVO (sesión vacía) solo saluda (hola/buenas/hi/hello/buenos días/start), ConversationService responde con una bienvenida precomputada + botones SIN invocar al modelo. Elimina el peor caso de latencia (un 'Hola' llegó a tardar 16 s -> ahora <1 s). Detección estricta (saludo puro) y solo en conversación nueva, para no perder contexto en curso. Tests 90->93 (detectGreeting, fast-path activo, fast-path NO en sesión con historial). --- docs/01-latency-optimization-plan.md | 167 ++++++++++++++++++ src/application/conversation-service.test.ts | 42 ++++- src/application/conversation-service.ts | 38 ++++ src/application/welcome.test.ts | 24 +++ src/application/welcome.ts | 52 ++++++ .../observability/conversation-logger.ts | 6 + 6 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 docs/01-latency-optimization-plan.md create mode 100644 src/application/welcome.test.ts create mode 100644 src/application/welcome.ts diff --git a/docs/01-latency-optimization-plan.md b/docs/01-latency-optimization-plan.md new file mode 100644 index 0000000..db36697 --- /dev/null +++ b/docs/01-latency-optimization-plan.md @@ -0,0 +1,167 @@ +# 01 · Plan de optimización de latencia — ResponseGrid ChatBot + +## Objetivo + +Reducir la latencia por turno, que hoy es la **causa nº 1 de abandono** (5 de 9 +sesiones fueron de un solo mensaje). El primer "Hola" de un usuario nuevo llegó +a tardar **16 s**: peor imposible como primera impresión. + +### Baseline medido (conversaciones reales, jul-2026) + +| Métrica | Valor | +|---|---| +| Media por turno | 6,1 s | +| Mediana | 5,8 s | +| p90 | 10,4 s | +| p95 | 12,7 s | +| Máximo | 16,4 s | +| Turns > 10 s | 8 de 63 | + +### Meta + +| Métrica | Objetivo | +|---|---| +| Mediana | **< 3 s** | +| p90 | **< 6 s** | +| Primer saludo ("Hola") | **< 2 s** | + +## Principio + +**Medir → optimizar → medir.** Hoy solo registramos el tiempo TOTAL del turno +(`ms`), no dónde se va. Sin desglose, optimizar es adivinar. Por eso la Fase 0 +es instrumentación. + +### De dónde viene la latencia (hipótesis a confirmar con Fase 0) + +1. **Round-trips al modelo.** Cada turno son 1..N llamadas al LLM. Con tools es + secuencial: modelo → tool (HTTP a la API) → modelo → … Un turno con 2 tools = + 3 llamadas al modelo + 2 a la API, en serie. +2. **Modelo grande por defecto.** `OPENAI_MODEL` está vacío → el SDK usa su + modelo por defecto (clase GPT-4). Cada round-trip son ~2-5 s. +3. **El saludo paga round-trip extra.** La bienvenida invoca `rg_present_options` + (que es una *tool*): modelo → tool → modelo, solo para saludar. +4. **Sin streaming.** El usuario espera la respuesta COMPLETA antes de ver nada. +5. **`resolveEmergencyId` hace `GET /emergencies/by-slug` en cada llamada** que + omite `emergencyId` — se repite dentro del mismo turno y entre turnos. +6. **Notas de voz:** transcripción (llamada extra a OpenAI) antes del agente. + +--- + +## Fase 0 — Instrumentación (medir antes de tocar) + +**Esfuerzo: S · Impacto: habilita todo lo demás** + +- **[0.1]** Añadir al log estructurado de conversación (`conversation-logger.ts` + + `conversation-service.ts`) métricas por fase: `ttfm` (time-to-first-model + response), `nModelCalls`, `nToolCalls`, `toolMs` (suma de tiempo en tools), + `transcribeMs` (si audio), además del `ms` total que ya existe. +- **[0.2]** Envolver cada `execute` de tool para medir su tiempo (un wrapper en + `tools.ts`), y contar round-trips del modelo (hooks del SDK `@openai/agents` + si los expone; si no, inferir por nº de tool-calls). +- **[0.3]** Un script `scripts/latency-stats.ts` que lea los logs y saque el + desglose (dónde se va el tiempo, por tipo de turno: saludo / búsqueda / + escritura). Reutiliza el consumidor de logs. + +**Entregable:** un desglose real "X% modelo, Y% tools, Z% transcripción" que +prioriza las fases siguientes con datos, no hipótesis. + +--- + +## Fase 1 — Quick wins (mayor impacto / menor esfuerzo) + +### [1.1] Fast-path para saludos — **Esfuerzo S · Impacto ALTO** +Detectar saludos puros (`hola`, `buenas`, `hi`, `hello`, `/start`, `/casos`, +`/puntos`) en `ConversationService` **antes** de invocar al agente, y responder +con la bienvenida + botones **precomputados** (texto estático + `choices` +fijos), sin llamar al modelo. Elimina el peor caso (8-16 s → < 500 ms). +- *Dónde:* `conversation-service.ts` (guard antes de `run`), reusando el copy de + bienvenida y las opciones que hoy define el prompt. +- *Riesgo:* que un "hola" con intención real se corte; mitigación: solo fast-path + si el mensaje es **exclusivamente** un saludo (regex estricta) y no hay sesión + en curso con contexto pendiente. + +### [1.2] Modelo más rápido por defecto (o tiering) — **Esfuerzo S · Impacto ALTO** +La frontera de seguridad es la **API**, no el LLM, así que un modelo más rápido +es aceptable para la mayoría de turnos. Fijar `OPENAI_MODEL` a un modelo rápido +(p. ej. un `-mini`/tier veloz) y **medir la calidad** en los flujos reales +(inventario, necesidades, donación, catálogo). Si algún flujo pierde calidad, +enrutar solo esos al modelo grande (ver Fase 3). +- *Dónde:* `.env` del server (`OPENAI_MODEL`) + `agent.ts` ya lo respeta. +- *Riesgo:* peor razonamiento en flujos multi-tool → mitigar con tiering (3.1) y + con un set de pruebas de regresión de conversación. + +### [1.3] Cache de `emergencyId` por proceso — **Esfuerzo S · Impacto MEDIO** +Cachear el `slug → id` (una sola emergencia activa) en memoria con TTL, para no +hacer `GET /by-slug` en cada tool. Ahorra 1 HTTP (~0,3-1 s) por tool que lo use. +- *Dónde:* `resolveEmergencyId` en `tools.ts` (Map con TTL, o resolver una vez + por turno y guardar en `AgentContext`). + +--- + +## Fase 2 — Percepción y round-trips + +### [2.1] Streaming de la respuesta — **Esfuerzo M · Impacto ALTO (percibido)** +Usar `run(..., { stream: true })` del SDK. +- **Telegram:** enviar un mensaje y **editarlo** con los tokens según llegan + (Telegram permite editar) → el usuario ve texto casi al instante. +- **WhatsApp:** no permite streaming de edición; el beneficio es enviar en cuanto + esté el primer bloque y mantener el indicador "escribiendo…". Menos impacto + que en Telegram pero mejora el arranque. +- *Dónde:* `conversation-service.ts` + adaptadores de canal. + +### [2.2] Reducir round-trips — **Esfuerzo M · Impacto MEDIO** +- `rg_present_options` fuerza un round-trip extra: evaluar resolverlo como + **efecto de salida** del turno (el modelo declara las opciones en su respuesta + final) en vez de como tool intermedia. +- Paralelizar tool-calls independientes si el SDK lo permite (búsquedas que no + dependen entre sí). +- Recorte de listas ya está a 25 (`tool-result.ts`) — mantener. + +--- + +## Fase 3 — Estructural (según datos de Fase 0) + +### [3.1] Enrutado de modelo por complejidad — **Esfuerzo M** +Modelo rápido para turnos simples (saludo, consulta, una sola tool); modelo +capaz solo para flujos complejos (multi-tool, estandarización de catálogo, +validación). Clasificador barato (heurística por intención/nº de tools). + +### [3.2] Adelgazar el prompt de sistema — **Esfuerzo M** +El system prompt es grande (~55 líneas) y se reenvía en cada turno (coste de +input tokens + tiempo). Separar en **núcleo conciso** + hints por-tool (en la +`description` de cada tool, donde el modelo ya los lee). Objetivo: system prompt +más corto sin perder reglas críticas (identidad, ubicación, donaciones). + +### [3.3] Reutilización de conexión / prewarm — **Esfuerzo S-M** +Verificar keep-alive del cliente OpenAI y del `ApiClient` (HTTP agent con +`keepAlive`) para evitar coste de handshake por llamada. + +--- + +## Orden recomendado + +1. **Fase 0** (instrumentar) — imprescindible, barato. +2. **1.1 (fast-path saludos)** + **1.2 (modelo rápido)** — el 80% de la mejora + percibida con poco esfuerzo. +3. **1.3 (cache emergencyId)** — barato, se cuela con lo anterior. +4. **2.1 (streaming Telegram)** — gran salto de percepción. +5. Revisar métricas de Fase 0 → decidir 2.2 / 3.x según dónde quede el tiempo. + +## Cómo validamos + +- Comparar el desglose de Fase 0 **antes y después** de cada palanca (no fiarse + de la sensación). +- Set de **conversaciones de regresión** (inventario, necesidades, donación, + catálogo "comida para bebés", publicar recurso) para asegurar que bajar el + modelo o cambiar round-trips no rompe la calidad. +- KPI de negocio: **tasa de continuación** (sesiones con > 1 mensaje) debería + subir al bajar la latencia del primer turno. + +## Riesgos y mitigaciones + +| Riesgo | Mitigación | +|---|---| +| Modelo rápido pierde calidad en flujos complejos | Tiering (3.1) + regresión de conversación | +| Fast-path corta un saludo con intención | Regex estricta; solo si no hay contexto en curso | +| Streaming complica el manejo de errores/tools | Empezar solo por Telegram, texto; mantener no-streaming como fallback | +| Cache de emergencyId sirve datos viejos | TTL corto; solo hay una emergencia activa | diff --git a/src/application/conversation-service.test.ts b/src/application/conversation-service.test.ts index 158d4e3..a0d3f77 100644 --- a/src/application/conversation-service.test.ts +++ b/src/application/conversation-service.test.ts @@ -56,7 +56,7 @@ test("ConversationService", async (t) => { ); const { channel, sent } = makeFakeChannel(); - await service.handle({ account, chatId: "111", text: "hola" }, channel); + await service.handle({ account, chatId: "111", text: "busca agua cerca" }, channel); assert.strictEqual(sent.length, 1); assert.strictEqual(sent[0].type, "text"); @@ -186,9 +186,47 @@ test("ConversationService recupera de un historial corrupto sin propagar el erro ); const { channel, sent } = makeFakeChannel(); - await service.handle({ account, chatId: "777", text: "hola" }, channel); // no debe lanzar + await service.handle({ account, chatId: "777", text: "busca agua cerca" }, channel); // no debe lanzar assert.ok(cleared, "reinicia la sesión corrupta"); assert.strictEqual(sent.length, 1); assert.match(sent[0].text, /reiniciar/); }); + +test("ConversationService · fast-path de bienvenida (saludo en sesión nueva) no invoca al agente", async () => { + const authStore = makeFakeAuthStore(); + let runCalls = 0; + const service = new ConversationService( + { getSession: () => makeFakeSession(), authStore, log: () => {} }, + async () => { + runCalls += 1; + return { finalOutput: "no debería llamarse" } as any; + }, + ); + const { channel, sent } = makeFakeChannel(); + + await service.handle({ account, chatId: "888", text: "Hola!" }, channel); + + assert.strictEqual(runCalls, 0, "no invoca al modelo para un saludo nuevo"); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].type, "choices"); + assert.match(sent[0].text, /ResponseGrid/); +}); + +test("ConversationService · con historial, un saludo SÍ va al agente", async () => { + const authStore = makeFakeAuthStore(); + let runCalls = 0; + const session = { ...makeFakeSession(), getItems: async () => [{ type: "message" }] } as ConversationStore; + const service = new ConversationService( + { getSession: () => session, authStore, log: () => {} }, + async () => { + runCalls += 1; + return { finalOutput: "hola de nuevo" } as any; + }, + ); + const { channel } = makeFakeChannel(); + + await service.handle({ account, chatId: "889", text: "hola" }, channel); + + assert.strictEqual(runCalls, 1, "con contexto en curso, el saludo lo maneja el agente"); +}); diff --git a/src/application/conversation-service.ts b/src/application/conversation-service.ts index a5b467e..5d26587 100644 --- a/src/application/conversation-service.ts +++ b/src/application/conversation-service.ts @@ -10,6 +10,7 @@ import type { AuthStore } from "../domain/ports/auth-store.port.js"; import { ApiClient } from "../infrastructure/responsegrid/api-client.js"; import { logConversation, type ConversationLogFields } from "../infrastructure/observability/conversation-logger.js"; import type { RateLimiter } from "./rate-limiter.js"; +import { detectGreeting, WELCOME } from "./welcome.js"; /** Longitud máxima de un mensaje de texto que se procesa (protege coste/abuso). */ export const MAX_TEXT_LENGTH = 8000; @@ -27,6 +28,23 @@ export function isCorruptedHistoryError(message: string): boolean { ); } +/** true si la sesión no tiene historial todavía (conversación nueva). */ +async function isEmptySession(session: ConversationStore): Promise { + try { + const items = await session.getItems(); + return !Array.isArray(items) || items.length === 0; + } catch { + return false; + } +} + +/** Cuenta las tool-calls que hizo el agente en el turno (instrumentación). */ +function countToolCalls(result: unknown): number | undefined { + const items = (result as { newItems?: unknown[] })?.newItems; + if (!Array.isArray(items)) return undefined; + return items.filter((i) => (i as { type?: string })?.type === "tool_call_item").length; +} + export interface ConversationServiceDeps { getSession(account: Account, chatId: string): ConversationStore; authStore: AuthStore; @@ -88,6 +106,25 @@ export class ConversationService { await channel.indicateReceived(chatId, inbound.messageId).catch(() => undefined); const startedAt = Date.now(); + + // Fast-path de bienvenida: si un usuario NUEVO (sesión vacía) solo saluda, + // respondemos con la bienvenida precomputada + botones sin invocar al modelo. + // Es el turno más lento hoy y no necesita razonamiento. + const greetingLang = detectGreeting(inbound.text); + if (greetingLang && (await isEmptySession(session))) { + const welcome = WELCOME[greetingLang]; + log({ + kind: "turn", + ...logBase, + ms: Date.now() - startedAt, + reply: welcome.text, + fastPath: true, + authenticated: context.authenticated, + }); + await channel.sendChoices(chatId, { text: welcome.text, options: welcome.options }); + return; + } + let result: { finalOutput: unknown }; try { result = await this.run(apiAgent, userText, { context, session }); @@ -125,6 +162,7 @@ export class ConversationService { ...logBase, ms: Date.now() - startedAt, reply, + tools: countToolCalls(result), authenticated: context.authenticated, }); diff --git a/src/application/welcome.test.ts b/src/application/welcome.test.ts new file mode 100644 index 0000000..c7ed90a --- /dev/null +++ b/src/application/welcome.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert"; +import { detectGreeting } from "./welcome.js"; + +test("detectGreeting", () => { + // Saludos puros en español. + assert.strictEqual(detectGreeting("Hola"), "es"); + assert.strictEqual(detectGreeting("hola!!"), "es"); + assert.strictEqual(detectGreeting("Buenas"), "es"); + assert.strictEqual(detectGreeting("Buenos días"), "es"); + assert.strictEqual(detectGreeting("buenas tardes"), "es"); + assert.strictEqual(detectGreeting("/start"), "es"); + + // Saludos puros en inglés. + assert.strictEqual(detectGreeting("Hi"), "en"); + assert.strictEqual(detectGreeting("hello"), "en"); + assert.strictEqual(detectGreeting("good morning"), "en"); + + // NO son saludos puros -> null (va al agente). + assert.strictEqual(detectGreeting("hola, quiero llevar agua"), null); + assert.strictEqual(detectGreeting("busca agua cerca"), null); + assert.strictEqual(detectGreeting(""), null); + assert.strictEqual(detectGreeting(undefined), null); +}); diff --git a/src/application/welcome.ts b/src/application/welcome.ts new file mode 100644 index 0000000..fe915c9 --- /dev/null +++ b/src/application/welcome.ts @@ -0,0 +1,52 @@ +/** + * Fast-path de bienvenida: cuando un usuario NUEVO solo saluda, respondemos con + * una bienvenida precomputada + botones, sin invocar al modelo. Es el turno más + * lento hoy (un "Hola" llegó a tardar 16 s) y no necesita razonamiento. + */ + +export type Lang = "es" | "en"; + +// Saludo puro (todo el mensaje es un saludo), tolerando puntuación y repeticiones. +const GREETING_RE = + /^[\s\p{P}]*(hola+|buenas(?:\s+(?:tardes|noches))?|buenos\s*d[ií]as|hey+|ey+|qu[eé]\s*tal|hi+|hello+|hey\s*there|good\s*(?:morning|afternoon|evening)|\/?start)[\s\p{P}]*$/iu; + +const EN_RE = /\b(hi|hello|hey|good\s*(morning|afternoon|evening))\b/i; + +/** Devuelve el idioma del saludo si el mensaje es SOLO un saludo; null si no. */ +export function detectGreeting(text: string | undefined): Lang | null { + if (!text) return null; + if (!GREETING_RE.test(text)) return null; + return EN_RE.test(text) ? "en" : "es"; +} + +export interface Welcome { + text: string; + options: { id: string; label: string }[]; +} + +// Los ids coinciden con los que ya maneja el agente cuando el usuario pulsa +// (buscar_ayuda, etc.), para que el siguiente turno fluya igual. +export const WELCOME: Record = { + es: { + text: + "¡Hola! Soy ResponseGrid, el asistente para coordinar la ayuda en una emergencia. " + + "Puedo ayudarte a buscar recursos y ayuda cerca, registrar un punto de acopio o recurso, " + + "actualizar inventario, y crear o gestionar necesidades. ¿En qué te ayudo?", + options: [ + { id: "buscar_ayuda", label: "Buscar ayuda cerca" }, + { id: "gestionar_recursos", label: "Gestionar recursos" }, + { id: "crear_necesidad", label: "Crear necesidad" }, + ], + }, + en: { + text: + "Hi! I'm ResponseGrid, the assistant for coordinating aid in an emergency. " + + "I can help you find resources and aid nearby, register a collection point or resource, " + + "update inventory, and create or manage needs. How can I help?", + options: [ + { id: "buscar_ayuda", label: "Find aid nearby" }, + { id: "gestionar_recursos", label: "Manage resources" }, + { id: "crear_necesidad", label: "Create a need" }, + ], + }, +}; diff --git a/src/infrastructure/observability/conversation-logger.ts b/src/infrastructure/observability/conversation-logger.ts index e00d6e5..d5c4de6 100644 --- a/src/infrastructure/observability/conversation-logger.ts +++ b/src/infrastructure/observability/conversation-logger.ts @@ -16,6 +16,10 @@ export interface ConversationLogFields { userText?: string; reply?: string; ms?: number; + /** Nº de tool-calls que hizo el agente en este turno (instrumentación de latencia). */ + tools?: number; + /** true si el turno se resolvió por fast-path (sin invocar al modelo). */ + fastPath?: boolean; error?: string; } @@ -45,6 +49,8 @@ export function buildLogLine( ...(fields.userText !== undefined ? { user: truncate(fields.userText) } : {}), ...(fields.reply !== undefined ? { reply: truncate(fields.reply) } : {}), ...(fields.ms !== undefined ? { ms: fields.ms } : {}), + ...(fields.tools !== undefined ? { tools: fields.tools } : {}), + ...(fields.fastPath !== undefined ? { fastPath: fields.fastPath } : {}), ...(fields.error !== undefined ? { error: truncate(fields.error, 500) } : {}), }; }