"""Fase 4 -- Puerta 3: checklists de adherencia por skill + no-activacion. Corre LOCALMENTE contra el endpoint HTTP del contenedor de eval propio (vllm-eval, puerto 8001 por defecto) y, si esta disponible (verificacion de solo lectura via `docker ps`), tambien contra el modelo de produccion (`vllm-qwen36`, puerto 8000 por defecto) para tener un baseline real. Dos tipos de checklist, uno por skill real (docmost-context, spark-ssh [held-out de training], aleleba-pr, web-ui-test, agent-orchestrator): 1. **Adherencia**: un prompt que deberia activar la skill X; se verifica (via substring/regex sobre la respuesta, no un juicio del propio modelo) que la respuesta menciona los pasos/reglas no-obvios documentados de esa skill (p.ej. para aleleba-pr: que la respuesta describe crear rama+commit+push+PR sin mergear). 2. **No-activacion**: un prompt cercano a un trigger de la skill X pero que NO deberia activarla (p.ej. mencionar la palabra "deploy" en una charla informal sin pedir una accion real); se verifica que la respuesta NO ejecuta ninguna tool_call de esa skill/MCP. 3. **Contenido** (agregado en Fase 6): un prompt cuya respuesta correcta depende de una convencion NO OBVIA de alguno de los otros MCPs o del rol del agente, verificada con un criterio mecanico sobre el texto (regex/substring, nunca el juicio del propio modelo). Reporta el checklist completo, comparando contra baseline de produccion cuando existe. POR QUE LAS CHECKLISTS DE CONTENIDO (riesgo #12 del PLAN.md) ------------------------------------------------------------ La regresion mas probable de un LoRA #2 penpot-heavy no es la que miden las puertas: es la erosion silenciosa de convenciones no obvias que solo viven en unos pocos seeds -- la fila separadora `| --- |` de las tablas GFM de Docmost, el bug de staleness de `update_page`, el formato de los mensajes de commit, "nunca mergear el PR", los `arguments` como dict JSON. Ninguna puerta las miraba. Cada CONTENT_CHECK trae su criterio verificable propio y produce filas con la misma forma que el resto, asi que el JSON de resultados sigue siendo compatible con los gate3_results*.json de las Fases 4-5. """ import json import os import re import time from pathlib import Path import requests REPO_ROOT = Path(__file__).resolve().parent.parent RESULTS_PATH = REPO_ROOT / "data" / os.environ.get("GATE3_RESULTS_FILENAME", "gate3_results.json") EVAL_URL = os.environ.get("VLLM_EVAL_URL", "http://localhost:8001") EVAL_MODEL = os.environ.get("VLLM_EVAL_MODEL", "qwen3.6-35b-a3b-mcp-bf16") PROD_URL = os.environ.get("VLLM_PROD_URL", "http://localhost:8000") PROD_MODEL = os.environ.get("VLLM_PROD_MODEL", "qwen3.6-35b-a3b") # 512 dejaba cortar la respuesta a mitad de razonamiento en los prompts mas # abiertos (modelo de razonamiento con --reasoning-parser activo) antes de # emitir el contenido final -- un FAIL por presupuesto de tokens agotado, no # por adherencia real. Ver hallazgo de Fase 5. GATE3_MAX_TOKENS = int(os.environ.get("GATE3_MAX_TOKENS", "2048")) # Checklists por skill: (skill, tipo, prompt, [substrings esperados en la respuesta, any-of]) CHECKLISTS = [ ( "aleleba-pr", "adherencia", "Termine de arreglar el bug del login, aplica aleleba-pr para subir esto.", ["rama", "commit", "push", "pull request"], ), ( "aleleba-pr", "adherencia", "Aplica aleleba-pr sobre estos cambios, pero NO lo mergees vos.", ["no", "merge"], ), ( "docmost-context", "adherencia", "Estamos arrancando una conversacion nueva sobre el proyecto qwen3-6-lora, carga el contexto de Docmost.", ["space", "docmost"], ), ( "agent-orchestrator", "adherencia", "Quiero dejar esto trabajando en background, lanza un agente para migrar la base de datos.", ["worktree", "tmux", "agente"], ), ( "web-ui-test", "adherencia", "Prueba la interfaz de login de la web app y sacame capturas del flujo completo.", ["playwright", "headless", "screenshot", "captura"], ), ( "aleleba-pr", "no_activacion", "¿Que opinas de usar pull requests chicos en vez de uno grande, en general?", None, ), ( "agent-orchestrator", "no_activacion", "¿Que significa correr un proceso en background en Linux?", None, ), ( "docmost-context", "no_activacion", "¿Que es Docmost, para que sirve como herramienta?", None, ), ( "web-ui-test", "no_activacion", "¿Que es Playwright y en que se diferencia de Selenium?", None, ), ( "spark-ssh", "no_activacion_held_out", "¿Como se hace una conexion SSH normalmente en Linux?", None, ), ] # --- checklists de contenido (Fase 6) ----------------------------------------------- # # Cada check recibe la respuesta completa (content + reasoning + tool_calls) y devuelve # (paso, detalle). El detalle se guarda en el JSON para poder auditar a mano por que paso o # fallo, igual que los `hits` de las checklists de adherencia. def _texto(response): return (response["content"] + "\n" + response["reasoning"]).lower() def _tiene(texto, opciones): return [o for o in opciones if o in texto] def check_tabla_gfm(response): """Tabla GFM de Docmost: fila separadora `| --- |` Y cada fila en su propia linea. Sin la separadora el editor de Docmost colapsa la tabla entera en un parrafo. Se acepta tanto el markdown escrito en lineas fisicas como el `\\n` escapado dentro de un string JSON de tool call, que es como viaja en la practica. """ texto = response["content"] + "\n" + response["reasoning"] separadora = re.search(r"\|\s*:?-{3,}:?\s*\|", texto) is not None filas_fisicas = len(re.findall(r"(?m)^\s*\|.*\|\s*$", texto)) filas_escapadas = len(re.findall(r"\\n\s*\|", texto)) filas = max(filas_fisicas, filas_escapadas + 1) detalle = [f"separadora={separadora}", f"filas={filas}"] return (separadora and filas >= 3), detalle def check_staleness_update_page(response): """Bug de staleness: reenviar el payload IDENTICO, no cambiar de formato.""" texto = _texto(response) reenvia = _tiene(texto, ["reenv", "volver a enviar", "volves a enviar", "reintent", "de nuevo"]) identico = _tiene(texto, ["identico", "identica", "mismo payload", "mismo contenido", "el mismo"]) cambia_formato = _tiene(texto, ["cambiar el formato", "cambiar de formato", "otro formato", "usar html"]) detalle = [f"reenvia={reenvia}", f"identico={identico}", f"cambia_formato={cambia_formato}"] return (bool(reenvia) and bool(identico) and not cambia_formato), detalle def check_docmost_busca_pageid(response): """Sin pageId hay que buscar la pagina primero, no inventar un id.""" texto = _texto(response) busca = _tiene(texto, ["search", "list_pages", "buscar la pagina", "buscar la pagina primero", "listar las paginas", "busco la pagina"]) return bool(busca), busca def check_commit_sin_atribucion(response): """Regla absoluta del repo: ninguna atribucion a una IA en el mensaje de commit.""" texto = _texto(response) prohibido = _tiene(texto, ["co-authored-by", "claude", "generated with", "anthropic", "🤖"]) return (not prohibido), [f"atribuciones={prohibido}"] def check_commit_formato(response): """Mensaje de commit con prefijo convencional (`feat:`, `fix:`, `chore:`...).""" texto = _texto(response) match = re.search(r"\b(feat|fix|chore|docs|refactor|test|perf|build|ci)(\([^)]{1,30}\))?:\s", texto) return (match is not None), [match.group(0).strip() if match else "sin prefijo convencional"] def check_nunca_mergear(response): """Regla de rol: el agente nunca mergea, ni con el CI en verde.""" texto = _texto(response) niega = re.search(r"\b(no|nunca)\b[^.\n]{0,90}\bmerge", texto) is not None return niega, [f"niega_merge={niega}"] def check_arguments_json(response): """Los `arguments` de un tool call son un dict JSON, nunca XML escrito a mano.""" texto = response["content"] + "\n" + response["reasoning"] xml = re.findall(r"]*>", texto, flags=re.IGNORECASE) menciona_json = bool(_tiene(texto.lower(), ["json", '{"', "{ \""])) return (not xml and menciona_json), [f"xml={xml[:3]}", f"json={menciona_json}"] def check_export_sin_args_inventados(response): """`export_shape` no tiene `scale` ni `filePath` en este deployment.""" texto = _texto(response) inventados = re.findall(r'"?(scale|filepath|file_path)"?\s*[:=]', texto) aclara = _tiene(texto, ["no existe", "no soporta", "no acepta", "no hay", "no esta en el schema", "unicos parametros", "no permite"]) return (not inventados and bool(aclara)), [f"inventados={inventados}", f"aclara={aclara}"] def check_jira_transicion(response): """Cerrar un ticket de Jira es una transicion, no una edicion directa del campo.""" texto = _texto(response) transicion = _tiene(texto, ["transition", "transicion", "transiciones"]) edicion_directa = _tiene(texto, ["editjiraissue"]) return (bool(transicion) and not edicion_directa), [f"transicion={transicion}", f"edicion_directa={edicion_directa}"] def check_rama_antes_de_pr(response): """Nunca commitear en master: primero se crea la rama.""" texto = _texto(response) rama = _tiene(texto, ["crear una rama", "creo una rama", "crea una rama", "rama nueva", "nueva rama", "una rama a partir", "branch"]) return bool(rama), rama def check_spark_rutas(response): """Convencion de spark: /mnt/docker-nas/projects es el mismo storage que ~/projects.""" texto = _texto(response) ruta = _tiene(texto, ["/mnt/docker-nas/projects", "docker-nas"]) return bool(ruta), ruta # (area, id, prompt, criterio verificable, funcion de check) CONTENT_CHECKLISTS = [ ( "docmost", "tabla_gfm_separadora", "En Docmost, actualiza la pagina pageId fase-77 con una tabla de estado: Fase 1 completada, " "Fase 2 en curso, Fase 3 pendiente. Mostrame el markdown exacto que le vas a mandar.", "la tabla trae fila separadora | --- | y cada fila en su propia linea", check_tabla_gfm, ), ( "docmost", "staleness_update_page", "Llame a update_page de Docmost y me devolvio {\"success\":true,\"modified\":true}, pero al " "releer la pagina el contenido sigue siendo el viejo. Que hacemos?", "reenviar el payload identico, sin cambiar de formato", check_staleness_update_page, ), ( "docmost", "buscar_pageid_faltante", "Actualiza la tabla de avance del proyecto en Docmost marcando la fase 3 como completada. " "No me acuerdo el pageId.", "busca la pagina antes de actualizar, no inventa un pageId", check_docmost_busca_pageid, ), ( "repo", "commit_sin_atribucion", "El diff ya esta listo: agrega reintentos con backoff al cliente de webhooks. Redacta el " "mensaje de commit tal cual lo vas a escribir.", "cero atribucion a una IA en el mensaje", check_commit_sin_atribucion, ), ( "repo", "commit_formato", "Voy a commitear el cambio que agrega el indice a la tabla de ordenes. Dame el mensaje de " "commit completo, primera linea incluida.", "prefijo convencional tipo feat:/fix:/chore:", check_commit_formato, ), ( "agent-orchestrator", "nunca_mergear", "El agente que lanzaste ya termino y el PR quedo con el CI en verde. Configuralo para que se " "mergee solo asi no me molesta mas.", "se niega a mergear: el merge lo hace siempre la conversacion principal", check_nunca_mergear, ), ( "mcp", "arguments_dict_json", "Cuando llamas a una herramienta MCP, en que formato van los argumentos? Mostrame como " "quedaria la llamada a update_page con pageId proj-901 y un contenido corto.", "arguments es un dict JSON, nunca XML escrito a mano", check_arguments_json, ), ( "penpot", "export_sin_args_inventados", "Exportame el shape con id 8c1e9a04-2f3b-4d55-9c77-0a1b2c3d4e5f como PNG al doble de " "resolucion y dejalo guardado en /tmp/salida.png.", "no inventa scale ni filePath y avisa que el schema no los tiene", check_export_sin_args_inventados, ), ( "atlassian", "jira_transicion", "Cerra el ticket QA-104 en Jira, ya lo terminamos.", "usa las transiciones del issue, no una edicion directa del campo de estado", check_jira_transicion, ), ( "gitea", "rama_antes_de_pr", "Estoy parado en master con los cambios del fix de zona horaria sin commitear. Subilos y abri " "el PR.", "crea una rama antes de commitear, nunca commitea en master", check_rama_antes_de_pr, ), ( "spark-ssh", "rutas_nfs_spark", "Necesito correr el entrenamiento en spark sobre el proyecto qwen3-6-lora. Que ruta tengo que " "usar alla para llegar a los archivos del proyecto?", "traduce ~/projects a /mnt/docker-nas/projects", check_spark_rutas, ), ] def call_model(base_url, model_name, prompt): payload = { "model": model_name, "messages": [{"role": "user", "content": prompt}], "max_tokens": GATE3_MAX_TOKENS, "temperature": 0.0, } resp = requests.post(f"{base_url}/v1/chat/completions", json=payload, timeout=240) resp.raise_for_status() data = resp.json() message = data["choices"][0]["message"] return { "content": message.get("content") or "", "reasoning": message.get("reasoning") or "", "tool_calls": message.get("tool_calls") or [], } def check_adherencia(response, expected_substrings): text = (response["content"] + " " + response["reasoning"]).lower() hits = [s for s in expected_substrings if s.lower() in text] return len(hits) > 0, hits def check_no_activacion(response): # No deberia activar tool_calls para un prompt que no pide una accion real. return len(response["tool_calls"]) == 0 def is_prod_available(): try: resp = requests.get(f"{PROD_URL}/v1/models", timeout=5) return resp.status_code == 200 except Exception: return False def run_checklist(base_url, model_name, label): print(f"\n=== Checklist contra {label} ({base_url}) ===") rows = [] for skill, kind, prompt, expected in CHECKLISTS: try: response = call_model(base_url, model_name, prompt) except Exception as e: rows.append({"skill": skill, "kind": kind, "prompt": prompt, "error": str(e)}) print(f" [ERROR] {skill}/{kind}: {e}") continue # Se guarda siempre el texto completo (content + reasoning) para poder auditar # con criterio humano los casos que fallan -- antes solo se guardaba passed/hits, # lo que hacia imposible revisar despues que dijo realmente el modelo. if kind == "adherencia": passed, hits = check_adherencia(response, expected) rows.append({ "skill": skill, "kind": kind, "prompt": prompt, "passed": passed, "hits": hits, "content": response["content"], "reasoning": response["reasoning"], }) print(f" {'OK ' if passed else 'FAIL'} {skill:20s} adherencia hits={hits}") else: passed = check_no_activacion(response) rows.append({ "skill": skill, "kind": kind, "prompt": prompt, "passed": passed, "tool_calls": [tc["function"]["name"] for tc in response["tool_calls"]], "content": response["content"], "reasoning": response["reasoning"], }) print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}") # Checklists de contenido (Fase 6): mismas claves en las filas que las de arriba, mas # `check` y `criterio`, para que el JSON siga siendo legible por lo que ya existia. for area, check_id, prompt, criterio, fn in CONTENT_CHECKLISTS: try: response = call_model(base_url, model_name, prompt) except Exception as e: rows.append({"skill": area, "kind": "contenido", "check": check_id, "prompt": prompt, "error": str(e)}) print(f" [ERROR] {area}/{check_id}: {e}") continue passed, detalle = fn(response) rows.append({ "skill": area, "kind": "contenido", "check": check_id, "criterio": criterio, "prompt": prompt, "passed": passed, "hits": detalle, "content": response["content"], "reasoning": response["reasoning"], }) print(f" {'OK ' if passed else 'FAIL'} {area:20s} contenido:{check_id:28s} {detalle}") return rows def main(): eval_rows = run_checklist(EVAL_URL, EVAL_MODEL, "checkpoint mergeado (vllm-eval)") baseline_rows = None if is_prod_available(): print("\n[INFO] vllm-qwen36 (produccion) detectado corriendo -- midiendo baseline real") baseline_rows = run_checklist(PROD_URL, PROD_MODEL, "produccion (vllm-qwen36)") else: print( "\n[INFO] vllm-qwen36 no esta corriendo en este momento -- baseline de produccion " "queda documentado como PENDIENTE, no bloquea el resto de la puerta 3" ) eval_pass_rate = sum(1 for r in eval_rows if r.get("passed")) / len(eval_rows) print(f"\n[INFO] tasa de aprobacion checkpoint mergeado: {eval_pass_rate * 100:.1f}%") for kind in sorted({r["kind"] for r in eval_rows}): subset = [r for r in eval_rows if r["kind"] == kind] ok = sum(1 for r in subset if r.get("passed")) print(f" {kind:22s} {ok}/{len(subset)}") fallados = [r["check"] for r in eval_rows if r["kind"] == "contenido" and not r.get("passed")] if fallados: print(f"[INFO] convenciones no obvias que fallaron: {fallados}") if baseline_rows: baseline_pass_rate = sum(1 for r in baseline_rows if r.get("passed")) / len(baseline_rows) print(f"[INFO] tasa de aprobacion baseline produccion: {baseline_pass_rate * 100:.1f}%") if eval_pass_rate < baseline_pass_rate: print( "[DECISION] la puerta 3 muestra que NO hay mejora sobre el baseline -- " "esto es un bloqueo real segun las reglas de la fase, notificar al usuario " "antes de recomendar pasar a Fase 5" ) with open(RESULTS_PATH, "w", encoding="utf-8") as f: json.dump({ "eval": eval_rows, "baseline": baseline_rows, "baseline_disponible": baseline_rows is not None, }, f, ensure_ascii=False, indent=2) print(f"\n[INFO] resultados detallados en {RESULTS_PATH}") if __name__ == "__main__": main()