Files
Claro-cases/BACKEND_HANDOFF.md
bryan_garcia 83e3ec2cff fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer
- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
2026-07-29 04:32:27 -05:00

448 lines
20 KiB
Markdown

# Handoff Document — Backend Python (Claro Cases)
> **Destinatario**: Agente de IA del equipo backend Python.
> **Objetivo**: Implementar el servidor REST + WebSocket que alimenta el dashboard HITL de Claro Cases.
> **Versión del contrato**: 1.0 — Julio 2026
---
## 1. Resumen Arquitectónico del Frontend
### Propósito del Proyecto
Dashboard *Human-in-the-Loop* (HITL) que permite a asesores humanos:
1. **Gestionar peticiones HITL** recibidas de un agente virtual durante conversaciones con clientes.
2. **Monitorear en tiempo real** todas las conversaciones activas, con capacidad de inyectar notas internas.
### Stack del Frontend
| Componente | Tecnología |
|-----------|-----------|
| Framework | React 19 + TypeScript |
| Build tool | Vite 6 |
| Estado global | Zustand |
| Ruteo | React Router (`/cases`, `/monitor`) |
| Estilos | Tailwind CSS v4 (CSS-first con `@theme`) |
| Validación | Zod |
| Comunicación | REST (canal autoritativo) + WebSocket (difusión/streaming) |
| Mock development | MSW (Mock Service Worker) — solo en modo `dev` |
### Arquitectura de Comunicación
```
┌──────────────────────────────────────┐
│ Frontend React │
│ │
│ /cases ───▶ REST POST /cases/:id/ │──▶ Backend
│ resolve (ESCRITURA) │ Python
│ │
│ /monitor ◀─── WebSocket /ws/ │◀──
│ dashboard (DIFUSIÓN) │
└──────────────────────────────────────┘
```
**Regla de oro**: REST es el **único canal autoritativo de escritura**. WebSocket es exclusivamente para difusión de eventos y streaming en tiempo real desde el servidor hacia el cliente. El cliente solo envía por WebSocket el evento `internal_note` (notas internas del asesor).
---
## 2. Contratos de la API REST
**Base URL**: `http://<host>:<port>/api/v1`
### 2.1 Listar Casos (con filtros y paginación)
```
GET /api/v1/cases?status=<status>&applicative=<app>&search=<query>&offset=<n>&limit=<n>
```
**Query Parameters** (todos opcionales):
| Parámetro | Tipo | Descripción | Ejemplo |
|-----------|------|-------------|---------|
| `status` | string | Filtrar por estado | `PENDING`, `IN_PROGRESS`, `RESOLVED`, `FAILED` |
| `applicative` | string | Filtrar por aplicativo | `AC+`, `ASCARD`, `DiMe`, `Formatos SGCS`, `Mi asistencia 360`, `Paradigma`, `RR`, `Phone Protect` |
| `search` | string | Búsqueda textual (título, ID externo, cédula, tipo solicitud) | `Pérez` |
| `offset` | integer | Offset de paginación (default 0) | `0` |
| `limit` | integer | Límite de items (default 20) | `20` |
**Respuesta** (`200 OK`):
```json
{
"items": [
{
"id": 1,
"title": "Validación de Proporcionales - Móvil",
"description": "Validar si el cliente tiene cobros proporcionales...",
"status": "PENDING",
"externalId": "EXT-001",
"cedula": "1020304050",
"tipoSolicitud": "Validar_Proporcionales_Movil",
"applicative": "AC+",
"uiPattern": "CONFIRMATION_WITH_VALUE",
"payload": {
"nombre": "Juan Pérez",
"telefono": "3101234567",
"linea": "3008001234"
},
"handlingTime": 0,
"createdAt": "2026-07-23T15:00:00.000Z"
}
],
"total": 53
}
```
**Campos del objeto `CaseRequest`**:
| Campo | Tipo | Requerido | Descripción |
|-------|------|:--------:|-------------|
| `id` | number | ✅ | ID único del caso |
| `title` | string | ✅ | Título descriptivo |
| `description` | string | ✅ | Descripción detallada |
| `status` | string | ✅ | `PENDING`, `IN_PROGRESS`, `RESOLVED`, `FAILED` |
| `externalId` | string | ❌ | ID externo de referencia (ej. número de ticket) |
| `cedula` | string | ❌ | Documento de identidad del cliente |
| `tipoSolicitud` | string | ✅ | **Debe coincidir con un `toolName` del CSV** (ver sección 5) |
| `applicative` | string | ✅ | Aplicativo origen: `AC+`, `ASCARD`, `DiMe`, `RR`, etc. |
| `uiPattern` | string | ✅ | Patrón de UI: `SIMPLE_CONFIRMATION`, `CONFIRMATION_WITH_VALUE`, `MULTI_FIELD_FORM`, `DATE_SIMPLE`, `FREE_TEXT`, `READ_ONLY` |
| `payload` | object | ✅ | Datos adicionales del caso (estructura libre) |
| `handlingTime` | number | ✅ | Tiempo de gestión en segundos (0 si no iniciado) |
| `createdAt` | string | ✅ | Timestamp ISO-8601 UTC |
### 2.2 Obtener Caso Individual
```
GET /api/v1/cases/:id
```
**Respuesta** (`200 OK`): Objeto `CaseRequest` (misma estructura que arriba).
**Error** (`404`): Si el caso no existe.
### 2.3 Resolver Caso (CANAL AUTORITATIVO)
```
POST /api/v1/cases/:id/resolve
Content-Type: application/json
```
**Request Body**:
```json
{
"action": "approved",
"payload": {
"confirmacion": true,
"valor": 15000
},
"note": "Cliente verificó con documento de identidad"
}
```
| Campo | Tipo | Requerido | Descripción |
|-------|------|:--------:|-------------|
| `action` | string | ✅ | `"approved"` o `"rejected"` |
| `payload` | object | ✅ | Datos de resolución (estructura depende del `uiPattern` del caso) |
| `note` | string | ❌ | Nota opcional del asesor |
**⚠️ El backend DEBE**:
1. Derivar `advisorId` del token de autenticación de la sesión HTTP (Bearer token o cookie). **El cliente NO envía `advisorId`.**
2. Actualizar el `status` del caso a `RESOLVED`.
3. Actualizar `handlingTime` con la diferencia entre `startedAt` y `resolvedAt` (timestamps propios del backend).
4. Fusionar el `payload` de resolución con el `payload` existente del caso.
5. Tras resolver, **emitir el evento `hitl_resolved` por WebSocket** a todos los clientes conectados (broadcast).
**Respuesta** (`200 OK`): Objeto `CaseRequest` actualizado.
### 2.4 Obtener Conversaciones Activas
```
GET /api/v1/conversations/active
```
**Respuesta** (`200 OK`):
```json
[
{
"id": "conv-1",
"clientId": "CLI-001",
"agentId": "AGENT-01",
"status": "active",
"messages": [
{
"id": "m1",
"conversationId": "conv-1",
"role": "user",
"content": "Hola, necesito ayuda con mi factura",
"timestamp": "2026-07-23T15:00:00.000Z",
"isStreaming": false,
"metadata": {}
}
],
"createdAt": "2026-07-23T15:00:00.000Z"
}
]
```
### 2.5 Obtener Conversación Individual
```
GET /api/v1/conversations/:id
```
**Respuesta** (`200 OK`): Objeto `Conversation` con todos sus mensajes.
---
## 3. Protocolo y Eventos WebSocket
**URL**: `ws://<host>:<port>/ws/dashboard`
### 3.1 Envelope Estándar
Todo mensaje WebSocket (en ambas direcciones) **debe** usar el siguiente envelope JSON:
```json
{
"type": "string",
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"occurredAt": "2026-07-23T15:00:00.000Z",
"payload": { }
}
```
| Campo | Tipo | Descripción |
|-------|------|-------------|
| `type` | string | Tipo de evento (ver tablas abajo) |
| `eventId` | string (UUID v4) | ID único del evento para deduplicación |
| `occurredAt` | string (ISO-8601 UTC) | Timestamp del lado emisor |
| `payload` | object | Carga específica del evento |
### 3.2 Ciclo de Vida de Conexión y Reconexión
1. **Handshake inicial**: El frontend se conecta a `ws://<host>/ws/dashboard`.
2. **`init_state`**: Al establecer la conexión, el backend **DEBE** enviar inmediatamente un evento `init_state` con el estado completo actual (conversaciones activas + casos pendientes).
3. **Reconexión**: El frontend implementa backoff exponencial (1s → 2s → 4s → 8s → 16s → máx 30s, factor 2x). Al reconectar, el backend envía nuevamente `init_state` y el frontend **reemplaza** su estado local completo.
4. **Heartbeat**: Se recomienda que el backend envíe pings periódicos (cada 30s) para detectar desconexiones.
### 3.3 Eventos Servidor → Cliente
| `type` | Payload | Cuándo se emite |
|--------|---------|----------------|
| `init_state` | `{ conversations: Conversation[], activeCases: CaseRequest[] }` | Al conectar o reconectar |
| `conversation_started` | `{ conversation: Conversation }` | Nueva conversación iniciada |
| `conversation_ended` | `{ conversationId: string, endedAt: string }` | Conversación finalizada |
| `user_message` | `{ conversationId: string, message: Message }` | Mensaje completo del usuario |
| `agent_stream_started` | `{ conversationId: string, messageId: string }` | El agente comienza a generar respuesta |
| `agent_stream_chunk` | `{ conversationId: string, messageId: string, token: string, index: number }` | Token individual (índice garantiza orden) |
| `agent_stream_completed` | `{ conversationId: string, messageId: string, fullContent: string }` | Streaming finalizado; `fullContent` es el texto completo |
| `agent_status_update` | `{ agentId: string, status: "ONLINE" \| "BUSY" \| "OFFLINE" }` | Cambio de estado del agente |
| `hitl_request` | `{ case: CaseRequest, conversationId: string }` | Se requiere intervención humana |
| `hitl_resolved` | `{ caseId: number, resolution: object }` | Caso resuelto (broadcast a todos los asesores) |
| `error` | `{ code: string, message: string, details?: object }` | Error del servidor notificable |
#### Ejemplo: Streaming de mensaje del agente
```
Servidor → Cliente:
1. { "type": "agent_stream_started", "payload": { "conversationId": "conv-1", "messageId": "m10" } }
2. { "type": "agent_stream_chunk", "payload": { "conversationId": "conv-1", "messageId": "m10", "token": "Cl", "index": 0 } }
3. { "type": "agent_stream_chunk", "payload": { "conversationId": "conv-1", "messageId": "m10", "token": "aro", "index": 1 } }
4. { "type": "agent_stream_chunk", "payload": { "conversationId": "conv-1", "messageId": "m10", "token": ", ", "index": 2 } }
5. { "type": "agent_stream_chunk", "payload": { "conversationId": "conv-1", "messageId": "m10", "token": "con", "index": 3 } }
...
N. { "type": "agent_stream_completed", "payload": { "conversationId": "conv-1", "messageId": "m10", "fullContent": "Claro, con gusto le ayudo..." } }
```
**⚠️ Importante**: Los tokens deben enviarse con `index` secuencial (0, 1, 2, ...) para que el frontend pueda reconstruir el orden incluso si los chunks llegan desordenados por la red.
#### Ejemplo: Solicitud HITL
```json
{
"type": "hitl_request",
"eventId": "a1b2c3d4-...",
"occurredAt": "2026-07-23T15:01:00.000Z",
"payload": {
"case": {
"id": 42,
"title": "Validación de Identidad - Cliente",
"description": "Validar la identidad del cliente...",
"status": "PENDING",
"externalId": "EXT-042",
"cedula": "1020304050",
"tipoSolicitud": "Validar_Identidad_Movil",
"applicative": "AC+",
"uiPattern": "SIMPLE_CONFIRMATION",
"payload": { "nombre": "Juan Pérez", "telefono": "3101234567" },
"handlingTime": 0,
"createdAt": "2026-07-23T15:01:00.000Z"
},
"conversationId": "conv-1"
}
}
```
Al recibir `hitl_request`, el frontend automáticamente:
- Inserta el caso en la lista del dashboard
- Reproduce una alerta sonora (Web Audio API)
- Muestra una notificación de escritorio HTML5
- Hace parpadear el título de la pestaña si el navegador no está enfocado
### 3.4 Eventos Cliente → Servidor
| `type` | Payload | Cuándo se envía |
|--------|---------|----------------|
| `internal_note` | `{ conversationId: string, content: string }` | Asesor inyecta nota interna desde el monitor |
**Ejemplo**:
```json
{
"type": "internal_note",
"eventId": "f9e8d7c6-...",
"occurredAt": "2026-07-23T15:02:00.000Z",
"payload": {
"conversationId": "conv-1",
"content": "Cliente tiene historial de reclamos similares. Verificar antes de aprobar."
}
}
```
**⚠️ El backend DEBE**:
- Derivar `advisorId` del contexto de la conexión WebSocket autenticada.
- **Ignorar cualquier campo `advisorId`** que pudiera venir en el payload (el cliente no lo envía, pero por seguridad).
- Insertar la nota como un mensaje con `role: "internal"` en la conversación indicada.
- Re-difundir el mensaje como `conversation_update` (o evento equivalente) a los demás clientes conectados.
---
## 4. Autenticación e Identidad del Asesor
### Regla de Negocio
> **La identidad del asesor (`advisorId`) NUNCA es enviada por el frontend.**
> El backend es el único responsable de derivarla desde el contexto autenticado de la conexión.
### Implementación esperada en el backend
| Canal | Mecanismo de autenticación | Cómo derivar `advisorId` |
|-------|---------------------------|-------------------------|
| **REST** | Bearer token en header `Authorization: Bearer <jwt>` o cookie de sesión | Extraer `advisorId` del payload del JWT o consultar la sesión |
| **WebSocket** | Token enviado como query param al conectar: `ws://host/ws/dashboard?token=<jwt>` | Validar JWT durante el handshake; almacenar `advisorId` en el contexto de la conexión |
### Endpoints REST que requieren autenticación
- `GET /api/v1/cases` — cualquier asesor autenticado
- `GET /api/v1/cases/:id` — cualquier asesor autenticado
- `POST /api/v1/cases/:id/resolve`**requiere autenticación**; el backend registra qué asesor resolvió el caso
- `GET /api/v1/conversations/active` — cualquier asesor autenticado
---
## 5. Taxonomía de Casos y Patrones de UI
El frontend clasifica los casos en **6 patrones de UI** que determinan qué formulario se renderiza al asesor:
| `uiPattern` | Descripción | Ejemplo de respuesta esperada |
|-------------|-------------|------------------------------|
| `SIMPLE_CONFIRMATION` | Confirmación binaria Sí/No | `{ "confirmacion": true }` |
| `CONFIRMATION_WITH_VALUE` | Confirmación + valor monetario | `{ "confirmacion": true, "valor": 15000 }` |
| `MULTI_FIELD_FORM` | Formulario con múltiples campos | `{ "numero_cuotas": 12, "valor_cuota": 85000, "dia_corte": 15, "dia_limite_pago": 25 }` |
| `DATE_SIMPLE` | Fecha única | `{ "fecha": "15-07-2026" }` |
| `FREE_TEXT` | Texto libre | `{ "respuesta": "Cargo corresponde a roaming internacional" }` |
| `READ_ONLY` | Solo informativo (sin campos) | `{}` |
### Lista completa de tipos de caso (`tipoSolicitud`)
Los 53 `tipoSolicitud` válidos están definidos en el CSV `Consulta de aplicativos - Claro - Facturación.csv` y mapeados en `src/data/caseTypeDefinitions.ts`. El backend **DEBE** enviar un `tipoSolicitud` que coincida exactamente con uno de estos `toolName`:
**AC+ (13 casos)**: `Validar_Proporcionales_Movil`, `Tickler_AC+_CreerEnElCliente`, `Validar_Suspensiones_Movil`, `Validar_Identidad_Movil`, `Validar_Moras_Movil`, `Validar_Fecha_Corte_Movil`, `Validar_Fecha_Limite_Movil`, `Activar_Roaming`, `Desactivar_Roaming`, `Validar_Finalizacion_Campaña_Movil`, `Validar_Cambio_Plan_Movil`, `Consulta_Ultima_Factura_Movil`
**ASCARD (9 casos)**: `Plan_De_Pagos_EF`, `Tasa_De_Interes_EF`, `Pago_Minimo_EF`, `Plan_Total_EF`, `Refinanciacion_EF`, `Paz_Salvo_EF`, `Unificar_Factura_EF`, `Desbloqueo_EF`, `IMEI_EF`
**DiMe (8 casos)**: `Validar_Creer_Cliente`, `Activa_Creer_Cliente`, `Activa_Creer_Cliente_Hogar`
**Formatos SGCS (2 casos)**: `Cambio_Ciclos_Movil`
**Mi asistencia 360 (2 casos)**: `Escalar_Pagos_No_Abonados`
**Paradigma (2 casos)**: `Validar_Aumento_Tarifario`
**RR (12 casos)**: `Validar_Seguros_Hogar`, `Validar_Aumento_Tarifario_Hogar`, `Validar_Campaña_Hogar`, `Cambio_Plan_Hogar`, `Validar_Clausula_Hogar`, `Cobros_Adicionales_Hogar`, `Validar_Identidad_Hogar`, `Validar_Moras_Hogar`, `Validar_Fecha_Corte_Hogar`, `Validar_Fecha_Limite_Hogar`, `Validar_Proporcionales_Hogar`, `Validar_Suspensiones_Hogar`, `Validar_Venta_Tecnología`, `Validar_OTT_1`, `Validar_OTT_2`
> **Nota**: Algunos `toolName` se repiten con diferente `specialist`/`objective`. Para la UI, solo importa el `toolName`. El mapeo completo (con `uiPattern`, `formFields`, y `validationSchema` Zod) está en `src/data/caseTypeDefinitions.ts`.
### Campos del `payload` de resolución (por `uiPattern`)
Cuando el frontend envía `POST /cases/:id/resolve`, el `payload` tiene esta estructura según el `uiPattern`:
| `uiPattern` | Estructura del `payload` |
|-------------|-------------------------|
| `SIMPLE_CONFIRMATION` | `{ "confirmacion": boolean }` |
| `CONFIRMATION_WITH_VALUE` | `{ "confirmacion": boolean, "valor": number }` |
| `MULTI_FIELD_FORM` | Estructura variable según el `tipoSolicitud` (ver `caseTypeDefinitions.ts` para cada caso) |
| `DATE_SIMPLE` | `{ "fecha": "dd-mm-aaaa" }` |
| `FREE_TEXT` | `{ "respuesta": string }` |
| `READ_ONLY` | `{}` |
---
## 6. Notas Técnicas para el Backend
### 6.1 Timestamps
- Todos los timestamps deben estar en **ISO-8601 UTC** (ej. `"2026-07-23T15:00:00.000Z"`).
- El campo `occurredAt` del envelope WebSocket usa el mismo formato.
### 6.2 Manejo de `handlingTime`
- El backend es la **fuente de verdad** para `handlingTime`.
- Cuando el asesor comienza a gestionar un caso, el backend registra `startedAt`.
- Al recibir `POST /cases/:id/resolve`, el backend calcula `handlingTime = resolvedAt - startedAt` (en segundos).
- El frontend muestra un cronómetro en UI como referencia visual, pero no es autoritativo.
### 6.3 Broadcast de `hitl_resolved`
- Al resolver un caso vía REST, el backend **DEBE** emitir `hitl_resolved` por WebSocket a **todos** los clientes conectados (no solo al que resolvió).
- Esto permite que otros asesores vean que el caso ya fue atendido.
### 6.4 Persistencia
- El backend debe persistir todos los casos y conversaciones en base de datos.
- El `payload` de los casos se almacena como JSON.
- Las notas internas (`internal_note`) se persisten como mensajes en la conversación con `role: "internal"`.
### 6.5 MSW (Desarrollo Frontend Independiente)
- El frontend incluye una capa MSW que simula todos los endpoints REST y datos mock.
- El backend puede desarrollarse en paralelo sin depender del frontend, ya que los contratos están completamente especificados aquí.
- Variable de entorno del frontend: `VITE_ENABLE_MSW=true` activa los mocks; `false` o ausente usa el backend real.
---
## 7. Checklist de Implementación para Backend
- [ ] Endpoint `GET /api/v1/cases` con filtros `status`, `applicative`, `search`, `offset`, `limit`
- [ ] Endpoint `GET /api/v1/cases/:id`
- [ ] Endpoint `POST /api/v1/cases/:id/resolve` (canal autoritativo)
- [ ] Endpoint `GET /api/v1/conversations/active`
- [ ] Endpoint `GET /api/v1/conversations/:id`
- [ ] Servidor WebSocket en `/ws/dashboard`
- [ ] Envelope JSON estándar `{ type, eventId, occurredAt, payload }`
- [ ] Evento `init_state` al conectar/reconectar
- [ ] Eventos de streaming: `agent_stream_started`, `agent_stream_chunk` (con `index`), `agent_stream_completed`
- [ ] Evento `hitl_request` al requerir intervención humana
- [ ] Evento `hitl_resolved` en broadcast tras resolución REST
- [ ] Evento `internal_note` recibido del cliente → persistir como mensaje `role: internal` → re-difundir
- [ ] Eventos `conversation_started`, `conversation_ended`, `user_message`, `agent_status_update`
- [ ] Autenticación REST vía Bearer token / cookie de sesión
- [ ] Autenticación WebSocket vía query param `?token=<jwt>`
- [ ] Derivar `advisorId` del contexto autenticado (NUNCA del payload del cliente)
- [ ] Calcular `handlingTime` como `resolvedAt - startedAt` (segundos)
- [ ] Timestamps en ISO-8601 UTC
- [ ] `tipoSolicitud` en casos coincide con los `toolName` del CSV
- [ ] `uiPattern` en casos coincide con uno de los 6 valores del enum
---
## 8. Referencia Rápida de Archivos del Frontend
| Archivo | Contenido relevante para backend |
|---------|--------------------------------|
| `src/types/index.ts` | Interfaces `CaseRequest`, `Conversation`, `Message` |
| `src/types/wsProtocol.ts` | Schemas Zod de todos los eventos WS + envelope |
| `src/data/caseTypeDefinitions.ts` | 53 tipos de caso con `uiPattern`, `formFields`, `validationSchema` |
| `src/services/api.ts` | Cliente REST (endpoints y formatos esperados) |
| `src/services/wsClient.ts` | Cliente WebSocket (reconexión, envelope) |
| `src/mocks/handlers.ts` | Datos mock de ejemplo (10 casos, 3 conversaciones) |
| `SPECIFICATION.md` | Especificación completa del proyecto |