Volver a la wiki

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

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:

Diagnóstico E2E (s197):

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:


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:

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


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):

Click-test del IT Tutor (modo tutor):


Contexto de descubrimiento


Véase también

Subir