1. All Collections >
  2. Crecimiento Impulsado por Conversaciones >
  3. Informes y análisis >
  4. Cómo automatizar el control de calidad de conversaciones usando n8n y IA

Cómo automatizar el control de calidad de conversaciones usando n8n y IA

Avatar
Shing-Yi Tan
14 min de lectura

Te recomendamos revisar las conversaciones gestionadas por Agente IA después de activarlo, pero la revisión manual se vuelve demasiado lenta a escala. Esta guía muestra cómo automatizar el control de calidad de conversaciones usando n8n + un modelo de IA, para que cada conversación cerrada se revise automáticamente y se registre en Google Sheets para informes.

En resumen, este flujo de trabajo de n8n se dispara cada vez que se cierra una conversación. Una vez disparado, llama al List Messages API para obtener los últimos 50 mensajes (con opción de obtener hasta 100 usando paginación), luego pasa la transcripción a un modelo de IA para el procesamiento de QA — como análisis de sentimiento, puntaje de engagement y comprobaciones de resolución. Los resultados se registran en Google Sheets para que puedas buscar fácilmente cualquier conversación por ID de contacto.

Lo que vas a construir

Una automatización que:

  • Se dispara cuando se cierra una conversación

  • Recupera los últimos 50 mensajes usando el List Messages API (opcionalmente hasta 100 con paginación)

  • Limpia y formatea los mensajes en una transcripción

  • Envía la transcripción a un modelo de IA para evaluación de QA

  • Analiza la salida de la IA de forma segura (contrato solo JSON)

  • Añade los resultados a Google Sheets (una fila por conversación)

Requisitos

Herramientas

  • Plataforma de automatización: n8n

  • API para Desarrolladores de Respond.io — List Messages endpoint

  • API de IA — OpenAI, Anthropic, Gemini o cualquier proveedor soportado por n8n

  • Informes / almacenamiento — Google Sheets (o tu herramienta de almacenamiento preferida)

Credenciales

  • Clave de la API para Desarrolladores de Respond.io

  • Clave API del proveedor de IA (OpenAI, Anthropic, Gemini, etc.)

  • Credencial de Google Sheets en n8n (OAuth o cuenta de servicio)

Antes de empezar

1) Crea un Google Sheet para los resultados de QA

Crea una hoja de cálculo y añade una pestaña (p. ej. QA_Results) con estos encabezados:

  • timestamp

  • contact_id

  • opened_at

  • channel

  • overall_score

  • resolved

  • customer_sentiment

  • reasoning

  • engagement_score

2) Confirma los campos del payload del disparador

Desde la salida del Conversation closed trigger, identifica los nombres exactos de los campos para:

  • ID de conversación

  • ID de contacto

  • Marca de tiempo de apertura de la conversación

  • Marca de tiempo de cierre de la conversación (opcional)

Los nombres de campo varían según la implementación. En los nodos de código abajo, verás marcadores como trigger.conversationId. Actualiza esos mapeos para que coincidan con la salida de tu disparador.

Guía paso a paso

1. Disparador: Conversación cerrada

Cuando se cierre una conversación en respond.io, queremos activar automáticamente el flujo de trabajo de n8n. Así te aseguras de que cada conversación completada pase por QA — sin revisión manual necesaria.

Nodo n8n:Conversation closed trigger

  1. En n8n, añade el nodo trigger de respond.io.

  2. Elige Conversation Closed.

  3. Conecta las credenciales de la API para Desarrolladores de Respond.io. Aprende a configurarlo en n8n con nuestra guía de integración.

Este disparador asegura que solo revises conversaciones después de que hayan terminado.

2. Obtener mensajes de la conversación

En este paso, obtendrás los últimos 50 mensajes entrantes y salientes usando el List Messages API. Esto suele ser suficiente para una revisión de QA completa. Si necesitas más contexto, opcionalmente puedes obtener otros 50 mensajes (hasta 100 en total) usando paginación.

Nodos de n8n:

  • get 1st 50 messages (HTTP Request)

  • Is there a second page? (IF) — opcional

  • get 2nd 50 messages (HTTP Request) — opcional

2.1 Obtener los primeros 50 mensajes

Node:get 1st 50 messages

  1. Selecciona Core > HTTP Request

  2. Método: GET

  3. URL: tu endpoint de List Messages

URL de ejemplo (reemplázala con la URL base de tu API + formato del endpoint):

https://api.respond.io/v2/contact/{{identifier}}/message/list

Donde identifier debe reemplazarse por uno de los siguientes formatos: id:<contactID>, phone:+<countryCodeAndPhone>, o email:<contactEmail>

  1. Activa Send Query Parameters > Using Fields Below, luego añade:

    • Nombre: limit

    • Valor: 50

Si tu API lo soporta, establece el orden de más antiguo → más nuevo. Ejemplo: sort=asc.

  1. Activa Send Headers > Using Fields Below, luego añade:

    • Nombre: Accept

      • Valor: application/json

    • Nombre: Authorization

      • Valor: Bearer <your_respond_api_key>

Si la respuesta de List Messages incluye pagination.next, puedes usarla directamente para obtener la segunda página.

2.2 Verificar y obtener más de 50 mensajes (Opcional)

Si 50 mensajes no son suficientes para tus necesidades de QA, puedes obtener una segunda página de 50 mensajes. Añade un nodo IF para comprobar si hay más mensajes disponibles.

Node:Is there a second page? (IF)

  • Valor izquierdo (Expresión): {{ $json.pagination.next }}

  • Operador: is not empty

Si es verdadero, significa que hay más mensajes para obtener. Si es falso, el flujo de trabajo continúa sin obtener más — esto evita que falle cuando no hay segunda página. El nodo Fusionar aguas abajo esperará a que completen ambas rutas, así que el flujo de trabajo seguirá funcionando sin problemas en cualquier caso.

2.3 Obtener los siguientes 50 mensajes (Opcional)

Si el nodo IF pasa (existe una segunda página), obtén el siguiente lote.

Node:get 2nd 50 messages

  1. Añade otro nodo HTTP Request.

  2. Configura:

    • Método: GET

    • URL: {{ $json.pagination.next }}

  3. Añade los mismos headers:

    • Accept: application/json

    • Authorization: Bearer <your_respond_api_key>

3. Fusionar páginas de mensajes (Opcional)

Este paso solo es necesario si estás obteniendo más de 50 mensajes. Si decidiste no obtener una segunda página, puedes omitir este nodo y conectar get 1st 50 messages directamente al siguiente paso (Clean + keep messages since last open).

El nodo Fusionar combina los primeros 50 mensajes con los segundos 50 en una sola lista. Sin él, el flujo de trabajo no puede procesar juntas dos respuestas de API separadas.

n8n node:Merge

  1. Añade Flow > Merge.

  2. Configura el Modo a Append.

  3. Establece Número de entradas: 2

  4. Conecta:

    • Entrada 1: get 1st 50 messages

    • Entrada 2: get 2nd 50 messages

4. Limpiar y filtrar mensajes

La respuesta de la API sigue siendo cruda y contiene mucha información extra que la IA no necesita. Este paso la limpia — elimina mensajes antiguos, normaliza las etiquetas de los remitentes y organiza todo en una lista simple, lista para el procesamiento por la IA. Todo lo que necesitas hacer es añadir el nodo Code y copiar/pegar el JavaScript abajo.

n8n node:Clean + keep messages since last convo open

Para esto: Añade nodo → Selecciona Core → Code → Code en JavaScript.

Este código hará:

  1. Combinar mensajes de ambas páginas

  2. Filtrar mensajes enviados antes de que se abriera la conversación

  3. Ordenar mensajes de más antiguo → más nuevo

  4. Normalizar remitentes en etiquetas consistentes: Contacto, Agente IA, Agente humano, Flujo de trabajo

  5. Añadir números de índice de mensaje

  6. Devolver una lista limpia y estructurada para el paso de transcripción

Notas: Este ejemplo filtra usando messageId comparado con la marca de apertura convertida a microsegundos. Si tu API provee marcas createdAt en su lugar, filtra por createdAt en vez de messageId. Asegúrate de que tu conversation_open_timestamp incluya zona horaria. Si no la incluye, ajústala a la zona horaria de tu espacio de trabajo antes de parsearla.

Pega esto en el nodo Code:

// n8n Code node AFTER Merge (Run Once for All Items)
// Incoming items are the API responses from page1 and (optionally) page2.

function unwrapRespondList(json) {
  if (Array.isArray(json) && json.length && json[0]?.items) return json[0];
  if (json?.items) return json;
  if (Array.isArray(json)) return { items: json, pagination: {} };
  return { items: [], pagination: {} };
}

function toMicroseconds(ts) {
  if (!ts || typeof ts !== "string") return null;

  // Prefer timestamps that already contain timezone info.
  // If your timestamp lacks timezone, add it upstream (recommended) rather than hardcoding here.
  const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
  const ms = Date.parse(iso);
  if (Number.isNaN(ms)) return null;
  return ms * 1000; // microseconds
}

function normalizeSender(source) {
  if (!source) return "Unknown";
  const s = String(source).toLowerCase();
  if (s === "contact") return "Contact";
  if (s.includes("ai")) return "AI Agent";
  if (s.includes("workflow") || s.includes("automation")) return "Workflow";
  if (s.includes("user") || s.includes("agent")) return "Human Agent";
  return source;
}

// Read from the Conversation closed trigger
const trigger = $("Conversation closed trigger").first().json;
const body = trigger.body ?? trigger;

const openedTs =
  body.conversation_open_timestamp ||
  body.conversation_opened_timestamp ||
  body.conversationOpenedAt ||
  body["conversation_open_timestamp "] ||
  null;

const openMicro = toMicroseconds(openedTs);

// Merge items from all incoming API payloads (1 or 2 pages)
let mergedItems = [];
for (const item of $input.all()) {
  const unwrapped = unwrapRespondList(item.json);
  if (Array.isArray(unwrapped.items)) mergedItems.push(...unwrapped.items);
}

// Filter messages after conversation opened timestamp
if (openMicro !== null) {
  mergedItems = mergedItems.filter((m) => {
    const idNum = Number(m?.messageId);
    return Number.isFinite(idNum) && idNum > openMicro;
  });
}

// Sort oldest -> newest
mergedItems.sort((a, b) => Number(a?.messageId ?? 0) - Number(b?.messageId ?? 0));

// Index + shape
const indexed = mergedItems.map((m, i) => {
  const traffic = m?.traffic ?? null;
  const sender = m?.sender || {};

  // Incoming traffic is always from Contact
  if (traffic === "incoming") {
    return {
      Index: i + 1,
      traffic,
      message: m?.message ?? null,
      Sender: { source: "Contact" },
    };
  }

  return {
    Index: i + 1,
    traffic,
    message: m?.message ?? null,
    Sender: {
      source: normalizeSender(sender?.source),
      userId: sender?.userId ?? null,
      teamId: sender?.teamId ?? null,
    },
  };
});

return [
  {
    json: {
      message_count: indexed.length,
      items: indexed,
      meta: {
        conversation_open_timestamp: openedTs,
        conversation_open_microseconds: openMicro,
        pages_received: $input.all().length,
      },
    },
  },
];

Asegúrate de establecer Mode = Run Once for All Items y Language = JavaScript.

El script anterior es una referencia. Tu espacio de trabajo puede devolver campos o estructuras diferentes. Para construir la lógica correcta de parseo JSON para tu configuración, copia la salida del nodo anterior, pégala en una herramienta de IA (p. ej., ChatGPT o Claude) junto con el script de referencia arriba, y describe el formato de salida que necesitas. La IA puede entonces adaptar el script para que coincida con tus datos reales.

5. Generar transcripción

Este paso convierte la lista limpia de mensajes en una transcripción estilo Markdown para el procesamiento por IA. Las transcripciones estructuradas mejoran la comprensión de la IA y reducen las alucinaciones.

n8n node:Build markdown transcript (Core → Code → JavaScript)

Pega esto en el nodo Code:

// Minimal parse of possibly-escaped JSON string
function parseMaybeEscapedJSON(raw) {
  if (raw == null) return null;
  if (typeof raw === 'object') return raw;
  let s = String(raw).trim();
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('"') && s.endsWith('"'))) s = s.slice(1, -1);
  s = s.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
  return JSON.parse(s);
}

// Flatten nested objects/arrays into k1_k2_0_k3 style keys
// Primitive-only arrays are joined as comma-delimited strings
function flatten(obj, prefix = '', out = {}) {
  if (obj == null) return out;
  const makeKey = (k) => (prefix ? `${prefix}_${k}` : String(k));
  if (Array.isArray(obj)) {
    // If every element is a primitive, join as comma-delimited string
    if (obj.every(v => v == null || typeof v !== 'object')) {
      out[prefix || 'value'] = obj.join(', ');
      return out;
    }
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }
  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }
  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];
for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}
return parsed; `${prefix}_${k}` : String(k));
  if (Array.isArray(obj)) {
    // If every element is a primitive, join as comma-delimited string
    if (obj.every(v => v == null || typeof v !== 'object')) {
      out[prefix || 'value'] = obj.join(', ');
      return out;
    }
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }
  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }
  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];
for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}
return parsed; null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}
return parsed; null } });
  }
}
return parsed;

Asegúrate de establecer Mode = Run Once for All Items y Language = JavaScript.

6. Revisiones de conversaciones con IA

Este nodo envía la transcripción a un modelo de IA (p. ej., OpenAI 5.4) y devuelve una respuesta JSON estructurada.

Cómo configurarlo

  1. Selecciona las credenciales de tu proveedor de IA. En este ejemplo, usaremos OpenAI.

  2. Resource: Enviar mensaje a un modelo

  3. Operation: Enviar mensaje a un asistente

  4. Mensajes:

    1. Tipo: Texto

    2. Rol: Usuario

    3. Prompt: {{ $json.transcript }}

  5. Simplificar salida: Activar

  6. Añadir opción:

    1. Instrucciones - Esta es solo una plantilla de ejemplo, pero puedes introducirla en otra IA (p. ej. ChatGPT, Claude, etc.) para editarla según tus necesidades:

Eres un analista de calidad de conversaciones para respond.io, una plataforma de mensajería para clientes. Tu trabajo es revisar conversaciones de soporte o ventas y producir puntajes de calidad precisos y consistentes.

Recibirás una transcripción de la conversación como una lista numerada de mensajes. Evalúala usando el proceso de razonamiento estructurado abajo antes de producir tu salida final.

---

## INPUT FORMAT

Each message in the transcript contains:

- A message number (e.g. `1`, `2`, `3`)
- `traffic:incoming` — message FROM the customer
- `traffic:outgoing` — message TO the customer
- `text:` — the message content
- `sender source:` — who sent it:
  - `Contact` → customer
  - `ai_agent` → AI Agent (automated — evaluate for quality)
  - `workflow` → system automation (do NOT score as agent engagement)
  - `user` → human agent (note the handover point)

When evaluating, treat `ai_agent` and `user` messages together as "the agent side." Track when a handover from AI to human occurred, as this affects resolution and engagement scoring. Ignora los mensajes `workflow` al puntuar engagement — son respuestas automáticas del sistema, no decisiones de juicio. La conversación puede estar en cualquier idioma. Evalúa sentimiento y contenido con precisión sin importar el idioma.

---

## PASO 1 — CADENA DE PENSAMIENTO (Razonamiento Interno)

Antes de producir cualquier puntuación, razona sobre la conversación paso a paso en este orden exacto: Identifica la intención principal del cliente.
¿Qué quería conseguir el cliente? Exprésalo en una sola oración.

**2. Traza la estructura de la conversación.
¿Quién gestionó la conversación — solo Agente IA, o hubo una transferencia a un humano? ¿En qué punto? ¿Participaron varios agentes humanos? ¿Hubo mensajes de flujo de trabajo que pudieron haber confundido al cliente?

**3. Traza la resolución.
¿El agente realmente atendió la intención principal del cliente? ¿El cliente confirmó que se resolvió? ¿La conversación terminó con el problema sin resolver, con una evasiva o con una transferencia adecuada?

**4. Rastrea el sentimiento.
¿Cómo comenzó emocionalmente el cliente? ¿Cómo terminó? Busca:
- Indicadores de frustración: preguntas repetidas, correcciones, respuestas cortas/abruptas
- Indicadores de satisfacción: "gracias", "entendido", "perfecto", cierres positivos
- Indicadores neutrales: transaccional, cooperativo, sin señales fuertes
Pesa más el tono final del cliente que el inicial.

**5. Evalúa el compromiso del agente.
Califica al agente según:
- ¿Reconoció la situación del cliente antes de proponer una solución?
- ¿Personalizó la respuesta usando el contexto de la conversación anterior?
- ¿Hizo preguntas aclaratorias inteligentes o hizo suposiciones?
- Si un humano tomó el control, ¿mantuvo la continuidad desde la IA o empezó desde cero?
- ¿Fueron las respuestas en el mismo idioma que el cliente?
- NO puntúes los mensajes `workflow` como engagement.

**6. Identifica fallas graves.
Comprueba explícitamente lo siguiente:
- ¿Repetió el cliente su pregunta principal sin que fuera reconocida?
- ¿Proporcionó el agente información factualmente incorrecta?
- ¿Ignoró el agente una parte clave del mensaje del cliente?
- ¿La conversación terminó sin resolución o sin pasos claros a seguir?
- ¿Hubo un tono poco profesional o inapropiado?
- ¿Hubo retrasos excesivos en las respuestas?
- ¿La ruta de escalación fue incorrecta o innecesaria?
- ¿Un agente humano no retomó el contexto de la transferencia desde la IA?
- ¿El agente respondió en un idioma diferente al del cliente?

**7. Redacta tu razonamiento.
En 2–4 oraciones, resume: qué sucedió, qué hizo bien el agente y qué falló. Sé específico — cita momentos reales o números de mensaje.

---

## PASO 2 — PUNTUACIÓN INICIAL

Basado en tu razonamiento del Paso 1, produce un borrador inicial de todas las puntuaciones:

- **`overall_score`** (entero, 1–10): Calidad holística de la conversación. Pesa la resolución, el sentimiento y la participación en conjunto. Las fallas graves limitan automáticamente la puntuación a 5.
- **`resolved`** (enum): Si el problema del cliente fue cerrado. NO marques `resolved` solo porque el agente envió un mensaje final. Requiere confirmación del cliente o una transferencia claramente completada.
- **`customer_sentiment`** (enum): El tono emocional dominante del cliente, ponderado hacia cómo terminó la conversación.
- **`engagement_score`** (entero, 1–10): Qué tan bien se comunicó el agente — personalización, empatía, claridad, concordancia de idioma y continuidad en las transferencias.
- **`reasoning`** (cadena): Tu resumen de 2–4 oraciones del Paso 1.
- **`flags`** (array): Lista de fallas graves identificadas. Array vacío si no hay ninguna.

---

## PASO 3 — CADENA DE VERIFICACIÓN

Antes de finalizar, realiza cada una de las siguientes comprobaciones y respóndelas explícitamente: Si se infiere, ¿`resolved` debería ser `"unresolved"` en su lugar?

**Comprobación 2 — Sentimiento:**
¿Estoy valorando el sentimiento según el tono final del cliente y no solo por su frustración inicial? ¿Mejoró su estado de ánimo, se mantuvo igual o empeoró?

**Comprobación 3 — Engagement:**
¿Resistiría este `engagement_score` la revisión de un gerente de QA? ¿Excluí correctamente los mensajes `workflow` del puntaje de engagement?

**Comprobación 4 — Calidad de la transferencia:**
Si un agente humano tomó el relevo, ¿mantuvo la continuidad — o reinició desde cero, ignoró el contexto o respondió en el idioma equivocado? Ajusta el `engagement_score` en consecuencia.

**Comprobación 5 — Consistencia de puntuaciones:**
- `resolved: unresolved` + `customer_sentiment: negative` → `overall_score` debe ser ≤ 4
- `resolved: resolved` + `customer_sentiment: positive` → `overall_score` debe ser ≥ 6
- Cualquier flag presente → `overall_score` debe ser ≤ 5

**Comprobación 6 — Exhaustividad de flags:**
Revisa nuevamente la lista completa de fallas graves. ¿Me faltó algo?

Si alguna comprobación falla, revisa la puntuación correspondiente antes de continuar.

---

## PASO 4 — RESULTADO FINAL

Entrega tus puntuaciones finales solo en el formato JSON requerido. No incluyas tu razonamiento interno de los Pasos 1–3. Solo el campo `reasoning` debe aparecer en la salida.

---

## REFERENCIA DE PUNTUACIÓN

### overall_score
| Score | Significado |
|-------|---------|
| 9–10 | Excelente — resuelto eficientemente, el cliente terminó positivo, el agente fue empático y personalizado |
| 7–8 | Bueno — resuelto con fricción menor, sentimiento neutral a positivo |
| 5–6 | Regular — parcialmente resuelto, o alto engagement pero resultado pobre |
| 3–4 | Malo — no resuelto, sentimiento negativo o claros fallos de engagement |
| 1–2 | Falla crítica — información errónea dada, cliente se fue enojado o conversación abandonada |

### engagement_score
| Score | Significado |
|-------|---------|
| 9–10 | Muy personalizado, empático, eficiente, proactivo, uso correcto del idioma en todo momento |
| 7–8 | Sólido — abordó el problema bien con cierto toque personal |
| 5–6 | Funcional pero robótico — técnicamente correcto, sin calidez ni personalización |
| 3–4 | Comunicación pobre — se hicieron suposiciones, preguntas ignoradas o handover sin continuidad |
| 1–2 | Dañino — grosero, desdeñoso, idioma incorrecto usado o totalmente fuera de tema |

Requerido: salida JSON estricta

Tus instrucciones de asistente deben requerir un JSON Schema.

Formato de salida recomendado:

  1. Tipo: JSON Schema (recomendado)

  2. Nombre: Conversation QA Schema

  3. Estricto: Activado

  4. Esquema: Este es únicamente un esquema de ejemplo para devolver el puntaje de calidad de la conversación, el estado del caso, el sentimiento del cliente, el puntaje de engagement, el razonamiento detrás del resultado y las banderas de fallo de la conversación. Recuerda editarlo para que se ajuste a tus necesidades.

{
  "type": "json_schema",
  "json_schema": {
    "name": "conversation_review",
    "strict": true,
    "schema": {
      "type": "object",
      "properties": {
        "overall_score": {
          "type": "integer",
          "description": "Puntuación holística de la calidad de la conversación. 1 = falla crítica, 10 = excelente. Cualquier flag presente limita esto a 5."
        },
        "resolved": {
          "type": "string",
          "enum": ["resolved", "unresolved", "escalated"],
          "description": "Si se cerró el problema principal del cliente. 'resolved' requiere confirmación explícita del cliente; 'escalated' indica una transferencia intencional y exitosa a un agente humano; 'unresolved' significa que la conversación terminó sin cierre."
        },
        "customer_sentiment": {
          "type": "string",
          "enum": ["positive", "neutral", "negative"],
          "description": "El tono emocional dominante del cliente, ponderado hacia cómo terminó la conversación. 'positive' = satisfecho/agradecido; 'neutral' = cooperativo/transaccional; 'negative' = frustrado o insatisfecho."
        },
        "engagement_score": {
          "type": "integer",
          "description": "Qué tan bien se comunicó el agente — personalización, empatía, claridad, coincidencia de idioma y continuidad en las transferencias. Excluye mensajes 'workflow'."
        }
      }
    }
  }
} 1 = falla crítica, 10 = excelente. Cualquier flag presente limita esto a 5.
        },
        "resolved": {
          "type": "string",
          "enum": ["resolved", "unresolved", "escalated"],
          "description": "Si se cerró el problema principal del cliente. 'resolved' requiere confirmación explícita del cliente; 'escalated' indica una transferencia intencional y exitosa a un agente humano; 'unresolved' significa que la conversación terminó sin cierre."
        },
        "customer_sentiment": {
          "type": "string",
          "enum": ["positive", "neutral", "negative"],
          "description": "El tono emocional dominante del cliente, ponderado hacia cómo terminó la conversación. 'positive' = satisfecho/agradecido; 'neutral' = cooperativo/transaccional; 'negative' = frustrado o insatisfecho."
        },
        "engagement_score": {
          "type": "integer",
          "description": "Qué tan bien se comunicó el agente — personalización, empatía, claridad, coincidencia de idioma y continuidad en las transferencias. Excluye mensajes 'workflow'."
        } 'resolved' requiere confirmación explícita del cliente. 'escalated' indica una transferencia intencional y exitosa a un agente humano. 'unresolved' significa que la conversación terminó sin cierre. 'positive' = satisfecho o agradecido. 'neutral' = cooperativo y transaccional. 'negative' = frustrado, repetitivo o insatisfecho. Excluye los mensajes del flujo de trabajo. 1 = dañino o desdeñoso, 10 = muy personalizado y proactivo. Debe referenciar momentos específicos de la conversación. Incluye lo que el agente hizo bien, lo que falló y qué motivó las puntuaciones finales. No hagas generalizaciones. Array vacío si ninguno aplica.

Después de este nodo, conéctalo a un nodo Map Output y continúa al paso 7.

7. Analizar y normalizar la salida de la IA

Este paso evita que respuestas de IA malformadas o parciales rompan tu flujo de trabajo.

Nodos de n8n:

  • Map Output (Data Transformation → Edit Fields / Set)

  • Parse Output (Core → Code → JavaScript)

7.1 Map Output

Node:Map Output

  • Modo: Mapeo manual

  • Crea un campo llamado Output y mapea la respuesta de la IA allí.

Esto hace que el siguiente nodo sea consistente (siempre puede leer $json.Output).

7.2 Analizar la salida de forma segura

Node:Parse Output

Pega esto en el nodo Code:

// Minimal parse of possibly-escaped JSON string
function parseMaybeEscapedJSON(raw) {
  if (raw == null) return null;
  if (typeof raw === 'object') return raw;
  let s = String(raw).trim();
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('"') && s.endsWith('"'))) s = s.slice(1, -1);
  s = s.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
  return JSON.parse(s);
}

// Flatten nested objects/arrays into k1_k2_0_k3 style keys
function flatten(obj, prefix = '', out = {}) {
  if (obj == null) return out;
  const makeKey = (k) => (prefix ? `${prefix}_${k}` : String(k));

  if (Array.isArray(obj)) {
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }

  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }

  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];

for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}

return parsed; `${prefix}_${k}` : String(k));

  if (Array.isArray(obj)) {
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }

  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }

  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];

for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}

return parsed; null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}

return parsed; null } });
  }
}

return parsed;

Nota sobre el aplanamiento: los arrays y los objetos anidados se convertirán en columnas como issues_0_type, issues_0_severity, etc. Esto facilita almacenarlo en Google Sheets.

8. Guardar resultados

El paso final almacena los resultados de QA en Google Sheets para seguimiento e informes.

n8n node: Google Sheets (append)

  • Credenciales para conectar: Conecta tu cuenta de Google Sheets

  • Recurso: Hoja dentro del documento

  • Operación: Agregar fila

  • Seleccionar documento: Desde la lista → Selecciona el nombre de la hoja de cálculo

  • Seleccionar hoja: Desde la lista → Nombre de la pestaña

  • Modo de mapeo de columnas: Mapea cada columna manualmente

Luego, mapea cada valor al nombre de columna que configuraste en tu hoja, idealmente siguiendo los nombres de columna descritos en la Sección 1.

De dónde proviene cada campo

Desde el disparador del Webhook (metadatos de la conversación):

Mapéalos usando la referencia del nodo Webhook, p. ej.:

{{ $('Webhook').first().json.body.contact_id }}

  • contact_id

  • conversation_opened_timestamp

  • conversation_closed_timestamp

Desde la salida analizada por la IA (Sección 7):

Asócialos usando los campos del resultado analizado, p. ej.:

{{ $json.overall_score }}

  • overall_score

  • resolved

  • sentiment

  • reasoning

  • engagement_score

Para depuración (recomendado durante la implementación):

  • raw_ai_output — almacena la respuesta completa de la IA para que puedas revisar los resultados y refinar tu prompt

💡 Consejo: Revisa la salida real de tu nodo Webhook para confirmar los nombres exactos de los campos — pueden variar según la configuración del disparador de tu espacio de trabajo.

Mejoras opcionales

  • Enmascara la información PII antes de la revisión por la IA (correos electrónicos, números de teléfono, IDs de pedido)

  • Procesa más de 100 mensajes iterando la paginación hasta que pagination.next esté vacío

  • Conversaciones de muestra (p. ej., revisa solo el 10% de las conversaciones cerradas para controlar costos)

  • Deriva problemas de alta severidad a Slack/Teams para un seguimiento más rápido

Compartir este artículo
Telegram
Facebook
Linkedin
Twitter

¿No encuentras lo que estás buscando? 🔎