"""A visão da tela do openTARS (era o Cooper até a 5.1; descontinuado em 18/10/2026). Desde o Gargantua 2 o próprio modelo enxerga: ele responde às perguntas sobre um print (onde está um botão, se algo está marcado, o que diz um campo) no mesmo formato em que o Cooper respondia, porque o treino de visão do Gargantua 2 usou exatamente essas perguntas. Instalar: ollama pull ProjectOpenTARS/Gargantua-2 (Antes tars_cooper.py; os nomes foram trocados por "visão" em 18/10. `e_cooper` e `MODELO_COOPER` ficam de propósito: servem para reconhecer um Cooper antigo que ainda esteja instalado, que só entra como reserva se não houver Gargantua 2.) Este módulo é puro (sem rede): as perguntas no formato do treino, a leitura das respostas e a conversão de milésimos (0-1000 da imagem) em pixels. Quem fala com o Ollama é o núcleo (nucleo/visao.py). """ import json import os import re MODELO_VISAO = os.environ.get("TARS_MODELO_GARGANTUA", "ProjectOpenTARS/Gargantua-2") COMANDO_INSTALAR = f"ollama pull {MODELO_VISAO}" MODELO_COOPER = "ProjectOpenTARS/cooper:1.2" # descontinuado: só para reconhecer um Cooper que já esteja instalado ESCALA = 1000 TIMEOUT_VISAO = 60 def ligado(env=None): """TARS_VISAO=0 desliga a visão da tela (TARS_COOPER=0, o nome antigo, continua valendo).""" env = os.environ if env is None else env valor = env.get("TARS_VISAO", env.get("TARS_COOPER", "1")) return str(valor).lower() not in ("0", "off", "no", "nao", "não", "false") def e_cooper(tag): """'ProjectOpenTARS/cooper:latest', 'cooper', 'cooper-1.0'... (o nome depois da última barra começa com 'cooper').""" nome = str(tag or "").strip().lower().split(":")[0].split("/")[-1] return nome.startswith("cooper") def e_gargantua_2(tag): """Quem enxerga: a família Gargantua-2 (:latest, 2B; :lite, 0,8B) e o Lite 2.0 antigo (Gargantua:2.0-lite, também Qwen3.5).""" base, _, etiqueta = str(tag or "").strip().lower().partition(":") return base.split("/")[-1].startswith("gargantua-2") or (base.split("/")[-1] == "gargantua" and etiqueta == "2.0-lite") def e_lite_sem_telas(tag): """O Lite 2.0 ANTIGO (Gargantua:2.0-lite, 17/10): enxerga, mas não foi treinado com telas (30% ao achar botão pelo app). O Gargantua-2:lite de 18/10 em diante foi treinado com elas (72%): vale como olho, atrás do 2B (85%).""" base, _, etiqueta = str(tag or "").strip().lower().partition(":") return base.split("/")[-1] == "gargantua" and etiqueta == "2.0-lite" def olho_valido(tag): """Quem pode olhar a tela por conta do openTARS: a família Gargantua-2 (o 2B e o Lite novo) e, só com TARS_VISAO_NO_LITE=1, o Lite 2.0 antigo.""" if not e_gargantua_2(tag): return False return not e_lite_sem_telas(tag) or str(os.environ.get("TARS_VISAO_NO_LITE", "0")).lower() in ("1", "true", "sim") def achar_instalado(tags): """A tag do modelo que olha a tela: o Gargantua 2 (o :latest primeiro); sem ele, um Cooper antigo; senão None.""" g2 = sorted(t for t in tags or [] if olho_valido(t)) if g2: return next((t for t in g2 if str(t).lower().endswith(":latest")), g2[0]) achados = [t for t in tags or [] if e_cooper(t)] if not achados: return None oficial = MODELO_COOPER.lower() for t in achados: # a versão oficial exata (cooper:1.2) primeiro if str(t).lower() == oficial: return t for t in achados: # depois qualquer etiqueta do mesmo nome (cooper:latest) if str(t).lower().split(":")[0] == oficial.split(":")[0]: return t return sorted(achados)[0] # ------------------------------------------------------------ # Perguntas (os modelos de pergunta do treino; ver o README do Cooper) # ------------------------------------------------------------ _TIPOS = ( # (palavras que aparecem no nome pedido, tipo que o Cooper conhece) (("checkbox", "check box", "caixa de seleção", "caixa de selecao"), "checkbox"), (("radio",), "radio button"), (("tab", "aba"), "tab"), (("menu",), "menu"), (("link",), "link"), (("icon", "ícone", "icone"), "icon"), (("field", "campo", "input", "text box", "textbox", "caixa de texto"), "field"), (("list item", "item da lista"), "list item"), (("button", "botão", "botao"), "button"), ) def tipo_do_alvo(nome): """('button', 'Save') a partir de 'the Save button' / 'botão Salvar' / 'Save'. Sem pista de tipo, assume botão (o caso mais comum).""" texto = str(nome or "").strip().strip("\"'") baixo = texto.lower() for palavras, tipo in _TIPOS: for p in palavras: achado = re.search(rf"(? pixels da imagem de tamanho largura x altura.""" px = round(float(x) / ESCALA * largura) py = round(float(y) / ESCALA * altura) return max(0, min(largura - 1, px)), max(0, min(altura - 1, py)) def descricao_em_pixels(texto, largura, altura): """Troca os pares (x, y) de milésimos, na descrição, por pixels da imagem; os que fogem de 0-1000 ficam como estão.""" def troca(m): x, y = float(m.group(1)), float(m.group(2)) if x > ESCALA or y > ESCALA: return m.group(0) px, py = para_pixels(x, y, largura, altura) return f"({px}, {py})" corpo = re.sub(r"\(\s*(\d{1,4}(?:\.\d+)?)\s*,\s*(\d{1,4}(?:\.\d+)?)\s*\)", troca, str(texto or "")) corpo = re.sub(r'\{\s*"x"\s*:\s*(\d{1,4}(?:\.\d+)?)\s*,\s*"y"\s*:\s*(\d{1,4}(?:\.\d+)?)\s*\}', troca_json(largura, altura), corpo) return corpo.strip() def troca_json(largura, altura): def f(m): x, y = float(m.group(1)), float(m.group(2)) if x > ESCALA or y > ESCALA: return m.group(0) px, py = para_pixels(x, y, largura, altura) return f"({px}, {py})" return f