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)