07_build_lora2_mix.py assembles train_lora2.jsonl (900), eval_lora2.jsonl and calibration_v2.jsonl from the new Penpot seeds plus a filtered replay sample of data/train.jsonl. It never writes data/train.jsonl or data/eval.jsonl: those are the provenance of the model in production and the gate 1 baseline, and regenerating them is not idempotent anyway, since stratified_split shuffles one RNG over the concatenated list, so touching the penpot bucket reshuffles every other bucket's split too. Two things worth flagging in the mix: The 45 "corrected penpot basics" the plan lists inside the replay portion do not come from data/train.jsonl. 21 of its 41 penpot seeds teach findShapeById(page, id) and 5 use shape.layout, so sampling that bucket would re-teach the exact bug this phase removes; the forbidden-pattern filter would drop them anyway. They come from the new corpus instead. This is a conscious deviation from the plan text and is recorded in the docstring. Variation comes only from hand-written meta.paraphrases, never from automatic value substitution. That is the v1 lesson: perturb_value rewrote only tool_calls.arguments and left the tool results and the final answer saying something else, producing 30 self-contradictory examples. A perturbed Penpot code payload is just broken code. Linter fixes, both false positives found by running it against the real corpus: - flex evidence for a bare appendChild is now scoped to the whole seed rather than the single payload. A multi-call seed builds the flex board in call one and stashes helpers in storage, so by the time call two does main.appendChild(...) neither addFlexLayout( nor .flex appears in that payload. The old scope flagged exactly the storage-persistence pattern that execute_code's own description asks for. - a grey hex is a problem when it is applied, not when it is searched for. The repair seeds have to name the greys they are about to replace, so greys are allowed in that group inside a comparison context.
673 lines
31 KiB
Python
673 lines
31 KiB
Python
"""Fase 6: lint estatico del bucket de Penpot. Hard-fail sobre patrones de API prohibidos,
|
|
grises de placeholder, tool results fabricados y desbalance de cobertura.
|
|
|
|
Corre LOCALMENTE. No necesita GPU, ni el modelo, ni el tokenizer -- es a proposito: tiene que
|
|
poder correrse cientos de veces mientras se escriben los seeds, sin depender de spark.
|
|
|
|
python3 scripts/07_lint_penpot_code.py [archivo.jsonl ...]
|
|
|
|
Sin argumentos lintea data/raw/seeds/penpot.jsonl. Con argumentos lintea los archivos dados
|
|
(util para lintear las partes de data/raw/seeds/_parts/ antes de concatenarlas).
|
|
|
|
POR QUE EXISTE
|
|
--------------
|
|
Los 41 seeds viejos ensenaban tres formas de API que no existen (findShapeById de 2 argumentos,
|
|
shape.layout, createText() sin argumento) y contenian resultados de penpot_api_info fabricados a
|
|
mano que afirmaban hechos falsos como si los hubiera dicho el servidor. Nada lo detectaba: el
|
|
dataset se veia perfectamente valido. Este script convierte cada una de esas fallas en un error
|
|
mecanico, para que un corpus a medio autorar falle ruidosamente en vez de reproducir el problema.
|
|
|
|
`node --check` es opcional: si node no esta instalado se avisa y se saltea esa verificacion (el
|
|
resto del lint corre igual). Con node presente, cada payload se envuelve en una funcion async y
|
|
se verifica que parsee.
|
|
"""
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMAS = REPO_ROOT / "data" / "schemas"
|
|
API_DOCS = SCHEMAS / "penpot_api_docs.md"
|
|
API_ERRORS = SCHEMAS / "penpot_errors.md"
|
|
SYSTEM_PROMPT = SCHEMAS / "penpot_system_prompt.md"
|
|
PENPOT_TOOLS = SCHEMAS / "penpot.json"
|
|
DEFAULT_TARGET = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
|
|
|
|
# Los tool results de execute_code de este grupo muestran, a proposito, un estado inicial lleno
|
|
# de grises de placeholder: es el INPUT que el asistente audita y repara. Es el unico lugar donde
|
|
# un gris puede aparecer, y nunca en el `code` que el asistente escribe.
|
|
GREY_INPUT_GROUP = "B8"
|
|
|
|
# Umbrales de cobertura. Existen para que un corpus a medio autorar falle: sin esto, es
|
|
# perfectamente posible escribir 96 seeds que reproduzcan el desbalance actual (1 addFlexLayout,
|
|
# 0 addGridLayout, 0 layoutChild) y que el lint pase igual.
|
|
COVERAGE_MIN = {
|
|
"addGridLayout": 5,
|
|
"shadows": 8,
|
|
"uploadMediaUrl": 6,
|
|
"layoutChild.horizontalSizing": 6,
|
|
"addFlexLayout": 12,
|
|
"borderRadius": 8,
|
|
"fillColorGradient": 4,
|
|
"library.local": 5,
|
|
"export_shape": 8,
|
|
}
|
|
MIN_UNIQUE_PAYLOADS = 130
|
|
MAX_HIGH_LEVEL_OVERVIEW_CALLS = 2
|
|
SYSTEM_SHARE_RANGE = (0.20, 0.45) # ~30% objetivo, con margen
|
|
|
|
UUID4_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
|
|
HEX_RE = re.compile(r"#[0-9a-fA-F]{6}\b")
|
|
TOY_ID_RE = re.compile(r"^(r|fb|s|shape|board|txt|t)[-_]?\d{1,3}$", re.I)
|
|
|
|
|
|
# --------------------------------------------------------------------------------------------
|
|
# Patrones prohibidos en los payloads de `code`
|
|
# --------------------------------------------------------------------------------------------
|
|
# Cada entrada: (nombre, regex, explicacion). La explicacion se imprime con el hallazgo, porque
|
|
# un lint que solo dice "linea 42: PATRON_7" obliga a ir a leer el lint para entender el error.
|
|
FORBIDDEN = [
|
|
(
|
|
"findShapeById con 2 argumentos",
|
|
re.compile(r"findShapeById\s*\([^)]*,"),
|
|
"findShapeById tiene aridad 1. La forma de 2 argumentos devuelve null SIN lanzar, y "
|
|
"revienta en la linea siguiente. Usar findShapeById(id).",
|
|
),
|
|
(
|
|
"propiedad .layout inexistente",
|
|
re.compile(r"\.layout\b(?!Child|Cell)"),
|
|
"shape.layout no existe ('layout' in shape === false). Son board.flex y board.grid. "
|
|
"La clave `layout` que ves en la salida de shapeStructure() es del OUTPUT del helper, "
|
|
"no una propiedad del shape.",
|
|
),
|
|
(
|
|
"flex.appendChild",
|
|
re.compile(r"\.flex\s*\.\s*appendChild\s*\("),
|
|
"board.flex.appendChild esta roto. Usar board.appendChild(shape), en orden visual.",
|
|
),
|
|
(
|
|
"fontSize/fontWeight/lineHeight/letterSpacing numerico",
|
|
re.compile(r"\.(fontSize|fontWeight|lineHeight|letterSpacing)\s*=\s*-?\d"),
|
|
"Son strings: fontSize = '48', no fontSize = 48.",
|
|
),
|
|
(
|
|
"textAlign",
|
|
re.compile(r"\.textAlign\s*="),
|
|
"Asignar textAlign LANZA (`Cannot add property textAlign, object is not extensible`) y "
|
|
"mata todo el execute_code. La propiedad es `align`.",
|
|
),
|
|
(
|
|
"propiedad color en Text",
|
|
re.compile(r"\b(text|txt|t|label|title|heading)\d*\s*\.\s*color\s*="),
|
|
"Text no tiene propiedad `color` y el objeto no es extensible: lanza. El color va en fills.",
|
|
),
|
|
(
|
|
"import_image / importImage / createImage / filePath",
|
|
re.compile(r"\b(importImage|import_image|createImage\s*\(|filePath)\b"),
|
|
"No existen en este deployment. Imagenes: await penpot.uploadMediaUrl(name, url).",
|
|
),
|
|
(
|
|
"asignacion a propiedad read-only",
|
|
re.compile(r"\.(width|height|parentX|parentY|boardX|boardY|bounds)\s*=(?!=)"),
|
|
"width/height/parentX/parentY/boardX/boardY/bounds son READ-ONLY. Usar resize(w,h) y "
|
|
"penpotUtils.setParentXY(shape, x, y).",
|
|
),
|
|
(
|
|
"shorthand gap",
|
|
re.compile(r"\.gap\s*="),
|
|
"No existe el shorthand `gap`. Son rowGap y columnGap.",
|
|
),
|
|
(
|
|
"Shadow.color como Fill",
|
|
re.compile(r"shadows\s*=\s*\[[^\]]*?color\s*:\s*\{[^}]*fillColor"),
|
|
"Shadow.color es un Color ({color, opacity}), no un Fill ({fillColor, fillOpacity}).",
|
|
),
|
|
(
|
|
"typography.setFont",
|
|
re.compile(r"\.setFont\s*\("),
|
|
"setFont figura en el tipo pero NO existe en el runtime (`t.setFont is not a function`). "
|
|
"Setear las propiedades de tipografia una por una.",
|
|
),
|
|
(
|
|
"createText sin argumento util",
|
|
re.compile(r"createText\s*\(\s*(''|\"\")?\s*\)"),
|
|
"createText() sin argumento y createText('') devuelven null. Es la forma que usa el "
|
|
"ejemplo de la documentacion oficial, y es la causa directa de la falla en produccion.",
|
|
),
|
|
(
|
|
"generateStyle con withChildren",
|
|
re.compile(r"withChildren\s*:"),
|
|
"La opcion real es `includeChildren`. `withChildren` (que usa el overview) se ignora en "
|
|
"silencio y el CSS sale sin los hijos.",
|
|
),
|
|
(
|
|
"layoutChild.horizontalSizing = 'fixed' o 'fit-content'",
|
|
re.compile(r"layoutChild\s*\.\s*(horizontal|vertical)Sizing\s*=\s*['\"](fixed|fit-content)['\"]"),
|
|
"Los valores de layoutChild son 'fill' | 'auto' | 'fix'. 'fit-content' es vocabulario de "
|
|
"FlexLayout (aplica al board contenedor), y 'fixed' no existe en ninguno de los dos.",
|
|
),
|
|
]
|
|
|
|
# `appendChild` solo es correcto sobre un board con flex (o `grid.appendChild(s, r, c)`). Sobre un
|
|
# padre sin layout hay que usar insertChild. No se puede resolver estaticamente en general, asi que
|
|
# la heuristica es exigir evidencia de flex.
|
|
#
|
|
# El alcance de esa evidencia es EL SEED ENTERO, no el payload. Un seed multi-llamada arma el board
|
|
# con flex en la primera llamada y guarda los helpers en `storage`; para cuando la segunda llamada
|
|
# hace `main.appendChild(...)`, ni `addFlexLayout(` ni `.flex` aparecen en ese payload. Buscar solo
|
|
# dentro del payload marcaba como error justo el patron de persistencia en `storage` que la
|
|
# descripcion de `execute_code` pide explicitamente.
|
|
APPEND_ONE_ARG_RE = re.compile(r"(\w+)\s*\.\s*appendChild\s*\(\s*[^,()]+\s*\)")
|
|
FLEX_EVIDENCE_RE = re.compile(r"addFlexLayout\s*\(|\.flex\b")
|
|
GRID_APPEND_RE = re.compile(r"\.grid\s*\.\s*appendChild\s*\(")
|
|
|
|
# Un gris en el `code` es un problema cuando se APLICA, no cuando se BUSCA. Los seeds de reparacion
|
|
# del grupo B8 tienen que poder detectar los grises que van a reemplazar, y para eso necesitan
|
|
# nombrarlos. Se permite unicamente en ese grupo y solo en contexto de comparacion/pertenencia.
|
|
GREY_COMPARISON_RE = re.compile(r"===|==|!==|!=|\.includes\s*\(|\.indexOf\s*\(|\.some\s*\(|\.has\s*\(")
|
|
|
|
|
|
def is_placeholder_grey(hex_str):
|
|
"""R2: saturacion <= 10 Y 100 <= max <= 220. Los casi-negros y casi-blancos pasan."""
|
|
r = int(hex_str[1:3], 16)
|
|
g = int(hex_str[3:5], 16)
|
|
b = int(hex_str[5:7], 16)
|
|
mx, mn = max(r, g, b), min(r, g, b)
|
|
sat = 0 if mx == 0 else 100.0 * (mx - mn) / mx
|
|
return sat <= 10 and 100 <= mx <= 220
|
|
|
|
|
|
def load_jsonl(path):
|
|
rows = []
|
|
with open(path, encoding="utf-8") as f:
|
|
for lineno, raw in enumerate(f, start=1):
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
rows.append((lineno, json.loads(raw), len(raw)))
|
|
except json.JSONDecodeError as e:
|
|
raise SystemExit(f"[FATAL] {path.name}:{lineno}: JSON invalido: {e}")
|
|
return rows
|
|
|
|
|
|
def load_doc_lines(path):
|
|
"""Todas las lineas no vacias del archivo de captura, para el chequeo de subconjunto."""
|
|
return [ln.rstrip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
|
|
|
|
def extract_system_block():
|
|
"""El bloque verbatim de penpot_system_prompt.md, seccion 'Bloque completo, verbatim'."""
|
|
text = SYSTEM_PROMPT.read_text(encoding="utf-8")
|
|
marker = "## Bloque completo, verbatim"
|
|
idx = text.index(marker)
|
|
fence_start = text.index("```", idx) + 3
|
|
fence_start = text.index("\n", fence_start) + 1
|
|
fence_end = text.index("```", fence_start)
|
|
return text[fence_start:fence_end].rstrip("\n")
|
|
|
|
|
|
def extract_error_strings():
|
|
"""Los strings de error verbatim de penpot_errors.md: todo `codigo` dentro de una celda de
|
|
tabla de la columna 'Mensaje verbatim', mas los de las tablas de fallos silenciosos."""
|
|
text = API_ERRORS.read_text(encoding="utf-8")
|
|
out = set()
|
|
for line in text.splitlines():
|
|
if not line.startswith("|"):
|
|
continue
|
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
for cell in cells:
|
|
for m in re.finditer(r"`([^`]+)`", cell):
|
|
out.add(m.group(1))
|
|
return out
|
|
|
|
|
|
def extract_return_keys(code):
|
|
"""Claves de primer nivel del ultimo `return { ... }` del payload.
|
|
|
|
Devuelve None si el payload no termina en un return de objeto literal (p.ej. retorna un
|
|
array, una expresion, o nada) -- en ese caso el chequeo de claves no aplica.
|
|
"""
|
|
idx = code.rfind("return {")
|
|
if idx == -1:
|
|
idx = code.rfind("return{")
|
|
if idx == -1:
|
|
return None
|
|
start = code.index("{", idx)
|
|
depth = 0
|
|
keys = []
|
|
i = start
|
|
in_str = None
|
|
buf_depth_zero = []
|
|
while i < len(code):
|
|
ch = code[i]
|
|
if in_str:
|
|
if ch == "\\":
|
|
i += 2
|
|
continue
|
|
if ch == in_str:
|
|
in_str = None
|
|
elif ch in "\"'`":
|
|
in_str = ch
|
|
elif ch in "{[(":
|
|
depth += 1
|
|
elif ch in "}])":
|
|
depth -= 1
|
|
if depth == 0 and ch == "}":
|
|
break
|
|
elif depth == 1:
|
|
buf_depth_zero.append((i, ch))
|
|
i += 1
|
|
if depth != 0:
|
|
return None
|
|
# Reconstruir el nivel 1 y sacar las claves antes de cada ':'
|
|
level1 = "".join(ch for _, ch in buf_depth_zero)
|
|
for part in level1.split(","):
|
|
if ":" not in part:
|
|
# shorthand `{ foo, bar }`
|
|
name = part.strip()
|
|
if re.fullmatch(r"[A-Za-z_$][\w$]*", name):
|
|
keys.append(name)
|
|
continue
|
|
key = part.split(":", 1)[0].strip().strip("'\"")
|
|
if re.fullmatch(r"[A-Za-z_$][\w$]*", key):
|
|
keys.append(key)
|
|
return keys or None
|
|
|
|
|
|
def node_check(payloads):
|
|
"""Verifica que cada payload parsee, envuelto en una funcion async (el servidor evalua el
|
|
codigo como cuerpo de funcion, por eso `await` y `return` de nivel superior son validos)."""
|
|
node = shutil.which("node")
|
|
if not node:
|
|
print("[WARN] node no esta instalado -- se saltea la verificacion de sintaxis JS")
|
|
return []
|
|
problems = []
|
|
with tempfile.TemporaryDirectory() as td:
|
|
for tag, code in payloads:
|
|
f = Path(td) / "chk.mjs"
|
|
f.write_text("async function __penpot_payload__(){\n" + code + "\n}\n", encoding="utf-8")
|
|
r = subprocess.run([node, "--check", str(f)], capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
first = (r.stderr.strip().splitlines() or ["error desconocido"])
|
|
msg = next((ln for ln in first if "SyntaxError" in ln), first[-1])
|
|
problems.append(f"{tag}: node --check fallo: {msg.strip()}")
|
|
return problems
|
|
|
|
|
|
def main():
|
|
targets = [Path(a) for a in sys.argv[1:]] or [DEFAULT_TARGET]
|
|
for p in targets:
|
|
if not p.exists():
|
|
raise SystemExit(f"[FATAL] no existe: {p}")
|
|
|
|
doc_lines = set(load_doc_lines(API_DOCS))
|
|
doc_order = load_doc_lines(API_DOCS)
|
|
doc_index = {}
|
|
for i, ln in enumerate(doc_order):
|
|
doc_index.setdefault(ln, []).append(i)
|
|
error_strings = extract_error_strings()
|
|
system_block = extract_system_block()
|
|
canonical_tools = {t["name"]: t for t in json.loads(PENPOT_TOOLS.read_text(encoding="utf-8"))}
|
|
|
|
problems = []
|
|
warnings = []
|
|
payloads = [] # (tag, code) para node --check
|
|
payload_texts = [] # para unicidad
|
|
coverage = Counter()
|
|
n_seeds = 0
|
|
n_with_system = 0
|
|
n_overview_calls = 0
|
|
n_intentional_mistakes = 0
|
|
max_line_len = 0
|
|
grupo_counts = Counter()
|
|
|
|
for path in targets:
|
|
for lineno, ex, raw_len in load_jsonl(path):
|
|
n_seeds += 1
|
|
tag = f"{path.name}:{lineno}"
|
|
max_line_len = max(max_line_len, raw_len)
|
|
grupo = ex.get("meta", {}).get("grupo", "?")
|
|
grupo_counts[grupo] += 1
|
|
|
|
if ex.get("meta", {}).get("bucket") != "penpot":
|
|
problems.append(f"{tag}: meta.bucket debe ser 'penpot'")
|
|
|
|
msgs = ex.get("messages", [])
|
|
if not msgs:
|
|
problems.append(f"{tag}: sin messages")
|
|
continue
|
|
|
|
# ---- mensaje system verbatim -------------------------------------------------
|
|
if msgs[0].get("role") == "system":
|
|
n_with_system += 1
|
|
if msgs[0].get("content", "").strip() != system_block.strip():
|
|
problems.append(
|
|
f"{tag}: el mensaje system no es el bloque verbatim de "
|
|
f"penpot_system_prompt.md (parafraseado o recortado)"
|
|
)
|
|
|
|
# ---- tools copiadas byte a byte ----------------------------------------------
|
|
for tool in ex.get("tools") or []:
|
|
name = tool.get("name")
|
|
if name not in canonical_tools:
|
|
problems.append(f"{tag}: tool '{name}' no existe en data/schemas/penpot.json")
|
|
continue
|
|
canon = canonical_tools[name]
|
|
if tool.get("description") != canon.get("description"):
|
|
problems.append(
|
|
f"{tag}: la description de la tool '{name}' fue recortada o reformulada; "
|
|
f"tiene que ser byte a byte la de penpot.json"
|
|
)
|
|
if tool.get("parameters") != canon.get("parameters"):
|
|
problems.append(f"{tag}: el JSON Schema de '{name}' no coincide con penpot.json")
|
|
|
|
# ---- biyeccion tool_call_id <-> mensajes tool --------------------------------
|
|
call_ids, result_ids = [], []
|
|
for m in msgs:
|
|
for tc in m.get("tool_calls") or []:
|
|
call_ids.append(tc.get("id"))
|
|
if m.get("role") == "tool":
|
|
result_ids.append(m.get("tool_call_id"))
|
|
if sorted(filter(None, call_ids)) != sorted(filter(None, result_ids)):
|
|
problems.append(
|
|
f"{tag}: los tool_call_id no son una biyeccion "
|
|
f"(llamadas={call_ids} resultados={result_ids})"
|
|
)
|
|
if len(set(call_ids)) != len(call_ids):
|
|
problems.append(f"{tag}: tool_call_id duplicados: {call_ids}")
|
|
|
|
# ---- assistant: reasoning_content y content --------------------------------
|
|
for i, m in enumerate(msgs):
|
|
if m.get("role") != "assistant":
|
|
continue
|
|
if not (m.get("reasoning_content") or "").strip():
|
|
problems.append(f"{tag}: turno assistant #{i} sin reasoning_content")
|
|
if m.get("tool_calls") and (m.get("content") or "") != "":
|
|
problems.append(
|
|
f"{tag}: turno assistant #{i} tiene tool_calls y content no vacio "
|
|
f"(el content visible va en el ULTIMO assistant, sin tool_calls)"
|
|
)
|
|
if msgs[-1].get("role") != "assistant" or msgs[-1].get("tool_calls"):
|
|
problems.append(f"{tag}: la trayectoria no cierra con un assistant sin tool_calls")
|
|
|
|
# ---- payloads de code ---------------------------------------------------------
|
|
# Los seeds de recuperacion de error (grupos A1 y D) TIENEN que contener el patron
|
|
# equivocado: es el material que ensena a diagnosticarlo. La excepcion no se declara
|
|
# a mano con una bandera en meta (eso seria un escape hatch que apaga el lint), se
|
|
# deriva mecanicamente: un payload puede traer un patron prohibido si y solo si
|
|
# (a) su tool result es un string de error de la allow-list, y
|
|
# (b) un payload POSTERIOR del mismo seed hace lo mismo SIN el patron.
|
|
# Es decir: el error tiene que haber sido real y tiene que haber sido corregido.
|
|
error_result_ids = {
|
|
m.get("tool_call_id")
|
|
for m in msgs
|
|
if m.get("role") == "tool" and (m.get("content") or "").strip() in error_strings
|
|
}
|
|
ordered_calls = []
|
|
for m in msgs:
|
|
for tc in m.get("tool_calls") or []:
|
|
if tc.get("function", {}).get("name") == "execute_code":
|
|
args = tc.get("function", {}).get("arguments")
|
|
ordered_calls.append(
|
|
(tc.get("id"), args.get("code", "") if isinstance(args, dict) else "")
|
|
)
|
|
|
|
def is_corrected_mistake(call_id, rx):
|
|
"""El patron aparece en una llamada que fallo, y una llamada posterior lo evita."""
|
|
if call_id not in error_result_ids:
|
|
return False
|
|
seen = False
|
|
for cid, code_ in ordered_calls:
|
|
if seen and not rx.search(code_):
|
|
return True
|
|
if cid == call_id:
|
|
seen = True
|
|
return False
|
|
|
|
seed_code_blob = "\n".join(code for _, code in ordered_calls)
|
|
seed_has_flex = bool(FLEX_EVIDENCE_RE.search(seed_code_blob))
|
|
|
|
code_by_call = {}
|
|
for m in msgs:
|
|
for tc in m.get("tool_calls") or []:
|
|
fn = tc.get("function", {})
|
|
args = fn.get("arguments")
|
|
if not isinstance(args, dict):
|
|
problems.append(
|
|
f"{tag}: tool_calls.function.arguments tiene que ser un dict JSON, "
|
|
f"no {type(args).__name__}"
|
|
)
|
|
continue
|
|
if fn.get("name") == "high_level_overview":
|
|
n_overview_calls += 1
|
|
if fn.get("name") == "export_shape":
|
|
coverage["export_shape"] += 1
|
|
for k in args:
|
|
if k not in ("shapeId", "format", "mode"):
|
|
problems.append(
|
|
f"{tag}: export_shape con el argumento inventado '{k}' "
|
|
f"(el schema solo acepta shapeId/format/mode)"
|
|
)
|
|
if fn.get("name") != "execute_code":
|
|
continue
|
|
code = args.get("code", "")
|
|
code_by_call[tc.get("id")] = code
|
|
payloads.append((tag, code))
|
|
payload_texts.append(code)
|
|
|
|
# patrones prohibidos
|
|
for pname, rx, why in FORBIDDEN:
|
|
if not rx.search(code):
|
|
continue
|
|
if is_corrected_mistake(tc.get("id"), rx):
|
|
n_intentional_mistakes += 1
|
|
continue
|
|
for mt in rx.finditer(code):
|
|
frag = code[max(0, mt.start() - 30):mt.end() + 30].replace("\n", " ")
|
|
problems.append(f"{tag}: [{pname}] ...{frag}...\n -> {why}")
|
|
|
|
# appendChild sobre receptor sin evidencia de flex
|
|
if (APPEND_ONE_ARG_RE.search(code) and not seed_has_flex
|
|
and not GRID_APPEND_RE.search(code)):
|
|
problems.append(
|
|
f"{tag}: usa `X.appendChild(shape)` sin ninguna evidencia de flex en el "
|
|
f"payload. appendChild de 1 argumento solo es correcto en boards con "
|
|
f"flex; para un padre sin layout va "
|
|
f"parent.insertChild(parent.children.length, shape)."
|
|
)
|
|
|
|
# console.log de algo que tambien se retorna
|
|
ret_keys = extract_return_keys(code) or []
|
|
for logged in re.findall(r"console\.log\s*\(\s*([A-Za-z_$][\w$]*)", code):
|
|
if logged in ret_keys:
|
|
problems.append(
|
|
f"{tag}: console.log('{logged}') de algo que tambien se retorna "
|
|
f"-- el servidor lo prohibe explicitamente (llega duplicado)"
|
|
)
|
|
|
|
# R2: grises de placeholder en el codigo. Un gris que se APLICA es siempre un
|
|
# error; uno que se BUSCA es legitimo, y solo en el grupo de reparacion, que
|
|
# necesita nombrar los grises que va a reemplazar.
|
|
for code_line in code.split("\n"):
|
|
for hx in HEX_RE.findall(code_line):
|
|
if not is_placeholder_grey(hx):
|
|
continue
|
|
if grupo == GREY_INPUT_GROUP and GREY_COMPARISON_RE.search(code_line):
|
|
continue
|
|
problems.append(
|
|
f"{tag}: gris de placeholder {hx} aplicado en el `code` "
|
|
f"(invariante R2). El gris solo puede aparecer como INPUT en los "
|
|
f"tool results del grupo {GREY_INPUT_GROUP}, o nombrado en una "
|
|
f"comparacion dentro de ese mismo grupo para detectarlo."
|
|
)
|
|
|
|
# cobertura
|
|
for key in ("addGridLayout", "addFlexLayout", "uploadMediaUrl",
|
|
"borderRadius", "fillColorGradient"):
|
|
if key in code:
|
|
coverage[key] += 1
|
|
if re.search(r"shadows\s*=", code):
|
|
coverage["shadows"] += 1
|
|
if re.search(r"layoutChild\s*\.\s*horizontalSizing", code):
|
|
coverage["layoutChild.horizontalSizing"] += 1
|
|
if "library.local" in code:
|
|
coverage["library.local"] += 1
|
|
|
|
# ---- tool results -------------------------------------------------------------
|
|
for m in msgs:
|
|
if m.get("role") != "tool":
|
|
continue
|
|
name = m.get("name")
|
|
content = m.get("content") or ""
|
|
cid = m.get("tool_call_id")
|
|
|
|
if name in ("penpot_api_info", "high_level_overview"):
|
|
# subconjunto de lineas de la captura, en orden original
|
|
lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()]
|
|
missing = [ln for ln in lines if ln not in doc_lines]
|
|
if missing:
|
|
problems.append(
|
|
f"{tag}: resultado de {name} con {len(missing)} linea(s) ausentes de "
|
|
f"penpot_api_docs.md -- documentacion FABRICADA. Primera: "
|
|
f"{missing[0][:110]!r}"
|
|
)
|
|
else:
|
|
last = -1
|
|
for ln in lines:
|
|
positions = [i for i in doc_index[ln] if i > last]
|
|
if not positions:
|
|
problems.append(
|
|
f"{tag}: resultado de {name} con lineas fuera del orden "
|
|
f"original de la captura (en {ln[:70]!r})"
|
|
)
|
|
break
|
|
last = positions[0]
|
|
|
|
elif name == "execute_code":
|
|
code = code_by_call.get(cid)
|
|
stripped = content.strip()
|
|
looks_json = stripped.startswith("{") or stripped.startswith("[")
|
|
if not looks_json:
|
|
# tiene que ser un error real de la allow-list
|
|
if stripped not in error_strings:
|
|
problems.append(
|
|
f"{tag}: resultado de execute_code que no es JSON y no esta en "
|
|
f"penpot_errors.md: {stripped[:110]!r}"
|
|
)
|
|
else:
|
|
try:
|
|
parsed = json.loads(stripped)
|
|
except json.JSONDecodeError as e:
|
|
problems.append(f"{tag}: resultado de execute_code con JSON invalido: {e}")
|
|
parsed = None
|
|
if isinstance(parsed, dict) and code is not None:
|
|
ret_keys = extract_return_keys(code)
|
|
if ret_keys is not None:
|
|
if sorted(parsed.keys()) != sorted(ret_keys):
|
|
problems.append(
|
|
f"{tag}: las claves del resultado {sorted(parsed.keys())} "
|
|
f"no coinciden con las del `return {{...}}` del payload "
|
|
f"{sorted(ret_keys)}"
|
|
)
|
|
# ids de juguete
|
|
for val in re.findall(r'"([^"]{1,24})"', stripped):
|
|
if TOY_ID_RE.fullmatch(val):
|
|
warnings.append(
|
|
f"{tag}: id con forma de juguete {val!r} en un tool result; "
|
|
f"los ids reales tienen forma de UUIDv4"
|
|
)
|
|
for val in re.findall(r'"(?:id|shapeId|boardId|rootId)"\s*:\s*"([^"]+)"', stripped):
|
|
if not UUID4_RE.match(val):
|
|
problems.append(
|
|
f"{tag}: id {val!r} en un tool result no tiene forma de UUIDv4 "
|
|
f"-- entrena al modelo a esperar ids de juguete"
|
|
)
|
|
|
|
# R2 en tool results: solo el grupo de reparacion puede traer grises
|
|
if grupo != GREY_INPUT_GROUP:
|
|
for hx in HEX_RE.findall(content):
|
|
if is_placeholder_grey(hx):
|
|
problems.append(
|
|
f"{tag}: gris de placeholder {hx} en un tool result del grupo "
|
|
f"{grupo}; solo el grupo {GREY_INPUT_GROUP} puede traerlos, "
|
|
f"como estado inicial a reparar"
|
|
)
|
|
|
|
# ---- chequeos globales -----------------------------------------------------------------
|
|
dupes = [c for c, n in Counter(payload_texts).items() if n > 1]
|
|
if dupes:
|
|
problems.append(
|
|
f"[GLOBAL] {len(dupes)} payload(s) de `code` duplicados exactos. Primero: "
|
|
f"{dupes[0][:120]!r}"
|
|
)
|
|
|
|
unique_payloads = len(set(payload_texts))
|
|
is_full_corpus = len(targets) == 1 and targets[0].resolve() == DEFAULT_TARGET.resolve()
|
|
|
|
print(f"\n=== SEEDS: {n_seeds} | payloads de code: {len(payload_texts)} "
|
|
f"(unicos: {unique_payloads}) ===")
|
|
print(f"=== linea mas larga: {max_line_len} caracteres ===")
|
|
print(f"=== grupos: {dict(sorted(grupo_counts.items()))} ===")
|
|
share = (n_with_system / n_seeds) if n_seeds else 0
|
|
print(f"=== seeds con mensaje system: {n_with_system}/{n_seeds} ({share:.0%}) ===")
|
|
print(f"=== errores intencionales (patron prohibido + error real + correccion posterior): "
|
|
f"{n_intentional_mistakes} ===")
|
|
print("=== cobertura por categoria ===")
|
|
for key, minimum in sorted(COVERAGE_MIN.items()):
|
|
got = coverage[key]
|
|
mark = "ok " if got >= minimum else "BAJO"
|
|
print(f" [{mark}] {key}: {got} (minimo {minimum})")
|
|
|
|
if is_full_corpus:
|
|
for key, minimum in COVERAGE_MIN.items():
|
|
if coverage[key] < minimum:
|
|
problems.append(
|
|
f"[COBERTURA] '{key}': {coverage[key]} seeds, minimo {minimum}. Un corpus a "
|
|
f"medio autorar reproduce el desbalance actual sin que nada lo note."
|
|
)
|
|
if unique_payloads < MIN_UNIQUE_PAYLOADS:
|
|
problems.append(
|
|
f"[GLOBAL] solo {unique_payloads} payloads unicos de execute_code, minimo "
|
|
f"{MIN_UNIQUE_PAYLOADS} (hoy el dataset viejo tiene 36)"
|
|
)
|
|
if not SYSTEM_SHARE_RANGE[0] <= share <= SYSTEM_SHARE_RANGE[1]:
|
|
problems.append(
|
|
f"[GLOBAL] share de seeds con mensaje system = {share:.0%}, fuera del rango "
|
|
f"{SYSTEM_SHARE_RANGE[0]:.0%}-{SYSTEM_SHARE_RANGE[1]:.0%} (objetivo ~30%)"
|
|
)
|
|
if n_overview_calls > MAX_HIGH_LEVEL_OVERVIEW_CALLS:
|
|
problems.append(
|
|
f"[GLOBAL] {n_overview_calls} llamadas a high_level_overview, maximo "
|
|
f"{MAX_HIGH_LEVEL_OVERVIEW_CALLS} (su propia descripcion prohibe llamarlo dos veces)"
|
|
)
|
|
else:
|
|
print("[INFO] modo parcial (no se lintea el corpus completo): los umbrales globales de "
|
|
"cobertura, unicidad y share de system NO se aplican")
|
|
|
|
problems.extend(node_check(payloads))
|
|
|
|
if warnings:
|
|
print(f"\n=== {len(warnings)} ADVERTENCIA(S) ===")
|
|
for w in warnings[:40]:
|
|
print(f" ! {w}")
|
|
|
|
if problems:
|
|
print(f"\n=== {len(problems)} PROBLEMA(S) ===")
|
|
for p in problems:
|
|
print(f" - {p}")
|
|
print(f"\n[LINT FAIL] {len(problems)} problema(s)")
|
|
sys.exit(1)
|
|
|
|
print("\n[LINT OK] 0 problemas")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|