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
| Campo | Valor |
|---|---|
| Fecha detección | 2026-04-30 |
| Severidad | Media (500 en endpoint /api/blueprints/autoplan/import) |
| Componente | blueprints/services/autoplan.py · método _analyze_with_ollama |
| Proveedor LLM | gemma-4-26B-A4B-it · llama-server b8984 |
| Fix | commit 9b5ad03a780a4727b13307b249b70fa0e9ce54a5 |
| Estado | Resuelto |
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:
- Envuelvan el JSON en triple backticks (
```json … ```) — comportamiento documentado para Gemma 4. - Añadan prosa introductoria (“Aquí tienes el análisis:”) antes del objeto.
- Incluyan comas finales antes de
}o](JSON inválido por RFC 7159). - 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
| Escenario | Antes | Después |
|---|---|---|
JSON envuelto en json … | 500 | ✅ OK |
| Prosa antes/después del JSON | 500 | ✅ OK (extracción por {…}) |
| Trailing commas | 500 | ✅ OK |
| Comillas tipográficas | 500 | ✅ OK |
| JSON genuinamente corrupto | 500 sin contexto | 500 con log contextual |
Modelos afectados / entidades relacionadas
AutoPlanService—blueprints/services/autoplan.py- Endpoint:
POST /api/blueprints/autoplan/import - Modelos Django relacionados:
Blueprint,BlueprintPlacement,MapAnnotation,AIPrompt - Proveedores LLM: Ollama (gemma-4, llama-server), OpenRouter, Gemini
Lecciones aprendidas
- 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.
- 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. _clean_llm_jsones 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)
- Extender
_clean_llm_jsonal método_analyze_with_gemini(actualmente no lo usa). - Considerar test unitario con fixtures de respuestas malformadas reales.
- Evaluar si
orjsonojson5aportarían mayor tolerancia como parser alternativo.
Véase también
- [[entity—blueprints—model—blueprint]]
- [[entity—blueprints—model—mapannotation]]
- [[entity—blueprints—model—aiprompt]]
- [[feature—blueprints—autoplan]]
- [[concept—ai—llm-output-sanitization]]