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:
- 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). - 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<br>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:
- Toma markdown (o se supone que lo hace).
- Aplica
escape()de Django para evitar XSS. - Transforma markdown a HTML (títulos, listas, negritas, etc.).
El problema: si el input ya contiene etiquetas HTML, escape() las convierte en <br> (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<script>(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 eventohtmlfinal.- Artículos del Help — normalización automática.
Tests y cobertura
3 tests nuevos en tests/api/test_help.py:
test_md_to_html_normalizes_model_emitted_html: verifica que<br>,<ul><li>,<b>se convierten a HTML real (no escapados).test_md_to_html_still_escapes_unknown_tags: confirma que<script>,<img onerror>se escapan a<script>(anti-XSS intacto).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.
- ✅ Viñetas reales (
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]]