Fase 4: puerta 1 (eval-loss offline por bucket) y contenedor/scripts de puertas 2-4
- scripts/30_eval_suite.py --gate 1: eval-loss sobre el checkpoint mergeado, agrupado por meta.bucket (aislando replay), comparado contra eval_loss=0.275 de Fase 3. - docker-compose.eval.yml: servicio vllm-eval propio (puerto 8001), sirviendo el checkpoint mergeado en BF16, con tool-call-parser=qwen3_coder y reasoning-parser=qwen3. No se pudo leer el compose real de produccion (/data/compose/43/docker-compose.yml no existe en spark, probablemente vive en el host del servidor Portainer) -- flags basados en la arquitectura conocida del modelo. - scripts/31_build_holdout_prompts.py: genera data/holdout_prompts.jsonl (200 prompts, 40 por MCP, sin overlap verificado contra train.jsonl/eval.jsonl). - scripts/32_gate2_toolcalls.py: valida tool-calls devueltas por vllm-eval (parser real de vLLM, nunca una regex propia) contra los 200 prompts held-out. - scripts/33_gate3_adherencia.py: checklists de adherencia por skill + no-activacion, con baseline opcional contra vllm-qwen36 si esta corriendo. - scripts/34_gate4_e2e.py: arma el plan de llamadas E2E contra los 5 MCPs y 5 skills via el checkpoint mergeado, para que el agente orquestador las ejecute con sus MCPs reales.
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user