Dataset build (05, 06): perturb_value is gone. It rewrote only tool_calls.arguments and left the tool results and the final answer saying something else, which is how data/train.jsonl ended up with 30 self-contradictory examples where the call says issue_number 82 and the answer says issue #77. Variation now comes from hand-written meta.paraphrases, or from meta.variation applied atomically across every field of the example at once. Nothing is substituted unless the seed declares it: guessing which number in a string is safe to change is what produced the contradictions in the first place. Prefix injection survives only as a fallback and only where the verb form can actually be conjugated, and there is a hard assert that no user turn matches the broken "Necesito que ¿Podés..." shape that 68 v1 prompts had. The penpot bucket is exempt from substitution entirely, since its payloads are code. Also asserts the bucket cannot collapse (verified: the old seeds give 320 rows from 83 unique trajectories and the build now fails) and scans for forbidden API patterns by importing them from the linter, so there is one source of truth. 06 now actually exits 1 on over-length rows. It printed [FILTERED], incremented a counter, and left the row in the file, which 10_train.py then trained on since it has no max_seq_length and batch 1. Gate 2 (32): reject any argument key absent from the schema, as its own failure category. It only checked required fields, so an invented scale or filePath passed - the gate was actively rewarding the exact behaviour this phase removes. Verified: export_shape with scale=2 now fails as unknown_argument, while a valid call still passes. Holdout (31, 35): rebalanced to penpot 60 / 35 each, added 20 real design templates, and replaced the full-string equality check with 6-gram shingles. Measured: a light paraphrase of a train.jsonl prompt scores 43% overlap and now fails the build, where the old check let it through at "not equal". Value pools are asserted disjoint from the corpus. The "2x resolution" template stays, relabelled as an invented-argument probe now that gate 2 can detect one; the createBoolean template stays because the API is real and the new B2 seeds teach it. Also dedupes: the old holdout had 15 duplicate prompts out of 200, i.e. 15 wasted measurements. Note: rebalancing the holdout means the 192/200 gate 2 baseline from phase 5 no longer applies to it, so that baseline has to be re-measured against production on the new file before it can be compared to. Gate 3 (33): 11 content checklists for the non-obvious conventions of the other MCPs - GFM table separators in Docmost, the update_page staleness retry, commit message shape, never merging the PR, dict-not-XML tool arguments. That is the most likely regression no gate currently covers. Mix builder: added the anti-collapse guard, so 420 new-portion rows that are really 96 trajectories repeated cannot pass unnoticed.
471 lines
19 KiB
Python
471 lines
19 KiB
Python
"""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"</?(?:function|parameter|invoke|antml)[^>]*>", 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()
|