# -*- 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)