Skip to content
Merged
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
167 changes: 167 additions & 0 deletions docs/01-latency-optimization-plan.md
Original file line number Diff line number Diff line change
@@ -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 |
42 changes: 40 additions & 2 deletions src/application/conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
38 changes: 38 additions & 0 deletions src/application/conversation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<boolean> {
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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -125,6 +162,7 @@ export class ConversationService {
...logBase,
ms: Date.now() - startedAt,
reply,
tools: countToolCalls(result),
authenticated: context.authenticated,
});

Expand Down
24 changes: 24 additions & 0 deletions src/application/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
52 changes: 52 additions & 0 deletions src/application/welcome.ts
Original file line number Diff line number Diff line change
@@ -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<Lang, Welcome> = {
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" },
],
},
};
6 changes: 6 additions & 0 deletions src/infrastructure/observability/conversation-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) } : {}),
};
}
Expand Down