CreaRack-SL

Endpoint POST /api/correo/ask — Chat de la Secretaria (F1)

Descripción

Handler de Cloudflare Workers que implementa el chat conversacional F1 del Menú Correo. Usa Gemma 4 (Google AI Studio) con decodificación restringida para elegir acciones (responder, buscar en Zoho Mail, leer cuerpos de mensaje). El chat es efímero: vive solo en la memoria del navegador, sin persistencia en D1 salvo telemetría anónima.

Ubicación: functions/api/correo/ask.ts (330 LOC).

Firma y entrada

export const onRequestPost: PagesFunction<CorreoEnv> = async ({ request, env }) => { ... }

Parámetros (body JSON)

CampoTipoRequeridoLímiteDescripción
questionstring✓2000 charsPregunta del usuario sobre su correo/digest
historyArray-12 msgsHistórico de conversación (role: ‘user’ | ‘assistant’, content)

Respuesta exitosa (200)

{
  "answer": "Según el informe, el último aviso de Hetzner fue...",
  "tool_rounds": 2,
  "duration_ms": 1234,
  "model": "gemma-4-26b-a4b-it"
}

Respuestas de error

CódigoRazón
403Usuario no autenticado (CF Access requerido)
400Body JSON inválido o question ausente
413Pregunta > 2000 chars
429Rate-limit: >20 queries/minuto
503GOOGLE_AI_API_KEY no configurada
500Error interno del handler o AI Studio

Lógica de ejecución

1. Autenticación y validación

  • Verifica CF Access (miembro del staff).
  • Valida question (presente, no vacía, max 2000 chars).
  • Valida history (filtrado a máx 12 mensajes, sin contenido vacío, roles user/assistant).
  • Comprueba rate-limit en correo_chat_queries (últimos 60s, max 20 queries).

2. Construcción del contexto

  • Obtiene el último digest del usuario desde correo_digests (tabla D1):
    • generated_at: timestamp de generación.
    • resumen: resumen del informe (ej. “3 correos urgentes, 12 normales”).
    • ruido_count: mensajes filtrados.
    • payload: JSON con los ítems del digest.
  • Si no existe digest aún: contextualiza con “(sin informe todavía)”.

3. Llamada a Gemma 4 (decodificación restringida)

Modelo: gemma-4-26b-a4b-it vía Google AI Studio.

Schema JSON forzado:

{
  "type": "OBJECT",
  "properties": {
    "action": { "type": "STRING", "enum": ["responder", "buscar", "leer"] },
    "answer": { "type": "STRING" },
    "query": { "type": "STRING" },
    "from": { "type": "STRING" },
    "subject": { "type": "STRING" },
    "messageId": { "type": "STRING" },
    "folderId": { "type": "STRING" }
  },
  "required": ["action"]
}

Prompt (pseudocódigo):

Eres la secretaria del buzón de [usuario]. Hablas llano y al grano.
Tienes 3 acciones:
- {"action":"responder","answer":"..."} — cuando puedas contestar.
- {"action":"buscar","query|from|subject":"..."} — buscar en el buzón real.
- {"action":"leer","messageId":"...","folderId":"..."} — leer el cuerpo de un mensaje.

Contexto (último digest):
[resumen + ítems + contexto histórico]

Pregunta del usuario: [question]

Responde SIEMPRE con JSON válido. Sin scratchpad.

Timeout: 40s por intento. Si falla por timeout, reintentos automáticos (máx 2 intentos, footgun s88: cold-start en AI Studio puede exceder el primer timeout).

4. Decodificación de la respuesta

Si action == "responder": devuelve answer al cliente y termina.

Si action == "buscar" (máx 3 rondas totales):

  • Ejecuta toolBuscar(env, member, args).
  • Busca en Zoho Mail por sender, subject o texto libre.
  • Registra el resultado en toolLog.
  • Re-pregunta a Gemma con los resultados incluidos (loop).

Si action == "leer" (máx 3 rondas totales):

  • Ejecuta toolLeer(env, member, args).
  • Obtiene el contenido HTML del mensaje desde Zoho.
  • Convierte HTML → texto plano (acotado a 4000 chars).
  • Registra en toolLog.
  • Re-pregunta a Gemma con el cuerpo incluido (loop).

Si se alcanzan 3 rondas: devuelve la respuesta más reciente, aunque esté incompleta.

5. Telemetría (best-effort)

Inserta en correo_chat_queries:

  • owner: usuario.
  • question: primeros 500 chars de la pregunta.
  • tool_rounds: número de rondas ejecutadas.
  • duration_ms: tiempo total de procesamiento.
  • created_at: timestamp actual.

Si falla la inserción, se continúa sin error (best-effort).

Componentes internos

htmlToText(html: string): string

Convierte HTML a texto plano legible:

  • Elimina <style>, <script>.
  • Reemplaza saltos de párrafo (</p>, </div>) con \n.
  • Desescapa entidades HTML (&amp; → &, &quot; → ").
  • Compacta espacios múltiples y líneas en blanco.
  • Acota a máx 4000 chars.

resolveAccountId(env, member, host): Promise<string>

Obtiene el accountId de Zoho Mail del miembro:

  • Llamada GET a https://{host}/api/accounts con OAuth del miembro.
  • Devuelve el primer accountId (usuario tiene una sola cuenta en Zoho Mail).

toolBuscar(env, member, args): Promise<string>

Búsqueda en el buzón Zoho:

  • Parámetros: query (texto libre), from (remitente), subject (asunto).
  • Endpoint: GET /api/accounts/{accountId}/messages/search?searchKey=....
  • Devuelve JSON con hasta 10 resultados: messageId, folderId, de, asunto, fecha, resumen (150 chars).

toolLeer(env, member, args): Promise<string>

Lectura de un mensaje concreto:

  • Parámetros requeridos: messageId, folderId (del resultado de búsqueda previa).
  • Endpoint: GET /api/accounts/{accountId}/folders/{folderId}/messages/{messageId}/content.
  • Devuelve el cuerpo HTML, convertido a texto (máx 4000 chars).

callGemma(apiKey: string, prompt: string): Promise<ModelAction>

Ejecuta el modelo Gemma 4:

  • Usa endpoint https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={apiKey}.
  • POST con contents, generationConfig (temperature 1.0, topK 64, max_tokens 1024, schema JSON).
  • Parse JSON de la respuesta; si falla (JSON roto pese al decoding), trata el texto como respuesta.

Guardarraíles

LímiteValorRazón
MAX_QUESTION_LEN2000Evitar prompts enormes
MAX_HISTORY_MESSAGES12Contexto acotado sin explotar tokens
MAX_TOOL_ROUNDS3Máx búsquedas/lecturas por pregunta
MAX_BODY_CHARS4000Limitar tamaño de cuerpos leídos
RATE_LIMIT_PER_MIN20Prevenir abuse vía telemetría
TIMEOUT_MS40000Detectar stalls, reintentar

Flujo de ejemplo

  1. Usuario: “¿Qué decía el último aviso de Hetzner?”
  2. Gemma (round 1): {"action":"buscar","from":"alerts@hetzner.de"}
  3. Worker ejecuta toolBuscar, obtiene 3 mensajes de Hetzner, actualiza toolLog.
  4. Gemma (round 2): {"action":"leer","messageId":"msg_123","folderId":"inbox"}
  5. Worker ejecuta toolLeer, obtiene cuerpo (ej. “Your server was rebooted…”).
  6. Gemma (round 3): {"action":"responder","answer":"El último aviso de Hetzner decía que tu servidor se reinició por mantenimiento."}
  7. Devuelve al cliente: { answer: "...", tool_rounds: 2, duration_ms: 1200 }.

Dependencias externas

  • Google AI Studio: Gemma 4 (Google AI API Key en env.GOOGLE_AI_API_KEY).
  • Zoho Mail API: búsqueda y lectura per-user (OAuth del miembro en CF Cookies).
  • D1 (Cloudflare): lectura de correo_digests, escritura de correo_chat_queries.
  • CF Access: autenticación de usuarios del staff.

Datos sensibles

  • Cuerpos de mensaje: se obtienen de Zoho, se envían al navegador, NUNCA se guardan en D1.
  • Tokens OAuth: viven en sesión CF Access / cookies del navegador, nunca en logs.
  • Preguntas: solo los primeros 500 chars se guardan en telemetría, anonimizados por owner.

Véase también

  • [[feature—workspace—correo-menu-f0-f1-f2]]
  • [[entity—workspace—endpoint—correo-peticiones]]
  • [[feature—autoplan—provider-google-genai]]
  • [[runbook—zoho—reautorizacion-oauth-scopes]]