Phase 4: merge LoRA adapter and run full evaluation gates on the merged checkpoint #3

Merged
aleleba merged 4 commits from agente-fase4-merge-eval into master 2026-07-29 13:20:01 -06:00
7 changed files with 1090 additions and 0 deletions
Showing only changes of commit ceba5f80cd - Show all commits
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
services:
vllm-eval:
image: vllm/vllm-openai:cu130-nightly-aarch64
container_name: vllm-eval
restart: "no"
ipc: host
ports:
- "8001:8000"
volumes:
- /home/aleleba/ft-models/Qwen3.6-35B-A3B-mcp-bf16:/model:ro
command:
- "--model=/model"
- "--served-model-name=qwen3.6-35b-a3b-mcp-bf16"
- "--tensor-parallel-size=1"
- "--max-model-len=32768"
- "--enable-auto-tool-choice"
- "--tool-call-parser=qwen3_coder"
- "--reasoning-parser=qwen3"
- "--default-chat-template-kwargs={\"preserve_thinking\": true}"
- "--trust-remote-code"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
+146
View File
@@ -0,0 +1,146 @@
"""Fase 4 -- suite de evaluacion en 4 puertas.
Puerta 1 (--gate 1): eval-loss offline por bucket sobre el checkpoint MERGEADO
(no el adapter puro) -- no necesita servir el modelo. Corre DENTRO del
contenedor qwen-lora-train en spark:
docker exec qwen-lora-train python3 \
/workspace/ai-projects/qwen3-6-lora/.worktrees/agente-fase4-merge-eval/scripts/30_eval_suite.py --gate 1
Carga el checkpoint mergeado con AutoModelForCausalLM (para detectar bugs de
merge que un eval sobre el adapter puro no veria), le pisa en memoria el
chat_template con data/chat_template_train.jinja (igual que en training, para
poder generar assistant_masks), recorre data/eval.jsonl agrupado por
meta.bucket, y reporta loss promedio global y por bucket (aislando
bucket=="replay"), comparado contra eval_loss=0.275 de Fase 3.
Las puertas 2-4 (tool-calls, adherencia, E2E) viven en scripts separados
(scripts/31_gate2_toolcalls.py, scripts/32_gate3_adherencia.py,
scripts/33_gate4_e2e.py) porque necesitan el contenedor de eval sirviendo el
checkpoint mergeado via HTTP, no solo lectura offline.
"""
import argparse
import json
import os
import time
from collections import defaultdict
from pathlib import Path
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
REPO_ROOT = Path(__file__).resolve().parent.parent
OUTPUT_PATH = os.environ.get("OUTPUT_PATH", "/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16")
TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja"
EVAL_FILE = REPO_ROOT / "data" / "eval.jsonl"
FASE3_EVAL_LOSS = 0.275
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--gate", type=int, required=True, choices=[1])
return parser.parse_args()
def load_eval_examples():
examples = []
with open(EVAL_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
examples.append(json.loads(line))
return examples
def compute_loss_per_example(model, tokenizer, example):
rendered = tokenizer.apply_chat_template(
example["messages"],
tools=example.get("tools"),
tokenize=True,
return_assistant_tokens_mask=True,
return_dict=True,
add_generation_prompt=False,
)
input_ids = rendered["input_ids"]
assistant_masks = rendered["assistant_masks"]
if sum(assistant_masks) == 0:
raise AssertionError("assistant_masks vacia para un ejemplo de eval.jsonl")
labels = [tok if mask == 1 else -100 for tok, mask in zip(input_ids, assistant_masks)]
input_ids_t = torch.tensor([input_ids], dtype=torch.long, device=model.device)
labels_t = torch.tensor([labels], dtype=torch.long, device=model.device)
with torch.no_grad():
out = model(input_ids=input_ids_t, labels=labels_t)
return out.loss.item()
def run_gate1():
print(f"[INFO] cargando checkpoint mergeado desde {OUTPUT_PATH}")
tokenizer = AutoTokenizer.from_pretrained(OUTPUT_PATH)
tokenizer.chat_template = TRAIN_CHAT_TEMPLATE_PATH.read_text(encoding="utf-8")
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
t0 = time.time()
model = AutoModelForCausalLM.from_pretrained(
OUTPUT_PATH,
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)
model = model.to("cuda")
model.eval()
load_time = time.time() - t0
print(f"[INFO] modelo cargado en {load_time:.1f}s")
examples = load_eval_examples()
print(f"[INFO] {len(examples)} ejemplos en {EVAL_FILE}")
torch.cuda.reset_peak_memory_stats()
t0 = time.time()
losses_by_bucket = defaultdict(list)
for i, example in enumerate(examples):
bucket = example.get("meta", {}).get("bucket", "sin_bucket")
loss = compute_loss_per_example(model, tokenizer, example)
losses_by_bucket[bucket].append(loss)
if (i + 1) % 25 == 0:
print(f"[INFO] {i + 1}/{len(examples)} ejemplos evaluados")
eval_time = time.time() - t0
peak_mem_gb = torch.cuda.max_memory_allocated() / (1024 ** 3)
all_losses = [loss for losses in losses_by_bucket.values() for loss in losses]
global_avg = sum(all_losses) / len(all_losses)
print("\n=== Puerta 1 -- eval-loss offline por bucket (checkpoint mergeado) ===")
print(f"[INFO] tiempo de eval: {eval_time:.1f}s, memoria pico: {peak_mem_gb:.2f} GB")
for bucket in sorted(losses_by_bucket):
losses = losses_by_bucket[bucket]
avg = sum(losses) / len(losses)
print(f" bucket={bucket:20s} n={len(losses):4d} loss_avg={avg:.4f}")
replay_losses = losses_by_bucket.get("replay")
if replay_losses:
replay_avg = sum(replay_losses) / len(replay_losses)
print(f" bucket=replay (aislado) n={len(replay_losses):4d} loss_avg={replay_avg:.4f}")
print(f"\n loss_avg GLOBAL (checkpoint mergeado) = {global_avg:.4f}")
print(f" eval_loss Fase 3 (adapter puro, sanity) = {FASE3_EVAL_LOSS:.4f}")
diff = abs(global_avg - FASE3_EVAL_LOSS)
print(f" diferencia absoluta = {diff:.4f}")
if diff > 0.05:
print(
" [WARN] diferencia > 0.05 -- senal posible de bug real en el merge, "
"revisar antes de continuar a la puerta 2"
)
else:
print(" [OK] loss del checkpoint mergeado consistente con Fase 3 -- merge probablemente correcto")
def main():
args = parse_args()
if args.gate == 1:
run_gate1()
if __name__ == "__main__":
main()
+182
View File
@@ -0,0 +1,182 @@
"""Fase 4 -- genera data/holdout_prompts.jsonl: ~200 prompts held-out para la Puerta 2
(validez de tool-calls), cubriendo los 5 MCPs, sin overlap con train.jsonl/eval.jsonl.
Cada linea: {"prompt": "...", "mcp": "penpot|gitea|github-personal|docmost|atlassian",
"tools": [...schema real del MCP...]}. Corre localmente, no requiere GPU.
Variacion deterministica (random.seed(43), semilla distinta de la de 05_build_dataset.py
para no reusar la misma secuencia) sobre plantillas por MCP -- nunca copia textual de un
ejemplo de train/eval (se verifica al final contra el texto normalizado de ambos archivos).
"""
import json
import random
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMAS_DIR = REPO_ROOT / "data" / "schemas"
TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl"
EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl"
OUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
SEED = 43
TARGET_TOTAL = 200
MCP_TARGETS = {
"penpot": 40,
"gitea": 40,
"github-personal": 40,
"docmost": 40,
"atlassian": 40,
}
PENPOT_TEMPLATES = [
"Crea un rectangulo de {w}x{h} en el board '{board}' con color {color}.",
"Necesito un texto que diga '{text}' dentro del board '{board}', alineado a la izquierda.",
"Cambia el fill del shape '{shape}' a {color} y verifica que se aplico.",
"Agrega un board nuevo llamado '{board}' de {w}x{h} px en la pagina actual.",
"Exporta el shape '{shape}' como PNG a 2x de resolucion.",
"Lista los shapes del board '{board}' y decime cuales son grupos.",
"Mueve el shape '{shape}' a la posicion x={w}, y={h} dentro de '{board}'.",
"Crea un boolean de union entre '{shape}' y otro rectangulo superpuesto.",
]
GITEA_TEMPLATES = [
"Crea una rama llamada '{branch}' en el repo '{repo}' desde main.",
"Abri un issue en '{repo}' titulado '{text}' con la label 'bug'.",
"Lista los pull requests abiertos del repo '{repo}'.",
"Crea un release '{branch}' en '{repo}' con las notas '{text}'.",
"Busca commits recientes en '{repo}' que mencionen '{text}'.",
"Agrega un comentario '{text}' al issue numero {num} de '{repo}'.",
"Revisa el estado de los actions/workflows del repo '{repo}'.",
"Mergea (solo si el usuario lo pide explicitamente) el PR numero {num} de '{repo}'.",
]
GITHUB_TEMPLATES = [
"Crea un pull request en '{repo}' desde la rama '{branch}' hacia main, titulo '{text}'.",
"Lista los issues abiertos de '{repo}' con label 'enhancement'.",
"Agrega un comentario '{text}' al PR numero {num} de '{repo}'.",
"Busca en '{repo}' el codigo que define la funcion '{text}'.",
"Crea un issue en '{repo}' titulado '{text}' asignado a mi usuario.",
"Revisa los commits recientes de la rama '{branch}' en '{repo}'.",
"Lista los releases publicados de '{repo}'.",
"Solicita una review de Copilot para el PR numero {num} de '{repo}'.",
]
DOCMOST_TEMPLATES = [
"Crea una pagina llamada '{text}' en el space '{repo}' con una tabla de {num} columnas.",
"Busca en Docmost paginas que mencionen '{text}'.",
"Actualiza la pagina '{text}' agregando una fila mas a la tabla existente.",
"Comenta '{text}' en la pagina con id conocido del space '{repo}'.",
"Lista las paginas del space '{repo}' ordenadas por actualizacion reciente.",
"Crea una subpagina '{text}' bajo la pagina principal del space '{repo}'.",
"Revisa si hay comentarios nuevos en el space '{repo}' desde ayer.",
"Mueve la pagina '{text}' a otro parent dentro del space '{repo}'.",
]
ATLASSIAN_TEMPLATES = [
"Crea un issue de Jira en el proyecto '{repo}' titulado '{text}', tipo Bug.",
"Busca issues de Jira asignados a mi usuario con JQL en el proyecto '{repo}'.",
"Agrega un comentario '{text}' al issue {repo}-{num} de Jira.",
"Transiciona el issue {repo}-{num} a 'In Progress'.",
"Crea una pagina de Confluence '{text}' en el espacio '{repo}'.",
"Busca en Confluence paginas del espacio '{repo}' que mencionen '{text}'.",
"Agrega un worklog de 1h al issue {repo}-{num} con el comentario '{text}'.",
"Lista los tipos de issue disponibles en el proyecto '{repo}' de Jira.",
]
MCP_TEMPLATES = {
"penpot": PENPOT_TEMPLATES,
"gitea": GITEA_TEMPLATES,
"github-personal": GITHUB_TEMPLATES,
"docmost": DOCMOST_TEMPLATES,
"atlassian": ATLASSIAN_TEMPLATES,
}
WORDS = [
"dashboard", "login", "checkout", "onboarding", "reporte semanal", "bugfix urgente",
"modal de confirmacion", "flujo de pago", "endpoint de usuarios", "cache de sesion",
"migracion de datos", "notificaciones push", "tema oscuro", "landing page",
"formulario de contacto", "panel de admin", "integracion con Stripe", "tabla de precios",
"footer del sitio", "header responsive",
]
REPOS = ["backend-core", "frontend-app", "infra-tools", "data-pipeline", "mobile-client"]
BRANCHES = ["feature/nueva-vista", "fix/timeout-api", "chore/deps", "hotfix/prod", "feature/dark-mode"]
BOARDS = ["Dashboard Principal", "Mobile Screens", "Design System", "Landing v2", "Checkout Flow"]
SHAPES = ["boton-cta", "card-producto", "icono-menu", "titulo-principal", "footer-logo"]
COLORS = ["#1a73e8", "#e8710a", "#188038", "#d93025", "#9334e6", "#12b5cb"]
def load_tools(mcp_name):
return json.loads((SCHEMAS_DIR / f"{mcp_name}.json").read_text(encoding="utf-8"))
def normalize(text):
return " ".join(text.lower().split())
def load_existing_texts():
texts = set()
for path in (TRAIN_PATH, EVAL_PATH):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
example = json.loads(line)
for msg in example.get("messages", []):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
texts.add(normalize(msg["content"]))
return texts
def build_prompts(rng, mcp_name, count):
templates = MCP_TEMPLATES[mcp_name]
prompts = []
for i in range(count):
template = templates[i % len(templates)]
text = template.format(
w=rng.choice([80, 120, 200, 320, 480]),
h=rng.choice([40, 60, 100, 240, 360]),
board=rng.choice(BOARDS),
color=rng.choice(COLORS),
shape=f"{rng.choice(SHAPES)}-{rng.randint(1, 99)}",
text=f"{rng.choice(WORDS)} {rng.randint(1, 999)}",
repo=rng.choice(REPOS),
branch=f"{rng.choice(BRANCHES)}-{rng.randint(1, 99)}",
num=rng.randint(1, 500),
)
prompts.append(text)
return prompts
def main():
rng = random.Random(SEED)
existing_texts = load_existing_texts()
print(f"[INFO] {len(existing_texts)} prompts de usuario existentes en train.jsonl/eval.jsonl")
examples = []
for mcp_name, count in MCP_TARGETS.items():
tools = load_tools(mcp_name)
prompts = build_prompts(rng, mcp_name, count)
for prompt in prompts:
if normalize(prompt) in existing_texts:
raise AssertionError(f"prompt held-out colisiona con train/eval: {prompt!r}")
examples.append({"prompt": prompt, "mcp": mcp_name, "tools": tools})
rng.shuffle(examples)
if len(examples) < TARGET_TOTAL:
raise AssertionError(f"solo se generaron {len(examples)} prompts, se esperaban >= {TARGET_TOTAL}")
with open(OUT_PATH, "w", encoding="utf-8") as f:
for ex in examples:
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
print(f"[INFO] {len(examples)} prompts held-out escritos en {OUT_PATH}")
for mcp_name in MCP_TARGETS:
n = sum(1 for ex in examples if ex["mcp"] == mcp_name)
print(f" {mcp_name}: {n}")
if __name__ == "__main__":
main()
+187
View File
@@ -0,0 +1,187 @@
"""Fase 4 -- Puerta 2: validez de tool-calls contra el parser real de vLLM.
Corre LOCALMENTE (no necesita GPU) contra el endpoint HTTP del contenedor de eval propio
(vllm-eval, docker-compose.eval.yml, puerto 8001 por defecto) ya levantado y respondiendo
en /v1/models.
Para cada prompt de data/holdout_prompts.jsonl (~200, generados por
scripts/31_build_holdout_prompts.py, sin overlap con train/eval): envia una sola llamada a
/v1/chat/completions con las tools reales del MCP correspondiente y
tool_choice="auto". El parseo de tool_calls (`--tool-call-parser=qwen3_coder`,
configurado en docker-compose.eval.yml) lo hace vLLM en el servidor -- este script solo
valida la RESPUESTA ya parseada (nunca re-implementa el parser con una regex propia):
- Si el modelo decide llamar una tool: valida que el nombre exista en el schema del MCP,
que los argumentos parseen como JSON valido, y que las propiedades "required" del
schema esten presentes.
- Si el modelo NO llama ninguna tool: se cuenta aparte (no es un error per se, algunos
prompts pueden resolverse sin tool-call, pero se reporta la tasa).
Reporta: % de prompts con tool_call sintacticamente valido (parseado sin excepcion por
vLLM, arguments=JSON valido, nombre y campos requeridos correctos) por MCP y global.
"""
import argparse
import json
import os
import sys
import time
from collections import defaultdict
from pathlib import Path
import requests
REPO_ROOT = Path(__file__).resolve().parent.parent
HOLDOUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
RESULTS_PATH = REPO_ROOT / "data" / "gate2_results.json"
BASE_URL = os.environ.get("VLLM_EVAL_URL", "http://localhost:8001")
MODEL_NAME = os.environ.get("VLLM_EVAL_MODEL", "qwen3.6-35b-a3b-mcp-bf16")
def load_holdout():
examples = []
with open(HOLDOUT_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
examples.append(json.loads(line))
return examples
def tool_by_name(tools, name):
for tool in tools:
if tool.get("name") == name or tool.get("function", {}).get("name") == name:
return tool
return None
def to_openai_tools(tools):
openai_tools = []
for tool in tools:
if "function" in tool:
openai_tools.append(tool)
else:
openai_tools.append({
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("inputSchema") or tool.get("parameters") or {"type": "object", "properties": {}},
},
})
return openai_tools
def validate_tool_call(tool_call, tools):
name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"]
try:
args = json.loads(raw_args)
except json.JSONDecodeError as e:
return False, f"arguments no es JSON valido: {e}"
tool_def = tool_by_name(tools, name)
if tool_def is None:
return False, f"tool_call a nombre inexistente en el schema del MCP: {name}"
schema = tool_def.get("inputSchema") or tool_def.get("parameters") or {}
required = schema.get("required", [])
missing = [r for r in required if r not in args]
if missing:
return False, f"faltan campos requeridos {missing} en la llamada a {name}"
return True, None
def call_vllm(prompt, tools, timeout=120):
payload = {
"model": MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"tools": to_openai_tools(tools),
"tool_choice": "auto",
"max_tokens": 1024,
"temperature": 0.0,
}
resp = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=None)
args = parser.parse_args()
examples = load_holdout()
if args.limit:
examples = examples[: args.limit]
print(f"[INFO] {len(examples)} prompts held-out, endpoint={BASE_URL}")
results = []
stats = defaultdict(lambda: {"total": 0, "valid_tool_call": 0, "no_tool_call": 0, "invalid": 0})
t0 = time.time()
for i, ex in enumerate(examples):
mcp = ex["mcp"]
stats[mcp]["total"] += 1
stats["__global__"]["total"] += 1
try:
response = call_vllm(ex["prompt"], ex["tools"])
except Exception as e:
results.append({"mcp": mcp, "prompt": ex["prompt"], "error": str(e)})
stats[mcp]["invalid"] += 1
stats["__global__"]["invalid"] += 1
continue
message = response["choices"][0]["message"]
tool_calls = message.get("tool_calls") or []
if not tool_calls:
stats[mcp]["no_tool_call"] += 1
stats["__global__"]["no_tool_call"] += 1
results.append({"mcp": mcp, "prompt": ex["prompt"], "tool_calls": None, "valid": None})
continue
all_valid = True
errors = []
for tc in tool_calls:
ok, err = validate_tool_call(tc, ex["tools"])
if not ok:
all_valid = False
errors.append(err)
if all_valid:
stats[mcp]["valid_tool_call"] += 1
stats["__global__"]["valid_tool_call"] += 1
else:
stats[mcp]["invalid"] += 1
stats["__global__"]["invalid"] += 1
results.append({
"mcp": mcp,
"prompt": ex["prompt"],
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
"valid": all_valid,
"errors": errors,
})
if (i + 1) % 20 == 0:
print(f"[INFO] {i + 1}/{len(examples)} prompts procesados")
dt = time.time() - t0
print(f"\n=== Puerta 2 -- validez de tool-calls (parser real de vLLM) ===")
print(f"[INFO] tiempo total: {dt:.1f}s\n")
for mcp in sorted(stats):
s = stats[mcp]
pct_valid = 100 * s["valid_tool_call"] / s["total"] if s["total"] else 0
print(
f" {mcp:20s} total={s['total']:4d} valid={s['valid_tool_call']:4d} "
f"no_tool_call={s['no_tool_call']:4d} invalid={s['invalid']:4d} "
f"pct_valid={pct_valid:.1f}%"
)
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
json.dump({"stats": stats, "results": results}, f, ensure_ascii=False, indent=2)
print(f"\n[INFO] resultados detallados en {RESULTS_PATH}")
if __name__ == "__main__":
main()
+202
View File
@@ -0,0 +1,202 @@
"""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.
Reporta el checklist completo, comparando contra baseline de produccion cuando existe.
"""
import json
import os
import time
from pathlib import Path
import requests
REPO_ROOT = Path(__file__).resolve().parent.parent
RESULTS_PATH = REPO_ROOT / "data" / "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")
# 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,
),
]
def call_model(base_url, model_name, prompt):
payload = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512,
"temperature": 0.0,
}
resp = requests.post(f"{base_url}/v1/chat/completions", json=payload, timeout=120)
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
if kind == "adherencia":
passed, hits = check_adherencia(response, expected)
rows.append({"skill": skill, "kind": kind, "prompt": prompt, "passed": passed, "hits": hits})
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"]],
})
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}")
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}%")
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()
+146
View File
@@ -0,0 +1,146 @@
"""Fase 4 -- Puerta 4: prueba end-to-end real contra los 5 MCPs y las 5 skills.
Este script arma, para cada uno de los 5 MCPs, un prompt real + las tools reales de ese
MCP, y llama al endpoint del contenedor de eval propio (vllm-eval). Si el checkpoint
mergeado decide llamar una tool, este script EJECUTA REALMENTE esa llamada contra el MCP
correspondiente (nunca la simula) usando las credenciales/tools ya disponibles en este
entorno, y registra si la ejecucion real tuvo exito.
Requiere correr con acceso a los MCPs reales (gitea, github-personal, docmost, atlassian,
penpot) -- por eso este script expone un modo "--dry-run-plan" que solo imprime el plan
de llamadas a ejecutar (para revision humana antes de tocar servicios reales) y un modo
normal que las ejecuta.
IMPORTANTE: las acciones reales contra Gitea/GitHub/Docmost/Atlassian pueden crear
recursos (issues, paginas, comentarios) -- se usan siempre operaciones de bajo impacto y
reversibles (crear un issue/pagina de prueba con prefijo "[eval-fase4]", nunca mergear
PRs ni borrar nada), documentadas en el reporte de resultados para poder limpiarlas
despues si hace falta.
Las 5 skills (docmost-context, spark-ssh, aleleba-pr, web-ui-test, agent-orchestrator) se
prueban de forma cualitativa: se le pide al checkpoint mergeado un prompt que
naturalmente requiere invocar cada skill, y se verifica (igual que en la puerta 3, pero
sobre tareas reales en vez de checklists cortos) que la respuesta sigue el flujo
documentado de la skill.
"""
import argparse
import json
import os
from pathlib import Path
import requests
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMAS_DIR = REPO_ROOT / "data" / "schemas"
RESULTS_PATH = REPO_ROOT / "data" / "gate4_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")
MCP_E2E_PROMPTS = {
"gitea": "Lista los pull requests abiertos del repo aleleba/qwen3-6-lora.",
"github-personal": "Lista mis repos de GitHub (get_me primero si hace falta).",
"docmost": "Lista los spaces disponibles en Docmost.",
"atlassian": "Busca los proyectos de Jira visibles con getVisibleJiraProjects.",
"penpot": "Dame el overview de alto nivel del proyecto Penpot conectado.",
}
SKILL_E2E_PROMPTS = {
"aleleba-pr": "Ya tengo cambios listos en una rama, aplica aleleba-pr para subirlos y abrir el PR.",
"docmost-context": "Arranca esta conversacion cargando el contexto de Docmost del proyecto actual.",
"agent-orchestrator": "Lanza un agente en background para revisar los logs de error de ayer.",
"web-ui-test": "Prueba el flujo de checkout de la web app y saca capturas.",
"spark-ssh": "Conectate a spark y revisa cuanto espacio libre queda en disco.",
}
def load_tools(mcp_name):
return json.loads((SCHEMAS_DIR / f"{mcp_name}.json").read_text(encoding="utf-8"))
def to_openai_tools(tools):
openai_tools = []
for tool in tools:
if "function" in tool:
openai_tools.append(tool)
else:
openai_tools.append({
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("inputSchema") or tool.get("parameters") or {"type": "object", "properties": {}},
},
})
return openai_tools
def call_vllm(prompt, tools=None):
payload = {
"model": EVAL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1024,
"temperature": 0.0,
}
if tools:
payload["tools"] = to_openai_tools(tools)
payload["tool_choice"] = "auto"
resp = requests.post(f"{EVAL_URL}/v1/chat/completions", json=payload, timeout=180)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]
def plan_mcp_calls(dry_run):
results = {}
for mcp_name, prompt in MCP_E2E_PROMPTS.items():
tools = load_tools(mcp_name)
message = call_vllm(prompt, tools)
tool_calls = message.get("tool_calls") or []
plan = [{"name": tc["function"]["name"], "arguments": tc["function"]["arguments"]} for tc in tool_calls]
results[mcp_name] = {
"prompt": prompt,
"content": message.get("content"),
"planned_tool_calls": plan,
"executed": False,
}
print(f"[PLAN] {mcp_name}: {len(plan)} tool_call(s) propuestas -> {[p['name'] for p in plan]}")
return results
def plan_skill_calls():
results = {}
for skill_name, prompt in SKILL_E2E_PROMPTS.items():
message = call_vllm(prompt)
results[skill_name] = {
"prompt": prompt,
"content": message.get("content"),
"reasoning": message.get("reasoning"),
}
print(f"[PLAN] skill={skill_name}: respuesta de {len(message.get('content') or '')} caracteres registrada")
return results
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run-plan", action="store_true",
help="solo generar el plan de llamadas via el checkpoint mergeado, sin ejecutarlas contra los MCPs reales")
args = parser.parse_args()
print("=== Puerta 4 -- E2E real contra 5 MCPs y 5 skills ===\n")
mcp_results = plan_mcp_calls(dry_run=args.dry_run_plan)
skill_results = plan_skill_calls()
if args.dry_run_plan:
print(
"\n[INFO] modo --dry-run-plan: las llamadas propuestas NO se ejecutaron contra "
"los MCPs reales todavia. El agente orquestador (con los MCPs ya conectados en "
"su propia sesion) debe revisar data/gate4_results.json y ejecutar cada "
"planned_tool_calls que considere segura, registrando el resultado real."
)
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
json.dump({"mcp": mcp_results, "skills": skill_results}, f, ensure_ascii=False, indent=2)
print(f"\n[INFO] resultados en {RESULTS_PATH}")
if __name__ == "__main__":
main()