# Sugerencias con IA en tickets

Rellena el formulario de un ticket con IA a partir de texto, audio o imágenes del usuario, y registra qué hizo con las sugerencias. Permiso support:tickets:ai.

Con estos endpoints la IA propone el título, la descripción, la
categoría y la prioridad de un ticket a partir del texto, el audio o
las imágenes que aporta el usuario. Funciona en segundo plano: envías
el contenido, recibes un identificador de trabajo (`jobId`) y lees el
resultado por SSE o consultando su estado cada poco. Todas las
peticiones llevan la cabecera `X-Api-Key`.

## Plan y permisos

Hace falta el aplicativo **Tickets de soporte** con la capacidad de
tickets propios (`support.own_tickets`) en el plan de tu organización,
y el permiso `support:tickets:ai` en la aplicación.

| Endpoint | Permiso de la API (scope) |
| - | - |
| `POST /external/v1/tickets/ai-suggest` | `support:tickets:ai` |
| `GET /external/v1/tickets/ai-suggest/:jobId/stream` | `support:tickets:ai` |
| `GET /external/v1/tickets/ai-suggest/:jobId` | `support:tickets:ai` |
| `POST /external/v1/tickets/ai-track-action` | `support:tickets:ai` |
| `POST /external/v1/tickets/ai-transcribe` | `support:tickets:ai` |
| `POST /external/v1/tickets/ai-transcribe-async` | `support:tickets:ai` |

`support:tickets:ai` es un permiso sensible: consume cuota de IA y
envía datos del usuario a un proveedor externo. Solo lo concede un
Super Administrador, con un motivo de al menos 20 caracteres. Se exige
desde la primera llamada, sin periodo de gracia: sin él, la respuesta
es `403 INSUFFICIENT_SCOPE`. Cómo se conceden los permisos está en
[Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

## Resumen de endpoints

| Método | Ruta | Descripción |
| - | - | - |
| `POST` | `/api/external/v1/tickets/ai-suggest` | Pedir sugerencias a partir de texto, audio o imágenes |
| `GET` | `/api/external/v1/tickets/ai-suggest/:jobId/stream` | Resultado del trabajo por SSE |
| `GET` | `/api/external/v1/tickets/ai-suggest/:jobId` | Estado del trabajo, consultado cada poco |
| `POST` | `/api/external/v1/tickets/ai-track-action` | Registrar qué hizo el usuario con las sugerencias |
| `POST` | `/api/external/v1/tickets/ai-transcribe` | Transcribir un audio en el momento (ver [Transcripción](https://os.utilia.ai/docs/developers/api/ticket-transcription)) |
| `POST` | `/api/external/v1/tickets/ai-transcribe-async` | Transcribir un audio en segundo plano |

> **Atención: El SSE no usa tipos de evento: léelo antes de integrar sin el SDK**
>
> El stream emite todos los eventos como un único tipo `message` con el cuerpo en JSON:
>
> ```text
> data: {"event":"progress","progress":42}
>
> data: {"event":"completed","result":{"suggestions":{...}}}
>
> ```
>
> No hay líneas `event:` aparte, así que los lectores que enlazan por tipo (`EventSource.addEventListener('completed', ...)`, `sseclient` con `event.event == 'completed'`) no reciben nunca los eventos y la integración se queda «cargando» para siempre. Lee el tipo en el campo `event` del JSON; más abajo tienes el ejemplo correcto.

Para autenticar el SSE lo normal es la cabecera `X-Api-Key`. Si usas `EventSource`, que no admite cabeceras, pide antes un [pase de conexión](https://os.utilia.ai/docs/developers/api/tickets#pase-de-conexión-sin-la-clave-en-la-dirección) con `route: "ai-suggest"` y el `jobId`, y abre la conexión con `?streamToken=`: es lo que hace el SDK oficial de JavaScript desde la 4.2.0 (ver [Versiones y compatibilidad](https://os.utilia.ai/docs/developers/sdk/versions)). De momento se acepta también la clave en el parámetro `?apiKey=`, **solo** en peticiones `GET` con `Accept: text/event-stream`, pero una clave en la dirección queda en los registros de servidores y servidores intermedios, en el historial del navegador y en la cabecera `Referer`: no la uses en integraciones nuevas.

## Flujo completo

Tu aplicación envía lo que cuenta el usuario, recoge las sugerencias
cuando están listas, rellena el formulario y, al final, avisa de qué
hizo el usuario con ellas.

**Sugerencias con IA para un ticket**

Participantes: Usuario, Tu aplicación y UTILIA OS.

1. Usuario → Tu aplicación: Describe el problema. Con texto, una nota de voz, capturas o una combinación.
2. Tu aplicación → UTILIA OS: `POST /api/external/v1/tickets/ai-suggest`. Texto, audio en base64 o imágenes.
3. UTILIA OS → Tu aplicación (respuesta): jobId. Identifica el trabajo.
4. Tu aplicación → UTILIA OS: `GET /api/external/v1/tickets/ai-suggest/:jobId/stream`. O consulta el estado cada poco con GET /ai-suggest/:jobId.
5. UTILIA OS (por su cuenta): Analiza el contenido con IA y envía eventos progress.
6. UTILIA OS → Tu aplicación (respuesta): Evento completed con las sugerencias. Título, descripción, categoría, prioridad y analyticsEventId.
7. Tu aplicación → Usuario: Formulario rellenado. Puede aceptar, cambiar o descartar cada sugerencia.
8. Tu aplicación → UTILIA OS: `POST /api/external/v1/tickets/ai-track-action`. La decisión del usuario, con el analyticsEventId, para medir la calidad.

## Pedir sugerencias

**`POST /api/external/v1/tickets/ai-suggest`**

Envía texto, audio o imágenes para que la IA proponga cómo rellenar el formulario del ticket. Permiso: support:tickets:ai.

### Encabezados requeridos

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Api-Key` | `string` | Sí | Clave de API externa (formato: utl_xxx) |
| `Content-Type` | `string` | Sí | application/json |
| `x-request-id` | `string` | No | Identificador único para seguir la petición |

### Cuerpo de la petición

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | Identificador externo del usuario. Tiene que existir antes: si es nuevo, llama primero a /users/identify |
| `text` | `string` | No | Descripción del problema en texto libre |
| `audioBase64` | `string` | No | Audio codificado en base64 para transcripción y análisis |
| `audioFilename` | `string` | No | Nombre del archivo de audio con extensión (por ejemplo, grabacion.webm) |
| `existingFields` | `ExternalExistingFieldsDto` | No | Valores actuales del formulario para contextualizar las sugerencias |
| `images` | `ImageBase64Dto[]` | No | Imágenes para análisis visual. Se analizan como máximo las 5 primeras (ver aviso más abajo) |
| `source` | `string` | No | Origen de la petición: SDK_EXTERNAL (por defecto) o WIDGET |

Tienes que enviar al menos uno de estos campos: `text`, `audioBase64` o `images`. Sin ninguno, la respuesta es `400` con el código `AI_NO_INPUT`.

> **Atención: Se analizan como máximo 5 imágenes por petición**
>
> Si `images` trae más de 5 elementos, la API acepta la petición igual
> (`201`), analiza solo las 5 primeras, en el orden en que vengan, y lo indica
> en la respuesta con tres campos que solo aparecen en ese caso:
> `imagesTruncated: true`, `imagesUsed` (las que se analizan) e
> `imagesReceived` (las que llegaron). Antes, las imágenes a partir de la
> sexta se descartaban sin que nada en la respuesta lo indicara. Si necesitas
> analizar más de cinco capturas, repártelas en varias peticiones.

#### Objeto `existingFields` (ExternalExistingFieldsDto)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `title` | `string` | No | Título actual del formulario |
| `description` | `string` | No | Descripción actual del formulario |
| `category` | `string` | No | Categoría seleccionada actualmente |
| `priority` | `string` | No | Prioridad seleccionada actualmente |
| `currentUrl` | `string` | No | Página en la que el usuario informa del problema |
| `userAgent` | `string` | No | Navegador del usuario |
| `appVersion` | `string` | No | Versión de tu aplicación |

#### Cada elemento de `images` (ImageBase64Dto)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `base64` | `string` | Sí | Contenido de la imagen codificado en base64 (sin el prefijo data:) |
| `filename` | `string` | Sí | Nombre del archivo con extensión (ejemplo: captura.png) |
| `mimeType` | `string` | Sí | Tipo MIME de la imagen (ejemplo: image/png, image/jpeg) |

### Respuesta correcta (201 Created)

| Nombre | Tipo | Descripción |
| - | - | - |
| `jobId` | `string` | Identificador del trabajo. Úsalo para consultar el resultado |
| `imagesTruncated` | `boolean` | Solo aparece, con valor true, si `images` traía más de 5: se analizan las 5 primeras y el resto se descarta |
| `imagesUsed` | `number` | Imágenes que se analizan. Solo aparece junto a `imagesTruncated` |
| `imagesReceived` | `number` | Imágenes que traía la petición. Solo aparece junto a `imagesTruncated` |

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 400 | `AI_NO_INPUT` | No se ha enviado texto, audio ni imágenes |
| 404 | `USER_NOT_FOUND` | No existe ningún usuario con ese identificador externo. Llama primero a /users/identify |
| 401 | -- | Falta la clave de API o no es válida |

### Ejemplos

#### curl

```bash
# Ejemplo con texto
curl -X POST 'https://os.utilia.ai/api/external/v1/tickets/ai-suggest' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-ai-suggest-001' \
  -d '{
    "userId": "usr-4821",
    "text": "Cuando intento exportar el informe mensual a PDF, la página se queda cargando indefinidamente y nunca genera el archivo.",
    "existingFields": {
      "category": "BUG"
    },
    "source": "SDK_EXTERNAL"
  }'
```

#### fetch

```javascript
const response = await fetch(
  'https://os.utilia.ai/api/external/v1/tickets/ai-suggest',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-ai-suggest-001',
    },
    body: JSON.stringify({
      userId: 'usr-4821',
      text: 'Cuando intento exportar el informe mensual a PDF, la página se queda cargando indefinidamente y nunca genera el archivo.',
      existingFields: {
        category: 'BUG',
      },
      source: 'SDK_EXTERNAL',
    }),
  }
);

const { jobId } = await response.json();
console.log(`Job creado: ${jobId}`);
// Ahora conecte al stream SSE o haga polling con el jobId
```

#### axios

```javascript
import axios from 'axios';

const { data } = await axios.post(
  'https://os.utilia.ai/api/external/v1/tickets/ai-suggest',
  {
    userId: 'usr-4821',
    text: 'Cuando intento exportar el informe mensual a PDF, la página se queda cargando indefinidamente y nunca genera el archivo.',
    existingFields: {
      category: 'BUG',
    },
    source: 'SDK_EXTERNAL',
  },
  {
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-ai-suggest-001',
    },
  }
);

console.log(`Job creado: ${data.jobId}`);
```

#### Python

```python
import requests

response = requests.post(
    "https://os.utilia.ai/api/external/v1/tickets/ai-suggest",
    headers={
        "Content-Type": "application/json",
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-ai-suggest-001",
    },
    json={
        "userId": "usr-4821",
        "text": "Cuando intento exportar el informe mensual a PDF, la página se queda cargando indefinidamente y nunca genera el archivo.",
        "existingFields": {
            "category": "BUG",
        },
        "source": "SDK_EXTERNAL",
    },
    timeout=30,
)

result = response.json()
print(f"Job creado: {result['jobId']}")
```

Envía en `existingFields` lo que el usuario ya haya rellenado: la IA lo tiene en cuenta para no repetirlo y proponer lo que falta.

## Recibir el resultado por SSE

**`GET /api/external/v1/tickets/ai-suggest/:jobId/stream`**

Conexión SSE que envía el progreso y el resultado del trabajo. Permiso: support:tickets:ai.

Parámetro de ruta: `jobId`.

Este endpoint abre una conexión Server-Sent Events (SSE) que envía eventos a medida que avanza el análisis. Si el trabajo ya había terminado al conectar, el servidor envía el resultado al momento y cierra la conexión. El método es **GET**: con POST, la respuesta es `404 Not Found`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `jobId` | `string` | Sí | Identificador del trabajo que devolvió POST /ai-suggest |

### Cabeceras

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Api-Key` | `string` | No | Clave de API externa (formato: utl_xxx). Obligatoria salvo que abras la conexión con un pase en el parámetro `?streamToken=` (route ai-suggest, mismo jobId) o, mientras se admita, con la clave en `?apiKey=`. |
| `Accept` | `string` | No | Recomendado: text/event-stream. Imprescindible si te autenticas con `?streamToken=` o `?apiKey=` en la dirección. |

### Formato de la respuesta

El servidor responde con `Content-Type: text/event-stream` y emite bloques separados por `\n\n`. **Todos los bloques se emiten como un único tipo de mensaje SSE (sin línea `event:`).** El tipo lógico del evento viene dentro del JSON, en el campo `event`:

```text
data: {"event":"progress","progress":25,"status":"processing"}

data: {"event":"progress","progress":75,"status":"processing"}

data: {"event":"completed","result":{"suggestions":{"title":"Error al exportar informe a PDF","description":"...","category":"BUG","priority":"ALTA"},"transcription":null,"analyticsEventId":"evt_xyz789"}}

```

Por eso **los lectores que enlazan por tipo no funcionan**. Por ejemplo, estas formas no reciben nunca los eventos:

```javascript
// INCORRECTO: el servidor no envía línea `event:`, así que estos listeners nunca se disparan.
es.addEventListener('completed', ...);
es.addEventListener('progress', ...);
```

```python
# INCORRECTO: sseclient enlaza por `event.event`, que aquí siempre es 'message'.
if event.event == 'completed': ...
```

Lo correcto es usar el manejador genérico (`onmessage` o `event.event == 'message'`) y leer el campo `event` del JSON, como en los ejemplos de más abajo.

### Tipos de eventos (campo `event` dentro del JSON)

| `event` | Descripción | Resto del JSON |
| - | - | - |
| `progress` | Progreso del análisis | `progress: number (0-100), status: string` |
| `completed` | El análisis ha terminado bien | `result: { suggestions?, transcription?, analyticsEventId? }` |
| `failed` | El análisis ha fallado | `error: string` |
| `heartbeat` | Señal para mantener viva la conexión, cada 30 segundos | (sin más campos) |

### Estructura del `result` cuando `event` es `completed`

| Nombre | Tipo | Descripción |
| - | - | - |
| `result.suggestions.title` | `string \| null` | Título sugerido para el ticket |
| `result.suggestions.description` | `string \| null` | Descripción sugerida |
| `result.suggestions.category` | `string \| null` | Categoría sugerida: BUG, CONSULTA o SUGERENCIA |
| `result.suggestions.priority` | `string \| null` | Prioridad sugerida: BAJA, MEDIA, ALTA o CRITICA |
| `result.transcription` | `string \| null` | Texto transcrito del audio (solo si se envió audio) |
| `result.analyticsEventId` | `string \| null` | Identificador que hay que enviar después a /ai-track-action |

### Ejemplos

#### curl

```bash
curl -N -X GET 'https://os.utilia.ai/api/external/v1/tickets/ai-suggest/job_abc123def456/stream' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'Accept: text/event-stream' \
  -H 'x-request-id: req-ai-stream-001'

# Salida real (sin líneas `event:`, el tipo va dentro del JSON):
# data: {"event":"progress","progress":0,"status":"pending"}
#
# data: {"event":"progress","progress":75,"status":"processing"}
#
# data: {"event":"completed","result":{"suggestions":{"title":"Error al exportar informe a PDF","description":"...","category":"BUG","priority":"ALTA"},"transcription":null,"analyticsEventId":"evt_xyz789"}}
```

#### EventSource (navegador)

```javascript
// EventSource nativo del navegador NO permite enviar headers. Las opciones son:
//
//   1) Pedir desde tu servidor un pase de conexión (POST /stream-token con
//      route 'ai-suggest' y el jobId) y abrir la conexión con
//      `?streamToken=`: el pase sirve una sola vez y no expone la clave.
//   2) Hacer proxy del SSE desde tu propio backend.
//   3) Usar un polyfill compatible con headers (p. ej. event-source-polyfill).
//   La clave como `?apiKey=` se acepta de momento, pero queda expuesta en
//   registros, servidores intermedios e historial: no la uses.
//
// Importante: el servidor emite todos los eventos como `message`. NO uses
// addEventListener('completed', ...). Lee el campo `event` del JSON.

import { EventSourcePolyfill } from 'event-source-polyfill';

const jobId = 'job_abc123def456';

const es = new EventSourcePolyfill(
  `https://os.utilia.ai/api/external/v1/tickets/ai-suggest/${jobId}/stream`,
  {
    headers: { 'X-Api-Key': 'utl_tu_clave_de_api' },
  },
);

es.onmessage = (msg) => {
  const data = JSON.parse(msg.data);
  switch (data.event) {
    case 'progress':
      console.log(`Progreso: ${data.progress}%`);
      break;
    case 'completed':
      console.log('Sugerencias:', data.result.suggestions);
      if (data.result.transcription) {
        console.log('Transcripción:', data.result.transcription);
      }
      es.close();
      break;
    case 'failed':
      console.error('Error en el análisis:', data.error);
      es.close();
      break;
    case 'heartbeat':
      // Mantener viva la conexión, sin acción.
      break;
  }
};

es.onerror = () => {
  // El SSE no expone el status; si es 401 lo verás aquí como error genérico.
  es.close();
};
```

#### fetch + ReadableStream

```javascript
// Alternativa sin EventSource: fetch con ReadableStream permite enviar
// el header X-Api-Key y leer los chunks SSE directamente. Es el patrón
// que conviene usar desde un backend o cuando no se quiere depender de
// un polyfill.

const jobId = 'job_abc123def456';

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets/ai-suggest/${jobId}/stream`,
  {
    method: 'GET',
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      Accept: 'text/event-stream',
      'x-request-id': 'req-ai-stream-001',
    },
  },
);

if (!response.ok || !response.body) {
  throw new Error(`SSE no abierto: HTTP ${response.status}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // Procesa cada bloque SSE completo (separado por \n\n)
  let sep = buffer.indexOf('\n\n');
  while (sep !== -1) {
    const bloque = buffer.slice(0, sep);
    buffer = buffer.slice(sep + 2);
    sep = buffer.indexOf('\n\n');

    // Concatena todas las líneas `data:` del bloque (puede haber varias)
    const dataLines = bloque
      .split('\n')
      .filter((l) => l.startsWith('data:'))
      .map((l) => l.slice(5).trimStart());
    if (dataLines.length === 0) continue;

    const data = JSON.parse(dataLines.join('\n'));
    // El tipo va dentro del JSON, no en una línea `event:` separada.
    if (data.event === 'progress') {
      console.log(`Progreso: ${data.progress}%`);
    } else if (data.event === 'completed') {
      console.log('Sugerencias:', data.result.suggestions);
      reader.cancel();
      break;
    } else if (data.event === 'failed') {
      console.error('Error:', data.error);
      reader.cancel();
      break;
    }
  }
}
```

#### Python (httpx)

```python
# Importante: NO uses sseclient con `event.event == "..."`. El servidor
# emite todos los eventos como `message` (no envía línea `event:`), por
# lo que el tipo lógico hay que leerlo del campo `event` dentro del JSON.

import json
import httpx

JOB_ID = "job_abc123def456"
URL = f"https://os.utilia.ai/api/external/v1/tickets/ai-suggest/{JOB_ID}/stream"
HEADERS = {
    "X-Api-Key": "utl_tu_clave_de_api",
    "Accept": "text/event-stream",
    "x-request-id": "req-ai-stream-001",
}


async def consumir_stream() -> dict | None:
    async with httpx.AsyncClient(timeout=None) as client:
        async with client.stream("GET", URL, headers=HEADERS) as r:
            if r.status_code != 200:
                raise RuntimeError(f"SSE no abierto: HTTP {r.status_code}")

            buffer = ""
            async for chunk in r.aiter_text():
                buffer += chunk
                while "\n\n" in buffer:
                    bloque, buffer = buffer.split("\n\n", 1)
                    data_lines = [
                        ln[5:].lstrip() for ln in bloque.split("\n") if ln.startswith("data:")
                    ]
                    if not data_lines:
                        continue
                    data = json.loads("\n".join(data_lines))

                    if data["event"] == "progress":
                        print(f"Progreso: {data.get('progress', 0)}%")
                    elif data["event"] == "completed":
                        return data["result"]
                    elif data["event"] == "failed":
                        raise RuntimeError(data.get("error", "Error desconocido"))
    return None
```

## Consultar el estado del trabajo

**`GET /api/external/v1/tickets/ai-suggest/:jobId`**

Estado actual de un trabajo de sugerencias, como alternativa al SSE. Permiso: support:tickets:ai.

Parámetro de ruta: `jobId`.

Es la alternativa al SSE: consultas el estado cada poco hasta que el trabajo termine o falle.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `jobId` | `string` | Sí | Identificador del trabajo que devolvió POST /ai-suggest |

### Respuesta correcta (200 OK)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `status` | `string` | Sí | Estado del trabajo: pending, processing, completed, failed o not_found |
| `progress` | `number` | No | Porcentaje de progreso (0-100). Solo presente en estados pending y processing |
| `result` | `ExternalAiResultDto` | No | Resultado del análisis. Solo presente cuando status es completed |
| `error` | `string` | No | Mensaje de error. Solo presente cuando status es failed |

#### Estructura de `result` (ExternalAiResultDto)

| Nombre | Tipo | Descripción |
| - | - | - |
| `suggestions` | `object` | Sugerencias generadas: { title?, description?, category?, priority? } |
| `transcription` | `string \| null` | Texto transcrito del audio, si se envió audio |
| `analyticsEventId` | `string \| null` | Identificador necesario para registrar la decisión del usuario con /ai-track-action |

### Ejemplos

#### curl

```bash
curl -X GET 'https://os.utilia.ai/api/external/v1/tickets/ai-suggest/job_abc123def456' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-ai-poll-001'

# Respuesta cuando el job está en progreso:
# {
#   "status": "processing",
#   "progress": 60
# }

# Respuesta cuando el job ha completado:
# {
#   "status": "completed",
#   "result": {
#     "suggestions": {
#       "title": "Error al exportar informe a PDF",
#       "description": "Al intentar exportar el informe mensual...",
#       "category": "BUG",
#       "priority": "ALTA"
#     },
#     "transcription": null,
#     "analyticsEventId": "evt_xyz789"
#   }
# }
```

#### fetch

```javascript
const jobId = 'job_abc123def456';

async function pollJob(jobId) {
  while (true) {
    const response = await fetch(
      `https://os.utilia.ai/api/external/v1/tickets/ai-suggest/${jobId}`,
      {
        headers: {
          'X-Api-Key': 'utl_tu_clave_de_api',
          'x-request-id': 'req-ai-poll-001',
        },
      }
    );

    const job = await response.json();

    if (job.status === 'completed') {
      console.log('Sugerencias:', job.result.suggestions);
      return job.result;
    }

    if (job.status === 'failed') {
      throw new Error(`Job fallido: ${job.error}`);
    }

    console.log(`Progreso: ${job.progress}%`);
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
}

const result = await pollJob(jobId);
```

#### axios

```javascript
import axios from 'axios';

const jobId = 'job_abc123def456';

async function pollJob(jobId) {
  while (true) {
    const { data: job } = await axios.get(
      `https://os.utilia.ai/api/external/v1/tickets/ai-suggest/${jobId}`,
      {
        headers: {
          'X-Api-Key': 'utl_tu_clave_de_api',
          'x-request-id': 'req-ai-poll-001',
        },
      }
    );

    if (job.status === 'completed') {
      console.log('Sugerencias:', job.result.suggestions);
      return job.result;
    }

    if (job.status === 'failed') {
      throw new Error(`Job fallido: ${job.error}`);
    }

    console.log(`Progreso: ${job.progress}%`);
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
}

const result = await pollJob(jobId);
```

#### Python

```python
import time
import requests

job_id = "job_abc123def456"


def poll_job(job_id: str) -> dict:
    while True:
        response = requests.get(
            f"https://os.utilia.ai/api/external/v1/tickets/ai-suggest/{job_id}",
            headers={
                "X-Api-Key": "utl_tu_clave_de_api",
                "x-request-id": "req-ai-poll-001",
            },
            timeout=30,
        )

        job = response.json()

        if job["status"] == "completed":
            print(f"Sugerencias: {job['result']['suggestions']}")
            return job["result"]

        if job["status"] == "failed":
            raise Exception(f"Job fallido: {job['error']}")

        print(f"Progreso: {job.get('progress', 0)}%")
        time.sleep(1)


result = poll_job(job_id)
```

Si prefieres no mantener una conexión SSE abierta, consulta este endpoint una vez por segundo. Para el usuario, el SSE da mejor experiencia.

## Registrar la decisión del usuario

**`POST /api/external/v1/tickets/ai-track-action`**

Registra qué hizo el usuario con las sugerencias, para medir su calidad. Permiso: support:tickets:ai.

Con este endpoint cuentas qué hizo el usuario con las sugerencias: si las aceptó tal cual, las cambió o las descartó. Sirve para medir y mejorar su calidad.

### Cabeceras

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Api-Key` | `string` | Sí | Clave de API externa (formato: utl_xxx) |
| `Content-Type` | `string` | Sí | application/json |
| `x-request-id` | `string` | No | Identificador único para seguir la petición |

### Cuerpo de la petición

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `eventId` | `string` | Sí | El analyticsEventId que vino en el resultado del trabajo |
| `userAction` | `string` | Sí | Lo que hizo el usuario: ACCEPTED_ALL, ACCEPTED_PARTIAL, MODIFIED, REJECTED o IGNORED |
| `fieldsAccepted` | `string[]` | No | Nombres de los campos que el usuario aceptó sin cambios (ejemplo: \["title", "category"]) |
| `fieldsModified` | `string[]` | No | Nombres de los campos que el usuario modificó |
| `fieldsRejected` | `string[]` | No | Nombres de los campos que el usuario descartó |
| `timeToDecisionMs` | `number` | No | Milisegundos desde que se mostraron las sugerencias hasta la decisión (mínimo 0). Ayuda a valorar su calidad. |
| `source` | `string` | No | Origen de la petición: SDK_EXTERNAL o WIDGET |

### Respuesta correcta (200 OK)

| Nombre | Tipo | Descripción |
| - | - | - |
| `success` | `boolean` | Indica si el registro se procesó correctamente |

### Ejemplos

#### curl

```bash
curl -X POST 'https://os.utilia.ai/api/external/v1/tickets/ai-track-action' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-ai-track-001' \
  -d '{
    "eventId": "evt_xyz789",
    "userAction": "MODIFIED",
    "fieldsAccepted": ["category", "priority"],
    "fieldsModified": ["title", "description"],
    "fieldsRejected": [],
    "timeToDecisionMs": 4500,
    "source": "SDK_EXTERNAL"
  }'
```

#### fetch

```javascript
const response = await fetch(
  'https://os.utilia.ai/api/external/v1/tickets/ai-track-action',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-ai-track-001',
    },
    body: JSON.stringify({
      eventId: 'evt_xyz789',
      userAction: 'MODIFIED',
      fieldsAccepted: ['category', 'priority'],
      fieldsModified: ['title', 'description'],
      fieldsRejected: [],
      timeToDecisionMs: 4500,
      source: 'SDK_EXTERNAL',
    }),
  }
);

const { success } = await response.json();
console.log(`Acción registrada: ${success}`);
```

#### axios

```javascript
import axios from 'axios';

const { data } = await axios.post(
  'https://os.utilia.ai/api/external/v1/tickets/ai-track-action',
  {
    eventId: 'evt_xyz789',
    userAction: 'MODIFIED',
    fieldsAccepted: ['category', 'priority'],
    fieldsModified: ['title', 'description'],
    fieldsRejected: [],
    timeToDecisionMs: 4500,
    source: 'SDK_EXTERNAL',
  },
  {
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-ai-track-001',
    },
  }
);

console.log(`Acción registrada: ${data.success}`);
```

#### Python

```python
import requests

response = requests.post(
    "https://os.utilia.ai/api/external/v1/tickets/ai-track-action",
    headers={
        "Content-Type": "application/json",
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-ai-track-001",
    },
    json={
        "eventId": "evt_xyz789",
        "userAction": "MODIFIED",
        "fieldsAccepted": ["category", "priority"],
        "fieldsModified": ["title", "description"],
        "fieldsRejected": [],
        "timeToDecisionMs": 4500,
        "source": "SDK_EXTERNAL",
    },
    timeout=30,
)

result = response.json()
print(f"Acción registrada: {result['success']}")
```

## Ejemplo completo

Este ejemplo junta todos los endpoints de sugerencias en un flujo real:

### fetch

```javascript
const API_BASE = 'https://os.utilia.ai/api/external/v1/tickets';
const headers = {
  'Content-Type': 'application/json',
  'X-Api-Key': 'utl_tu_clave_de_api',
};

// 1. Enviar texto para análisis
const suggestResponse = await fetch(`${API_BASE}/ai-suggest`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    userId: 'usr-4821',
    text: 'La página de informes no carga y muestra un error 500 al pulsar Exportar PDF.',
    source: 'SDK_EXTERNAL',
  }),
});
const { jobId } = await suggestResponse.json();

// 2. Consultar resultado con polling
let result;
while (true) {
  const pollResponse = await fetch(`${API_BASE}/ai-suggest/${jobId}`, { headers });
  const job = await pollResponse.json();

  if (job.status === 'completed') {
    result = job.result;
    break;
  }
  if (job.status === 'failed') {
    throw new Error(job.error);
  }
  await new Promise((r) => setTimeout(r, 1000));
}

// 3. Mostrar sugerencias en el formulario
console.log('Título sugerido:', result.suggestions.title);
console.log('Descripción sugerida:', result.suggestions.description);
console.log('Categoría sugerida:', result.suggestions.category);
console.log('Prioridad sugerida:', result.suggestions.priority);

// 4. Registrar la decisión del usuario
const startTime = Date.now();
// ... el usuario revisa y modifica las sugerencias ...
const decisiónTime = Date.now() - startTime;

await fetch(`${API_BASE}/ai-track-action`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    eventId: result.analyticsEventId,
    userAction: 'MODIFIED',
    fieldsAccepted: ['category', 'priority'],
    fieldsModified: ['title', 'description'],
    timeToDecisionMs: decisiónTime,
    source: 'SDK_EXTERNAL',
  }),
});
```

### Python

```python
import time
import requests

API_BASE = "https://os.utilia.ai/api/external/v1/tickets"
HEADERS = {
    "Content-Type": "application/json",
    "X-Api-Key": "utl_tu_clave_de_api",
}

# 1. Enviar texto para análisis
suggest_response = requests.post(
    f"{API_BASE}/ai-suggest",
    headers=HEADERS,
    json={
        "userId": "usr-4821",
        "text": "La página de informes no carga y muestra un error 500 al pulsar Exportar PDF.",
        "source": "SDK_EXTERNAL",
    },
    timeout=30,
)
job_id = suggest_response.json()["jobId"]

# 2. Consultar resultado con polling
while True:
    poll_response = requests.get(
        f"{API_BASE}/ai-suggest/{job_id}",
        headers=HEADERS,
        timeout=30,
    )
    job = poll_response.json()

    if job["status"] == "completed":
        result = job["result"]
        break
    if job["status"] == "failed":
        raise Exception(job["error"])

    time.sleep(1)

# 3. Mostrar sugerencias al usuario
print(f"Título sugerido: {result['suggestions']['title']}")
print(f"Descripción sugerida: {result['suggestions']['description']}")
print(f"Categoría sugerida: {result['suggestions']['category']}")
print(f"Prioridad sugerida: {result['suggestions']['priority']}")

# 4. Registrar la decisión del usuario
start_time = time.time()
# ... el usuario revisa y modifica las sugerencias ...
decisión_time_ms = int((time.time() - start_time) * 1000)

requests.post(
    f"{API_BASE}/ai-track-action",
    headers=HEADERS,
    json={
        "eventId": result["analyticsEventId"],
        "userAction": "MODIFIED",
        "fieldsAccepted": ["category", "priority"],
        "fieldsModified": ["title", "description"],
        "timeToDecisionMs": decisión_time_ms,
        "source": "SDK_EXTERNAL",
    },
    timeout=30,
)
```

## Siguiente paso

Con el formulario relleno, crea el ticket con
[`POST /external/v1/tickets`](https://os.utilia.ai/docs/developers/api/tickets).
Si el usuario prefiere hablar, la
[transcripción de audio](https://os.utilia.ai/docs/developers/api/ticket-transcription)
convierte su mensaje de voz en texto. Desde el SDK, lo mismo está en
[Sugerencias con IA desde el SDK](https://os.utilia.ai/docs/developers/sdk/ai-suggestions).
