Incidentedraftcreado Fri Sep 11#biblioteca#correo#gemma-4#reliability#defensas-software#ai-studio#json-parsing
Cuándo
11-09-2026. Visto por Edu en el chat del Correo (el asistente de bandeja de Zoho integrado en el workspace).
Síntomas visibles
La respuesta del chat mostró al usuario el JSON crudo del modelo, sin parsear, con una subcadena repetida cientos de veces:
{"action":"responder","answer":"...resak-resak-resak-resak-..."}
El texto no llegó a ningún punto de corte porque el JSON nunca se cerró — el modelo agotó los tokens de salida en mitad del bucle, dentro del valor de "answer".
Causa raíz
Dos fallos distintos, uno en cada capa:
- La defensa por palabras no ve una subcadena repetida sin espacios.
clampRepetition()— ver [[entity—biblioteca—function—clamp-repetition]] — trocea el texto por espacios; una racha como"resak-resak-resak-..."es UN solo token para esa función, así que no dispara el corte. La defensa por carácter del Oráculo tampoco lo cazaba porque solo comparaba un carácter contra el anterior, no un patrón de varios caracteres. callGemma()en el chat del Correo devolvía el texto crudo si el JSON no parseaba. Cuando el modelo entra en bucle dentro del valor de"answer"y agotamaxOutputTokensantes de cerrar las comillas y la llave de cierre,JSON.parse()falla. El código anterior cortaba los primeros 2000 caracteres del texto tal cual llegó del modelo — incluyendo el envoltorio{"action":"responder","answer":"— y se lo mandaba al usuario como si fuera la respuesta.
Fix aplicado
Commit bcd9b95f5f06ff3f626f78be32ef36b8e403170c (PR #177):
- Nueva función
clampSubstringRepetition()enarchivo-core.ts— detecta un patrón de 2 a 12 caracteres repetido 10 veces o más seguidas (regex de backreference(.{2,12}?)\1{9,}) y corta justo antes. Se aplica trasclampRepetition(), tanto ensynthesizeAnswer()(Oráculo) como en el chat del Correo. callGemma()encorreo/ask.tsahora rescata el valor de"answer"con una regex tolerante a JSON sin cerrar, en vez de devolver el texto crudo completo.- Si tras el corte queda menos de 20 caracteres útiles, ambos consumidores devuelven una respuesta honesta (“se me ha ido la respuesta, el modelo entró en bucle…”) en vez de un fragmento vacío o inútil; el chat del Correo además marca la respuesta con
degraded: truepara que el cliente no la reenvíe como historial de conversación. - Test añadido:
test/clamp-substring-repetition.test.ts(3 casos: el bucle real de Edu, texto sano, confirmación de que el corte por palabras sigue vivo). Suite completa 187/187 en verde.
Lecciones
- Las defensas contra degeneración de Gemma 4 (ver [[feature—biblioteca—anti-degeneracion-gemma-4]]) no cubrían el caso de repetición sin separadores — un hueco que solo apareció tras meses en producción, en una superficie (el chat del Correo) distinta de donde se vio el primer incidente de este tipo (Help Widget + Oráculo, ver [[incident—20260605—gemma-4-help-loop]]).
- Un fallback que “muestra el texto crudo si el JSON no parsea” es peligroso: si el texto crudo incluye el envoltorio JSON sin cerrar, el usuario ve literalmente la sintaxis interna del sistema.
Preventivos futuros
- Cualquier endpoint nuevo que llame a Gemma con salida JSON forzada debería aplicar
clampRepetition()+clampSubstringRepetition()sobre el resultado ANTES de decidir qué devolver al usuario, no solo cuando el JSON parsea limpio. - Vigilar si aparecen más variantes del footgun (por ejemplo, repetición de una frase completa con puntuación variable) que ninguna de las dos defensas actuales detecte.
Véase también
- [[incident—20260605—gemma-4-help-loop]]
- [[feature—biblioteca—anti-degeneracion-gemma-4]]
- [[entity—biblioteca—function—clamp-repetition]]
- [[entity—functions—handler—synthesizeanswer]]
- [[entity—functions—tool—bib-ask]]