CreaRack-SL

Help Widget — Streaming en tiempo real + formato legible del Tutor (v1.45.5)

Funcionalidadactivecreado Sat Jul 04#core#help#tutor#streaming#asincro#django#asgi#aiohttp

Resumen ejecutivo

Fix doble en el Help Widget (versión v1.45.5, commit c870ef6) que resuelve dos problemas graves en la experiencia de usuario:

  1. El streaming nunca funcionó realmente: aunque el backend generaba respuestas palabra a palabra, Django bajo ASGI bufferizaba entero cualquier iterador síncrono de StreamingHttpResponse, entregando toda la respuesta de golpe al final (13.3 s primer byte).
  2. El IT Tutor mostraba etiquetas como texto literal: Gemma a veces emite HTML puro (<br>, <ul><li>, <b>) en vez de markdown, y el sistema las escapaba viéndolas como &lt;br&gt; en pantalla.

Ambos problemas están resueltos. El usuario ahora ve texto apareciendo progresivamente (2-5 s en arrancar) con formato limpio (viñetas, negritas reales, sin etiquetas visibles).


Problema 1: Streaming buffered (arquitectónico)

Síntoma

El click-test de Edu sobre el Help del IT Tutor reveló que las respuestas llegaban repentinamente enteras al navegador, no progresivamente. En las herramientas de red, el primer byte tardaba ~13.3 s (= tiempo de respuesta completa).

Raíz

El endpoint /ask-stream (introducido en s194) usaba un generador síncrono con requests.post():

def event_stream():
    upstream = requests.post(..., stream=True)
    for line in upstream.iter_lines(...):
        yield (line + "\n").encode("utf-8")

Django bajo ASGI (Daphne), desde versión 4.2, bufferiza entero cualquier iterador síncrono de un StreamingHttpResponse — una limitación documentada. El navegador recibía:

  • Nada durante 13.3 s.
  • Luego todas las cabeceras HTTP.
  • Luego todo el body SSE.
  • Nunca hubo chunked encoding real.

Diagnóstico E2E (s197):

  • ✅ Worker de Cloudflare emite deltas cada ~0.5 s (correcto).
  • ❌ Daphne directo: buffered (causa raíz).
  • ✅ Traefik y Cloudflare: exonerados (DNS-only en crearack.com).
  • ✅ JavaScript del widget: pinta deltas correctamente.

Solución

Convertir el generador a asíncrono usando aiohttp (ya disponible en la imagen Docker):

async def event_stream():
    import aiohttp
    timeout_cfg = aiohttp.ClientTimeout(sock_connect=10, total=TIMEOUT)
    async with (
        aiohttp.ClientSession(timeout=timeout_cfg) as session,
        session.post(...) as upstream,
    ):
        async for raw in upstream.content:
            line = raw.decode("utf-8", "replace").rstrip("\r\n")
            # ... procesar y emitir
            yield (line + "\n").encode("utf-8")

Django bajo ASGI no bufferiza generadores asíncronos — los chunks se transmiten conforme se generan. Ahora el navegador recibe deltas en tiempo real cada ~0.5 s, igual que el Worker CF.

Impacto de rendimiento:

  • Primer byte: 2–5 s (tiempo que Gemma tarda en generar el primer token).
  • Texto visible progresivamente, no de golpe.

Problema 2: HTML literal en respuestas del Tutor (de formato)

Síntoma

El click-test del IT Tutor de Edu mostraba que algunas respuestas tenían etiquetas como texto visible: <br>, <ul><li>, <b>. Ejemplo:

La escala OSPF es interior.<br><br>### Key Differences
<ul><li><b>OSPF</b>: link-state.</li>...

Renderizado así en pantalla: texto literal que no formatea nada.

Raíz

Gemma (especialmente en modo tutor) a veces genera HTML en lugar de markdown puro. El endpoint /ask (y /ask-stream) pasa la respuesta por _md_to_html(), que:

  1. Toma markdown (o se supone que lo hace).
  2. Aplica escape() de Django para evitar XSS.
  3. Transforma markdown a HTML (títulos, listas, negritas, etc.).

El problema: si el input ya contiene etiquetas HTML, escape() las convierte en &lt;br&gt; (visible), porque escape() se aplica después de cualquier normalización.

Además, los regex por-línea que detectan markdown (p. ej., ### para títulos, - para listas) no matcheaban porque Gemma había emitido todo con <br> en una sola línea.

Solución

Normalizar HTML a markdown ANTES de escapar, aplicando una whitelist de etiquetas seguras (formatos que se espera):

def _md_to_html(md):
    from django.utils.html import escape

    # Normalizar etiquetas seguras a markdown
    md = re.sub(r"<br\s*/?>", "\n", md, flags=re.IGNORECASE)
    md = re.sub(r"</?(?:ul|ol)>", "\n", md, flags=re.IGNORECASE)
    md = re.sub(r"<li>\s*", "\n- ", md, flags=re.IGNORECASE)
    md = re.sub(r"</li>", "", md, flags=re.IGNORECASE)
    md = re.sub(r"</?(?:b|strong)>", "**", md, flags=re.IGNORECASE)
    md = re.sub(r"</?(?:i|em)>", "*", md, flags=re.IGNORECASE)

    # Luego escape normal (anti-XSS)
    html = escape(md)
    # ... resto del rendering

Garantías:

  • Etiquetas en la whitelist → convertidas a markdown → renderizadas correctamente.
  • Etiquetas fuera de la whitelist (p. ej., <script>, <img onerror>) → escapadas a &lt;script&gt; (texto literal inofensivo).
  • El anti-XSS sigue intacto (test dedicado).

Gemelo en workspace (commit 001ca25f): el prompt del IT Tutor en buildTutorPrompts gana la regla “MARKDOWN ONLY, nunca HTML” para evitar el problema de raíz en futuras generaciones.

Impacto

  • /ask (modo normal) — normaliza antes de renderizar final.
  • /ask-stream — normaliza antes de cada delta, además del evento html final.
  • Artículos del Help — normalización automática.

Tests y cobertura

3 tests nuevos en tests/api/test_help.py:

  1. test_md_to_html_normalizes_model_emitted_html: verifica que <br>, <ul><li>, <b> se convierten a HTML real (no escapados).
  2. test_md_to_html_still_escapes_unknown_tags: confirma que <script>, <img onerror> se escapan a &lt;script&gt; (anti-XSS intacto).
  3. test_md_to_html_plain_markdown_unchanged: asegura que markdown normal (sin etiquetas HTML) sigue funcionando igual.

Fakes actualizados: los mocks de event_stream() ahora usan duck-type de aiohttp (async context manager) en lugar de requests.

Resultado: 58/58 tests en test_help.py.


Cómo verificar en producción

Click-test del Help (cualquier pregunta):

  • Abre la barra de Help (esquina inferior derecha).
  • Haz una pregunta normal (p. ej., “¿Qué es un rack?”).
  • Espera 2–5 s hasta ver el primer carácter.
  • Observa que el texto aparece palabra a palabra, no de golpe.

Click-test del IT Tutor (modo tutor):

  • Abre el Help.
  • Activa el toggle “IT Tutor” o “Modo Tutor”.
  • Haz una pregunta técnica (p. ej., “Explica OSPF vs BGP”).
  • Verifica que el texto tenga:
    • ✅ Viñetas reales (- convertidas a <li>).
    • ✅ Negritas reales (**texto** convertido a <strong>).
    • ✅ Saltos de línea reales (no <br> visible).
    • ✅ Sin <ul>, <li>, <br>, <b> como texto literal.

Contexto de descubrimiento

  • Detectado en: s197 (click-test del Help widget de Edu).
  • Referencia de workspace: commit 001ca25f (workspace compartido para el prompt MARKDOWN ONLY).
  • Notas pendientes: Gemma en streaming genera a veces artefactos (palabras duplicadas, tokens de otros alfabetos, posible truncado) visibles en los deltas crudos del Worker — se decidirá con Edu si investigar aparte.

Véase también

  • [[entity—core—endpoint—help-ask-stream]]
  • [[entity—core—service—help-intent]]
  • [[feature—core—help-widget-it-tutor]]
  • [[feature—help—chat-tutor-multi-turn]]
  • [[concept—supercontext—streaming-asincro]]