Volver a la wiki

Incidente: AutoPlan falla con JSONDecodeError ante salida LLM malformada

Incidente: AutoPlan falla con JSONDecodeError ante salida LLM malformada

Resumen ejecutivo

El servicio AutoPlanService._analyze_with_ollama lanzaba un JSONDecodeError no capturado → HTTP 500 cuando el modelo LLM retornaba respuestas con envolturas markdown, prosa extra, comas finales o comillas tipográficas. El fallo fue confirmado con gemma-4-26B-A4B-it vía llama-server b8984, con un cuerpo de 10 286 chars que fallaba en el carácter 2832. El fix introduce _clean_llm_json como sanitizador previo al parseo y añade logging estructurado del contexto de error para diagnóstico futuro.


Metadatos del incidente

CampoValor
Fecha detección2026-04-30
SeveridadMedia (500 en endpoint /api/blueprints/autoplan/import)
Componenteblueprints/services/autoplan.py · método _analyze_with_ollama
Proveedor LLMgemma-4-26B-A4B-it · llama-server b8984
Fixcommit 9b5ad03a780a4727b13307b249b70fa0e9ce54a5
EstadoResuelto

Causa raíz

json.loads() recibía directamente el content del mensaje sin ninguna sanitización defensiva. Los LLMs locales (Ollama/llama-server) no garantizan salida JSON pura; es habitual que:

  1. Envuelvan el JSON en triple backticks (```json … ```) — comportamiento documentado para Gemma 4.
  2. Añadan prosa introductoria (“Aquí tienes el análisis:”) antes del objeto.
  3. Incluyan comas finales antes de } o ] (JSON inválido por RFC 7159).
  4. Usen comillas tipográficas (" " ' ') en lugar de ASCII.

El código anterior solo hacía .replace("```json", "").replace("```", "").strip() directamente en el sitio de llamada (sin extraer el bloque JSON más externo ni corregir comas/comillas), insuficiente ante salidas más elaboradas.


Síntoma observable

json.JSONDecodeError: Expecting ',' delimiter: line 47 col 12 (char 2832)

El endpoint /api/blueprints/autoplan/import devolvía HTTP 500 sin feedback al usuario.
En logs, solo aparecía el traceback de Django sin contexto de qué parte del JSON era inválida.


Fix aplicado

_clean_llm_json(text: str) -> str — nuevo método estático

@staticmethod
def _clean_llm_json(text: str) -> str:
    """Strip markdown/prose, fix trailing commas, normalize smart quotes."""
    text = text.replace("```json", "").replace("```", "").strip()
    first, last = text.find("{"), text.rfind("}")
    if first != -1 and last > first:
        text = text[first : last + 1]          # extrae bloque JSON más externo
    text = re.sub(r",(\s*[}\]])", r"\1", text)  # elimina trailing commas
    return text.translate(str.maketrans({
        "\u201c": '"', "\u201d": '"',           # " "
        "\u2018": "'", "\u2019": "'"            # ' '
    }))

Logging diagnóstico en _analyze_with_ollama

Cuando json.loads() sigue fallando tras la limpieza, se registra el contexto ±80 chars alrededor de la posición del error:

except json.JSONDecodeError as exc:
    ctx = cleaned[max(0, exc.pos - 80) : exc.pos + 80].replace("\n", "\\n")
    logger.error(
        "[AutoPlan] JSON decode error at line %d col %d (char %d): %s | ...%s...",
        exc.lineno, exc.colno, exc.pos, exc.msg, ctx,
    )
    raise

El raise preserva el 500 pero hace el fallo diagnosable desde logs sin necesidad de reproducción local.


Impacto del fix

EscenarioAntesDespués
JSON envuelto en json … 500✅ OK
Prosa antes/después del JSON500✅ OK (extracción por {…})
Trailing commas500✅ OK
Comillas tipográficas500✅ OK
JSON genuinamente corrupto500 sin contexto500 con log contextual

Modelos afectados / entidades relacionadas


Lecciones aprendidas

  1. Los LLMs locales no son deterministas en formato: el mismo modelo puede retornar JSON limpio 99/100 veces y fallar en la centésima con prosa o markdown. El sanitizador debe ser parte del contrato de integración, no un parche puntual.
  2. El logging de contexto es crítico: sin exc.pos ± 80 chars, reproducir el fallo exacto requiere capturar el cuerpo completo. Loggear el fragmento relevante reduce el MTTR de futuros incidentes.
  3. _clean_llm_json es reutilizable: si se añaden nuevos métodos de análisis (_analyze_with_gemini, futuros providers), deben invocar este sanitizador también.

Acciones pendientes (post-fix)


Véase también

Subir