P0 contexto: ventana por modelo + recuperación ante overflow + self-heal del catálogo

Que las conversaciones largas no se rompan ni gasten de más:

Ventana de contexto por modelo (antes: budget estático 120k/200k para todos):
- cost.resolve_context_window: lee context_length del catálogo OpenRouter/DeepSeek
  en Redis, con fallback a litellm. config.budget_for_window deriva el budget de
  la ventana real (window - max_output - reserve). build_context lo aplica por
  turno (param model_id) en vez del fijo de settings.
- Self-heal del catálogo OpenRouter: el admin panel lo cachea con TTL 1h y solo lo
  repuebla al abrir su ventana de IA → en runtime caducaba y se perdían ventana y
  precio. Ahora cost._get_catalog lo refresca solo (fetch público, mismo shape,
  cooldown 5min, TTL 24h). Arregla también el coste (caía al fijo).

Recuperación ante overflow:
- adapters.base.ContextOverflowError; openai_adapter traduce el error de
  context-length del proveedor (init e iteración del stream).
- base.py: retry proactivo que recompacta hasta caber en la ventana ANTES de
  llamar al LLM; si ni así cabe → error accionable (no rompe la sesión).
- engine.py: mensaje user-facing claro (modelo + ventana).

Tests: ventana/budget, self-heal (mockeado), overflow, y sesión REAL de Redis. 106 verdes.

evals/: harness para evaluar al agente acai-code (driver + README + resultados).
Comparativa kimi vs deepseek vs glm (deepseek-v4-pro high = mejor calidad/precio).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan
2026-06-20 13:48:19 +01:00
parent 9d11a59fb8
commit 651d61b096
15 changed files with 997 additions and 36 deletions

2
evals/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Los logs de sesión contienen contenido real de proyectos de cliente.
logs/

43
evals/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Evals del agente acai-code
Harness para evaluar el comportamiento del agente IA (`acai`) montando una
landing real con módulos gestionables, capturando cada turno (thinking, tool
calls, resultados, errores). Sirve para **comparar entre modelos** y discernir
si un fallo es del **modelo** o de la **documentación/KB** (mismo flujo, mismo
proyecto, distinto modelo → ¿cambian los errores?).
## Cómo correrlo
1. Elige el modelo activo en el **Forge Admin Panel → ventana de IA** (provider +
modelo + reasoning). El catálogo OpenRouter se auto-repuebla en runtime aunque
caduque (ver `orchestrator/cost.py: _get_catalog`).
2. Usa un proyecto **en modo TEST** (no producción) — el agente escribe módulos/
records reales en la copia forge-local. Nunca corras esto contra producción.
3. Lanza cada turno con el driver, reutilizando el `session_id` que devuelve el
primer turno para mantener la MISMA conversación:
```bash
NET=acai-vscode-plugin_acai-net # red docker del compose
docker run --rm --network $NET \
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \
-e EVAL_PROJECT=empleo.cocosolution.com \
-w /data acai-vscode-plugin-agentic \
python /data/driver.py "Móntame una sección de beneficios con 3 tarjetas"
# turno 2 (reusa el SESSION_ID del turno 1):
docker run ... python /data/driver.py "Ahora una sección de equipo con fotos y enlaces" "<SESSION_ID>"
```
- El log completo (en vivo) se acumula en `evals/logs/session.log`.
- El driver autentica con `X-Acai-User` hiteando `app:9091` directo en la red
interna (somos superadmin en infra de confianza).
## Métricas que captura
- nº de tool calls, errores (`success:false`, HTTP_4xx), tools repetidas (señal
de bucle), tokens de input/output (coste del thrashing).
## Resultados
Ver [`results-landing-build.md`](./results-landing-build.md) — un apartado por
modelo, para comparar.

148
evals/driver.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Driver de evaluación del agente acai-code (chat agentic).
Manda UN mensaje de usuario al chat, consume el SSE, loguea EN VIVO cada
tool/resultado/error y resume el turno. Reutiliza session_id para mantener la
MISMA conversación a lo largo de varios turnos.
Uso (dentro de la red docker, hitea `app` directo con auth interna X-Acai-User):
docker run --rm --network <proj>_acai-net \\
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \\
-w /data acai-vscode-plugin-agentic \\
python /data/driver.py "<mensaje del usuario>" "<session_id opcional>"
Variables de entorno opcionales: EVAL_PROJECT (slug), EVAL_USER (default superadmin).
Sirve para comparar el comportamiento/errores del MISMO flujo entre distintos
modelos (cambia el modelo activo en el admin panel y repite). Ver README.md.
"""
import os
import sys
import json
import time
import urllib.request
APP = os.environ.get("EVAL_APP", "http://app:9091")
USER = os.environ.get("EVAL_USER", "superadmin")
PROJECT = os.environ.get("EVAL_PROJECT", "empleo.cocosolution.com")
LOG = os.environ.get("EVAL_LOG", "/logs/session.log")
msg = sys.argv[1]
session_id = sys.argv[2] if len(sys.argv) > 2 else ""
def log(s):
with open(LOG, "a") as f:
f.write(s + "\n")
f.flush()
body = {"project": PROJECT, "message": msg, "agent_id": "acai", "plan_mode": "off"}
if session_id:
body["session_id"] = session_id
req = urllib.request.Request(
APP + "/api/agentic/chat",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "X-Acai-User": USER},
method="POST",
)
log("\n" + "=" * 80)
log("[{}] >>> USER: {}".format(time.strftime("%H:%M:%S"), msg))
sid = session_id
text_parts = []
thinking_chars = 0
tool_calls = []
tool_results = {}
errors = []
usage = {}
seen = {}
# IMPORTANTE: el agentic re-emite el snapshot `assistant` con TODOS los bloques
# acumulados tras cada tool (reconciliación, claude_format.py). Hay que
# deduplicar por `tool_use` id o se cuenta el mismo tool decenas de veces.
seen_ids = set()
try:
resp = urllib.request.urlopen(req, timeout=1200)
except Exception as e:
log("!!! HTTP ERROR: {}".format(e))
print("HTTP_ERROR", e)
sys.exit(1)
for raw in resp:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if not line.startswith("data: "):
continue
payload = line[6:].strip()
if not payload:
continue
try:
ev = json.loads(payload)
except Exception:
continue
t = ev.get("type")
if t == "session":
sid = ev.get("session_id") or sid
elif t == "stream_event":
e = ev.get("event", {})
et = e.get("type")
if et == "content_block_delta":
d = e.get("delta", {})
if d.get("type") == "text_delta" or "text" in d:
text_parts.append(d.get("text", ""))
elif d.get("type") == "thinking_delta":
thinking_chars += len(d.get("thinking", ""))
elif t == "assistant":
for blk in ev.get("message", {}).get("content", []):
if blk.get("type") != "tool_use":
continue
bid = blk.get("id") or ""
if bid and bid in seen_ids:
continue # snapshot de reconciliación re-emite bloques ya vistos
if bid:
seen_ids.add(bid)
name = blk.get("name", "?")
inp = json.dumps(blk.get("input", {}), ensure_ascii=False)
sig = name + "|" + inp[:200]
seen[sig] = seen.get(sig, 0) + 1 # repeticiones REALES (mismo tool+input, otro id)
tool_calls.append((name, inp, bid))
rep = " [REPETIDA x{}]".format(seen[sig]) if seen[sig] >= 2 else ""
log(" [{}] TOOL {} {}{}".format(time.strftime("%H:%M:%S"), name, inp[:300], rep))
elif t == "tool_result":
tid = ev.get("tool_use_id")
content = ev.get("content")
cstr = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
is_err = bool(ev.get("is_error")) or ('"success": false' in cstr) or ('"success":false' in cstr)
tool_results[tid] = (is_err, cstr[:500])
log(" ->{} {}".format(" [ERROR]" if is_err else " ok", cstr[:300]))
if is_err:
errors.append("TOOL_ERROR: " + cstr[:300])
elif t == "result":
usage = ev.get("usage", {}) or {}
if ev.get("content") and not text_parts:
text_parts.append(ev["content"])
elif t == "error":
errors.append("STREAM_ERROR: " + str(ev.get("error")))
log(" !! STREAM_ERROR: " + str(ev.get("error"))[:300])
elif t == "done":
break
full_text = "".join(text_parts)
repeated = {s: c for s, c in seen.items() if c >= 2}
log("[ASSISTANT] " + full_text[:1500])
log("[resumen] tools={} errores={} repetidas={} thinking~{}c usage in={} out={}".format(
len(tool_calls), len(errors), len(repeated), thinking_chars,
usage.get("input_tokens"), usage.get("output_tokens")))
print("SESSION_ID={}".format(sid))
print("TOOLS={} ERRORS={} REPEATED={}".format(len(tool_calls), len(errors), len(repeated)))
for (name, inp, tid) in tool_calls:
res = tool_results.get(tid)
print(" - {}{} {}".format(name, " [ERR]" if (res and res[0]) else "", inp[:110]))
for e in errors:
print(" !! " + e[:220])
print("ASSISTANT:", full_text[:1400])
print("USAGE in={} out={}".format(usage.get("input_tokens"), usage.get("output_tokens")))

View File

@@ -0,0 +1,156 @@
# Resultados — eval "montar landing" (acai-code)
Flujo fijo de 3 turnos sobre el proyecto **empleo.cocosolution.com (en TEST)**:
1. **T1** — sección sencilla: "Beneficios" con 3 tarjetas (icono, título, texto).
2. **T2** — módulo complejo: "Conoce al equipo", multi-registro v2, 3 personas con
**foto generada + nombre + puesto + testimonio + enlace LinkedIn**.
3. **T3** — edición: cambiar el puesto de una persona y borrar otra tarjeta.
Objetivo: comparar entre modelos para ver si los fallos son **del modelo** o de la
**KB/docs** (mismo flujo → si todos fallan igual, es la doc; si solo uno, es el modelo).
## Comparativa entre modelos
> ⚠️ **Corrección metodológica (importante).** Mi primera versión contaba los `tool_use`
> de los snapshots `assistant` del SSE. El agentic **re-emite el snapshot con todos los
> bloques acumulados tras cada tool** (`claude_format.py:_build_assistant_snapshot`), así que
> el mismo tool se contaba muchas veces → **los conteos de tool calls estaban inflados**
> (p.ej. "30 generate_image" cuando el `consumo_acaicode` real era **3**). El driver ya
> deduplica por id. **Solo son fiables: tokens de `result.usage`, `consumo_acaicode`, y el
> razonamiento del propio modelo.** Abajo solo se usan esas señales.
| Modelo | Fecha | Tareas OK | Tokens in (3 turnos) | Resolvió record de página | Calidad observada (razonamiento) |
|---|---|---|---|---|---|
| `openrouter/moonshotai/kimi-k2.7-code` (medium) | 2026-06-20 | 3/3 | **~2,66M** | **NO — alucinó `num=1`** | Actúa, falla, reintenta. Edita código a ciegas (`line_replace` no casa). Mucho thrashing. |
| `deepseek/deepseek-v4-pro` (high) | 2026-06-20 | 3/3 | **~649k** | `num=267` ✅ | Explora a fondo y acierta. 0 errores. Maneja ambigüedad (Laura→Elena). |
| `z-ai/glm-5.2` (high) | 2026-06-20 | 3/3 | **~720k** | `num=267` ✅ | Sólido. Autocorrige (Twig `=``==`; fotos cruzadas al borrar). Maneja ambigüedad. |
Imágenes generadas (de `consumo_acaicode`): **3 por turno en los 3 modelos** — correcto, una por
persona. **No hubo sobre-generación** (era artefacto de medición).
## Conclusión modelo vs KB (3 modelos, mismo flujo, misma KB)
- **Señal autoritativa = tokens.** kimi gasta **~4× más** (~2,66M vs ~650720k) para la MISMA
tarea → reintentos/thrashing reales (cada step reenvía contexto). Es el indicador más fiable.
- **`num=1` alucinado → MODELO (kimi).** Deepseek **y** GLM, con la **misma KB**, resolvieron el
record de la página correctamente (lo dicen en su propio razonamiento). Kimi no. **Definitivo:
es kimi, no la documentación.**
- **NO hay evidencia de un problema de KB en el flujo multi-registro/imágenes.** Lo que parecía
sobre-generación (×30) era mi bug de conteo; los tres modelos generaron 3 imágenes (correcto).
**Retirada** la "acción de KB #1" anterior.
- **Bug real encontrado por GLM:** al borrar un registro de un módulo multi-registro, el sistema
reutiliza nums y **las fotos quedan cruzadas**; GLM lo detectó y corrigió. Merece revisar el
flujo delete/reorder (plataforma).
- **Calidad de modelo:** kimi es claramente el más flojo; **deepseek-v4-pro y GLM-5.2 (high) son
sólidos y comparables**.
**Acciones sugeridas:** (1) **no usar kimi-k2.7 como default**; deepseek-v4-pro o GLM-5.2 (high)
son buenos. (2) Revisar el bug delete→fotos cruzadas. (3) (Opcional) re-medir con el driver
deduplicado si se quieren conteos exactos de tool calls; las conclusiones por tokens no cambian.
---
## kimi-k2.7-code — 2026-06-20
**Veredicto:** entrega las 3 tareas, pero con **mucho thrashing** y errores recurrentes
de los que **no aprende dentro del turno**.
### Por turno
- **T1 (beneficios):** completado. Reutilizó un módulo de tarjetas existente. Errores:
`acai_line_replace``HTTP_409 "Search block not found"` (edita código a ciegas) y
acceso a `record num=1` inexistente en `apartados`. Se recuperó. **1,77M tokens input**
(acumulado de ~9 steps por los reintentos).
- **T2 (equipo, multi-registro v2):** completado (módulo `conocealequipo_coco`, 3 personas,
fotos generadas, enlaces LinkedIn en nueva pestaña). Pero **`add_module_to_record` ×11
sobre el mismo módulo** (bucle en el workflow multi-registro; idempotente, devolvió el
mismo `sectionId` → NO duplicó en la página) y **2 ciclos de generación de imágenes**
(6 `generate_image` para 3 personas). 606k tokens.
- **T3 (edición):** completado limpio (**0 errores**), Carlos→CTO + Laura eliminada. Pero
**7× `list_record_uploads`** redundante. 284k tokens.
### Inventario de errores (sesión completa)
| Error | Veces | Diagnóstico |
|---|---:|---|
| `Record num=1 not found in 'apartados'` | **52** | Alucina el record de la página (real = `num=267`). **No aprende** del error y reintenta con `num=1`. |
| `Search block not found` (HTTP_409, `acai_line_replace`) | 22 | Genera bloques de búsqueda que no casan con el fichero; edita código sin verlo bien. |
| `add_module_to_record` mismo módulo | 11 | Bucle en el workflow multi-registro v2. |
- 139 tool calls · ~74 `success:false` · 148 llamadas marcadas como repetidas.
### ¿Modelo o KB? (hipótesis a confirmar con otros modelos)
- **`num=1` (×52):** huele a **KB** — falta una regla clara de "obtén el `num` real de la
página con `list_table_records` antes de operar; nunca asumas num=1". Si otros modelos
caen igual → es la doc.
- **multi-registro v2 (bucle):** probablemente **KB** — falta un doc de "cómo añadir N
registros a un módulo repetible".
- **`line_replace` a ciegas:** mezcla — la KB debería exigir `acai_view` previo y casar
exacto.
### Notas de contexto / coste (P0)
- **Cero overflow** en los 3 turnos pese a 1,77M tokens acumulados/turno → no se rompió.
- La ventana real de kimi es **262144** (256k). El catálogo OpenRouter había **caducado**
(TTL 1h) → al principio se usó budget estático; tras el self-heal (`cost.py`) ya resuelve
la ventana real. Coste real de kimi: ~$0.61 in / $3.07 out por 1M.
---
## deepseek-v4-pro (high) — 2026-06-20
**Veredicto: ELEGIDO** (mejor relación calidad/precio). 3/3 tareas, **0 errores**, eficiente y
**cauto ante acciones destructivas ambiguas**.
### Re-medición con driver deduplicado (números AUTORITATIVOS, baseline limpio)
| Turno | Tool calls (reales) | Errores | `generate_image` | Tokens in |
|---|---:|---:|---:|---:|
| T1 beneficios | **19** | 0 | 3 | 264k |
| T2 equipo (multi-registro) | **14** | 0 | 3 | 320k |
| T3 edición ambigua | **1** | 0 | 0 | 77k |
| **Total** | **34** | **0** | 6 | **~661k** |
- Imágenes = 3 por módulo (correcto, coincide con `consumo_acaicode`). **Sin thrashing** — los
"135/77/30" de abajo eran del artefacto de conteo, ya corregido.
- **T3 (lo mejor):** ante "quita a Laura Gómez" (no existía; sus personas eran Marina/Carlos/
Lucía), **no adivinó: paró y preguntó** a quién borrar, ofreciendo ya el cambio claro
(Carlos→CTO). Cautela correcta con un borrado ambiguo.
### Por turno (medición ANTIGUA — inflada por el artefacto, ver banner arriba)
- **T1 (beneficios):** 135 tools, **0 err**, 238k tok. Exploró el proyecto (tablas, records,
módulos), **resolvió bien `apartados num=267`**, localizó un módulo de referencia
(`sobrenosotrosbeneficios_8pjhao`) y creó un módulo nuevo con `multiv2`. Renderizó OK.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 183k tok. Módulo `conocealequipo_j8m3k7`
con 3 personas, fotos y enlaces. **Pero 30 `generate_image` + 8 `add_module_to_record`**
para 3 personas → mismo thrashing del workflow multi-registro/imágenes que kimi (peor en
imágenes).
- **T3 (edición):** 27 tools, **0 err**, 228k tok. Sus personas eran Marina/Carlos/Elena;
ante "quita a Laura" razonó *"no existe Laura, asumo que es Elena"* y la quitó + Carlos→CTO.
Manejo inteligente de la ambigüedad.
- Totales: **239 tools, 0 errores, ~649k tok input** (4× más barato que kimi pese a más calls).
- Ventana real deepseek-v4-pro: **1.000.000**. Coste catálogo: ~$0.435 in / $0.87 out por 1M.
---
## glm-5.2 (high) — 2026-06-20 (baseline limpio)
**Veredicto:** 3/3 tareas. Comportamiento sólido y con **muy buena autocorrección**.
Mismo perfil que deepseek (explora y acierta), no aluciona el record de la página.
### Por turno
- **T1 (beneficios):** 90 tools, 250k tok. Resolvió `apartados num=267` bien. Escribió el
template Twig con `=` en un `c-if` (en vez de `==`) → fallos de `acai_write`/compilación,
pero **se autodiagnosticó** ("el compilador no convierte `=` en este contexto") y lo arregló.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 232k tok. Módulo `equipococotalento_k8e2qr`.
**30 `generate_image` + 8 `add_module`** para 3 personas — idéntico a deepseek.
- **T3 (edición):** 35 tools, **0 err**, 237k tok. Sus personas eran Diego/Laura Méndez/Carmen;
infirió bien la petición. Detectó que al borrar un registro **las fotos quedaron cruzadas**
(reuso de nums 22726/22727) y las **reemplazó correctamente**.
- Totales: ~202 tools, errores solo en T1 (recuperados), ~720k tok input.
- Ventana real glm-5.2: **1.048.576**. Coste catálogo: ~$1.2 in / $4.1 out por 1M.
### Limpieza pendiente
Tras el reset, empleo (en TEST) tiene solo los módulos de la prueba de GLM:
- módulo de beneficios (`multiv2`) + `equipococotalento_k8e2qr`.
Borrar si no se quieren conservar, y **revertir empleo a producción**.