File size: 8,471 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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"(?<![\w]){re.escape(p)}(?![\w])", baixo)
            if achado:
                limpo = (texto[:achado.start()] + " " + texto[achado.end():]).strip()
                limpo = re.sub(r"^(the|o|a|um|uma|de|do|da)\s+", "", limpo, flags=re.IGNORECASE).strip(" \"'")
                return tipo, (limpo or texto)
    return "button", texto


def pergunta_localizar(nome, janela=None):
    tipo, rotulo = tipo_do_alvo(nome)
    rotulo = rotulo.replace('"', "'")
    onde = f' in the window "{str(janela).replace(chr(34), chr(39))}"' if janela else ""
    return (f'Where is the {tipo} "{rotulo}"{onde} on the screen? Answer with its center as JSON '
            '{"x": 0-1000, "y": 0-1000} (thousandths of the image width and height). '
            'If it is not on the screen, answer {"found": false}.')


def pergunta_descrever(pedido):
    pedido = str(pedido or "").replace('"', "'")
    return ('Describe this screenshot for an assistant that must operate the computer to do: '
            f'"{pedido}". List the open windows and the buttons, fields and other clickable elements, '
            'each with its center as (x, y) in thousandths of the image width and height (0-1000).')


# ------------------------------------------------------------
# Respostas
# ------------------------------------------------------------

def _json_da_resposta(texto):
    texto = re.sub(r"```(?:json)?", "", str(texto or "")).strip()
    for achado in re.finditer(r"\{[^{}]*\}", texto):
        try:
            dados = json.loads(achado.group(0))
        except ValueError:
            continue
        if isinstance(dados, dict):
            return dados
    return None


def ler_ponto(texto):
    """('achou', x, y) com x e y em milésimos, ('ausente', None, None) se
    o Cooper disse que não está na tela, ou None se a resposta não presta."""
    dados = _json_da_resposta(texto)
    if dados is not None:
        if dados.get("found") is False and "x" not in dados:
            return ("ausente", None, None)
        try:
            x, y = float(dados["x"]), float(dados["y"])
        except (KeyError, TypeError, ValueError):
            return None
        if 0 <= x <= ESCALA and 0 <= y <= ESCALA:
            return ("achou", x, y)
        return None
    par = re.search(r"\(\s*(\d{1,4}(?:\.\d+)?)\s*,\s*(\d{1,4}(?:\.\d+)?)\s*\)", str(texto or ""))
    if par:
        x, y = float(par.group(1)), float(par.group(2))
        if x <= ESCALA and y <= ESCALA:
            return ("achou", x, y)
    return None


def para_pixels(x, y, largura, altura):
    """Milésimos -> 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