Spaces:
Sleeping
Sleeping
File size: 9,995 Bytes
7cf0047 c78069e 7cf0047 c78069e 7cf0047 c78069e 7cf0047 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | # -*- coding: utf-8 -*-
import os
import json
import base64
import tempfile
import re
from concurrent.futures import ThreadPoolExecutor
import gradio as gr
from openai import OpenAI
from playwright.sync_api import sync_playwright
GROQ_KEY = os.environ.get("GROQ_KEY", "")
# Modelo de vision de Groq (si cambia el nombre, ajustalo aqui).
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
# Respaldo solo-texto si la vision falla.
TEXT_MODEL = "llama-3.3-70b-versatile"
_groq = OpenAI(api_key=GROQ_KEY, base_url="https://api.groq.com/openai/v1") if GROQ_KEY else None
# Playwright (API sync) debe correr en un hilo SIN loop asyncio. Usamos un
# executor propio para garantizarlo.
_executor = ThreadPoolExecutor(max_workers=1)
def _interactuar(pagina):
"""Best-effort: rellena inputs/selects visibles y pulsa un boton de accion
real, para que el Tester vea la app DESPUES de usarla, no solo al cargar.
Devuelve el texto del primer error de Gradio detectado en el DOM tras el
clic, o "" si no se detecto ninguno."""
try:
inputs = pagina.locator("input[type=text], input[type=number], input:not([type]), textarea").all()
for inp in inputs[:6]:
try:
if not inp.is_visible():
continue
tipo = inp.get_attribute("type") or "text"
inp.fill("5" if tipo == "number" else "prueba")
except Exception:
continue
except Exception:
pass
try:
selects = pagina.locator("select").all()
for sel in selects[:4]:
try:
if not sel.is_visible():
continue
opciones = sel.locator("option").all()
if len(opciones) > 1:
valor = opciones[1].get_attribute("value")
if valor is not None:
sel.select_option(valor)
except Exception:
continue
except Exception:
pass
error_detectado = ""
try:
for btn in pagina.locator("button").all():
try:
if not btn.is_visible():
continue
texto_btn = (btn.inner_text() or "").strip().lower()
if any(p in texto_btn for p in ["clear", "limpiar", "borrar", "flag"]):
continue
btn.click(timeout=3000)
pagina.wait_for_timeout(3000)
try:
pagina.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
error_detectado = _detectar_error_gradio(pagina)
break
except Exception:
continue
except Exception:
pass
return error_detectado
def _detectar_error_gradio(pagina):
"""Busca en el DOM senales explicitas de error que Gradio muestra tras
fallar un event handler: el toast rojo de error, o un componente Label
cuyo contenido literal es 'Error' / 'error'. No depende del LLM."""
try:
# Toast de error de Gradio (aparece en la esquina al fallar un evento).
toasts = pagina.locator(".toast-body.error, [class*='toast'][class*='error']").all()
for t in toasts:
try:
if t.is_visible():
txt = (t.inner_text() or "").strip()
return txt or "Gradio mostro un toast de error tras la accion."
except Exception:
continue
except Exception:
pass
try:
# Componente Label/salida cuyo unico contenido es literalmente "Error".
candidatos = pagina.locator("text=/^Error$/i").all()
for c in candidatos:
try:
if c.is_visible():
return "Un componente de salida muestra literalmente 'Error' tras la accion."
except Exception:
continue
except Exception:
pass
return ""
def _abrir_y_capturar(url):
"""Abre la URL, interactua con la app (rellena+clic), y devuelve
(texto_visible, ruta_captura, error_dom) DESPUES de esa interaccion.
error_dom es "" si no se detecto ningun error explicito de Gradio, o el
texto del error si si se detecto."""
with sync_playwright() as p:
navegador = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
pagina = navegador.new_page(viewport={"width": 1280, "height": 1600})
pagina.goto(url, wait_until="domcontentloaded", timeout=45000)
try:
pagina.wait_for_selector(".gradio-container, gradio-app, #root", timeout=15000)
except Exception:
pass
pagina.wait_for_timeout(2500)
error_dom = _interactuar(pagina)
pagina.wait_for_timeout(1000)
texto = pagina.inner_text("body")[:6000]
ruta = os.path.join(tempfile.gettempdir(), "captura.png")
pagina.screenshot(path=ruta, full_page=True)
navegador.close()
return texto, ruta, error_dom
def _abrir_seguro(url):
return _executor.submit(_abrir_y_capturar, url).result()
def _img_b64(ruta):
with open(ruta, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
SYSTEM = (
"Eres un tester de control de calidad de webs y apps, estricto y desconfiado. "
"Te dan el OBJETIVO de una pagina, el texto visible y una captura tomada "
"DESPUES de rellenar campos y pulsar un boton de accion real (no es la "
"primera carga). Marca ok=false si: la pagina esta en blanco; el texto o "
"la captura muestran la palabra 'Error', un traceback, un mensaje en rojo, "
"o un recuadro/toast de error; falta alguna seccion pedida en el objetivo; "
"o la accion principal (el boton) no parece haber producido ningun "
"resultado visible. Ante la duda entre 'parece que funciona' y 'no estoy "
"seguro', responde ok=false: es preferible un falso negativo a aprobar "
"una app rota. "
'Responde SOLO JSON: {"ok": true/false, "problemas": ["..."], "resumen": "..."}'
)
def _juzgar(objetivo, texto, ruta_img):
if _groq is None:
return '{"ok": false, "problemas": ["Falta GROQ_KEY en los secrets"], "resumen": "Sin clave"}'
base = f"OBJETIVO:\n{objetivo}\n\nTEXTO VISIBLE EN LA PAGINA:\n{texto}"
# 1) Intento con vision (incluye la captura)
try:
b64 = _img_b64(ruta_img)
resp = _groq.chat.completions.create(
model=VISION_MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": [
{"type": "text", "text": base},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
]},
],
temperature=0,
)
return resp.choices[0].message.content
except Exception:
pass
# 2) Respaldo solo-texto
try:
resp = _groq.chat.completions.create(
model=TEXT_MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": base},
],
temperature=0,
)
return resp.choices[0].message.content
except Exception as e:
return '{"ok": false, "problemas": ["Error del modelo: ' + str(e)[:120] + '"], "resumen": "Fallo al juzgar"}'
def _normalizar_url(url):
"""Si pegan la URL-envoltorio de HF (huggingface.co/spaces/...), la
convierte a la URL directa .hf.space, porque en la envoltorio los
componentes reales viven dentro de un iframe y Playwright no los ve."""
m = re.search(r"huggingface\.co/spaces/([^/]+)/([^/?#]+)", url)
if m:
slug = re.sub(r"[^a-z0-9]+", "-", f"{m.group(1)}-{m.group(2)}".lower()).strip("-")
return f"https://{slug}.hf.space"
return url
def probar(url, objetivo):
url = (url or "").strip()
if not url:
return "Pon una URL.", None
if not url.startswith("http"):
url = "https://" + url
url = _normalizar_url(url)
try:
texto, ruta, error_dom = _abrir_seguro(url)
except Exception as e:
return f"No se pudo abrir la pagina: {e}", None
# Veto programatico: si el DOM muestra un error explicito de Gradio tras
# la interaccion, NO PASA sin importar lo que opine el LLM de vision.
if error_dom:
salida = (
f"### NO PASA\n\n"
f"La pagina mostro un error real al usarla (no solo al cargar).\n\n"
f"- {error_dom}"
)
return salida, ruta
veredicto = _juzgar(objetivo or "La pagina debe cargar sin errores y verse completa.", texto, ruta)
try:
limpio = veredicto.replace("```json", "").replace("```", "").strip()
d = json.loads(limpio)
estado = "PASA" if d.get("ok") else "NO PASA"
problemas = "\n".join(f"- {x}" for x in d.get("problemas", []))
salida = f"### {estado}\n\n{d.get('resumen','')}\n\n{problemas}".strip()
except Exception:
# No se pudo interpretar el veredicto del LLM como JSON valido.
# Por seguridad, NO se asume PASA: se marca como fallo explicito.
salida = (
f"### NO PASA\n\n"
f"El Tester no pudo interpretar su propio veredicto como JSON valido "
f"(respuesta cruda no concluyente).\n\n"
f"- Respuesta del modelo: {str(veredicto)[:300]}"
)
return salida, ruta
with gr.Blocks(title="Forja Tester") as demo:
gr.Markdown("# Forja Tester\nAbre una app en un navegador real, la mira y dice si funciona.")
url_in = gr.Textbox(label="URL de la app a probar")
obj_in = gr.Textbox(label="Que deberia hacer / contener", lines=3)
btn = gr.Button("Probar", variant="primary")
out = gr.Markdown()
img = gr.Image(label="Lo que vio el navegador")
btn.click(probar, [url_in, obj_in], [out, img], api_name="probar")
demo.launch(server_name="0.0.0.0", server_port=7860)
|