Spaces:
Sleeping
Sleeping
| # -*- 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) | |