Phase 4: merge LoRA adapter and run full evaluation gates on the merged checkpoint #3
File diff suppressed because one or more lines are too long
@@ -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]
|
||||||
@@ -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