Mirror of openTARS 5.2 Ranger (revision 2026110101) (part 2)
Browse files- .gitattributes +1 -0
- app/tests/test_qt_imagem.py +64 -0
- app/tests/test_qt_lancador.py +65 -0
- app/tests/test_qt_mascote.py +158 -0
- app/tests/test_qt_modos.py +121 -0
- app/tests/test_qt_primeiro_uso.py +168 -0
- app/tests/test_qt_primeiro_uso_gargantua2.py +115 -0
- app/tests/test_qt_primeiro_uso_sistema.py +75 -0
- app/tests/test_qt_principal.py +317 -0
- app/tests/test_qt_probes.py +187 -0
- app/tests/test_qt_rolagem.py +32 -0
- app/tests/test_qt_rotinas.py +256 -0
- app/tests/test_raciocinio_sem_pedir.py +67 -0
- app/tests/test_recusas.py +115 -0
- app/tests/test_retomada.py +119 -0
- app/tests/test_revisao45.py +189 -0
- app/tests/test_revisao_50.py +191 -0
- app/tests/test_robustez_v29.py +78 -0
- app/tests/test_rotinas.py +140 -0
- app/tests/test_rotinas_nucleo.py +70 -0
- app/tests/test_screenshot_resize.py +46 -0
- app/tests/test_search_web.py +57 -0
- app/tests/test_sem_falso_positivo_modelo.py +41 -0
- app/tests/test_setup_gargantua.py +80 -0
- app/tests/test_shell_portatil.py +120 -0
- app/tests/test_sob_demanda.py +65 -0
- app/tests/test_superlativos.py +54 -0
- app/tests/test_tarefa_com_print.py +148 -0
- app/tests/test_tarefa_longa.py +83 -0
- app/tests/test_terminal_tela_cheia.py +48 -0
- app/tests/test_textos.py +81 -0
- app/tests/test_think_vazado.py +129 -0
- app/tests/test_troca_nao_desce.py +42 -0
- app/tests/test_url_site.py +33 -0
- app/tests/test_v2_janelas.py +98 -0
- app/tests/test_v2_nucleo.py +214 -0
- app/tests/test_variantes_gargantua.py +73 -0
- app/tests/test_ver_imagem.py +44 -0
- app/tests/test_visao_fallback.py +91 -0
- app/tests/test_visao_fallback_ferramentas.py +35 -0
- app/tests/test_voz_continua.py +51 -0
- app/tests/test_voz_kokoro.py +208 -0
- app/tests/test_voz_progresso.py +86 -0
- app/tests/test_voz_v30.py +336 -0
- app/tests/test_wait_e_polling.py +26 -0
- logo.svg +10 -0
- opentars_all.deb +3 -0
.gitattributes
CHANGED
|
@@ -38,3 +38,4 @@ app/tars_qt/fontes/IBMPlexMono-Regular.ttf filter=lfs diff=lfs merge=lfs -text
|
|
| 38 |
app/tars_qt/fontes/IBMPlexSans-Medium.ttf filter=lfs diff=lfs merge=lfs -text
|
| 39 |
app/tars_qt/fontes/IBMPlexSans-Regular.ttf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
app/tars_qt/fontes/IBMPlexSans-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 38 |
app/tars_qt/fontes/IBMPlexSans-Medium.ttf filter=lfs diff=lfs merge=lfs -text
|
| 39 |
app/tars_qt/fontes/IBMPlexSans-Regular.ttf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
app/tars_qt/fontes/IBMPlexSans-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
opentars_all.deb filter=lfs diff=lfs merge=lfs -text
|
app/tests/test_qt_imagem.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Imagem que o modelo manda no Markdown (``): só arquivo local vira figura; o resto nunca é carregado."""
|
| 3 |
+
import os, sys, tempfile
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 6 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
try:
|
| 8 |
+
from PySide6.QtGui import QImage, QColor
|
| 9 |
+
from PySide6.QtWidgets import QApplication
|
| 10 |
+
except ImportError:
|
| 11 |
+
print("test_qt_imagem: PULADO (PySide6 não instalado)")
|
| 12 |
+
sys.exit(0)
|
| 13 |
+
app = QApplication.instance() or QApplication([])
|
| 14 |
+
from tars_qt import conversa, tema
|
| 15 |
+
app.setStyleSheet(tema.folha_principal() if hasattr(tema, "folha_principal") else "")
|
| 16 |
+
|
| 17 |
+
pasta = tempfile.mkdtemp(prefix="opentars-img-")
|
| 18 |
+
grande = os.path.join(pasta, "foto grande.png")
|
| 19 |
+
pequena = os.path.join(pasta, "icone.png")
|
| 20 |
+
for caminho, w, h in ((grande, 1600, 1200), (pequena, 64, 48)):
|
| 21 |
+
im = QImage(w, h, QImage.Format_RGB32); im.fill(QColor("#5FD97A")); assert im.save(caminho)
|
| 22 |
+
texto_falso = os.path.join(pasta, "nota.txt"); open(texto_falso, "w").write("x")
|
| 23 |
+
quebrada = os.path.join(pasta, "quebrada.png"); open(quebrada, "w").write("não sou uma imagem")
|
| 24 |
+
|
| 25 |
+
# 1. o que vira figura e o que não vira
|
| 26 |
+
assert conversa.caminho_de_imagem(grande) and conversa.caminho_de_imagem(pequena)
|
| 27 |
+
assert conversa.caminho_de_imagem(f"<{grande}>"), "com sinais < >"
|
| 28 |
+
assert conversa.caminho_de_imagem("file://" + pequena.replace(" ", "%20")), "file:// e %20"
|
| 29 |
+
for ruim in ("https://exemplo.com/a.png", "http://127.0.0.1/a.png", "data:image/png;base64,AAAA", "foto.png",
|
| 30 |
+
"../foto.png", os.path.join(pasta, "nao-existe.png"), texto_falso, pasta, "ftp://x/a.png"):
|
| 31 |
+
assert conversa.caminho_de_imagem(ruim) is None, ruim
|
| 32 |
+
print("OK: só arquivo local de imagem existente passa")
|
| 33 |
+
|
| 34 |
+
# 2. partir em blocos
|
| 35 |
+
p = conversa.partir_em_blocos(f"Olha:\n\nE pronto.")
|
| 36 |
+
assert [x[0] for x in p] == ["texto", "imagem", "texto"], p
|
| 37 |
+
assert p[1][1] == Path(pequena).resolve() and p[1][2] == "minha foto"
|
| 38 |
+
p = conversa.partir_em_blocos(f"```\n\n```\n e ")
|
| 39 |
+
assert [x[0] for x in p] == ["codigo", "imagem", "texto", "imagem"], [x[0] for x in p]
|
| 40 |
+
assert "![nao]" in p[0][1], "dentro de cerca de código não vira figura"
|
| 41 |
+
r = conversa.partir_em_blocos(" fim")
|
| 42 |
+
assert [x[0] for x in r] == ["texto"] and "![" not in r[0][1] and "[remota](https://exemplo.com/rastreio.png)" in r[0][1], r
|
| 43 |
+
print("OK: texto, imagem e código na ordem; remota vira link comum")
|
| 44 |
+
|
| 45 |
+
# 3. o widget: reduzida para caber, sem carregar a imagem inteira
|
| 46 |
+
b = conversa.BlocoTexto()
|
| 47 |
+
b.acrescentar(f"Aqui:\n\n\n\n\n\n")
|
| 48 |
+
b.concluir()
|
| 49 |
+
figs = [w for w in b._pecas if isinstance(w, conversa.ImagemIA)]
|
| 50 |
+
assert len(figs) == 3, len(figs)
|
| 51 |
+
g, pq, q = figs
|
| 52 |
+
assert g.pixmap().width() <= 640 and g.pixmap().height() <= 480 and g.pixmap().width() * 3 == g.pixmap().height() * 4 or abs(g.pixmap().width() / g.pixmap().height() - 4 / 3) < 0.02
|
| 53 |
+
assert (pq.pixmap().width(), pq.pixmap().height()) == (64, 48), "pequena não é ampliada"
|
| 54 |
+
assert q.pixmap() is None or q.pixmap().isNull()
|
| 55 |
+
assert q.text() == "quebrada" and q.property("papel") == "aviso-linha", "arquivo que não abre mostra o texto"
|
| 56 |
+
assert g.toolTip() == "foto grande.png" and g.accessibleName() == "grande"
|
| 57 |
+
print("OK: figura reduzida, pequena intacta, arquivo quebrado vira texto")
|
| 58 |
+
|
| 59 |
+
# 4. resposta só com texto continua igual; imagem remota sozinha não gera figura
|
| 60 |
+
b2 = conversa.BlocoTexto(); b2.acrescentar("oi **tudo**"); b2.concluir()
|
| 61 |
+
assert not b2._pecas and b2.rotulo.text() == "oi **tudo**"
|
| 62 |
+
b3 = conversa.BlocoTexto(); b3.acrescentar(""); b3.concluir()
|
| 63 |
+
assert not b3._pecas and "![" not in b3.rotulo.text()
|
| 64 |
+
print("test_qt_imagem: TUDO OK.")
|
app/tests/test_qt_lancador.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.2 (F4): a barra rápida como lançador. Vazia mostra as capacidades; "Buscar um arquivo" busca aqui mesmo e os links
|
| 2 |
+
dos resultados só abrem o que a busca achou (pelo número), nunca um caminho vindo de fora."""
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 9 |
+
from _util import RAIZ # noqa: E402,F401
|
| 10 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 11 |
+
from PySide6.QtCore import QUrl # noqa: E402
|
| 12 |
+
from PySide6.QtWidgets import QApplication # noqa: E402
|
| 13 |
+
|
| 14 |
+
from tars_qt import barra_rapida as br # noqa: E402
|
| 15 |
+
|
| 16 |
+
app = QApplication.instance() or QApplication([])
|
| 17 |
+
abertos = []
|
| 18 |
+
br.plataforma.abrir = abertos.append
|
| 19 |
+
br.busca.buscar = lambda frase: [{"caminho": "/casa/Documentos/Contrato de aluguel 2024.pdf", "nome": "Contrato de aluguel 2024.pdf",
|
| 20 |
+
"pasta": "/casa/Documentos", "modificado": 1704000000, "trecho": "…O LOCATÁRIO obriga-se…",
|
| 21 |
+
"sem_texto": False, "pontos": 9}] if "aluguel" in frase else []
|
| 22 |
+
|
| 23 |
+
barra = br.BarraRapida()
|
| 24 |
+
barra.mostrar()
|
| 25 |
+
app.processEvents()
|
| 26 |
+
assert barra.capacidades.isVisibleTo(barra) and len(barra.capacidades.findChildren(br.QPushButton)) == 6
|
| 27 |
+
botoes = {b.accessibleName(): b for b in barra.capacidades.findChildren(br.QPushButton)}
|
| 28 |
+
print("OK: vazia, a barra mostra as capacidades do mock-up e a de editar uma foto")
|
| 29 |
+
pedidos_foto = []
|
| 30 |
+
barra.editar_foto.connect(lambda: pedidos_foto.append(1))
|
| 31 |
+
botoes[br.t("rapido.cap.foto")].click()
|
| 32 |
+
assert pedidos_foto == [1] and not barra.isVisible(), "Editar uma foto fecha a barra e pede o seletor de fotos"
|
| 33 |
+
barra.mostrar()
|
| 34 |
+
print("OK: Editar uma foto fecha a barra e abre o seletor de fotos do assistente")
|
| 35 |
+
|
| 36 |
+
botoes[br.t("rapido.cap.web")].click()
|
| 37 |
+
assert barra.entrada.toPlainText() == br.t("rapido.pref.web") and not barra.capacidades.isVisibleTo(barra)
|
| 38 |
+
barra.entrada.clear()
|
| 39 |
+
print("OK: pesquisar na web preenche o começo do pedido e a lista some")
|
| 40 |
+
|
| 41 |
+
botoes[br.t("rapido.cap.arquivo")].click()
|
| 42 |
+
assert barra.modo == "arquivo"
|
| 43 |
+
barra.entrada.setPlainText("o contrato de aluguel de 2024")
|
| 44 |
+
barra._enviar()
|
| 45 |
+
fim = time.time() + 5
|
| 46 |
+
while time.time() < fim and not barra.achados:
|
| 47 |
+
app.processEvents()
|
| 48 |
+
time.sleep(0.02)
|
| 49 |
+
assert barra.achados and "Contrato de aluguel 2024.pdf" in barra._texto and "opentars-abrir:0" in barra._texto, barra._texto
|
| 50 |
+
barra._link(QUrl("opentars-abrir:0"))
|
| 51 |
+
barra._link(QUrl("opentars-pasta:0"))
|
| 52 |
+
barra._link(QUrl("opentars-abrir:7")) # número que a busca não deu: nada
|
| 53 |
+
barra._link(QUrl("file:///etc/passwd")) # esquema de fora: o caminho seguro ignora
|
| 54 |
+
assert abertos == ["/casa/Documentos/Contrato de aluguel 2024.pdf", "/casa/Documentos"], abertos
|
| 55 |
+
print("OK: a busca roda na barra; Abrir e Mostrar na pasta abrem só o que ela achou")
|
| 56 |
+
|
| 57 |
+
barra.entrada.setPlainText("planilha de marte")
|
| 58 |
+
barra._enviar()
|
| 59 |
+
fim = time.time() + 5
|
| 60 |
+
while time.time() < fim and br.t("busca.nada") not in barra._texto:
|
| 61 |
+
app.processEvents()
|
| 62 |
+
time.sleep(0.02)
|
| 63 |
+
assert br.t("busca.nada") in barra._texto
|
| 64 |
+
print("OK: sem resultado, diz que não achou")
|
| 65 |
+
print("test_qt_lancador: TUDO OK.")
|
app/tests/test_qt_mascote.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""O mascote (o monólito): anda dentro da conversa, reage ao que a IA faz, dá dicas sem repetir e se desliga pela
|
| 2 |
+
configuração e por TARS_SEM_MASCOTE. Janela de verdade, sem tela (offscreen) e sem Ollama."""
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 10 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 11 |
+
os.environ["HOME"] = CASA
|
| 12 |
+
for k in ("SESSOES", "NUVEM", "PRIMEIRO_USO"):
|
| 13 |
+
os.environ[f"TARS_ARQUIVO_{k}"] = os.path.join(CASA, k.lower() + ".json")
|
| 14 |
+
os.environ["OLLAMA_HOST"] = "http://127.0.0.1:9"
|
| 15 |
+
os.environ["TARS_IDIOMA"] = "pt_BR"
|
| 16 |
+
os.environ.pop("TARS_SEM_MASCOTE", None)
|
| 17 |
+
|
| 18 |
+
from _util import carregar_tars, RAIZ # noqa: E402,F401
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from PySide6.QtWidgets import QApplication
|
| 22 |
+
except ImportError:
|
| 23 |
+
print("test_qt_mascote: PULADO (PySide6 não instalado)")
|
| 24 |
+
sys.exit(0)
|
| 25 |
+
|
| 26 |
+
tars = carregar_tars()
|
| 27 |
+
import tars_i18n # noqa: E402
|
| 28 |
+
from tars_qt import mascote as mod, principal, tema # noqa: E402
|
| 29 |
+
|
| 30 |
+
app = QApplication.instance() or QApplication([])
|
| 31 |
+
tema.carregar_fontes()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def esperar(s):
|
| 35 |
+
fim = time.time() + s
|
| 36 |
+
while time.time() < fim:
|
| 37 |
+
app.processEvents()
|
| 38 |
+
time.sleep(0.01)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
w = principal.JanelaPrincipal(nucleo=tars, capturar_stdout=False)
|
| 42 |
+
w.resize(1200, 780)
|
| 43 |
+
w.show()
|
| 44 |
+
esperar(0.5)
|
| 45 |
+
m = w.mascote
|
| 46 |
+
|
| 47 |
+
# 1) aparece por padrão, dentro da conversa, em cima do campo de escrever
|
| 48 |
+
assert m.isVisible() and m.parentWidget() is not w.lateral
|
| 49 |
+
chao = w.compositor.mapTo(m.parentWidget(), w.compositor.rect().topLeft()).y()
|
| 50 |
+
assert abs((m.y() + mod.ALTURA) - chao) <= 20, (m.y(), chao)
|
| 51 |
+
print("OK: aparece sozinho, em cima do campo de escrever")
|
| 52 |
+
|
| 53 |
+
# 2) anda e nunca sai dos limites (nem quando a janela encolhe)
|
| 54 |
+
m._parado_ate = 0
|
| 55 |
+
x0 = m.x()
|
| 56 |
+
esperar(1.0)
|
| 57 |
+
assert m.x() != x0, "não andou"
|
| 58 |
+
for largura in (1200, 900, 1500):
|
| 59 |
+
w.resize(largura, 780)
|
| 60 |
+
m._x = 99999
|
| 61 |
+
esperar(0.2)
|
| 62 |
+
assert 16 <= m.x() <= m.parentWidget().width() - mod.LARGURA - 16 + 1, (largura, m.x())
|
| 63 |
+
print("OK: anda e fica dentro da conversa")
|
| 64 |
+
|
| 65 |
+
# 3) reage ao que a IA faz
|
| 66 |
+
w.ponte.ocupado.emit(True)
|
| 67 |
+
esperar(0.1)
|
| 68 |
+
assert m.estado == "pensando"
|
| 69 |
+
w._ao_evento("ferramenta", {"nome": "type_text", "resumo": "x"})
|
| 70 |
+
assert m.estado == "trabalhando" and "…" in m.balao.text() and m.balao.isVisible(), m.balao.text()
|
| 71 |
+
m._ultima_fala = 0
|
| 72 |
+
w._ao_evento("ferramenta_fim", {"nome": "type_text", "sucesso": False, "segundos": 1})
|
| 73 |
+
assert m.estado == "erro" and m.balao.text() == tars_i18n.t("mascote.ops"), (m.estado, m.balao.text())
|
| 74 |
+
m._ultima_fala = 0
|
| 75 |
+
w._ao_evento("plano", {"etapas": ["a", "b", "c"]})
|
| 76 |
+
assert "3" in m.balao.text()
|
| 77 |
+
m._ultima_fala = 0
|
| 78 |
+
w.ponte.aprovacao.emit(type("A", (), {"comando": "rm x", "responder": lambda self, ok: None})())
|
| 79 |
+
assert m.estado == "espera" and m.balao.text() == tars_i18n.t("mascote.permissao")
|
| 80 |
+
w.ponte.ocupado.emit(False)
|
| 81 |
+
w.ponte.concluido.emit("pronto")
|
| 82 |
+
esperar(0.1)
|
| 83 |
+
assert m.estado == "feliz" and m.balao.text() == tars_i18n.t("mascote.pronto")
|
| 84 |
+
esperar(2.8)
|
| 85 |
+
assert m.estado == "ocioso", m.estado
|
| 86 |
+
# pedido cancelado não comemora
|
| 87 |
+
m.balao.hide(); m._ultima_fala = 0; w._cancelou = True
|
| 88 |
+
w.ponte.concluido.emit("parei")
|
| 89 |
+
assert m.estado != "feliz"
|
| 90 |
+
w._cancelou = False
|
| 91 |
+
print("OK: reage (pensando, ferramenta, erro, plano, permissão, pronto)")
|
| 92 |
+
|
| 93 |
+
# 4) não metralha balões: duas ferramentas seguidas, só a primeira fala
|
| 94 |
+
m.balao.hide(); m._ultima_fala = 0
|
| 95 |
+
w._ao_evento("ferramenta", {"nome": "type_text"}); primeiro = m.balao.text()
|
| 96 |
+
w._ao_evento("ferramenta", {"nome": "click_mouse"})
|
| 97 |
+
assert m.balao.text() == primeiro
|
| 98 |
+
print("OK: balões não se atropelam")
|
| 99 |
+
|
| 100 |
+
# 5) dicas: as 10 saem sem repetir antes de acabar a rodada
|
| 101 |
+
vistas = []
|
| 102 |
+
for _ in range(mod.N_DICAS):
|
| 103 |
+
m._ultima_fala = 0
|
| 104 |
+
m.dica()
|
| 105 |
+
vistas.append(m.balao.text())
|
| 106 |
+
assert len(set(vistas)) == mod.N_DICAS, vistas
|
| 107 |
+
print("OK: 10 dicas, sem repetir na rodada")
|
| 108 |
+
|
| 109 |
+
# 6) textos: todas as chaves existem em pt_BR e en, com os mesmos {campos}
|
| 110 |
+
import re # noqa: E402
|
| 111 |
+
chaves = [k for k in json.load(open(os.path.join(RAIZ, "idiomas", "pt_BR.json"), encoding="utf-8")) if k.startswith(("mascote.", "cfg.mascote"))]
|
| 112 |
+
en = json.load(open(os.path.join(RAIZ, "idiomas", "en.json"), encoding="utf-8"))
|
| 113 |
+
pt = json.load(open(os.path.join(RAIZ, "idiomas", "pt_BR.json"), encoding="utf-8"))
|
| 114 |
+
assert len(chaves) >= 20 and all(k in en for k in chaves)
|
| 115 |
+
assert all(re.findall(r"{\w+}", pt[k]) == re.findall(r"{\w+}", en[k]) for k in chaves)
|
| 116 |
+
usadas = set(re.findall(r't\("(mascote\.[a-z_]+)"', open(os.path.join(RAIZ, "tars_qt", "mascote.py"), encoding="utf-8").read()))
|
| 117 |
+
assert usadas <= set(chaves), usadas - set(chaves)
|
| 118 |
+
print("OK: textos nos dois idiomas")
|
| 119 |
+
|
| 120 |
+
# 7) liga e desliga: configuração, menu dele e variável de ambiente
|
| 121 |
+
tars_i18n.salvar_config(mascote=False)
|
| 122 |
+
w.config.mudou_mascote.emit()
|
| 123 |
+
assert not m.isVisible() and not m.balao.isVisible()
|
| 124 |
+
tars_i18n.salvar_config(mascote=True)
|
| 125 |
+
w.config.mudou_mascote.emit()
|
| 126 |
+
assert m.isVisible()
|
| 127 |
+
m._esconder()
|
| 128 |
+
assert not m.isVisible() and tars_i18n.ler_config()["mascote"] is False
|
| 129 |
+
tars_i18n.salvar_config(mascote=True)
|
| 130 |
+
os.environ["TARS_SEM_MASCOTE"] = "1"
|
| 131 |
+
assert not mod.ligado()
|
| 132 |
+
del os.environ["TARS_SEM_MASCOTE"]
|
| 133 |
+
assert mod.ligado()
|
| 134 |
+
print("OK: liga e desliga (configuração, menu, TARS_SEM_MASCOTE)")
|
| 135 |
+
|
| 136 |
+
# 8) cada humor tem um rosto próprio (fora da piscada, que é igual em todos)
|
| 137 |
+
def _bytes(estado):
|
| 138 |
+
img = mod.desenhar_sprite(estado, 0.0, False, 1, 1.0) # agora=1.0: longe da piscada
|
| 139 |
+
return bytes(img.constBits())[:img.sizeInBytes()]
|
| 140 |
+
rostos = {e: _bytes(e) for e in ("ocioso", "pensando", "trabalhando", "espera", "erro", "feliz", "sono")}
|
| 141 |
+
assert len(set(rostos.values())) == len(rostos), [e for e in rostos if list(rostos.values()).count(rostos[e]) > 1]
|
| 142 |
+
print("OK: um rosto por humor")
|
| 143 |
+
|
| 144 |
+
# 9) cochila depois de muito tempo parado e acorda com qualquer coisa
|
| 145 |
+
m.mostrar_de_novo() # o passo 7 terminou com ele escondido pelo menu
|
| 146 |
+
m.estado = "ocioso"
|
| 147 |
+
m._ultima_atividade = time.monotonic() - mod.SONO_S - 1
|
| 148 |
+
esperar(0.1)
|
| 149 |
+
assert m.estado == "sono"
|
| 150 |
+
x0 = m.x(); esperar(0.5)
|
| 151 |
+
assert m.x() == x0, "anda dormindo"
|
| 152 |
+
w.ponte.ocupado.emit(True); esperar(0.05)
|
| 153 |
+
assert m.estado == "pensando"
|
| 154 |
+
w.ponte.ocupado.emit(False); esperar(0.05)
|
| 155 |
+
print("OK: cochila e acorda")
|
| 156 |
+
|
| 157 |
+
w.close()
|
| 158 |
+
print("\nTUDO OK.")
|
app/tests/test_qt_modos.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.1: os modos na janela de verdade (offscreen, Ollama de mentira). O Lite esconde histórico, memória, escolha de
|
| 2 |
+
modelo e as páginas avançadas; o Pro mostra tudo; trocar o modo em Configurações > Geral muda a janela na hora; as
|
| 3 |
+
páginas Ajustes avançados e Diagnóstico gravam e leem o que devem."""
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 10 |
+
CASA = tempfile.mkdtemp(prefix="opentars-modos-qt-")
|
| 11 |
+
os.environ["HOME"] = CASA
|
| 12 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 13 |
+
os.environ["TARS_ARQUIVO_NUVEM"] = os.path.join(CASA, "nuvem.json")
|
| 14 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(CASA, "primeiro_uso.json")
|
| 15 |
+
os.environ["TARS_ARQUIVO_CONFIG"] = os.path.join(CASA, "config.json")
|
| 16 |
+
|
| 17 |
+
from _util import RAIZ # noqa: E402,F401
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from PySide6.QtTest import QTest
|
| 21 |
+
except ImportError:
|
| 22 |
+
print("test_qt_modos: PULADO (PySide6 não instalado)")
|
| 23 |
+
sys.exit(0)
|
| 24 |
+
|
| 25 |
+
import fake_ollama # noqa: E402
|
| 26 |
+
|
| 27 |
+
servidor, url = fake_ollama.subir(modelos=["qwen3:4b"])
|
| 28 |
+
os.environ["OLLAMA_HOST"] = url
|
| 29 |
+
|
| 30 |
+
from _util import carregar_tars # noqa: E402
|
| 31 |
+
|
| 32 |
+
tars = carregar_tars()
|
| 33 |
+
tars.avisos_de_ambiente = lambda: []
|
| 34 |
+
tars.detectar_gpu = lambda forcar=False: None
|
| 35 |
+
tars.aquecer_ajudante = lambda: None
|
| 36 |
+
|
| 37 |
+
import tars_historico # noqa: E402
|
| 38 |
+
import tars_modos as modos # noqa: E402
|
| 39 |
+
from tars_qt.configuracoes import ultimas_ferramentas # noqa: E402
|
| 40 |
+
from tars_qt.primeiro_uso import criar_aplicativo # noqa: E402
|
| 41 |
+
from tars_qt.principal import JanelaPrincipal # noqa: E402
|
| 42 |
+
|
| 43 |
+
app = criar_aplicativo(["teste"])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def esperar(cond, segundos=15, o_que=""):
|
| 47 |
+
fim = time.time() + segundos
|
| 48 |
+
while time.time() < fim:
|
| 49 |
+
app.processEvents()
|
| 50 |
+
if cond():
|
| 51 |
+
return
|
| 52 |
+
QTest.qWait(20)
|
| 53 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
modos.definir_modo("lite")
|
| 57 |
+
j = JanelaPrincipal(tars, historico=tars_historico.Historico(os.path.join(CASA, "historico.json")), capturar_stdout=False)
|
| 58 |
+
j.resize(1200, 780)
|
| 59 |
+
j.show()
|
| 60 |
+
esperar(lambda: j._modelos, o_que="modelos")
|
| 61 |
+
cfg = j.config
|
| 62 |
+
assert not j.lateral.lista_w.isVisibleTo(j) and not j.lateral.caixa_busca.isVisibleTo(j), "Lite: sem histórico"
|
| 63 |
+
assert not j.lateral.b_memoria.isVisibleTo(j) and not j.cabecalho.pilula.isVisibleTo(j), "Lite: sem memória e seletor"
|
| 64 |
+
assert j.lateral.versao.text() == tars.VERSAO_EXIBIDA
|
| 65 |
+
j.abrir_config("avancado") # página escondida no Lite: cai na Geral
|
| 66 |
+
assert cfg.pilha.currentWidget() is cfg.paginas["geral"]
|
| 67 |
+
visiveis = {n for n, b in cfg.nav.items() if b.isVisibleTo(cfg)}
|
| 68 |
+
assert visiveis == {"geral", "assistente", "voz", "atualizacoes", "sobre"}, visiveis # 5.2: o assistente vale em todo modo
|
| 69 |
+
print("OK: o Lite esconde histórico, memória, seletor de modelo e as páginas que não são dele")
|
| 70 |
+
|
| 71 |
+
# trocar para Pro pela própria tela
|
| 72 |
+
cfg.paginas["geral"].chips_modo["pro"].click()
|
| 73 |
+
assert modos.modo_atual() == "pro"
|
| 74 |
+
assert not j.lateral.lista_w.isHidden() and not j.lateral.b_memoria.isHidden() and not j.cabecalho.pilula.isHidden()
|
| 75 |
+
visiveis = {n for n, b in cfg.nav.items() if b.isVisibleTo(cfg)}
|
| 76 |
+
assert {"avancado", "diagnostico", "memoria", "rotinas", "nuvem"} <= visiveis, visiveis
|
| 77 |
+
print("OK: trocar para o Pro em Configurações > Geral mostra tudo na hora")
|
| 78 |
+
|
| 79 |
+
# Ajustes avançados: grava o que está na tela
|
| 80 |
+
cfg.ir("avancado")
|
| 81 |
+
av = cfg.paginas["avancado"]
|
| 82 |
+
av.temperatura.setCurrentIndex(av.temperatura.findData(0.7))
|
| 83 |
+
av.contexto.setCurrentIndex(av.contexto.findData(16384))
|
| 84 |
+
av.instrucoes.setPlainText("seja breve")
|
| 85 |
+
av.caixas["execute_terminal"].setChecked(False)
|
| 86 |
+
av._salvar()
|
| 87 |
+
a = modos.avancado()
|
| 88 |
+
assert a == {"temperatura": 0.7, "contexto": 16384, "instrucoes": "seja breve",
|
| 89 |
+
"ferramentas_desligadas": ["execute_terminal"]}, a
|
| 90 |
+
assert av.msg.text() and not av.aviso.isVisibleTo(cfg)
|
| 91 |
+
cfg.paginas["geral"].chips_modo["base"].click()
|
| 92 |
+
cfg.ir("geral")
|
| 93 |
+
assert not cfg.nav["avancado"].isVisibleTo(cfg) and modos.avancado()["temperatura"] is None
|
| 94 |
+
cfg.paginas["geral"].chips_modo["pro"].click()
|
| 95 |
+
print("OK: Ajustes avançados gravam e só valem no Pro")
|
| 96 |
+
|
| 97 |
+
# Diagnóstico: lê as ferramentas do log
|
| 98 |
+
log = os.path.join(CASA, "tars.log")
|
| 99 |
+
with open(log, "w", encoding="utf-8") as f:
|
| 100 |
+
f.write('2026-10-16 10:00:01,000 INFO ferramenta=open_application args={"app": "calc"} sucesso=True tempo=0.1s mensagem="ok"\n')
|
| 101 |
+
f.write("2026-10-16 10:00:02,000 INFO outra coisa\n")
|
| 102 |
+
f.write('2026-10-16 10:00:03,000 INFO ferramenta=execute_terminal args={"command": "ls"} sucesso=False tempo=0s mensagem="x"\n')
|
| 103 |
+
assert ultimas_ferramentas(log) == ['10:00:01 ✓ open_application {"app": "calc"}', '10:00:03 × execute_terminal {"command": "ls"}']
|
| 104 |
+
assert ultimas_ferramentas(os.path.join(CASA, "nao-existe.log")) == []
|
| 105 |
+
tars.ARQUIVO_LOG = log
|
| 106 |
+
cfg.ir("diagnostico")
|
| 107 |
+
assert "open_application" in cfg.paginas["diagnostico"].log.toPlainText()
|
| 108 |
+
print("OK: o Diagnóstico mostra as últimas ferramentas do log")
|
| 109 |
+
|
| 110 |
+
# 5.1: modelo da nuvem do Ollama escolhido à mão: o rodapé avisa que sai do computador
|
| 111 |
+
tars.modo_modelo, tars.modelo_manual = "MANUAL", "gpt-oss:20b-cloud"
|
| 112 |
+
j._mostrar_modelo_em_uso()
|
| 113 |
+
assert "Ollama Cloud" in j.compositor.rodape.text(), j.compositor.rodape.text()
|
| 114 |
+
tars.modo_modelo, tars.modelo_manual = "MANUAL", "qwen3:4b"
|
| 115 |
+
j._mostrar_modelo_em_uso()
|
| 116 |
+
assert "Ollama Cloud" not in j.compositor.rodape.text()
|
| 117 |
+
print("OK: o rodapé avisa quando o modelo escolhido é da nuvem do Ollama")
|
| 118 |
+
|
| 119 |
+
j._saindo = True
|
| 120 |
+
j.close()
|
| 121 |
+
print("\nTUDO OK.")
|
app/tests/test_qt_primeiro_uso.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0 Tesseract: o assistente de primeiro uso em Qt, de ponta a ponta, sem
|
| 2 |
+
tela (QT_QPA_PLATFORM=offscreen) e contra um Ollama de mentira."""
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import stat
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 11 |
+
os.environ["TARS_MODELO_GARGANTUA"] = "ProjectOpenTARS/Gargantua-2"
|
| 12 |
+
|
| 13 |
+
from _util import RAIZ # noqa: F401 (raiz no sys.path, idioma de teste)
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from PySide6.QtTest import QTest
|
| 17 |
+
from PySide6.QtWidgets import QApplication
|
| 18 |
+
except ImportError:
|
| 19 |
+
print("test_qt_primeiro_uso: PULADO (PySide6 não instalado)")
|
| 20 |
+
sys.exit(0)
|
| 21 |
+
|
| 22 |
+
import fake_ollama
|
| 23 |
+
import tars_i18n
|
| 24 |
+
import tars_primeiro_uso as pu
|
| 25 |
+
|
| 26 |
+
pasta = tempfile.mkdtemp(prefix="opentars-qt-")
|
| 27 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(pasta, "primeiro_uso.json")
|
| 28 |
+
os.environ["TARS_ARQUIVO_NUVEM"] = os.path.join(pasta, "nuvem.json")
|
| 29 |
+
|
| 30 |
+
servidor, url = fake_ollama.subir(modelos=["qwen3:4b"], chave="boa")
|
| 31 |
+
os.environ["OLLAMA_HOST"] = url
|
| 32 |
+
|
| 33 |
+
from tars_qt.primeiro_uso import JanelaPrimeiroUso, criar_aplicativo # noqa: E402
|
| 34 |
+
|
| 35 |
+
app = criar_aplicativo(["teste"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def esperar(condicao, segundos=15, o_que=""):
|
| 39 |
+
fim = time.time() + segundos
|
| 40 |
+
while time.time() < fim:
|
| 41 |
+
app.processEvents()
|
| 42 |
+
if condicao():
|
| 43 |
+
return
|
| 44 |
+
QTest.qWait(20)
|
| 45 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def passar_pelo_ollama(j):
|
| 49 |
+
j.ir("ollama")
|
| 50 |
+
p = j.paginas["ollama"]
|
| 51 |
+
esperar(lambda: p.b_continuar.isEnabled(), o_que="Ollama encontrado")
|
| 52 |
+
p.b_continuar.click()
|
| 53 |
+
assert j.atual == "nuvem"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def ate_o_fim(j):
|
| 57 |
+
esperar(lambda: j.paginas["gargantua"].b_continuar.isEnabled(), o_que="download do Gargantua")
|
| 58 |
+
assert j.paginas["gargantua"].barra.value() == 1000
|
| 59 |
+
j.paginas["gargantua"].b_continuar.click()
|
| 60 |
+
assert j.atual == "teste"
|
| 61 |
+
esperar(lambda: j.paginas["teste"].b_continuar.isEnabled(), o_que="teste rápido")
|
| 62 |
+
j.paginas["teste"].b_continuar.click()
|
| 63 |
+
if j.atual == "sistema": # Windows e macOS: o passo das permissões e do atalho
|
| 64 |
+
j.paginas["sistema"].nav.itemAt(j.paginas["sistema"].nav.count() - 1).widget().click()
|
| 65 |
+
assert j.atual == "pronto"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# --- 1) caminho padrão: só no computador ---------------------------------
|
| 69 |
+
tars_i18n.definir_idioma("pt_BR")
|
| 70 |
+
j = JanelaPrimeiroUso()
|
| 71 |
+
j.show()
|
| 72 |
+
assert j.atual == "idioma" and len(j.paginas) == 8 + ("sistema" in j.paginas)
|
| 73 |
+
assert j.windowTitle() == tars_i18n.t("pu.janela")
|
| 74 |
+
j.paginas["idioma"].continuar() # mantém português
|
| 75 |
+
assert j.atual == "modo" # 5.1: o modo vem logo depois do idioma
|
| 76 |
+
j.paginas["modo"].continuar() # Base, já marcado
|
| 77 |
+
assert j.atual == "ollama"
|
| 78 |
+
esperar(lambda: j.paginas["ollama"].b_continuar.isEnabled(), o_que="Ollama")
|
| 79 |
+
j.paginas["ollama"].b_continuar.click()
|
| 80 |
+
assert j.atual == "nuvem"
|
| 81 |
+
assert j.paginas["nuvem"].grupo.marcado() == 0, "o padrão é 'Não, só no meu computador'"
|
| 82 |
+
j.paginas["nuvem"].continuar()
|
| 83 |
+
assert j.atual == "gargantua"
|
| 84 |
+
ate_o_fim(j)
|
| 85 |
+
assert not pu.carregar_nuvem()["ativa"]
|
| 86 |
+
assert not pu.primeiro_uso_concluido()
|
| 87 |
+
assert j.paginas["pronto"].resumo_nuvem.texto.plano() == tars_i18n.t("pu.pronto.nuvem_nao")
|
| 88 |
+
j.terminar()
|
| 89 |
+
assert pu.primeiro_uso_concluido() and j.terminou
|
| 90 |
+
puxados = [c["model"] for m, p, c in servidor.pedidos if p == "/api/pull"]
|
| 91 |
+
assert puxados == ["ProjectOpenTARS/Gargantua-2", "ProjectOpenTARS/Gargantua-2:lite"], puxados # o 2B e o Lite obrigatório
|
| 92 |
+
|
| 93 |
+
# --- 2) trocar de idioma reconstrói a janela -------------------------------
|
| 94 |
+
pu.marcar_concluido(False)
|
| 95 |
+
j2 = JanelaPrimeiroUso()
|
| 96 |
+
j2.show()
|
| 97 |
+
pt_titulo = j2.paginas["idioma"].h1.plano()
|
| 98 |
+
codigos = j2.paginas["idioma"].codigos
|
| 99 |
+
assert "en" in codigos
|
| 100 |
+
j2.paginas["idioma"].grupo.escolher(j2.paginas["idioma"].grupo.cartoes[codigos.index("en")])
|
| 101 |
+
j2.paginas["idioma"].continuar()
|
| 102 |
+
assert tars_i18n.idioma_atual() == "en"
|
| 103 |
+
assert j2.atual == "modo"
|
| 104 |
+
assert j2.paginas["modo"].h1.plano() == tars_i18n.t("pu.modo.titulo")
|
| 105 |
+
assert j2.paginas["ollama"].h1.plano() != pt_titulo
|
| 106 |
+
assert j2.paginas["ollama"].h1.plano() == tars_i18n.t("pu.ollama.titulo")
|
| 107 |
+
j2.close()
|
| 108 |
+
tars_i18n.definir_idioma("pt_BR")
|
| 109 |
+
|
| 110 |
+
# --- 3) com nuvem: chave inválida, depois válida, salva com modo 0600 -------
|
| 111 |
+
j3 = JanelaPrimeiroUso()
|
| 112 |
+
j3.show()
|
| 113 |
+
passar_pelo_ollama(j3)
|
| 114 |
+
nuvem = j3.paginas["nuvem"]
|
| 115 |
+
nuvem.grupo.escolher(nuvem.sim)
|
| 116 |
+
nuvem.continuar()
|
| 117 |
+
assert j3.atual == "chave"
|
| 118 |
+
c = j3.paginas["chave"]
|
| 119 |
+
assert c.prov_id == "ollama" and c.nota.isVisibleTo(c), "Ollama Cloud avisa que o endereço é a confirmar"
|
| 120 |
+
c.chips["groq"].click()
|
| 121 |
+
assert c.prov_id == "groq" and "groq" in c.endereco.text() and not c.nota.isVisibleTo(c)
|
| 122 |
+
c.continuar() # sem chave: não avança
|
| 123 |
+
assert j3.atual == "chave" and pu.carregar_nuvem()["ativa"] is False
|
| 124 |
+
c.chips["outro"].click()
|
| 125 |
+
c.endereco.setText(url)
|
| 126 |
+
c.chave.setText("ruim")
|
| 127 |
+
c.b_testar.click()
|
| 128 |
+
esperar(lambda: c.b_testar.isEnabled(), o_que="teste da chave ruim")
|
| 129 |
+
assert c.resultado.plano() == tars_i18n.t("pu.chave.erro_chave"), c.resultado.plano()
|
| 130 |
+
assert "ruim" not in c.resultado.plano()
|
| 131 |
+
c.chave.setText("boa")
|
| 132 |
+
c.b_testar.click()
|
| 133 |
+
esperar(lambda: c.b_testar.isEnabled(), o_que="teste da chave boa")
|
| 134 |
+
assert c.resultado.plano() == tars_i18n.t("pu.chave.ok"), c.resultado.plano()
|
| 135 |
+
c.continuar()
|
| 136 |
+
assert j3.atual == "gargantua"
|
| 137 |
+
n = pu.carregar_nuvem()
|
| 138 |
+
assert n == {"ativa": True, "provedor": "outro", "endereco": url, "chave": "boa"}, n
|
| 139 |
+
assert os.name == "nt" or stat.S_IMODE(os.stat(pu.arquivo_nuvem()).st_mode) == 0o600
|
| 140 |
+
ate_o_fim(j3)
|
| 141 |
+
assert j3.paginas["pronto"].resumo_nuvem.texto.plano() == tars_i18n.t("pu.pronto.nuvem_sim", provedor="Outro")
|
| 142 |
+
j3.close()
|
| 143 |
+
|
| 144 |
+
# --- 4) Gargantua já instalado: não baixa de novo ----------------------------
|
| 145 |
+
servidor2, url2 = fake_ollama.subir(modelos=["ProjectOpenTARS/Gargantua:latest", "ProjectOpenTARS/cooper:latest"])
|
| 146 |
+
os.environ["OLLAMA_HOST"] = url2
|
| 147 |
+
j4 = JanelaPrimeiroUso()
|
| 148 |
+
j4.show()
|
| 149 |
+
passar_pelo_ollama(j4)
|
| 150 |
+
j4.paginas["nuvem"].continuar()
|
| 151 |
+
esperar(lambda: j4.paginas["gargantua"].pronto and j4.paginas["gargantua"].b_continuar.isEnabled(), o_que="só o Lite")
|
| 152 |
+
puxados2 = [c["model"] for m, p, c in servidor2.pedidos if p == "/api/pull"]
|
| 153 |
+
assert puxados2 == ["ProjectOpenTARS/Gargantua-2:lite"], puxados2 # o principal já existe; só o Lite obrigatório vem
|
| 154 |
+
j4.close()
|
| 155 |
+
|
| 156 |
+
# --- 5) Ollama fora do ar: oferece instalar, não avança ------------------------
|
| 157 |
+
os.environ["OLLAMA_HOST"] = "http://127.0.0.1:9"
|
| 158 |
+
j5 = JanelaPrimeiroUso()
|
| 159 |
+
j5.show()
|
| 160 |
+
j5.ir("ollama")
|
| 161 |
+
p5 = j5.paginas["ollama"]
|
| 162 |
+
esperar(lambda: p5.linha.texto.plano() != tars_i18n.t("pu.ollama.procurando"), o_que="busca do Ollama")
|
| 163 |
+
assert not p5.b_continuar.isEnabled()
|
| 164 |
+
j5.close()
|
| 165 |
+
|
| 166 |
+
for s in (servidor, servidor2):
|
| 167 |
+
s.shutdown()
|
| 168 |
+
print("test_qt_primeiro_uso: OK")
|
app/tests/test_qt_primeiro_uso_gargantua2.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""18/10: a tela de download do primeiro uso baixa só o Gargantua 2 (que enxerga a tela; o Cooper foi descontinuado e não
|
| 2 |
+
é mais oferecido). Qt offscreen, Ollama de mentira."""
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 9 |
+
os.environ["TARS_MODELO_GARGANTUA"] = "ProjectOpenTARS/Gargantua-2"
|
| 10 |
+
from _util import RAIZ # noqa: F401,E402
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from PySide6.QtTest import QTest
|
| 14 |
+
except ImportError:
|
| 15 |
+
print("test_qt_primeiro_uso_gargantua2: PULADO (PySide6 não instalado)")
|
| 16 |
+
sys.exit(0)
|
| 17 |
+
|
| 18 |
+
import fake_ollama # noqa: E402
|
| 19 |
+
import tars_i18n # noqa: E402
|
| 20 |
+
import tars_primeiro_uso as pu # noqa: E402
|
| 21 |
+
|
| 22 |
+
pasta = tempfile.mkdtemp(prefix="opentars-qt-garg2-")
|
| 23 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(pasta, "primeiro_uso.json")
|
| 24 |
+
os.environ["TARS_ARQUIVO_NUVEM"] = os.path.join(pasta, "nuvem.json")
|
| 25 |
+
from tars_qt.primeiro_uso import JanelaPrimeiroUso, criar_aplicativo # noqa: E402
|
| 26 |
+
|
| 27 |
+
app = criar_aplicativo(["teste"])
|
| 28 |
+
tars_i18n.definir_idioma("pt_BR")
|
| 29 |
+
GARG2, GARG1, COOPER = "ProjectOpenTARS/Gargantua-2:latest", "ProjectOpenTARS/Gargantua:latest", "ProjectOpenTARS/cooper:1.2"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def esperar(condicao, segundos=20, o_que=""):
|
| 33 |
+
fim = time.time() + segundos
|
| 34 |
+
while time.time() < fim:
|
| 35 |
+
app.processEvents()
|
| 36 |
+
if condicao():
|
| 37 |
+
return
|
| 38 |
+
QTest.qWait(20)
|
| 39 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def abrir(modelos, **opcoes):
|
| 43 |
+
servidor, url = fake_ollama.subir(modelos=modelos, **opcoes)
|
| 44 |
+
os.environ["OLLAMA_HOST"] = url
|
| 45 |
+
j = JanelaPrimeiroUso()
|
| 46 |
+
j.show()
|
| 47 |
+
j.ir("gargantua")
|
| 48 |
+
return j, j.paginas["gargantua"], servidor
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def puxados(servidor):
|
| 52 |
+
return [c["model"] for m, p, c in servidor.pedidos if p == "/api/pull"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# 1) nada instalado: baixa o Gargantua 2 e o Lite obrigatório (25/10), sem opção nem fase do Cooper
|
| 56 |
+
j, pg, srv = abrir([])
|
| 57 |
+
esperar(lambda: pg.pronto and pg.b_continuar.isEnabled(), o_que="baixar o Gargantua 2")
|
| 58 |
+
assert puxados(srv) == [pu.MODELO_GARGANTUA, pu.MODELO_GARGANTUA_LITE] == \
|
| 59 |
+
["ProjectOpenTARS/Gargantua-2", "ProjectOpenTARS/Gargantua-2:lite"], puxados(srv)
|
| 60 |
+
assert not hasattr(pg, "c_cooper") and not hasattr(pg, "baixar_cooper"), "o Cooper não é mais oferecido"
|
| 61 |
+
j.close()
|
| 62 |
+
print("OK: sem nenhum modelo, baixa o Gargantua 2 e o Lite (a IA de segundo plano)")
|
| 63 |
+
|
| 64 |
+
# 2) o Gargantua 2 já está instalado: nada é baixado
|
| 65 |
+
j, pg, srv = abrir([GARG2, pu.MODELO_GARGANTUA_LITE])
|
| 66 |
+
esperar(lambda: pg.b_continuar.isEnabled(), o_que="já instalado")
|
| 67 |
+
assert puxados(srv) == [] and pg.h1.plano() == tars_i18n.t("pu.garg.titulo_ja")
|
| 68 |
+
j.close()
|
| 69 |
+
j, pg, srv = abrir([GARG2])
|
| 70 |
+
esperar(lambda: pg.pronto and pg.b_continuar.isEnabled(), o_que="só falta o Lite")
|
| 71 |
+
assert puxados(srv) == [pu.MODELO_GARGANTUA_LITE], puxados(srv)
|
| 72 |
+
j.close()
|
| 73 |
+
print("OK: com o Gargantua 2 e o Lite instalados, nada é baixado; sem o Lite, só ele vem")
|
| 74 |
+
|
| 75 |
+
# 3) quem já tem o Gargantua 1.x continua com ele (não é obrigado a baixar o novo)
|
| 76 |
+
j, pg, srv = abrir([GARG1])
|
| 77 |
+
esperar(lambda: pg.pronto and pg.b_continuar.isEnabled(), o_que="Gargantua antigo")
|
| 78 |
+
assert puxados(srv) == [pu.MODELO_GARGANTUA_LITE], puxados(srv)
|
| 79 |
+
j.close()
|
| 80 |
+
print("OK: o Gargantua 1.x instalado vale como principal; só o Lite obrigatório vem")
|
| 81 |
+
|
| 82 |
+
# 4) um Cooper instalado não conta como Gargantua e nada o baixa
|
| 83 |
+
j, pg, srv = abrir([COOPER])
|
| 84 |
+
esperar(lambda: pg.pronto and pg.b_continuar.isEnabled(), o_que="só o Cooper")
|
| 85 |
+
assert puxados(srv) == [pu.MODELO_GARGANTUA, pu.MODELO_GARGANTUA_LITE], puxados(srv)
|
| 86 |
+
j.close()
|
| 87 |
+
print("OK: o Cooper antigo não substitui o Gargantua, e o Gargantua 2 vem baixado")
|
| 88 |
+
|
| 89 |
+
# 4b) 25/10: os modelinhos de foto entram na fila depois dos modelos (obrigatórios); aqui de mentira
|
| 90 |
+
import tars_modelinhos # noqa: E402
|
| 91 |
+
os.environ.pop("TARS_SEM_MODELINHOS", None)
|
| 92 |
+
originais = tars_modelinhos.faltando, tars_modelinhos.baixar_todos
|
| 93 |
+
baixados = []
|
| 94 |
+
tars_modelinhos.faltando = lambda: [] if baixados else ["fundo"]
|
| 95 |
+
tars_modelinhos.bytes_faltando = lambda: 300_000_000
|
| 96 |
+
tars_modelinhos.baixar_todos = lambda ao_progredir=None, cancelado=None: (ao_progredir(1, 1), baixados.append(1), (True, ""))[2]
|
| 97 |
+
j, pg, srv = abrir([GARG2, pu.MODELO_GARGANTUA_LITE])
|
| 98 |
+
esperar(lambda: pg.pronto and pg.b_continuar.isEnabled(), o_que="modelinhos")
|
| 99 |
+
assert baixados == [1] and puxados(srv) == [], (baixados, puxados(srv))
|
| 100 |
+
j.close()
|
| 101 |
+
tars_modelinhos.faltando, tars_modelinhos.baixar_todos = originais
|
| 102 |
+
os.environ["TARS_SEM_MODELINHOS"] = "1"
|
| 103 |
+
print("OK: os modelinhos de foto entram na fila do primeiro uso, mesmo com os modelos já instalados")
|
| 104 |
+
|
| 105 |
+
# 5) espaço: o Gargantua 2 precisa de ~1,7 GB, e o aviso aparece quando falta
|
| 106 |
+
real = pu.espaco_livre_gb
|
| 107 |
+
pu.espaco_livre_gb = lambda caminho=None: 1.0
|
| 108 |
+
j, pg, srv = abrir([])
|
| 109 |
+
esperar(lambda: pg.baixando or pg.pronto or pg.b_continuar.isEnabled(), o_que="pouco espaço")
|
| 110 |
+
assert "1.0" in pg.l_espaco.texto.plano(), pg.l_espaco.texto.plano()
|
| 111 |
+
pu.espaco_livre_gb = real
|
| 112 |
+
j.close()
|
| 113 |
+
print("OK: pouco espaço avisa")
|
| 114 |
+
|
| 115 |
+
print("\nTUDO OK.")
|
app/tests/test_qt_primeiro_uso_sistema.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.2 (F3.5): o passo "Permissões e atalho" do primeiro uso. No macOS mostra Acessibilidade e Gravação de Tela com o
|
| 2 |
+
estado de cada uma e o botão que abre os Ajustes; no Windows, o atalho das ações e o iniciar com o sistema; no Linux o
|
| 3 |
+
passo não existe. As funções do sistema são de mentira aqui (as de verdade estão em tars_plataforma/macos.py)."""
|
| 4 |
+
import importlib
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 10 |
+
from _util import RAIZ # noqa: E402,F401
|
| 11 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 12 |
+
from PySide6.QtWidgets import QApplication # noqa: E402
|
| 13 |
+
|
| 14 |
+
import tars_plataforma as plataforma # noqa: E402
|
| 15 |
+
import tars_assistente # noqa: E402
|
| 16 |
+
import tars_atalho # noqa: E402
|
| 17 |
+
from tars_qt import primeiro_uso # noqa: E402
|
| 18 |
+
|
| 19 |
+
app = QApplication.instance() or QApplication([])
|
| 20 |
+
original = plataforma.sistema, plataforma.permissoes, plataforma.pedir_permissao
|
| 21 |
+
estado, pedidos = {"acessibilidade": True, "tela": False}, []
|
| 22 |
+
try:
|
| 23 |
+
plataforma.sistema = lambda: "macos"
|
| 24 |
+
plataforma.permissoes = lambda: dict(estado)
|
| 25 |
+
plataforma.pedir_permissao = pedidos.append
|
| 26 |
+
pu = importlib.reload(primeiro_uso)
|
| 27 |
+
assert pu.PASSOS[-1] == "sistema" and pu.DEPOIS_DO_TESTE == "sistema"
|
| 28 |
+
jan = pu.JanelaPrimeiroUso()
|
| 29 |
+
jan.ir("sistema")
|
| 30 |
+
pag = jan.paginas["sistema"]
|
| 31 |
+
assert pag.pedidos["acessibilidade"].text() == pu.t("pu.sist.concedida") and not pag.pedidos["acessibilidade"].isEnabled()
|
| 32 |
+
assert pag.pedidos["tela"].isEnabled()
|
| 33 |
+
pag.pedidos["tela"].click()
|
| 34 |
+
assert pedidos == ["tela"], pedidos
|
| 35 |
+
estado["tela"] = True # a pessoa concedeu em Ajustes e voltou: o relógio da página atualiza
|
| 36 |
+
pag.atualizar()
|
| 37 |
+
assert pag.pedidos["tela"].text() == pu.t("pu.sist.concedida")
|
| 38 |
+
jan.paginas["teste"].b_continuar.setEnabled(True)
|
| 39 |
+
jan.paginas["teste"].b_continuar.click()
|
| 40 |
+
assert jan.atual == "sistema"
|
| 41 |
+
print("OK: macOS: Acessibilidade e Gravação de Tela com o estado, o botão pede a permissão e a lista se atualiza")
|
| 42 |
+
jan.close()
|
| 43 |
+
|
| 44 |
+
plataforma.sistema = lambda: "windows"
|
| 45 |
+
inicio, registrado = {"ligado": False}, []
|
| 46 |
+
originais_win = tars_assistente.inicia_com_sistema, tars_assistente.iniciar_com_sistema, tars_atalho.registrar_acoes
|
| 47 |
+
tars_assistente.inicia_com_sistema = lambda: inicio["ligado"]
|
| 48 |
+
tars_assistente.iniciar_com_sistema = lambda v: inicio.update(ligado=v) or v
|
| 49 |
+
tars_atalho.registrar_acoes = lambda: registrado.append(1) or (True, "")
|
| 50 |
+
try:
|
| 51 |
+
pu = importlib.reload(primeiro_uso)
|
| 52 |
+
jan = pu.JanelaPrimeiroUso()
|
| 53 |
+
jan.ir("sistema")
|
| 54 |
+
pag = jan.paginas["sistema"]
|
| 55 |
+
assert pag.b_inicio.text() == pu.t("pu.sist.desligado")
|
| 56 |
+
pag.b_inicio.click()
|
| 57 |
+
assert inicio["ligado"] and pag.b_inicio.text() == pu.t("pu.sist.ligado")
|
| 58 |
+
pag.b_atalho.click()
|
| 59 |
+
assert registrado and pag.b_atalho.text() == pu.t("pu.sist.ligado")
|
| 60 |
+
print("OK: Windows: o atalho Ctrl+Alt+R e o iniciar com o sistema ligam pelo passo (desligados por padrão)")
|
| 61 |
+
jan.close()
|
| 62 |
+
finally:
|
| 63 |
+
tars_assistente.inicia_com_sistema, tars_assistente.iniciar_com_sistema, tars_atalho.registrar_acoes = originais_win
|
| 64 |
+
|
| 65 |
+
plataforma.sistema = lambda: "linux"
|
| 66 |
+
pu = importlib.reload(primeiro_uso)
|
| 67 |
+
assert "sistema" not in pu.PASSOS and pu.DEPOIS_DO_TESTE == "pronto"
|
| 68 |
+
jan = pu.JanelaPrimeiroUso()
|
| 69 |
+
assert "sistema" not in jan.paginas
|
| 70 |
+
print("OK: Linux: o passo não existe e o teste leva direto ao pronto")
|
| 71 |
+
jan.close()
|
| 72 |
+
finally:
|
| 73 |
+
plataforma.sistema, plataforma.permissoes, plataforma.pedir_permissao = original
|
| 74 |
+
importlib.reload(primeiro_uso)
|
| 75 |
+
print("test_qt_primeiro_uso_sistema: TUDO OK.")
|
app/tests/test_qt_principal.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0 Tesseract: a janela principal em Qt, de ponta a ponta, sem tela (offscreen), com o núcleo de verdade
|
| 2 |
+
contra um Ollama de mentira."""
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import stat
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 15 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 16 |
+
os.environ["HOME"] = CASA
|
| 17 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 18 |
+
os.environ["TARS_ARQUIVO_NUVEM"] = os.path.join(CASA, "nuvem.json")
|
| 19 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(CASA, "primeiro_uso.json")
|
| 20 |
+
|
| 21 |
+
from _util import RAIZ # noqa: E402,F401
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
from PySide6.QtTest import QTest
|
| 25 |
+
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
| 26 |
+
except ImportError:
|
| 27 |
+
print("test_qt_principal: PULADO (PySide6 não instalado)")
|
| 28 |
+
sys.exit(0)
|
| 29 |
+
|
| 30 |
+
chats = []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Falso(BaseHTTPRequestHandler):
|
| 34 |
+
def log_message(self, *a):
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
def _json(self, dados, codigo=200):
|
| 38 |
+
corpo = json.dumps(dados).encode()
|
| 39 |
+
self.send_response(codigo)
|
| 40 |
+
self.send_header("Content-Type", "application/json")
|
| 41 |
+
self.send_header("Content-Length", str(len(corpo)))
|
| 42 |
+
self.end_headers()
|
| 43 |
+
self.wfile.write(corpo)
|
| 44 |
+
|
| 45 |
+
def do_GET(self):
|
| 46 |
+
if self.path == "/api/version":
|
| 47 |
+
return self._json({"version": "0.12.9"})
|
| 48 |
+
if self.path == "/api/tags":
|
| 49 |
+
return self._json({"models": [{"name": "qwen3:4b", "model": "qwen3:4b", "size": 3_000_000_000, "details": {
|
| 50 |
+
"family": "qwen3", "parameter_size": "4.0B", "quantization_level": "Q4_K_M"}}]})
|
| 51 |
+
if self.path == "/api/ps":
|
| 52 |
+
return self._json({"models": []})
|
| 53 |
+
self._json({}, 404)
|
| 54 |
+
|
| 55 |
+
def do_POST(self):
|
| 56 |
+
corpo = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
| 57 |
+
if self.path == "/api/show":
|
| 58 |
+
return self._json({"capabilities": ["completion", "tools"], "details": {
|
| 59 |
+
"family": "qwen3", "parameter_size": "4.0B", "quantization_level": "Q4_K_M"}, "model_info": {}, "parameters": ""})
|
| 60 |
+
if self.path == "/api/generate":
|
| 61 |
+
return self._json({"model": corpo.get("model"), "done": True, "response": ""})
|
| 62 |
+
if self.path == "/api/chat":
|
| 63 |
+
chats.append(corpo)
|
| 64 |
+
linhas = [{"model": "qwen3:4b", "message": {"role": "assistant", "content": "Pronto, **feito**."}, "done": False},
|
| 65 |
+
{"model": "qwen3:4b", "message": {"role": "assistant", "content": ""}, "done": True, "done_reason": "stop"}]
|
| 66 |
+
dados = ("\n".join(json.dumps(l) for l in linhas) + "\n").encode()
|
| 67 |
+
self.send_response(200)
|
| 68 |
+
self.send_header("Content-Type", "application/x-ndjson")
|
| 69 |
+
self.send_header("Content-Length", str(len(dados)))
|
| 70 |
+
self.end_headers()
|
| 71 |
+
self.wfile.write(dados)
|
| 72 |
+
return
|
| 73 |
+
self._json({}, 404)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
servidor = ThreadingHTTPServer(("127.0.0.1", 0), Falso)
|
| 77 |
+
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
| 78 |
+
os.environ["OLLAMA_HOST"] = f"127.0.0.1:{servidor.server_address[1]}"
|
| 79 |
+
|
| 80 |
+
from _util import carregar_tars # noqa: E402
|
| 81 |
+
|
| 82 |
+
tars = carregar_tars()
|
| 83 |
+
tars.avisos_de_ambiente = lambda: []
|
| 84 |
+
tars.detectar_gpu = lambda forcar=False: None
|
| 85 |
+
tars.escolher_modelo = lambda texto, tarefa=None, complexa=False: tars.modelo_manual or "qwen3:4b"
|
| 86 |
+
tars.aquecer_ajudante = lambda: None
|
| 87 |
+
|
| 88 |
+
import tars_historico # noqa: E402
|
| 89 |
+
import tars_i18n # noqa: E402
|
| 90 |
+
import tars_primeiro_uso as pu # noqa: E402
|
| 91 |
+
from tars_i18n import t # noqa: E402
|
| 92 |
+
from tars_qt.primeiro_uso import criar_aplicativo # noqa: E402
|
| 93 |
+
from tars_qt.principal import JanelaPrincipal # noqa: E402
|
| 94 |
+
from tars_qt.conversa import CartaoAprovacao, MensagemIA # noqa: E402
|
| 95 |
+
|
| 96 |
+
app = criar_aplicativo(["teste"])
|
| 97 |
+
SAIDA = os.environ.get("TARS_CAPTURAS")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def esperar(cond, segundos=15, o_que=""):
|
| 101 |
+
fim = time.time() + segundos
|
| 102 |
+
while time.time() < fim:
|
| 103 |
+
app.processEvents()
|
| 104 |
+
if cond():
|
| 105 |
+
return
|
| 106 |
+
QTest.qWait(20)
|
| 107 |
+
import faulthandler
|
| 108 |
+
faulthandler.dump_traceback(all_threads=True) # onde cada thread parou (no CI do Windows e do macOS, a única pista)
|
| 109 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def foto(j, nome):
|
| 113 |
+
if SAIDA:
|
| 114 |
+
QTest.qWait(500)
|
| 115 |
+
j.grab().save(os.path.join(SAIDA, nome + ".png"))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def textos(widget):
|
| 119 |
+
return [l.text() for l in widget.findChildren(QLabel)]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def mensagens_ia(j):
|
| 123 |
+
return j.area.findChildren(MensagemIA)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
historico = tars_historico.Historico(os.path.join(CASA, "historico.json"))
|
| 127 |
+
j = JanelaPrincipal(tars, historico=historico, capturar_stdout=False)
|
| 128 |
+
j.resize(1200, 780)
|
| 129 |
+
j.show()
|
| 130 |
+
esperar(lambda: j._modelos, o_que="modelos carregados")
|
| 131 |
+
assert j.pilha.currentIndex() == 0 and j.vazio is not None, "começa no estado vazio"
|
| 132 |
+
assert j.cabecalho.pilula.text() == t("jp.modelo_auto"), j.cabecalho.pilula.text()
|
| 133 |
+
assert not j.cabecalho.b_parar.isVisibleTo(j), "Parar só aparece trabalhando"
|
| 134 |
+
assert j.lateral.v_titulo.text() == "" and not j.lateral.cartao_versao.isVisibleTo(j)
|
| 135 |
+
foto(j, "principal_vazio")
|
| 136 |
+
print("OK: abre no estado vazio, modelo automático, sem botão Parar")
|
| 137 |
+
|
| 138 |
+
# --- enviar um pedido de verdade --------------------------------------------------------------
|
| 139 |
+
j.compositor.entrada.setPlainText("abra o navegador")
|
| 140 |
+
j.compositor.b_enviar.click()
|
| 141 |
+
assert j.vazio is None and j.ponte.esta_ocupado
|
| 142 |
+
assert j.cabecalho.b_parar.isVisibleTo(j), "Parar aparece enquanto trabalha"
|
| 143 |
+
esperar(lambda: not j.ponte.esta_ocupado and mensagens_ia(j) and "feito" in mensagens_ia(j)[-1].texto_completo(), o_que="resposta")
|
| 144 |
+
esperar(lambda: not j.cabecalho.b_parar.isVisibleTo(j))
|
| 145 |
+
assert j.compositor.texto() == "", "a entrada esvazia ao enviar"
|
| 146 |
+
assert j.cabecalho.titulo.text() == "abra o navegador", j.cabecalho.titulo.text()
|
| 147 |
+
assert any("abra o navegador" in x for x in textos(j.area))
|
| 148 |
+
esperar(lambda: len(historico.listar()) == 1, o_que="histórico gravado")
|
| 149 |
+
assert historico.listar()[0]["titulo"] == "abra o navegador"
|
| 150 |
+
esperar(lambda: j.lateral._itens, o_que="conversa na barra lateral")
|
| 151 |
+
foto(j, "principal_conversa")
|
| 152 |
+
print("OK: pedido de ponta a ponta (bolha da pessoa, resposta em streaming, título, histórico)")
|
| 153 |
+
|
| 154 |
+
# --- etapas, ferramentas e aviso (o que o núcleo emite) --------------------------------------------
|
| 155 |
+
j.ponte.evento.emit("plano", {"etapas": ["Abrir o editor", "Escrever o texto", "Salvar"]})
|
| 156 |
+
ia = mensagens_ia(j)[-1] if mensagens_ia(j) else None
|
| 157 |
+
j._ia = None
|
| 158 |
+
j._ao_evento("plano", {"etapas": ["Abrir o editor", "Escrever o texto", "Salvar"]})
|
| 159 |
+
ia = j._ia
|
| 160 |
+
assert ia.etapas is not None and ia.etapas.total() == 3 and ia.etapas.feitas == 0
|
| 161 |
+
j._ao_evento("ferramenta", {"nome": "open_application", "resumo": "gedit"})
|
| 162 |
+
j._ao_evento("ferramenta_fim", {"nome": "open_application", "sucesso": True, "segundos": 0.4})
|
| 163 |
+
assert ia.etapas.feitas == 1, ia.etapas.feitas
|
| 164 |
+
j._ao_evento("ferramenta", {"nome": "take_screenshot", "resumo": ""})
|
| 165 |
+
j._ao_evento("ferramenta_fim", {"nome": "take_screenshot", "sucesso": True, "segundos": 0.1})
|
| 166 |
+
assert ia.etapas.feitas == 1, "olhar a tela não conclui etapa"
|
| 167 |
+
assert t("jp.etapa_de", atual=2, total=3) == j.cabecalho.sub.text(), j.cabecalho.sub.text()
|
| 168 |
+
j._ao_texto("[openTARS] ✗ falhou algo\n")
|
| 169 |
+
assert any("falhou algo" in x for x in textos(ia))
|
| 170 |
+
j._ao_concluido("ok")
|
| 171 |
+
assert ia.etapas.feitas == 3, "ao concluir, as etapas fecham"
|
| 172 |
+
assert j.cabecalho.sub.text() == t("jp.pronto")
|
| 173 |
+
print("OK: etapas avançam por ação concluída, fecham no fim; avisos viram linhas")
|
| 174 |
+
|
| 175 |
+
# --- pergunta antes de ação arriscada ---------------------------------------------------------------
|
| 176 |
+
resultado = {}
|
| 177 |
+
j._ia = None
|
| 178 |
+
j.ponte.esta_ocupado = False
|
| 179 |
+
threading.Thread(target=lambda: resultado.setdefault("a", tars.confirmar_comando_hook("rm -rf ~/teste")), daemon=True).start()
|
| 180 |
+
esperar(lambda: j.area.findChildren(CartaoAprovacao), o_que="cartão de aprovação")
|
| 181 |
+
cartao = j.area.findChildren(CartaoAprovacao)[-1]
|
| 182 |
+
assert j.cabecalho.sub.text() == t("jp.espera_voce")
|
| 183 |
+
botoes = {b.text(): b for b in cartao.findChildren(QPushButton)}
|
| 184 |
+
foto(j, "principal_aprovacao")
|
| 185 |
+
botoes[t("jp.negar")].click()
|
| 186 |
+
esperar(lambda: "a" in resultado)
|
| 187 |
+
assert resultado["a"] is False
|
| 188 |
+
resultado.clear()
|
| 189 |
+
threading.Thread(target=lambda: resultado.setdefault("a", tars.confirmar_comando_hook("sudo apt remove x")), daemon=True).start()
|
| 190 |
+
esperar(lambda: len(j.area.findChildren(CartaoAprovacao)) == 2)
|
| 191 |
+
{b.text(): b for b in j.area.findChildren(CartaoAprovacao)[-1].findChildren(QPushButton)}[t("jp.permitir")].click()
|
| 192 |
+
esperar(lambda: "a" in resultado)
|
| 193 |
+
assert resultado["a"] is True
|
| 194 |
+
print("OK: ação arriscada pergunta no cartão; Negar e Permitir chegam ao núcleo")
|
| 195 |
+
|
| 196 |
+
# --- nova conversa, histórico, busca ---------------------------------------------------------------
|
| 197 |
+
primeiro_id = j.ponte.id_conversa
|
| 198 |
+
j.nova_conversa()
|
| 199 |
+
assert j.vazio is not None and j.ponte.id_conversa != primeiro_id
|
| 200 |
+
assert len(tars.conversa) == 1, "o núcleo recomeçou do zero"
|
| 201 |
+
j.compositor.entrada.setPlainText("segundo assunto")
|
| 202 |
+
j.enviar()
|
| 203 |
+
esperar(lambda: not j.ponte.esta_ocupado and len(historico.listar()) == 2, o_que="2 conversas")
|
| 204 |
+
j.abrir_conversa(primeiro_id)
|
| 205 |
+
assert j.cabecalho.titulo.text() == "abra o navegador"
|
| 206 |
+
assert any("abra o navegador" in x for x in textos(j.area)) and not any("segundo assunto" in x for x in textos(j.area))
|
| 207 |
+
assert any(m["role"] == "user" and "abra o navegador" in m["content"] for m in tars.conversa), "o contexto voltou pra IA"
|
| 208 |
+
j.lateral.busca.setText("segundo")
|
| 209 |
+
esperar(lambda: len(j.lateral._itens) == 1)
|
| 210 |
+
j.lateral.busca.setText("zzzz")
|
| 211 |
+
esperar(lambda: not j.lateral._itens)
|
| 212 |
+
assert any(t("jp.nenhum_resultado") in x for x in textos(j.lateral))
|
| 213 |
+
j.lateral.busca.setText("")
|
| 214 |
+
esperar(lambda: len(j.lateral._itens) == 2)
|
| 215 |
+
j._apagar_conversa(primeiro_id)
|
| 216 |
+
esperar(lambda: len(historico.listar()) == 1)
|
| 217 |
+
print("OK: nova conversa, abrir devolve o contexto, busca e apagar")
|
| 218 |
+
|
| 219 |
+
# --- modelo -----------------------------------------------------------------------------------------
|
| 220 |
+
j._escolher_modelo("qwen3:4b")
|
| 221 |
+
assert tars.modo_modelo == "MANUAL" and "qwen3:4b" in j.cabecalho.pilula.text()
|
| 222 |
+
j._escolher_modelo(None)
|
| 223 |
+
assert tars.modo_modelo == "AUTO" and j.cabecalho.pilula.text() == t("jp.modelo_auto")
|
| 224 |
+
print("OK: escolher o modelo na pílula e voltar ao automático")
|
| 225 |
+
|
| 226 |
+
# --- anexos ---------------------------------------------------------------------------------------------
|
| 227 |
+
pasta = Path(tempfile.mkdtemp(prefix="opentars-anx-"))
|
| 228 |
+
(pasta / "nota.txt").write_text("conteudo do arquivo de teste\n" * 5, encoding="utf-8")
|
| 229 |
+
novos = j.anexar_arquivos([str(pasta / "nota.txt")])
|
| 230 |
+
assert len(novos) == 1 and j.compositor.chips_w.isVisibleTo(j) and len(j.compositor.anexos()) == 1
|
| 231 |
+
chats.clear()
|
| 232 |
+
j.compositor.entrada.setPlainText("leia a nota")
|
| 233 |
+
j.enviar()
|
| 234 |
+
esperar(lambda: not j.ponte.esta_ocupado and chats, o_que="anexo enviado")
|
| 235 |
+
pedidos = [m["content"] for m in chats[0]["messages"] if m["role"] == "user"]
|
| 236 |
+
assert any("conteudo do arquivo de teste" in c and c.startswith("leia a nota") for c in pedidos), pedidos
|
| 237 |
+
assert not j.compositor.anexos() and not j.compositor.chips_w.isVisibleTo(j)
|
| 238 |
+
print("OK: anexo chega ao Ollama e o chip some")
|
| 239 |
+
|
| 240 |
+
# --- voz sem estar instalada abre a configuração de voz ---------------------------------------------------
|
| 241 |
+
j.voz.pronta = lambda: False
|
| 242 |
+
j.voz.faltando = lambda: ["faster-whisper"]
|
| 243 |
+
j._mudar_modo_voz("continua")
|
| 244 |
+
j._mic_clicado()
|
| 245 |
+
assert j.pilha.currentWidget() is j.config and j.config.nav["voz"].isChecked()
|
| 246 |
+
foto(j, "config_voz")
|
| 247 |
+
j.pilha.setCurrentIndex(0)
|
| 248 |
+
j._mudar_modo_voz("segurar")
|
| 249 |
+
print("OK: microfone sem voz instalada leva à página Voz")
|
| 250 |
+
|
| 251 |
+
# --- tela de voz --------------------------------------------------------------------------------------------
|
| 252 |
+
j.pilha.setCurrentIndex(1)
|
| 253 |
+
j.tela_voz.ao_estado("ouvindo")
|
| 254 |
+
foto(j, "tela_voz")
|
| 255 |
+
j._sair_da_voz()
|
| 256 |
+
assert j.pilha.currentIndex() == 0
|
| 257 |
+
|
| 258 |
+
# --- configurações --------------------------------------------------------------------------------------------
|
| 259 |
+
for nome in ("geral", "voz", "memoria", "nuvem", "atualizacoes", "sobre"):
|
| 260 |
+
j.abrir_config(nome)
|
| 261 |
+
app.processEvents()
|
| 262 |
+
assert j.config.nav[nome].isChecked() and j.pilha.currentIndex() == 2
|
| 263 |
+
foto(j, f"config_{nome}")
|
| 264 |
+
esperar(lambda: "Ollama" in j.config.paginas["geral"].estado.text() and "…" != j.config.paginas["geral"].estado.text(), o_que="estado do Ollama")
|
| 265 |
+
assert t("cfg.gargantua_falta") in j.config.paginas["geral"].estado.text()
|
| 266 |
+
|
| 267 |
+
# memória
|
| 268 |
+
tars.memoria.lembrar("meu navegador é o firefox")
|
| 269 |
+
j.abrir_config("memoria")
|
| 270 |
+
pm = j.config.paginas["memoria"]
|
| 271 |
+
assert any("firefox" in x for x in textos(pm))
|
| 272 |
+
pm._esquecer("meu navegador é o firefox")
|
| 273 |
+
assert tars.memoria.listar() == [] and any(t("cfg.memoria.vazia") in x for x in textos(pm))
|
| 274 |
+
tars.memoria.lembrar("x")
|
| 275 |
+
pm.b_geral.click()
|
| 276 |
+
assert "x" in tars.memoria.listar(), "apagar tudo pede confirmação (duas batidas)"
|
| 277 |
+
pm.b_geral.click()
|
| 278 |
+
assert tars.memoria.listar() == [] and historico.listar() == []
|
| 279 |
+
print("OK: memória listada, esquecida item a item; apagar tudo pede confirmação")
|
| 280 |
+
|
| 281 |
+
# nuvem: a chave fica em arquivo 0600 e nunca aparece na tela
|
| 282 |
+
j.abrir_config("nuvem")
|
| 283 |
+
pn = j.config.paginas["nuvem"]
|
| 284 |
+
pn.chips["groq"].click()
|
| 285 |
+
assert pn.endereco.text() == "https://api.groq.com/openai/v1"
|
| 286 |
+
pn.ativa.setChecked(True)
|
| 287 |
+
pn.chave.setText("SEGREDO-123")
|
| 288 |
+
pn._salvar()
|
| 289 |
+
d = pu.carregar_nuvem()
|
| 290 |
+
assert d["ativa"] and d["provedor"] == "groq" and d["chave"] == "SEGREDO-123"
|
| 291 |
+
assert os.name == "nt" or stat.S_IMODE(os.stat(pu.arquivo_nuvem()).st_mode) == 0o600
|
| 292 |
+
assert pn.chave.text() == "" and not any("SEGREDO" in x for x in textos(pn))
|
| 293 |
+
j.abrir_config("nuvem")
|
| 294 |
+
assert "SEGREDO" not in pn.chave.placeholderText()
|
| 295 |
+
print("OK: nuvem salva a chave em 0600 e nunca a mostra")
|
| 296 |
+
|
| 297 |
+
# idioma: trocar pede pra reconstruir a janela
|
| 298 |
+
j.abrir_config("geral")
|
| 299 |
+
assert not getattr(j, "reconstruir", False)
|
| 300 |
+
j.config.paginas["geral"]._trocar(tars_i18n.idioma_atual())
|
| 301 |
+
assert not getattr(j, "reconstruir", False), "o mesmo idioma não reconstrói"
|
| 302 |
+
|
| 303 |
+
j.close()
|
| 304 |
+
print("test_qt_principal: OK")
|
| 305 |
+
|
| 306 |
+
# 26/10: o openTARS abre com o computador antes do Ollama: o aviso "não responde" some sozinho quando ele volta
|
| 307 |
+
j_fora = JanelaPrincipal(tars, historico=historico, capturar_stdout=False)
|
| 308 |
+
j_fora.show()
|
| 309 |
+
esperar(lambda: j_fora._modelos is not None, o_que="início")
|
| 310 |
+
j_fora._ao_iniciado({"online": False, "avisos": [], "n_modelos": 0})
|
| 311 |
+
assert j_fora._avisos_iniciais and j_fora._vigia_ollama.isActive()
|
| 312 |
+
chamadas = []
|
| 313 |
+
j_fora._procurar_atualizacao = lambda: chamadas.append(1)
|
| 314 |
+
j_fora.ponte.reconectado.emit()
|
| 315 |
+
assert not j_fora._vigia_ollama.isActive() and not j_fora._avisos_iniciais and chamadas == [1]
|
| 316 |
+
j_fora.close()
|
| 317 |
+
print("OK: o aviso de Ollama fora some sozinho quando ele volta (o app abre antes dele ao ligar o PC)")
|
app/tests/test_qt_probes.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.1: a tela dos Probes na janela de verdade (offscreen), sem Ollama. O passo a passo cria um Probe de pasta com regra
|
| 2 |
+
fixa e já liga tudo (Probe e serviço); campo errado bloqueia o botão; "com as minhas palavras" passa pelo modelo de
|
| 3 |
+
mentira e a prévia vem escapada; recusa e pasta proibida viram mensagem clara; o sino mostra o número de avisos, a faixa
|
| 4 |
+
avisa, o painel marca como lido e resolve a lixeira; "Rodar agora" de um Probe com regra não precisa de modelo; "Parar
|
| 5 |
+
tudo" desliga; o Lite esconde Probes e sino; a bandeja sobe e sai quando já há um serviço rodando."""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 13 |
+
CASA = tempfile.mkdtemp(prefix="opentars-probes-qt-")
|
| 14 |
+
os.environ["HOME"] = os.environ["USERPROFILE"] = CASA # no Windows o ~ vem do USERPROFILE
|
| 15 |
+
os.environ["XDG_CONFIG_HOME"] = os.path.join(CASA, ".config")
|
| 16 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 17 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(CASA, "primeiro_uso.json")
|
| 18 |
+
os.environ["TARS_ARQUIVO_CONFIG"] = os.path.join(CASA, "config.json")
|
| 19 |
+
os.environ["TARS_ARQUIVO_PROBES"] = os.path.join(CASA, "probes.json")
|
| 20 |
+
|
| 21 |
+
from _util import RAIZ # noqa: E402,F401
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
from PySide6.QtTest import QTest
|
| 25 |
+
except ImportError:
|
| 26 |
+
print("test_qt_probes: PULADO (PySide6 não instalado)")
|
| 27 |
+
sys.exit(0)
|
| 28 |
+
|
| 29 |
+
import fake_ollama # noqa: E402
|
| 30 |
+
|
| 31 |
+
servidor, url = fake_ollama.subir(modelos=["ProjectOpenTARS/Gargantua:latest"])
|
| 32 |
+
os.environ["OLLAMA_HOST"] = url
|
| 33 |
+
|
| 34 |
+
from _util import carregar_tars # noqa: E402
|
| 35 |
+
|
| 36 |
+
tars = carregar_tars()
|
| 37 |
+
tars.avisos_de_ambiente = lambda: []
|
| 38 |
+
tars.detectar_gpu = lambda forcar=False: None
|
| 39 |
+
tars.aquecer_ajudante = lambda: None
|
| 40 |
+
|
| 41 |
+
import tars_historico # noqa: E402
|
| 42 |
+
import tars_modos as modos # noqa: E402
|
| 43 |
+
import tars_probes as tp # noqa: E402
|
| 44 |
+
from tars_qt.primeiro_uso import criar_aplicativo # noqa: E402
|
| 45 |
+
from tars_qt.principal import JanelaPrincipal # noqa: E402
|
| 46 |
+
|
| 47 |
+
app = criar_aplicativo(["teste"])
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def esperar(cond, segundos=15, o_que=""):
|
| 51 |
+
fim = time.time() + segundos
|
| 52 |
+
while time.time() < fim:
|
| 53 |
+
app.processEvents()
|
| 54 |
+
if cond():
|
| 55 |
+
return
|
| 56 |
+
QTest.qWait(20)
|
| 57 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
downloads = Path(CASA) / "Downloads"
|
| 61 |
+
downloads.mkdir()
|
| 62 |
+
respostas = {"plano": ({"name": "Instaladores <b>", "watch": ["~/Downloads"], "look_for": ".deb", "check": {"every_minutes": 30},
|
| 63 |
+
"tools": ["list_folder"], "alert": {"when": "aparece .deb", "max_per_day": 2}, "will_not": ["apagar"]}, None)}
|
| 64 |
+
tp.planejar = lambda missao, host, instalados: tp.validar_plano(respostas["plano"][0]) if respostas["plano"][1] is None \
|
| 65 |
+
else (None, respostas["plano"][1])
|
| 66 |
+
servico = {"ligado": False}
|
| 67 |
+
tp.ligar_servico = lambda ligado, iniciar=True: servico.update(ligado=ligado) or ligado
|
| 68 |
+
tp.servico_ligado = lambda: servico["ligado"]
|
| 69 |
+
tp.servico_rodando = lambda: 4242 if servico["ligado"] else None
|
| 70 |
+
|
| 71 |
+
modos.definir_modo("base")
|
| 72 |
+
j = JanelaPrincipal(tars, historico=tars_historico.Historico(os.path.join(CASA, "historico.json")), capturar_stdout=False)
|
| 73 |
+
j.resize(1200, 780)
|
| 74 |
+
j.show()
|
| 75 |
+
assert not j.lateral.b_probes.isHidden() and not j.cabecalho.sino.isHidden(), "Base tem Probes e o sino"
|
| 76 |
+
j.lateral.b_probes.click()
|
| 77 |
+
cfg = j.config
|
| 78 |
+
pg = cfg.paginas["probes"]
|
| 79 |
+
assert cfg.pilha.currentWidget() is pg
|
| 80 |
+
assert "desligad" in pg.estado.text().lower() or "off" in pg.estado.text().lower()
|
| 81 |
+
|
| 82 |
+
# 1) passo a passo: pasta, com filtro; criar já liga o Probe e o serviço
|
| 83 |
+
pg.blocos["pasta"].click()
|
| 84 |
+
assert pg.passo2.isVisibleTo(cfg) and pg.acoes.isVisibleTo(cfg)
|
| 85 |
+
pg.f["pasta"].setText(str(downloads))
|
| 86 |
+
pg.f["filtro"].setText(".deb")
|
| 87 |
+
assert ".deb" in pg.resumo.text() and pg.b_criar.isEnabled(), pg.resumo.text()
|
| 88 |
+
pg.b_criar.click()
|
| 89 |
+
p = tp.Probes().listar()[0]
|
| 90 |
+
assert p["ativo"] and p["regra"] == {"tipo": "arquivos", "filtro": [".deb"]} and servico["ligado"], p
|
| 91 |
+
assert not pg.passo2.isVisibleTo(cfg) and pg.lista.count() == 1
|
| 92 |
+
print("OK: o passo a passo cria o Probe de pasta com regra fixa e liga o Probe e o serviço de uma vez")
|
| 93 |
+
|
| 94 |
+
# 2) campo errado bloqueia; sistema e programa montam a frase
|
| 95 |
+
pg.blocos["web"].click()
|
| 96 |
+
pg.f["url"].setText("exemplo")
|
| 97 |
+
assert not pg.b_criar.isEnabled() and "https://" in pg.resumo.text()
|
| 98 |
+
pg.blocos["sistema"].click()
|
| 99 |
+
pg.f["condicao"].setCurrentIndex(tp.CONDICOES_DO_SISTEMA.index("internet"))
|
| 100 |
+
assert pg.b_criar.isEnabled() and not pg.campo_limite_sis.isVisibleTo(cfg)
|
| 101 |
+
pg.blocos["programa"].click()
|
| 102 |
+
assert not pg.b_criar.isEnabled()
|
| 103 |
+
pg.f["programa"].setText("steam")
|
| 104 |
+
assert pg.b_criar.isEnabled() and "steam" in pg.resumo.text()
|
| 105 |
+
print("OK: campo errado bloqueia o botão; a frase do que ele vai fazer acompanha os campos")
|
| 106 |
+
|
| 107 |
+
# 3) com as minhas palavras: o modelo planeja, a prévia vem escapada
|
| 108 |
+
pg.blocos["palavras"].click()
|
| 109 |
+
assert not pg.b_criar.isEnabled()
|
| 110 |
+
pg.missao.setPlainText("fica de olho nos Downloads e me avisa quando aparecer um .deb")
|
| 111 |
+
pg._entender()
|
| 112 |
+
esperar(lambda: pg.b_criar.isEnabled(), o_que="plano do modelo")
|
| 113 |
+
assert "<b>" in pg.resumo.text() and "Instaladores <b>" not in pg.resumo.text(), "o plano vem escapado"
|
| 114 |
+
pg.c_lixeira.setChecked(True)
|
| 115 |
+
pg.b_criar.click()
|
| 116 |
+
pl = [x for x in tp.Probes().listar() if x["id"] != p["id"]][0]
|
| 117 |
+
assert pl["ativo"] and pl["regra"] is None and pl["acoes"]["lixeira"] and pl["plano"]["alert"]["max_per_day"] == 2
|
| 118 |
+
respostas["plano"] = ({}, "recusa:Probe não apaga arquivos")
|
| 119 |
+
pg.blocos["palavras"].click()
|
| 120 |
+
pg.missao.setPlainText("apaga os .tmp toda noite")
|
| 121 |
+
pg._entender()
|
| 122 |
+
esperar(lambda: "recusada" in pg.msg.text(), o_que="recusa")
|
| 123 |
+
respostas["plano"] = ({"watch": ["~/.ssh"], "tools": ["read_file"]}, None)
|
| 124 |
+
pg.missao.setPlainText("lê minhas chaves ssh")
|
| 125 |
+
pg._entender()
|
| 126 |
+
esperar(lambda: ".ssh" in pg.msg.text(), o_que="pasta proibida")
|
| 127 |
+
print("OK: com as minhas palavras passa pelo modelo (prévia escapada); recusa e pasta proibida viram mensagem clara")
|
| 128 |
+
|
| 129 |
+
# 4) o sino: número, faixa, painel que marca como lido e resolve a lixeira
|
| 130 |
+
sino = j.cabecalho.sino
|
| 131 |
+
sino.olhar()
|
| 132 |
+
ps = tp.Probes()
|
| 133 |
+
ps.registrar(p["id"], {"alertar": True, "mensagem": "1 arquivo novo: app.deb ★ 新しい", "itens": ["/tmp/x★.deb"], "estado": {}})
|
| 134 |
+
(downloads / "velho.deb").write_bytes(b"x")
|
| 135 |
+
ps.aplicar_acoes(pl["id"], [str(downloads / "velho.deb")])
|
| 136 |
+
assert ps.nao_lidos() == 2
|
| 137 |
+
sino.olhar()
|
| 138 |
+
assert not j.faixa.isHidden() and "app.deb" in j.faixa.texto.text(), "aviso novo aparece na faixa"
|
| 139 |
+
sino.click()
|
| 140 |
+
painel = sino.painel
|
| 141 |
+
assert painel.isVisible() and ps.nao_lidos() == 1, "abrir o painel marca os avisos como lidos (a lixeira continua)"
|
| 142 |
+
lixo = []
|
| 143 |
+
tp.mandar_para_lixeira = lambda c: lixo.append(c) or (True, "")
|
| 144 |
+
pend = ps.pendentes()[0]
|
| 145 |
+
painel._resolver(pend[0]["id"], pend[1]["id"], True)
|
| 146 |
+
assert lixo == [str(downloads / "velho.deb")] and ps.nao_lidos() == 0
|
| 147 |
+
# glifos: tudo que a página e o painel mostram tem que existir na IBM Plex (senão o fontconfig dá segfault em algumas
|
| 148 |
+
# máquinas: foi o que derrubou a janela ao abrir os Probes na 2026101603, com o "•" do estado)
|
| 149 |
+
from PySide6.QtGui import QRawFont # noqa: E402
|
| 150 |
+
from PySide6.QtWidgets import QAbstractButton, QLabel # noqa: E402
|
| 151 |
+
fontes = [QRawFont(str(f), 15) for f in sorted((Path(RAIZ) / "tars_qt" / "fontes").glob("*.ttf"))]
|
| 152 |
+
ruins = {}
|
| 153 |
+
for raiz in (pg, painel, j):
|
| 154 |
+
for w in raiz.findChildren(QLabel) + raiz.findChildren(QAbstractButton):
|
| 155 |
+
falta = {c for c in w.text() if ord(c) > 0x7F and not any(f.supportsCharacter(ord(c)) for f in fontes)}
|
| 156 |
+
if falta:
|
| 157 |
+
ruins[w.text()[:40]] = falta
|
| 158 |
+
assert fontes and not ruins, f"glifos fora da IBM Plex: {ruins}"
|
| 159 |
+
print("OK: nenhum texto da página dos Probes e do painel usa glifo fora da IBM Plex")
|
| 160 |
+
painel._limpar()
|
| 161 |
+
assert ps.avisos() == []
|
| 162 |
+
painel.hide()
|
| 163 |
+
print("OK: o sino conta avisos e pedidos, a faixa mostra o novo, o painel marca como lido, resolve a lixeira e limpa")
|
| 164 |
+
|
| 165 |
+
# 5) Rodar agora de um Probe com regra: sem modelo nenhum
|
| 166 |
+
pg._rodar_agora(p["id"])
|
| 167 |
+
esperar(lambda: tp.Probes().obter(p["id"])["estado"].get("impressoes"), o_que="primeira olhada")
|
| 168 |
+
(downloads / "outro.deb").write_bytes(b"x")
|
| 169 |
+
pg._rodar_agora(p["id"])
|
| 170 |
+
esperar(lambda: "outro.deb" in pg.msg.text(), o_que="rodar agora com regra")
|
| 171 |
+
print("OK: Rodar agora de um Probe com regra fixa não precisa de modelo")
|
| 172 |
+
|
| 173 |
+
pg._parar_tudo()
|
| 174 |
+
assert not any(x["ativo"] for x in tp.Probes().listar()) and not servico["ligado"]
|
| 175 |
+
cfg.paginas["geral"].chips_modo["lite"].click()
|
| 176 |
+
assert j.lateral.b_probes.isHidden() and j.cabecalho.sino.isHidden() and not cfg.nav["probes"].isVisibleTo(cfg)
|
| 177 |
+
print("OK: Parar tudo desliga tudo, e o Lite esconde os Probes e o sino")
|
| 178 |
+
j._saindo = True
|
| 179 |
+
j.close()
|
| 180 |
+
|
| 181 |
+
# 6) a bandeja: ícone com selo e o laço do serviço (aqui um que já está rodando: sai com 3)
|
| 182 |
+
from tars_qt import avisos # noqa: E402
|
| 183 |
+
assert not avisos.icone_com_selo(3).pixmap(32, 32).isNull()
|
| 184 |
+
tp.servico = lambda *a, **k: 3
|
| 185 |
+
assert avisos.rodar_bandeja("http://x", lambda: []) == 3
|
| 186 |
+
print("OK: a bandeja desenha o selo e sai quando já há um serviço rodando")
|
| 187 |
+
print("\nTUDO OK.")
|
app/tests/test_qt_rolagem.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.2: a rolagem da conversa termina na última mensagem (antes sobravam centenas de pixels de vazio, porque o miolo
|
| 2 |
+
usava o sizeHint dos textos com quebra de linha, que passa da altura real)."""
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 7 |
+
from _util import RAIZ # noqa: E402,F401
|
| 8 |
+
|
| 9 |
+
from PySide6.QtTest import QTest # noqa: E402
|
| 10 |
+
|
| 11 |
+
from tars_qt import conversa as c # noqa: E402
|
| 12 |
+
from tars_qt.primeiro_uso import criar_aplicativo # noqa: E402
|
| 13 |
+
|
| 14 |
+
app = criar_aplicativo(["teste"])
|
| 15 |
+
area = c.AreaConversa()
|
| 16 |
+
area.resize(900, 600)
|
| 17 |
+
area.show()
|
| 18 |
+
for i in range(6):
|
| 19 |
+
area.adicionar_pessoa(f"pergunta {i}")
|
| 20 |
+
m = area.adicionar_ia()
|
| 21 |
+
m.acrescentar_texto("resposta **x** " * 80)
|
| 22 |
+
m.fim_do_trecho()
|
| 23 |
+
QTest.qWait(500)
|
| 24 |
+
ultimo = area.itens[-1]
|
| 25 |
+
fundo = ultimo.mapTo(area.widget(), ultimo.rect().bottomLeft()).y()
|
| 26 |
+
sobra = area.widget().height() - fundo
|
| 27 |
+
assert area.verticalScrollBar().maximum() > 0 and sobra <= 40, sobra
|
| 28 |
+
area.resize(700, 400)
|
| 29 |
+
QTest.qWait(500)
|
| 30 |
+
fundo = ultimo.mapTo(area.widget(), ultimo.rect().bottomLeft()).y()
|
| 31 |
+
assert area.widget().height() - fundo <= 40, area.widget().height() - fundo
|
| 32 |
+
print("OK: a rolagem termina na última mensagem, também depois de mudar o tamanho da janela")
|
app/tests/test_qt_rotinas.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0 Tesseract: rotinas agendadas na janela Qt (timer, execução, avisos, pré-aprovação, bandeja), com o núcleo
|
| 2 |
+
de verdade contra um Ollama de mentira e um relógio controlado."""
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import stat
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 15 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 16 |
+
os.environ["HOME"] = CASA
|
| 17 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 18 |
+
os.environ["TARS_ARQUIVO_AGENDA"] = os.path.join(CASA, "agenda.json")
|
| 19 |
+
os.environ["TARS_ARQUIVO_NUVEM"] = os.path.join(CASA, "nuvem.json")
|
| 20 |
+
os.environ["TARS_ARQUIVO_PRIMEIRO_USO"] = os.path.join(CASA, "primeiro_uso.json")
|
| 21 |
+
|
| 22 |
+
from _util import RAIZ # noqa: E402,F401
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
from PySide6.QtTest import QTest
|
| 26 |
+
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
| 27 |
+
except ImportError:
|
| 28 |
+
print("test_qt_rotinas: PULADO (PySide6 não instalado)")
|
| 29 |
+
sys.exit(0)
|
| 30 |
+
|
| 31 |
+
chats = []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Falso(BaseHTTPRequestHandler):
|
| 35 |
+
def log_message(self, *a):
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
def _json(self, dados, codigo=200):
|
| 39 |
+
corpo = json.dumps(dados).encode()
|
| 40 |
+
self.send_response(codigo)
|
| 41 |
+
self.send_header("Content-Type", "application/json")
|
| 42 |
+
self.send_header("Content-Length", str(len(corpo)))
|
| 43 |
+
self.end_headers()
|
| 44 |
+
self.wfile.write(corpo)
|
| 45 |
+
|
| 46 |
+
def do_GET(self):
|
| 47 |
+
if self.path == "/api/version":
|
| 48 |
+
return self._json({"version": "0.12.9"})
|
| 49 |
+
if self.path == "/api/tags":
|
| 50 |
+
return self._json({"models": [{"name": "qwen3:4b", "model": "qwen3:4b", "size": 3_000_000_000, "details": {
|
| 51 |
+
"family": "qwen3", "parameter_size": "4.0B", "quantization_level": "Q4_K_M"}}]})
|
| 52 |
+
if self.path == "/api/ps":
|
| 53 |
+
return self._json({"models": []})
|
| 54 |
+
self._json({}, 404)
|
| 55 |
+
|
| 56 |
+
def do_POST(self):
|
| 57 |
+
corpo = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
| 58 |
+
if self.path == "/api/show":
|
| 59 |
+
return self._json({"capabilities": ["completion", "tools"], "details": {
|
| 60 |
+
"family": "qwen3", "parameter_size": "4.0B", "quantization_level": "Q4_K_M"}, "model_info": {}, "parameters": ""})
|
| 61 |
+
if self.path == "/api/generate":
|
| 62 |
+
return self._json({"model": corpo.get("model"), "done": True, "response": ""})
|
| 63 |
+
if self.path == "/api/chat":
|
| 64 |
+
chats.append(corpo)
|
| 65 |
+
linhas = [{"model": "qwen3:4b", "message": {"role": "assistant", "content": "Pronto, **feito**."}, "done": False},
|
| 66 |
+
{"model": "qwen3:4b", "message": {"role": "assistant", "content": ""}, "done": True, "done_reason": "stop"}]
|
| 67 |
+
dados = ("\n".join(json.dumps(l) for l in linhas) + "\n").encode()
|
| 68 |
+
self.send_response(200)
|
| 69 |
+
self.send_header("Content-Type", "application/x-ndjson")
|
| 70 |
+
self.send_header("Content-Length", str(len(dados)))
|
| 71 |
+
self.end_headers()
|
| 72 |
+
self.wfile.write(dados)
|
| 73 |
+
return
|
| 74 |
+
self._json({}, 404)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
servidor = ThreadingHTTPServer(("127.0.0.1", 0), Falso)
|
| 78 |
+
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
| 79 |
+
os.environ["OLLAMA_HOST"] = f"127.0.0.1:{servidor.server_address[1]}"
|
| 80 |
+
|
| 81 |
+
from _util import carregar_tars # noqa: E402
|
| 82 |
+
|
| 83 |
+
tars = carregar_tars()
|
| 84 |
+
tars.avisos_de_ambiente = lambda: []
|
| 85 |
+
tars.detectar_gpu = lambda forcar=False: None
|
| 86 |
+
tars.escolher_modelo = lambda texto, tarefa=None, complexa=False: tars.modelo_manual or "qwen3:4b"
|
| 87 |
+
tars.aquecer_ajudante = lambda: None
|
| 88 |
+
|
| 89 |
+
import tars_historico # noqa: E402
|
| 90 |
+
import tars_i18n # noqa: E402
|
| 91 |
+
import tars_primeiro_uso as pu # noqa: E402
|
| 92 |
+
from tars_i18n import t # noqa: E402
|
| 93 |
+
from tars_qt.primeiro_uso import criar_aplicativo # noqa: E402
|
| 94 |
+
from tars_qt.principal import JanelaPrincipal # noqa: E402
|
| 95 |
+
from tars_qt.conversa import CartaoAprovacao, MensagemIA # noqa: E402
|
| 96 |
+
|
| 97 |
+
app = criar_aplicativo(["teste"])
|
| 98 |
+
SAIDA = os.environ.get("TARS_CAPTURAS")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def esperar(cond, segundos=15, o_que=""):
|
| 102 |
+
fim = time.time() + segundos
|
| 103 |
+
while time.time() < fim:
|
| 104 |
+
app.processEvents()
|
| 105 |
+
if cond():
|
| 106 |
+
return
|
| 107 |
+
QTest.qWait(20)
|
| 108 |
+
raise AssertionError(f"tempo esgotado: {o_que}")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def foto(j, nome):
|
| 112 |
+
if SAIDA:
|
| 113 |
+
QTest.qWait(500)
|
| 114 |
+
j.grab().save(os.path.join(SAIDA, nome + ".png"))
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def textos(widget):
|
| 118 |
+
return [l.text() for l in widget.findChildren(QLabel)]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def mensagens_ia(j):
|
| 122 |
+
return j.area.findChildren(MensagemIA)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
import datetime as dt # noqa: E402
|
| 127 |
+
|
| 128 |
+
import tars_rotinas as agenda_lib # noqa: E402
|
| 129 |
+
|
| 130 |
+
relogio = [dt.datetime(2026, 10, 6, 7, 59, 0)]
|
| 131 |
+
tars.agenda = agenda_lib.Agenda(os.path.join(CASA, "agenda.json"), agora=lambda: relogio[0])
|
| 132 |
+
historico = tars_historico.Historico(os.path.join(CASA, "historico.json"))
|
| 133 |
+
j = JanelaPrincipal(tars, historico=historico, capturar_stdout=False)
|
| 134 |
+
j.resize(1200, 780)
|
| 135 |
+
j.show()
|
| 136 |
+
esperar(lambda: j._modelos, o_que="modelos carregados")
|
| 137 |
+
assert j.agenda is tars.agenda and not j.faixa.isVisibleTo(j)
|
| 138 |
+
falas = []
|
| 139 |
+
j.voz.falar = lambda *a, **k: falas.append(a)
|
| 140 |
+
|
| 141 |
+
# --- criar pelo formulário -------------------------------------------------------------------------
|
| 142 |
+
j.abrir_config("rotinas")
|
| 143 |
+
pr = j.config.paginas["rotinas"]
|
| 144 |
+
assert any(t("rot.vazia") in x for x in textos(pr))
|
| 145 |
+
pr._abrir_form(None)
|
| 146 |
+
pr.f_nome.setText("Jornal")
|
| 147 |
+
pr.f_pedido.setPlainText("abra o navegador")
|
| 148 |
+
pr.f_hora.setText("08:00")
|
| 149 |
+
pr._salvar()
|
| 150 |
+
assert [r["nome"] for r in tars.agenda.listar()] == ["Jornal"], tars.agenda.listar()
|
| 151 |
+
assert any("Jornal" in x for x in textos(pr)) and not pr.form.isVisibleTo(pr)
|
| 152 |
+
pr._abrir_form(None)
|
| 153 |
+
pr.f_nome.setText("")
|
| 154 |
+
pr._salvar()
|
| 155 |
+
assert pr.erro.isVisibleTo(pr) and pr.erro.text() == t("rot.erro_nome"), pr.erro.text()
|
| 156 |
+
pr.form.hide()
|
| 157 |
+
foto(j, "rotinas_lista")
|
| 158 |
+
print("OK: criar rotina pelo formulário; erro de validação aparece")
|
| 159 |
+
|
| 160 |
+
# --- na hora: roda sozinha, em conversa própria, sem falar --------------------------------------------
|
| 161 |
+
j._tick_agenda()
|
| 162 |
+
assert j._rotina_em_curso is None, "ainda não é 08:00"
|
| 163 |
+
relogio[0] = dt.datetime(2026, 10, 6, 8, 1, 0)
|
| 164 |
+
j.abrir_config("geral")
|
| 165 |
+
j._tick_agenda()
|
| 166 |
+
assert j._rotina_em_curso is not None and tars.ROTINA_ATUAL["nome"] == "Jornal"
|
| 167 |
+
assert j.pilha.currentIndex() == 0 and j.cabecalho.titulo.text() == t("rot.titulo_conversa", nome="Jornal")
|
| 168 |
+
esperar(lambda: j._rotina_em_curso is None and not j.ponte.esta_ocupado, o_que="rotina terminou")
|
| 169 |
+
assert tars.ROTINA_ATUAL is None
|
| 170 |
+
u = tars.agenda.listar()[0]
|
| 171 |
+
assert u["ultima"]["resultado"] == "ok" and u["proxima"].startswith("2026-10-07T08:00"), u
|
| 172 |
+
assert j.faixa.isVisibleTo(j) and j.faixa.texto.text() == t("rot.aviso_ok", nome="Jornal")
|
| 173 |
+
assert any("abra o navegador" in str(m) for m in chats[-1]["messages"])
|
| 174 |
+
assert falas == [], "rotina não fala em voz alta"
|
| 175 |
+
esperar(lambda: any(c["titulo"] == t("rot.titulo_conversa", nome="Jornal") for c in historico.listar()), o_que="título no histórico")
|
| 176 |
+
print("OK: rotina roda na hora, em conversa própria (↻), anota resultado, agenda a próxima, avisa e não fala")
|
| 177 |
+
|
| 178 |
+
# --- parou: precisava de permissão ---------------------------------------------------------------------------
|
| 179 |
+
tars.agenda.criar("Limpa", "apague a pasta velha", {"tipo": "diaria", "hora": "09:00"})
|
| 180 |
+
relogio[0] = dt.datetime(2026, 10, 6, 9, 0, 30)
|
| 181 |
+
j._tick_agenda()
|
| 182 |
+
assert j._rotina_em_curso["nome"] == "Limpa"
|
| 183 |
+
tars.ROTINA_BLOQUEOU.append("rm -rf ~/velha") # o que o hook anotaria ao negar o comando
|
| 184 |
+
esperar(lambda: j._rotina_em_curso is None and not j.ponte.esta_ocupado, o_que="rotina parou")
|
| 185 |
+
u = tars.agenda.achar_por_nome("Limpa")["ultima"]
|
| 186 |
+
assert u["resultado"] == "parou" and "rm -rf" in u["detalhe"], u
|
| 187 |
+
assert "rm -rf" in j.faixa.texto.text() and tars.ROTINA_BLOQUEOU == []
|
| 188 |
+
print("OK: rotina que precisa de permissão para e avisa o comando")
|
| 189 |
+
|
| 190 |
+
# --- computador desligado: pulada, e avisa -------------------------------------------------------------------------
|
| 191 |
+
tars.agenda.criar("Tarde", "faça o backup", {"tipo": "diaria", "hora": "10:00"})
|
| 192 |
+
relogio[0] = dt.datetime(2026, 10, 6, 10, 30, 0)
|
| 193 |
+
j._tick_agenda()
|
| 194 |
+
assert j._rotina_em_curso is None
|
| 195 |
+
assert t("rot.aviso_perdida", nome="Tarde", hora="10:00") == j.faixa.texto.text(), j.faixa.texto.text()
|
| 196 |
+
assert tars.agenda.achar_por_nome("Tarde")["ultima"]["resultado"] == "perdida"
|
| 197 |
+
print("OK: rotina vencida há mais de 10 min é pulada e a pessoa é avisada")
|
| 198 |
+
|
| 199 |
+
# --- ocupado: espera ---------------------------------------------------------------------------------------------------
|
| 200 |
+
tars.agenda.criar("Espera", "abra o navegador", {"tipo": "diaria", "hora": "10:40"})
|
| 201 |
+
relogio[0] = dt.datetime(2026, 10, 6, 10, 41, 0)
|
| 202 |
+
j.compositor.entrada.setPlainText("oi")
|
| 203 |
+
j.compositor.b_enviar.click()
|
| 204 |
+
j._tick_agenda()
|
| 205 |
+
assert j._rotina_em_curso is None, "ocupado: não atropela a conversa"
|
| 206 |
+
esperar(lambda: not j.ponte.esta_ocupado, o_que="conversa normal terminou")
|
| 207 |
+
j._tick_agenda()
|
| 208 |
+
assert j._rotina_em_curso is not None and j._rotina_em_curso["nome"] == "Espera"
|
| 209 |
+
esperar(lambda: j._rotina_em_curso is None and not j.ponte.esta_ocupado)
|
| 210 |
+
print("OK: se o TARS está ocupado, a rotina espera a vez")
|
| 211 |
+
|
| 212 |
+
# --- rodar agora: não mexe no calendário ------------------------------------------------------------------------------------
|
| 213 |
+
antes = tars.agenda.achar_por_nome("Jornal")["proxima"]
|
| 214 |
+
j.abrir_config("rotinas")
|
| 215 |
+
j.config.rodar_rotina.emit(tars.agenda.achar_por_nome("Jornal")["id"])
|
| 216 |
+
assert j._rotina_em_curso is not None
|
| 217 |
+
esperar(lambda: j._rotina_em_curso is None and not j.ponte.esta_ocupado)
|
| 218 |
+
r = tars.agenda.achar_por_nome("Jornal")
|
| 219 |
+
assert r["proxima"] == antes and r["ultima"]["resultado"] == "ok"
|
| 220 |
+
print("OK: 'Rodar agora' roda sem mexer na próxima execução")
|
| 221 |
+
|
| 222 |
+
# --- criada pela IA: nasce pendente, a pessoa aprova ---------------------------------------------------------------------------
|
| 223 |
+
res = tars.executar_ferramenta("schedule_task", {"name": "Da IA", "request": "abra o navegador", "when_type": "daily", "time": "18:00"})
|
| 224 |
+
assert res["sucesso"]
|
| 225 |
+
j._avisar_rotinas_da_ia()
|
| 226 |
+
assert j.faixa.texto.text() == t("rot.aviso_ia", nome="Da IA")
|
| 227 |
+
j.abrir_config("rotinas")
|
| 228 |
+
assert any(t("rot.pendente") in x for x in textos(pr))
|
| 229 |
+
botao = [b for b in pr.findChildren(QPushButton) if b.text() == t("rot.aprovar")]
|
| 230 |
+
assert len(botao) == 1, (len(botao), [r["nome"] for r in tars.agenda.listar() if not r["aprovada"]])
|
| 231 |
+
botao[0].click()
|
| 232 |
+
r = tars.agenda.achar_por_nome("Da IA")
|
| 233 |
+
assert r["aprovada"] and r["ativa"] and r["proxima"]
|
| 234 |
+
foto(j, "rotinas_aprovada")
|
| 235 |
+
print("OK: rotina criada pela IA espera aprovação e só liga depois")
|
| 236 |
+
|
| 237 |
+
# --- bandeja: com rotinas ligadas, fechar esconde; 'Sair' fecha de vez -------------------------------------------------------------
|
| 238 |
+
class Bandeja:
|
| 239 |
+
escondida = False
|
| 240 |
+
def isVisible(self): return not self.escondida
|
| 241 |
+
def hide(self): self.escondida = True
|
| 242 |
+
def showMessage(self, *a): pass
|
| 243 |
+
def setVisible(self, v): self.escondida = not v
|
| 244 |
+
|
| 245 |
+
j._tray = Bandeja()
|
| 246 |
+
j.close()
|
| 247 |
+
assert j.isHidden() and not j._saindo, "fechou a janela, o openTARS continua na bandeja"
|
| 248 |
+
j.show()
|
| 249 |
+
for r in tars.agenda.listar():
|
| 250 |
+
tars.agenda.atualizar(r["id"], ativa=False)
|
| 251 |
+
j._saindo = False
|
| 252 |
+
j._relogio.stop()
|
| 253 |
+
j.close()
|
| 254 |
+
assert not j.isVisible()
|
| 255 |
+
print("OK: com rotinas ligadas a janela vai pra bandeja; sem nenhuma, fecha de verdade")
|
| 256 |
+
print("test_qt_rotinas: OK")
|
app/tests/test_raciocinio_sem_pedir.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modelo que raciocina mesmo com think=false: o turno é cortado e refeito com /no_think."""
|
| 2 |
+
import os as _os
|
| 3 |
+
import tempfile as _tmp
|
| 4 |
+
_os.environ["TARS_ARQUIVO_SESSOES"] = _os.path.join(_tmp.mkdtemp(prefix="opentars-"), "s.json") # a lista de quem raciocina fica ao lado
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 11 |
+
from _util import carregar_tars # noqa: E402
|
| 12 |
+
|
| 13 |
+
tars = carregar_tars()
|
| 14 |
+
payloads = []
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Resp:
|
| 18 |
+
def __init__(self, linhas):
|
| 19 |
+
self.linhas, self.fechada = linhas, False
|
| 20 |
+
|
| 21 |
+
def iter_lines(self):
|
| 22 |
+
for x in self.linhas:
|
| 23 |
+
if self.fechada:
|
| 24 |
+
return
|
| 25 |
+
yield json.dumps(x).encode()
|
| 26 |
+
|
| 27 |
+
def close(self):
|
| 28 |
+
self.fechada = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def teimoso(payload):
|
| 32 |
+
payloads.append(payload)
|
| 33 |
+
if payload["messages"][-1]["content"].endswith("/no_think"):
|
| 34 |
+
return Resp([{"message": {"content": "feito"}}, {"done": True}]), None
|
| 35 |
+
pedacos = [{"message": {"content": "<think>"}}] + [{"message": {"content": "pensando e pensando. " * 3}} for _ in range(40)]
|
| 36 |
+
return Resp(pedacos + [{"done": True}]), None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
tars._chamar_ollama_stream = teimoso
|
| 40 |
+
tars.emitir = lambda *a, **k: None
|
| 41 |
+
msgs = [{"role": "system", "content": "s"}, {"role": "user", "content": "olhe a tela"}]
|
| 42 |
+
res, erro = tars._executar_turno_streaming("ProjectOpenTARS/gargantua:1.0", msgs, None, pensar=False)
|
| 43 |
+
assert erro is None and res["content"] == "feito", (res, erro)
|
| 44 |
+
assert len(payloads) == 2 and payloads[1]["messages"][-1]["content"] == "olhe a tela\n/no_think"
|
| 45 |
+
assert msgs[-1]["content"] == "olhe a tela", "o histórico guardado não muda"
|
| 46 |
+
assert "ProjectOpenTARS/gargantua:1.0" in tars._PENSA_MESMO_SEM_PEDIR
|
| 47 |
+
# na vez seguinte já vai com /no_think de cara (1 chamada só)
|
| 48 |
+
payloads.clear()
|
| 49 |
+
res, erro = tars._executar_turno_streaming("ProjectOpenTARS/gargantua:1.0", msgs, None, pensar=False)
|
| 50 |
+
assert res["content"] == "feito" and len(payloads) == 1
|
| 51 |
+
# quem foi pedido pra pensar não é cortado
|
| 52 |
+
payloads.clear()
|
| 53 |
+
tars._PENSA_MESMO_SEM_PEDIR.clear()
|
| 54 |
+
tars._chamar_ollama_stream = lambda p: (payloads.append(p), (Resp([{"message": {"content": "<think>" + "x" * 2000}}, {"message": {"content": "</think>ok"}}, {"done": True}]), None))[1]
|
| 55 |
+
res, erro = tars._executar_turno_streaming("qwen3:8b", msgs, None, pensar=True)
|
| 56 |
+
assert len(payloads) == 1 and res["content"] == "ok"
|
| 57 |
+
print("OK: raciocínio sem pedido é cortado e refeito com /no_think; quem pensa por pedido não é tocado")
|
| 58 |
+
print("\nTUDO OK.")
|
| 59 |
+
|
| 60 |
+
# a lista sobrevive a fechar e abrir o openTARS (fica em disco)
|
| 61 |
+
tars._PENSA_MESMO_SEM_PEDIR.clear()
|
| 62 |
+
tars._lembrar_que_pensa("ProjectOpenTARS/gargantua:1.0")
|
| 63 |
+
tars._PENSA_MESMO_SEM_PEDIR.clear()
|
| 64 |
+
tars._pensa_carregado["ok"] = False
|
| 65 |
+
tars._carregar_pensa_sem_pedir()
|
| 66 |
+
assert "ProjectOpenTARS/gargantua:1.0" in tars._PENSA_MESMO_SEM_PEDIR
|
| 67 |
+
print("OK: quem raciocina sem pedir fica guardado em disco")
|
app/tests/test_recusas.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IA recusando abrir/fechar apps (log real, PC com 6GB de VRAM):
|
| 2 |
+
- "ação" ia pro qwen2.5-coder, que responde "não consigo abrir aplicativos";
|
| 3 |
+
- a recusa ficava no histórico e o qwen3:8b recusava também;
|
| 4 |
+
- "counter strike" não achava "Counter-Strike 2";
|
| 5 |
+
- pedir pra fechar o próprio openTARS."""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
|
| 11 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = tempfile.mktemp(suffix=".json")
|
| 12 |
+
|
| 13 |
+
from _util import carregar_tars
|
| 14 |
+
|
| 15 |
+
tars = carregar_tars()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def m(tag, p, caps, cat, v):
|
| 19 |
+
return {"tag": tag, "parametros_b": p, "categoria": cat, "vram_estimado_gb": v,
|
| 20 |
+
"capacidades": set(caps), "capacidades_conhecidas": True}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# O catálogo do log do usuário
|
| 24 |
+
catalogo = {x["tag"]: x for x in [
|
| 25 |
+
m("qwen3.8:27b", 27.3, {"thinking", "tools", "vision"}, "visao", 17.0),
|
| 26 |
+
m("mistral-small3.2:24b", 24, {"tools", "vision"}, "visao", 15.0),
|
| 27 |
+
m("gemma3:12b", 12.2, {"vision"}, "visao", 7.9),
|
| 28 |
+
m("gemma4:e4b", 8, {"thinking", "tools", "vision"}, "visao", 5.4),
|
| 29 |
+
m("qwen2.5-coder:7b", 7.6, {"tools"}, "tecnico", 5.2),
|
| 30 |
+
m("qwen3:8b", 8.2, {"thinking", "tools"}, "geral", 5.5),
|
| 31 |
+
m("qwen3:0.6b", 0.75, {"thinking", "tools"}, "simples", 1.1),
|
| 32 |
+
m("qwen2.5:0.5b", 0.49, {"tools"}, "simples", 0.9),
|
| 33 |
+
m("gemma3:270m", 0.27, set(), "simples", 0.8),
|
| 34 |
+
]}
|
| 35 |
+
tars.detectar_gpu = lambda forcar=False: {"vram_total_gb": 6.0, "vram_livre_gb": 5.1}
|
| 36 |
+
for tarefa in ("acao", "busca"):
|
| 37 |
+
cadeia = tars.construir_cadeia_fallback(tarefa, catalogo)
|
| 38 |
+
assert cadeia[0] == "qwen3:8b" and cadeia.index("qwen2.5-coder:7b") > cadeia.index("gemma4:e4b"), cadeia
|
| 39 |
+
assert tars.construir_cadeia_fallback("codigo", catalogo)[0] == "qwen2.5-coder:7b"
|
| 40 |
+
for tarefa in ("acao", "busca", "visao", "tecnico", "geral", "simples"):
|
| 41 |
+
cadeia = tars.construir_cadeia_fallback(tarefa, catalogo)
|
| 42 |
+
assert cadeia[-1] == "qwen2.5-coder:7b", (tarefa, cadeia)
|
| 43 |
+
assert tars.construir_cadeia_fallback("tecnico", catalogo)[0] == "qwen3:8b"
|
| 44 |
+
assert tars.detectar_tarefa("abra o terminal") == "acao" and tars.detectar_tarefa("abra o vs code") == "acao"
|
| 45 |
+
print("OK: modelo de código só em pedido de código (nas outras tarefas, só se todo o resto falhar)")
|
| 46 |
+
|
| 47 |
+
# Nomes com hífen
|
| 48 |
+
tars.desktop_entries = lambda forcar=False: [{
|
| 49 |
+
"nome": "Counter-Strike 2", "outros_nomes": [], "arquivo": "/x/cs2.desktop",
|
| 50 |
+
"exec": "steam steam://rungameid/730",
|
| 51 |
+
}]
|
| 52 |
+
tars.flatpak_apps = lambda forcar=False: []
|
| 53 |
+
tars.comando_para_desktop_entry = lambda arq, ex: ["steam", "steam://rungameid/730"]
|
| 54 |
+
tars.shutil.which = lambda n: None
|
| 55 |
+
achado = tars._buscar_app_no_sistema("counter strike")
|
| 56 |
+
assert achado and achado["nome"] == "Counter-Strike 2", achado
|
| 57 |
+
print("OK: 'counter strike' acha 'Counter-Strike 2'")
|
| 58 |
+
import shutil # noqa: E402
|
| 59 |
+
tars.shutil.which = shutil.which
|
| 60 |
+
|
| 61 |
+
r = tars.fechar_aplicativo("openTARS")
|
| 62 |
+
assert not r["sucesso"] and "sair" in r["mensagem"], r
|
| 63 |
+
print("OK: pedir pra fechar o openTARS explica como sair, em vez de tentar se matar")
|
| 64 |
+
|
| 65 |
+
# Recusa -> lembrete -> ferramenta, e a recusa não fica no histórico
|
| 66 |
+
roteiro = iter([
|
| 67 |
+
{"content": "Desculpe, mas não consigo fechar aplicativos diretamente.", "tool_calls": []},
|
| 68 |
+
{"content": "", "tool_calls": [{"function": {"name": "close_application", "arguments": {"app": "claude"}}}]},
|
| 69 |
+
{"content": "Fechei o Claude.", "tool_calls": []},
|
| 70 |
+
])
|
| 71 |
+
vistos = []
|
| 72 |
+
tars._executar_turno_streaming = lambda mod, msgs, tools, **kw: (vistos.append([dict(x) for x in msgs]), (next(roteiro), None))[1]
|
| 73 |
+
tars.carregar_modelo = lambda mod: True
|
| 74 |
+
tars.modo_modelo = "MANUAL"
|
| 75 |
+
fechados = []
|
| 76 |
+
tars._DISPATCH_FERRAMENTAS["close_application"] = lambda a: fechados.append(a["app"]) or {"sucesso": True, "mensagem": "SUCESSO"}
|
| 77 |
+
resposta = tars.perguntar_modelo("qwen3:8b", "fecha o claude")
|
| 78 |
+
assert resposta == "Fechei o Claude." and fechados == ["claude"], (resposta, fechados)
|
| 79 |
+
assert "DO have tools" in vistos[1][-1]["content"]
|
| 80 |
+
assert not any("não consigo" in (x.get("content") or "") for x in tars.conversa)
|
| 81 |
+
print("OK: quando a IA diz 'não consigo', o openTARS lembra das ferramentas e ela fecha o app")
|
| 82 |
+
|
| 83 |
+
# Recusa DEPOIS de tentar uma ferramenta é legítima: não insiste
|
| 84 |
+
roteiro = iter([
|
| 85 |
+
{"content": "", "tool_calls": [{"function": {"name": "close_application", "arguments": {"app": "xyz"}}}]},
|
| 86 |
+
{"content": "Não consigo fechar o xyz: ele não está aberto.", "tool_calls": []},
|
| 87 |
+
])
|
| 88 |
+
vistos.clear()
|
| 89 |
+
tars.perguntar_modelo("qwen3:8b", "fecha o xyz")
|
| 90 |
+
assert len(vistos) == 2
|
| 91 |
+
print("OK: 'não consigo' depois de uma ferramenta falhar é resposta válida, sem insistência")
|
| 92 |
+
|
| 93 |
+
# Histórico antigo com recusas é limpo ao carregar
|
| 94 |
+
antigo = [
|
| 95 |
+
{"role": "user", "content": "abra counter strike"},
|
| 96 |
+
{"role": "assistant", "content": "Desculpe, mas não consigo abrir aplicativos."},
|
| 97 |
+
{"role": "user", "content": "abra o firefox"},
|
| 98 |
+
{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "open_application"}}]},
|
| 99 |
+
{"role": "tool", "content": "{}"},
|
| 100 |
+
{"role": "assistant", "content": "Abri o Firefox."},
|
| 101 |
+
{"role": "user", "content": "fecha o claude"},
|
| 102 |
+
{"role": "assistant", "content": "I can't close applications directly."},
|
| 103 |
+
]
|
| 104 |
+
json.dump({"versao": 2, "conversa": antigo}, open(os.environ["TARS_ARQUIVO_SESSOES"], "w"))
|
| 105 |
+
carregada = tars.carregar_sessoes()
|
| 106 |
+
textos = [x.get("content") for x in carregada[1:]]
|
| 107 |
+
assert textos == ["abra o firefox", "", "{}", "Abri o Firefox."], textos
|
| 108 |
+
print("OK: recusas antigas saem do histórico ao abrir (e levam o pedido junto)")
|
| 109 |
+
|
| 110 |
+
assert not tars._parece_recusa("Abri a calculadora.")
|
| 111 |
+
assert not tars._parece_recusa("O comando terminou com erro: permissão negada.")
|
| 112 |
+
print("OK: respostas normais não são confundidas com recusa")
|
| 113 |
+
|
| 114 |
+
os.remove(os.environ["TARS_ARQUIVO_SESSOES"])
|
| 115 |
+
print("\nTUDO OK.")
|
app/tests/test_retomada.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""18/10: "use the built-in tool" depois de um pedido (captura do Enzo). O Lite abriu o "app" google (= Google Chrome) e disse
|
| 2 |
+
que tinha pesquisado. Três travas de código, nenhuma de treino:
|
| 3 |
+
1. a frase que só manda fazer o pedido anterior é reconhecida: a tarefa, o modelo e a IA usam o pedido anterior;
|
| 4 |
+
2. search_web diz ao modelo que ele NÃO leu os resultados (não pode afirmar o que eles dizem);
|
| 5 |
+
3. open_application com nome de site (google, youtube...) abre o site, a não ser que exista um programa com esse nome."""
|
| 6 |
+
from _util import carregar_tars
|
| 7 |
+
|
| 8 |
+
tars = carregar_tars()
|
| 9 |
+
n = tars.normalizar
|
| 10 |
+
|
| 11 |
+
for s in ["use the built-in tool", "use the search engine", "usa a ferramenta", "use o buscador", "faz de verdade",
|
| 12 |
+
"faça isso de verdade", "do it", "actually do it", "try again", "tenta de novo", "de novo", "ok, use your tools",
|
| 13 |
+
"pesquisa de verdade", "search it online", "use the tool please", "mas usa a ferramenta", "do it for real"]:
|
| 14 |
+
assert tars.escolha.eh_retomada(n(s)), s
|
| 15 |
+
for s in ["use the search engine to tell me the current president of the usa", "abre a calculadora", "faz uma pasta Fotos",
|
| 16 |
+
"use firefox to open github", "what tool do you use?", "but i need to wash the car", "try the new restaurant", ""]:
|
| 17 |
+
assert not tars.escolha.eh_retomada(n(s)), s
|
| 18 |
+
print("OK: reconhece 'use a ferramenta', 'faz de verdade', 'try again'... e não confunde com pedidos de verdade")
|
| 19 |
+
|
| 20 |
+
conversa = [{"role": "system", "content": "x"},
|
| 21 |
+
{"role": "user", "content": "use the search engine to tell me the current president of the usa"},
|
| 22 |
+
{"role": "user", "content": "[Context from openTARS, not a new request] Open windows right now: 'Brave'."},
|
| 23 |
+
{"role": "assistant", "content": "The current president of the USA is ..."}]
|
| 24 |
+
assert tars._ultimo_pedido_real(conversa) == "use the search engine to tell me the current president of the usa"
|
| 25 |
+
ctx = tars._contexto_do_pedido("use the built-in tool", None, False, retoma="use the search engine to tell me X")
|
| 26 |
+
assert ctx and 'previous request: "use the search engine to tell me X"' in ctx and "only what the tool results confirm" in ctx
|
| 27 |
+
assert tars._contexto_do_pedido("oi", None, False) is None
|
| 28 |
+
print("OK: a IA recebe o pedido anterior junto com 'use a ferramenta'")
|
| 29 |
+
|
| 30 |
+
abertos = []
|
| 31 |
+
tars._abrir_no_navegador = lambda url: abertos.append(url)
|
| 32 |
+
r = tars.pesquisar_web("current president of the usa", "google")
|
| 33 |
+
assert r["sucesso"] and "NOT read the results" in r["mensagem"] and "own knowledge" in r["mensagem"], r
|
| 34 |
+
print("OK: search_web avisa que os resultados só foram abertos, não lidos")
|
| 35 |
+
|
| 36 |
+
tars.desktop_entries = lambda: [{"nome": "Google Chrome", "outros_nomes": [], "exec": "google-chrome", "arquivo": "/x/google-chrome.desktop"},
|
| 37 |
+
{"nome": "Spotify", "outros_nomes": [], "exec": "spotify", "arquivo": "/x/spotify.desktop"}]
|
| 38 |
+
real_which = tars.shutil.which
|
| 39 |
+
tars.shutil.which = lambda nome: None
|
| 40 |
+
r = tars.abrir_aplicativo("google")
|
| 41 |
+
assert r["sucesso"] and "is a website, not an installed application" in r["mensagem"] and abertos[-1].startswith("https://"), (r, abertos)
|
| 42 |
+
assert "google" in abertos[-1]
|
| 43 |
+
assert tars._app_com_nome_exato("spotify") and not tars._app_com_nome_exato("google")
|
| 44 |
+
tars.shutil.which = real_which
|
| 45 |
+
print("OK: open_application('google') abre o site, não o Google Chrome; um programa com o nome exato continua app")
|
| 46 |
+
print("\nTUDO OK.")
|
| 47 |
+
|
| 48 |
+
# 4) a resposta de memória depois de uma busca não lida ganha o aviso do app (o 2B diz "abri no navegador": sem aviso)
|
| 49 |
+
assert not tars._RE_FALA_DO_NAVEGADOR.search(n("The current president of the USA is Joe Biden."))
|
| 50 |
+
for frase in ("O resultado da busca foi aberto no navegador.", "The results are open in your browser.",
|
| 51 |
+
"Abri a pesquisa, confira na tela."):
|
| 52 |
+
assert tars._RE_FALA_DO_NAVEGADOR.search(n(frase)), frase
|
| 53 |
+
assert tars.t("msg.busca_nao_lida")
|
| 54 |
+
print("OK: resposta de memória depois de uma busca não lida é sinalizada pelo app")
|
| 55 |
+
|
| 56 |
+
# 5) 18/10 (decisão do Enzo): com o SearXNG ligado, "pesquisa X" só consulta o SearXNG e responde; nada abre no PC, a não
|
| 57 |
+
# ser que a pessoa peça para abrir, ou que seja um site com busca própria (YouTube). Desligada: abre, como antes.
|
| 58 |
+
tars.web_lib.config = lambda: {"ligada": True, "endereco": "http://x"}
|
| 59 |
+
tars.web_lib.buscar = lambda q: [{"titulo": "President of the United States", "url": "https://en.wikipedia.org/wiki/P", "trecho": "The current president is X."}]
|
| 60 |
+
for pedido, servico in (("pesquisa quem é o presidente dos eua", "google"), ("search latest bonsai model releases using searxng", "searxng"),
|
| 61 |
+
("procura isso", None), ("what's the price of bitcoin", "bing")):
|
| 62 |
+
abertos.clear()
|
| 63 |
+
tars._pedido_atual["texto"] = pedido
|
| 64 |
+
r = tars.pesquisar_web("x", servico)
|
| 65 |
+
assert r["sucesso"] and r["leu_resultados"] and not abertos, (pedido, r, abertos)
|
| 66 |
+
assert "Nothing was opened" in r["mensagem"] and "wikipedia.org" in r["mensagem"], r["mensagem"]
|
| 67 |
+
for pedido in ("pesquisa o presidente e abre no navegador", "search it and open the results in the browser",
|
| 68 |
+
"mostra no google o preço do dólar", "open google results for bonsai"):
|
| 69 |
+
abertos.clear()
|
| 70 |
+
tars._pedido_atual["texto"] = pedido
|
| 71 |
+
r = tars.pesquisar_web("x", "google")
|
| 72 |
+
assert r["leu_resultados"] and abertos and "also opened" in r["mensagem"], (pedido, r)
|
| 73 |
+
abertos.clear()
|
| 74 |
+
tars._pedido_atual["texto"] = "procura lofi no youtube"
|
| 75 |
+
r = tars.pesquisar_web("lofi", "youtube")
|
| 76 |
+
assert abertos and "youtube.com" in abertos[-1] and not r.get("leu_resultados"), "YouTube: abre a página do YouTube"
|
| 77 |
+
def _quebrado(q):
|
| 78 |
+
raise ValueError("SearXNG not reachable")
|
| 79 |
+
tars.web_lib.buscar = _quebrado
|
| 80 |
+
abertos.clear()
|
| 81 |
+
tars._pedido_atual["texto"] = "pesquisa o tempo amanhã"
|
| 82 |
+
r = tars.pesquisar_web("tempo amanhã", None)
|
| 83 |
+
assert r["sucesso"] and abertos and "NOT read the results" in r["mensagem"], "SearXNG fora do ar (26/10): abre no navegador, sem fingir que leu"
|
| 84 |
+
tars.web_lib.config = lambda: {"ligada": False, "endereco": "http://x"}
|
| 85 |
+
abertos.clear()
|
| 86 |
+
r = tars.pesquisar_web("x", None)
|
| 87 |
+
assert r["sucesso"] and abertos and "NOT read" in r["mensagem"], "pesquisa desligada: abre no navegador, como antes"
|
| 88 |
+
assert not tars.pediu_para_abrir("pesquisa quem ganhou o jogo") and tars.pediu_para_abrir("abre o google com isso")
|
| 89 |
+
print("OK: com o SearXNG ligado, pesquisar não abre nada (só se pedir ou no YouTube); desligado, abre como antes")
|
| 90 |
+
|
| 91 |
+
# 6) 18/10: o Gargantua 2 recém-exportado (fora do catálogo em cache) recebe think=false mesmo assim; sem isso o Qwen3.5 pensava
|
| 92 |
+
# e a resposta vinha vazia (a visão do Lite novo parecia errar 40 de 40)
|
| 93 |
+
enviados = []
|
| 94 |
+
class _R:
|
| 95 |
+
status_code = 200
|
| 96 |
+
def json(self): return {"message": {"content": "ok"}}
|
| 97 |
+
tars._SESSION.post = lambda url, json=None, timeout=None: enviados.append(json) or _R()
|
| 98 |
+
tars._cache_catalogo["dados"] = {}
|
| 99 |
+
tars._chat_unico("Gargantua-2:qualquer-tag-nova", "oi")
|
| 100 |
+
tars._chat_unico("qwen3:4b", "oi")
|
| 101 |
+
assert enviados[0].get("think") is False and "think" not in enviados[1], enviados
|
| 102 |
+
print("OK: modelo Gargantua-2 fora do catálogo em cache também recebe think=false")
|
| 103 |
+
|
| 104 |
+
# 7) 18/10: lembrete de idioma só para pedido em inglês; nota quando a resposta diz que abriu algo e nada abriu
|
| 105 |
+
for f in ("what is the capital of sweden", "search latest bonsai model releases using searxng", "open the calculator", "tell me a joke",
|
| 106 |
+
"who painted the mona lisa", "yeah search latest bonsai model releases using searxng"):
|
| 107 |
+
assert tars.escolha.pedido_em_ingles(n(f)), f
|
| 108 |
+
for f in ("qual a capital da suécia", "pesquisa quem ganhou a copa", "abre a calculadora", "open", "oi", "calculadora", "google",
|
| 109 |
+
"abre o spotify", "como faço arroz"):
|
| 110 |
+
assert not tars.escolha.pedido_em_ingles(n(f)), f
|
| 111 |
+
assert "answer in English" in tars._contexto_do_pedido("what is the capital of sweden", None, False)
|
| 112 |
+
assert tars._contexto_do_pedido("qual a capital da suécia", None, False) is None
|
| 113 |
+
for frase in ("O resultado da busca foi aberto no navegador.", "I opened the results in your browser.", "Encontrado no Google: ...",
|
| 114 |
+
"Abri a pesquisa na tela.", "The search results are open in the browser."):
|
| 115 |
+
assert tars._RE_DIZ_QUE_ABRIU.search(n(frase)), frase
|
| 116 |
+
for frase in ("O presidente é X, segundo a Wikipedia.", "Bitcoin is at $81 thousand according to TradingView.", "A capital é Estocolmo."):
|
| 117 |
+
assert not tars._RE_DIZ_QUE_ABRIU.search(n(frase)), frase
|
| 118 |
+
assert tars.t("msg.busca_nada_aberto")
|
| 119 |
+
print("OK: lembrete de idioma só para inglês; nota quando a resposta diz que abriu o navegador e nada abriu")
|
app/tests/test_revisao45.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""4.5 Endurance (revisão): um teste pra cada bug achado na revisão geral.
|
| 2 |
+
|
| 3 |
+
Chat e anexos, controle do mouse e do teclado, escolha das IAs e linha de comando."""
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 16 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 17 |
+
os.environ["HOME"] = CASA
|
| 18 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 19 |
+
from _util import carregar_tars # noqa: E402
|
| 20 |
+
|
| 21 |
+
tars = carregar_tars()
|
| 22 |
+
from PIL import Image # noqa: E402
|
| 23 |
+
|
| 24 |
+
RAIZ = Path(__file__).resolve().parent.parent
|
| 25 |
+
COOPER = "ProjectOpenTARS/cooper:latest"
|
| 26 |
+
|
| 27 |
+
# ------------------------------------------------------------
|
| 28 |
+
# Chat
|
| 29 |
+
# ------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
# 1. O print do openTARS não apaga o pedido do usuário (com o texto dos arquivos anexados)
|
| 32 |
+
msgs = [
|
| 33 |
+
{"role": "system", "content": "s"},
|
| 34 |
+
{"role": "user", "content": "resuma\n\n[Files attached]\nVENDAS 1234", "images": ["AAA"]},
|
| 35 |
+
{"role": "user", "content": "[Automatic message from openTARS, not from the user] This is the screenshot", "images": ["BBB"]},
|
| 36 |
+
]
|
| 37 |
+
tars._print_em_texto = lambda *a, **k: "descrição do print"
|
| 38 |
+
tars._tirar_imagens_da_conversa(msgs, "resuma", "api:texto")
|
| 39 |
+
assert msgs[1]["content"].endswith("VENDAS 1234") and "images" not in msgs[1], msgs[1]
|
| 40 |
+
assert msgs[2]["content"] == "descrição do print" and "images" not in msgs[2]
|
| 41 |
+
print("OK: imagem anexada pelo usuário perde só a imagem; o texto do pedido e do arquivo ficam")
|
| 42 |
+
|
| 43 |
+
# 2. O botão Parar vale enquanto o Ollama ainda não respondeu
|
| 44 |
+
tars._cancelar.clear()
|
| 45 |
+
inicio = time.time()
|
| 46 |
+
threading.Timer(0.3, tars._cancelar.set).start()
|
| 47 |
+
try:
|
| 48 |
+
tars._post_cancelavel(lambda: time.sleep(5))
|
| 49 |
+
raise AssertionError("devia cancelar")
|
| 50 |
+
except tars.RespostaCancelada:
|
| 51 |
+
pass
|
| 52 |
+
assert time.time() - inicio < 1.5, time.time() - inicio
|
| 53 |
+
tars._cancelar.clear()
|
| 54 |
+
assert tars._post_cancelavel(lambda: "pronto") == "pronto"
|
| 55 |
+
try:
|
| 56 |
+
tars._post_cancelavel(lambda: (_ for _ in ()).throw(ValueError("x")))
|
| 57 |
+
raise AssertionError("devia repassar o erro")
|
| 58 |
+
except ValueError:
|
| 59 |
+
pass
|
| 60 |
+
print("OK: Parar interrompe a espera pelo Ollama em ~0,1 s e os erros passam adiante")
|
| 61 |
+
|
| 62 |
+
# 3. Resposta normal que começa com "Okay," num modelo que não raciocina NÃO é cortada
|
| 63 |
+
class Resp:
|
| 64 |
+
def __init__(self, linhas):
|
| 65 |
+
self.linhas, self.fechada = linhas, False
|
| 66 |
+
|
| 67 |
+
def iter_lines(self):
|
| 68 |
+
for x in self.linhas:
|
| 69 |
+
if self.fechada:
|
| 70 |
+
return
|
| 71 |
+
yield json.dumps(x).encode()
|
| 72 |
+
|
| 73 |
+
def close(self):
|
| 74 |
+
self.fechada = True
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
pedidos = []
|
| 78 |
+
longa = "Okay, here is a quick overview of what the command does: it lists all files in the folder. " * 12
|
| 79 |
+
tars._chamar_ollama_stream = lambda p: (pedidos.append(p), (Resp([{"message": {"content": longa}}, {"done": True}]), None))[1]
|
| 80 |
+
tars.emitir = lambda *a, **k: None
|
| 81 |
+
tars._PENSA_MESMO_SEM_PEDIR.clear()
|
| 82 |
+
res, erro = tars._executar_turno_streaming("gemma3:4b", [{"role": "user", "content": "explique"}], None, pensar=False)
|
| 83 |
+
assert len(pedidos) == 1 and "gemma3:4b" not in tars._PENSA_MESMO_SEM_PEDIR and len((res["content"] + res["thinking"])) > 600
|
| 84 |
+
print("OK: 'Okay, ...' em modelo que não raciocina não dispara o corte nem a repetição")
|
| 85 |
+
|
| 86 |
+
# 5. Contexto velho ("janelas abertas agora") e foto de pedido antigo saem do histórico
|
| 87 |
+
hist = [{"role": "system", "content": "s"},
|
| 88 |
+
{"role": "user", "content": "pedido 1", "images": ["IMG"]},
|
| 89 |
+
{"role": "assistant", "content": "ok"},
|
| 90 |
+
{"role": "user", "content": "pedido 2"}]
|
| 91 |
+
tars._podar_historico(hist)
|
| 92 |
+
assert "images" not in hist[1] and hist[1]["content"] == "pedido 1"
|
| 93 |
+
print("OK: a foto de um pedido antigo não viaja em toda mensagem seguinte")
|
| 94 |
+
|
| 95 |
+
# ------------------------------------------------------------
|
| 96 |
+
# Controle: mouse, teclado, terminal, janelas
|
| 97 |
+
# ------------------------------------------------------------
|
| 98 |
+
tars._tamanho_real = lambda: (1280, 800)
|
| 99 |
+
assert tars._longe_da_quina(3000, 2000) == (1276, 796)
|
| 100 |
+
assert tars._longe_da_quina(-50, 0) == (3, 3)
|
| 101 |
+
assert tars._longe_da_quina(640, 400) == (640, 400)
|
| 102 |
+
tars._mirar_bruto = lambda *a, **k: (5000, 5000, "x")
|
| 103 |
+
assert tars._mirar(5000, 5000, None, None)[:2] == (1276, 796), "clique fora da tela não encosta na quina"
|
| 104 |
+
cliques = []
|
| 105 |
+
tars._mirar_bruto = lambda *a, **k: (100, 100, "")
|
| 106 |
+
tars.pyautogui = type("P", (), {
|
| 107 |
+
"moveTo": staticmethod(lambda *a, **k: None),
|
| 108 |
+
"click": staticmethod(lambda **k: cliques.append(k)),
|
| 109 |
+
"doubleClick": staticmethod(lambda **k: None),
|
| 110 |
+
"isValidKey": staticmethod(lambda k: k in ("enter", "pagedown", "ctrl", "s", "win", "esc", "down")),
|
| 111 |
+
"press": staticmethod(lambda k: cliques.append(("press", k))),
|
| 112 |
+
"hotkey": staticmethod(lambda *k: cliques.append(("hotkey", k))),
|
| 113 |
+
})
|
| 114 |
+
tars._conferir = lambda fazer, *a: (fazer(), "mudou")[1]
|
| 115 |
+
tars.PAUSA_HOVER_SEG = 0
|
| 116 |
+
tars.DURACAO_MOVER_MOUSE_SEG = 0
|
| 117 |
+
nome, args, _ = tars.consertar_chamada("right_click", {"x": 100, "y": 100})
|
| 118 |
+
assert nome == "click_mouse" and args["button"] == "right", (nome, args)
|
| 119 |
+
tars.clicar_mouse("Right", 100, 100)
|
| 120 |
+
assert cliques[-1]["button"] == "right", cliques
|
| 121 |
+
tars.clicar_mouse("right-click", 100, 100)
|
| 122 |
+
assert cliques[-1]["button"] == "right"
|
| 123 |
+
assert tars.pressionar_tecla("Page Down")["sucesso"] and cliques[-1] == ("press", "pagedown")
|
| 124 |
+
assert tars.pressionar_tecla("Super")["sucesso"] and cliques[-1] == ("press", "win")
|
| 125 |
+
assert not tars.pressionar_tecla("bogus")["sucesso"] and not tars.pressionar_tecla("")["sucesso"]
|
| 126 |
+
assert tars.pressionar_tecla("ctrl+s")["sucesso"] and cliques[-1] == ("hotkey", ("ctrl", "s"))
|
| 127 |
+
assert not tars.atalho_teclado(["ctrl", "bogus"])["sucesso"] and not tars.atalho_teclado([])["sucesso"]
|
| 128 |
+
print("OK: clique fora da tela não chega à quina, botão direito de verdade, teclas inválidas avisam")
|
| 129 |
+
|
| 130 |
+
r = tars._executar_terminal_de_fato("printf 'ol\\341 mundo\\n'")
|
| 131 |
+
assert r["sucesso"] and "mundo" in r["stdout"], r
|
| 132 |
+
print("OK: saída que não é UTF-8 não derruba o comando")
|
| 133 |
+
|
| 134 |
+
JANELAS = [dict(id=1, titulo="Calculator", classe="gnome-calculator Gnome-calculator", pid=10, x=0, y=0, largura=300, altura=400, minimizada=False),
|
| 135 |
+
dict(id=2, titulo="Calculadora online - Google Chrome", classe="google-chrome Google-chrome", pid=11, x=0, y=0, largura=900, altura=700, minimizada=False)]
|
| 136 |
+
tars._buscar_app_no_sistema = lambda n: {"tipo": "desktop", "nome": "Calculator", "arquivo": "/usr/share/applications/org.gnome.Calculator.desktop",
|
| 137 |
+
"exec": "gnome-calculator", "comando": ["gtk-launch", "x"], "origem": "x"} if n.lower().startswith("calc") else None
|
| 138 |
+
assert tars.encontrar_janela_por_nome("calculadora", JANELAS)["titulo"] == "Calculator", "o app vence a aba do navegador"
|
| 139 |
+
assert tars.encontrar_janela_por_nome("chrome", JANELAS)["id"] == 2
|
| 140 |
+
print("OK: 'calculadora' acha o programa e não a aba do navegador com o mesmo nome")
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------
|
| 143 |
+
# Escolha das IAs
|
| 144 |
+
# ------------------------------------------------------------
|
| 145 |
+
def modelo(tag, caps, params, categoria="geral", quant="Q4_K_M"):
|
| 146 |
+
return {"tag": tag, "familia": "x", "categoria": categoria, "parametros_b": params, "capacidades": set(caps),
|
| 147 |
+
"capacidades_conhecidas": True, "vram_estimado_gb": 2.0 if params is None else params * 0.6 + 0.6}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
CAT = {
|
| 151 |
+
"ProjectOpenTARS/Gargantua:latest": modelo("ProjectOpenTARS/Gargantua:latest", {"tools", "completion"}, 4.0),
|
| 152 |
+
COOPER: modelo(COOPER, {"tools", "vision", "completion"}, 2.0, "visao"),
|
| 153 |
+
"nomic-embed-text:latest": modelo("nomic-embed-text:latest", {"embedding"}, 0.14, "embedding"),
|
| 154 |
+
"qwen3:8b": modelo("qwen3:8b", {"tools", "completion"}, 8.0),
|
| 155 |
+
"api:bonsai": modelo("api:bonsai", {"tools"}, None),
|
| 156 |
+
}
|
| 157 |
+
CAT["api:bonsai"]["capacidades_conhecidas"] = False
|
| 158 |
+
assert tars.modelo_ajudante(CAT) != COOPER, "o Cooper não resolve nome de app"
|
| 159 |
+
for alvo in ("modelo.menor", "modelo.maior", "modelo.visao", "modelo.tecnico"):
|
| 160 |
+
escolhido = tars._RESOLVEDORES_MODELO[alvo](CAT) if hasattr(tars, "_RESOLVEDORES_MODELO") else None
|
| 161 |
+
assert escolhido not in (COOPER, "nomic-embed-text:latest"), (alvo, escolhido)
|
| 162 |
+
assert COOPER not in tars.construir_cadeia_fallback("visao", CAT)
|
| 163 |
+
assert tars.construir_cadeia_fallback("acao", CAT)[-1] != "x"
|
| 164 |
+
cadeia = tars.construir_cadeia_fallback("busca", CAT)
|
| 165 |
+
assert cadeia.index("api:bonsai") > cadeia.index("qwen3:8b"), cadeia # tamanho desconhecido: no fim
|
| 166 |
+
assert tars._estimar_vram_gb(8.2, "BF16") > 15
|
| 167 |
+
print("OK: Cooper e embedding não conversam nem ajudam; tamanho desconhecido vai pro fim; BF16 não é Q4")
|
| 168 |
+
|
| 169 |
+
# modelo que já está na placa responde a conversa curta (sem trocar de modelo)
|
| 170 |
+
tars.catalogar_modelos = lambda forcar=False: CAT
|
| 171 |
+
tars._cache_cadeias.clear()
|
| 172 |
+
tars.modo_modelo = "AUTO"
|
| 173 |
+
tars.modelo_atual = "qwen3:8b"
|
| 174 |
+
assert tars.escolher_modelo("oi", tarefa="simples") == "qwen3:8b"
|
| 175 |
+
tars.modelo_atual = None
|
| 176 |
+
|
| 177 |
+
# ------------------------------------------------------------
|
| 178 |
+
# Linha de comando
|
| 179 |
+
# ------------------------------------------------------------
|
| 180 |
+
def tars_cli(*args, entrada=""):
|
| 181 |
+
env = {**os.environ, "OLLAMA_HOST": "127.0.0.1:1", "HOME": CASA, "TARS_SEM_INTERFACE": "1", "TARS_SEM_AVISO_VERSAO": "1"}
|
| 182 |
+
return subprocess.run([sys.executable, str(RAIZ / "tars.py"), *args], input=entrada, capture_output=True, text=True, env=env, timeout=60)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
r = tars_cli("--chat", "oi") # Ollama fora do ar: erro e código de saída diferente de 0
|
| 186 |
+
assert r.returncode == 1, (r.returncode, r.stdout[-300:])
|
| 187 |
+
print("OK: --chat com o Ollama fora do ar dá código de saída diferente de 0")
|
| 188 |
+
|
| 189 |
+
print("\nTUDO OK.")
|
app/tests/test_revisao_50.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0: regressões da revisão geral do código (bugs achados e corrigidos)."""
|
| 2 |
+
|
| 3 |
+
import datetime as dt
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 13 |
+
os.environ["HOME"] = CASA
|
| 14 |
+
os.environ["TARS_ARQUIVO_AGENDA"] = os.path.join(CASA, "agenda.json")
|
| 15 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 16 |
+
os.environ["TARS_DIR_LOG"] = os.path.join(CASA, "log")
|
| 17 |
+
|
| 18 |
+
from _util import carregar_tars # noqa: E402
|
| 19 |
+
|
| 20 |
+
tars = carregar_tars()
|
| 21 |
+
import tars_rotinas as rot # noqa: E402
|
| 22 |
+
import tars_historico as hist # noqa: E402
|
| 23 |
+
import tars_openai as api # noqa: E402
|
| 24 |
+
|
| 25 |
+
# --- rotinas -------------------------------------------------------------------------------------------------
|
| 26 |
+
assert rot.ler_dias("seg,qua,sex") == [0, 2, 4] and rot.ler_dias("mon, wed, fri") == [0, 2, 4]
|
| 27 |
+
assert rot.ler_dias(["weekdays"]) == [0, 1, 2, 3, 4] and rot.ler_dias(["seg, qua"]) == [0, 2]
|
| 28 |
+
assert rot.ler_dias("terça e quinta") == [1, 3]
|
| 29 |
+
q, erro = rot.normalizar_quando({"tipo": "uma_vez", "data": "2026-10-31T09:00:00Z"})
|
| 30 |
+
assert erro is None and "+" not in q["data"] and "Z" not in q["data"], q # data com fuso não derruba o agendador
|
| 31 |
+
assert rot.proxima_execucao(q, dt.datetime(2020, 1, 1)) is not None
|
| 32 |
+
r = {"aprovada": True, "permitidas": ["apagar"]}
|
| 33 |
+
assert rot.autorizado(r, "rm /tmp/x") and not rot.autorizado(r, "rm /tmp/x && git push origin main"), "aprovar uma categoria não libera outra"
|
| 34 |
+
assert not rot.autorizado(r, "ls") and not rot.autorizado(dict(r, aprovada=False), "rm /tmp/x")
|
| 35 |
+
print("OK: dias da semana, data com fuso e pré-aprovação por categoria")
|
| 36 |
+
|
| 37 |
+
ag = rot.Agenda(os.path.join(CASA, "ag2.json"), agora=lambda: dt.datetime(2026, 10, 6, 8, 0, 5))
|
| 38 |
+
novo, erro = ag.criar("Diaria", "faz x", {"tipo": "diaria", "hora": "08:00"}, permitidas=(), ativa=True, aprovada=True)
|
| 39 |
+
assert not erro
|
| 40 |
+
# a próxima execução é amanhã 08:00; "devidas" não devolve nada hoje, então forçamos a data para a execução de agora
|
| 41 |
+
rotinas = json.loads(Path(ag.caminho).read_text())
|
| 42 |
+
rotinas[0]["proxima"] = "2026-10-06T08:00:00"
|
| 43 |
+
Path(ag.caminho).write_text(json.dumps(rotinas))
|
| 44 |
+
rodar, _ = ag.devidas()
|
| 45 |
+
assert len(rodar) == 1
|
| 46 |
+
assert ag.reservar(rodar[0]["id"], rodar[0]["prevista"]) is True
|
| 47 |
+
assert ag.reservar(rodar[0]["id"], rodar[0]["prevista"]) is False, "a mesma execução não roda duas vezes"
|
| 48 |
+
assert ag.obter(rodar[0]["id"])["proxima"].startswith("2026-10-07")
|
| 49 |
+
print("OK: uma execução reservada não roda duas vezes")
|
| 50 |
+
|
| 51 |
+
Path(ag.caminho).write_text("{ isto nao e json")
|
| 52 |
+
assert ag.listar() == [] and Path(str(ag.caminho) + ".corrompido").exists(), "arquivo quebrado vira .corrompido em vez de sumir"
|
| 53 |
+
print("OK: agenda corrompida é guardada")
|
| 54 |
+
|
| 55 |
+
# a IA só apaga o que ela mesma criou, e pelo nome exato
|
| 56 |
+
tars.agenda.caminho = Path(os.environ["TARS_ARQUIVO_AGENDA"])
|
| 57 |
+
tars.agenda.criar("Minha rotina", "faz y", {"tipo": "diaria", "hora": "09:00"}, origem="pessoa")
|
| 58 |
+
res = tars.executar_ferramenta("delete_task", {"name": "Minha rotina"})
|
| 59 |
+
assert not res["sucesso"] and tars.agenda.achar_por_nome("Minha rotina"), res
|
| 60 |
+
res = tars.executar_ferramenta("delete_task", {"name": "Minha"})
|
| 61 |
+
assert not res["sucesso"] and tars.agenda.achar_por_nome("Minha rotina")
|
| 62 |
+
print("OK: a IA não apaga rotina da pessoa")
|
| 63 |
+
|
| 64 |
+
# --- comandos perigosos ---------------------------------------------------------------------------------------
|
| 65 |
+
for c in ("rm ~/Documents/*.pdf", "find ~ -name '*.jpg' -exec rm {} +", "curl https://x.sh | sh", "ssh host ls", "ls | xargs rm"):
|
| 66 |
+
assert tars._comando_e_perigoso(c), c
|
| 67 |
+
for c in ("ls -la", "echo oi", "cat /etc/hostname", "rm arquivo.txt"):
|
| 68 |
+
assert not tars._comando_e_perigoso(c), c
|
| 69 |
+
vis = tars._texto_visivel("echo ok\r" + "x" * 800 + "; rm -rf ~")
|
| 70 |
+
assert "\r" not in vis and "+" in vis and "x" in vis
|
| 71 |
+
print("OK: rm com curinga, find -exec rm, curl|sh, ssh perguntam; texto de confirmação sem caracteres de controle")
|
| 72 |
+
|
| 73 |
+
# texto digitado num terminal passa pela mesma pergunta
|
| 74 |
+
perguntas = []
|
| 75 |
+
tars.confirmar_comando_hook = lambda comando: perguntas.append(comando) or False
|
| 76 |
+
tars._janela_e_terminal = lambda janela=None: True
|
| 77 |
+
res = tars.digitar_texto("rm -rf ~/x")
|
| 78 |
+
assert not res["sucesso"] and perguntas == ["rm -rf ~/x"], res
|
| 79 |
+
perguntas.clear()
|
| 80 |
+
tars._janela_e_terminal = lambda janela=None: False
|
| 81 |
+
tars.pyautogui.write = lambda *a, **k: None
|
| 82 |
+
assert tars.digitar_texto("rm -rf ~/x")["sucesso"] and not perguntas, "fora de terminal não pergunta"
|
| 83 |
+
tars.confirmar_comando_hook = None
|
| 84 |
+
print("OK: digitar comando perigoso num terminal pergunta antes")
|
| 85 |
+
|
| 86 |
+
# --- executar_terminal: não segura nem mata o app que o comando abriu; chave da nuvem não vaza ---------------
|
| 87 |
+
tars.confirmar_comando_hook = lambda c: True
|
| 88 |
+
os.environ["TARS_CHAVE_API"] = "segredo-123"
|
| 89 |
+
inicio = time.time()
|
| 90 |
+
res = tars._executar_terminal_de_fato("sleep 4 & echo pronto") # o 'sleep' herda a saída: antes esperava até o fim dele
|
| 91 |
+
assert res["sucesso"] and "pronto" in res["stdout"] and time.time() - inicio < 3, (res, time.time() - inicio)
|
| 92 |
+
res = tars._executar_terminal_de_fato("env")
|
| 93 |
+
assert "segredo-123" not in res["stdout"] and "TARS_CHAVE_API" not in res["stdout"]
|
| 94 |
+
res = tars._executar_terminal_de_fato("yes | head -c 3000000 | tr -d '\\n' | head -c 50000; echo fim")
|
| 95 |
+
assert res["sucesso"] and len(res["stdout"]) <= tars.LIMITE_STDOUT_CHARS
|
| 96 |
+
res = tars._executar_terminal_de_fato("printf 'caf\\xe9'")
|
| 97 |
+
assert res["sucesso"], "saída fora de UTF-8 não derruba o comando"
|
| 98 |
+
res = tars._executar_terminal_de_fato("sleep 1 &")
|
| 99 |
+
assert res["sucesso"] and "background" in res["mensagem"]
|
| 100 |
+
del os.environ["TARS_CHAVE_API"]
|
| 101 |
+
import threading
|
| 102 |
+
antes_fios = threading.active_count()
|
| 103 |
+
res = tars._executar_terminal_de_fato("true &") # comando com & termina logo: o filho tem que ser recolhido (sem zumbi)
|
| 104 |
+
time.sleep(1.0)
|
| 105 |
+
import subprocess as _sp
|
| 106 |
+
zumbis = [l for l in _sp.run(["ps", "-o", "stat=", "--ppid", str(os.getpid())], capture_output=True, text=True).stdout.split() if l.startswith("Z")]
|
| 107 |
+
assert res["sucesso"] and not zumbis, (res, zumbis)
|
| 108 |
+
print("OK: comando com & não deixa processo zumbi")
|
| 109 |
+
print("OK: terminal não espera filho herdando a saída, não vaza a chave, limita a saída")
|
| 110 |
+
|
| 111 |
+
# --- ferramentas ----------------------------------------------------------------------------------------------
|
| 112 |
+
res = tars.fechar_aplicativo("")
|
| 113 |
+
assert not res["sucesso"] and tars._terminar_processos("") == [] and tars._terminar_processos(" ") == []
|
| 114 |
+
n, args, _ = tars.consertar_chamada("click_mouse", '{"x": 1, "y": 2}')
|
| 115 |
+
assert args == {"x": 1, "y": 2}
|
| 116 |
+
n, args, _ = tars.consertar_chamada("click_mouse", ["lixo"])
|
| 117 |
+
assert args == {}
|
| 118 |
+
n, args, _ = tars.consertar_chamada("scroll_mouse", {"amount": float("inf")})
|
| 119 |
+
assert isinstance(args, dict)
|
| 120 |
+
n, args, _ = tars.consertar_chamada("list_files", {"path": "~/Docs"})
|
| 121 |
+
assert args["command"].startswith("ls -la -- ") and "~" not in args["command"] and CASA in args["command"], args
|
| 122 |
+
print("OK: close_application vazio não mata nada; argumentos estranhos não derrubam o turno")
|
| 123 |
+
|
| 124 |
+
assert tars._chamadas_no_texto('{"name": ["a"], "arguments": {}}') == [] if hasattr(tars, "_chamadas_no_texto") else True
|
| 125 |
+
|
| 126 |
+
# --- OpenAI / nuvem ---------------------------------------------------------------------------------------------
|
| 127 |
+
assert api.normalizar_url("api.groq.com/openai/v1") == "https://api.groq.com/openai/v1"
|
| 128 |
+
assert api.normalizar_url("localhost:1234") == "http://localhost:1234/v1"
|
| 129 |
+
assert api.normalizar_url("192.168.0.5:8000") == "http://192.168.0.5:8000/v1"
|
| 130 |
+
assert api.normalizar_url("https://x.com/v1beta/openai") == "https://x.com/v1beta/openai"
|
| 131 |
+
msgs = [{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "a", "arguments": {}}}]},
|
| 132 |
+
{"role": "user", "content": "e então?"}]
|
| 133 |
+
saida = api.para_openai(msgs)
|
| 134 |
+
assert [m["role"] for m in saida] == ["assistant", "tool", "user"], saida # chamada sem resultado ganha um vazio
|
| 135 |
+
print("OK: endereço da nuvem sem esquema vira https; chamada sem resultado é fechada")
|
| 136 |
+
|
| 137 |
+
# --- histórico mantém o começo da conversa --------------------------------------------------------------------------
|
| 138 |
+
h = hist.Historico(os.path.join(CASA, "h.json"))
|
| 139 |
+
completa = [{"role": "user", "content": f"m{i}"} if i % 2 == 0 else {"role": "assistant", "content": f"r{i}"} for i in range(40)]
|
| 140 |
+
h.salvar("a", completa[:30])
|
| 141 |
+
h.salvar("a", completa[10:40]) # a conversa em memória foi podada (começa na 10)
|
| 142 |
+
assert [m["content"] for m in h.abrir("a")][0] == "m0" and len(h.abrir("a")) == 40
|
| 143 |
+
Path(h.caminho).write_text("lixo")
|
| 144 |
+
assert h.listar() == [] and Path(str(h.caminho) + ".corrompido").exists()
|
| 145 |
+
print("OK: histórico não perde o começo da conversa podada nem some com arquivo quebrado")
|
| 146 |
+
|
| 147 |
+
# --- log não guarda o que foi digitado -----------------------------------------------------------------------------
|
| 148 |
+
assert "senha123" not in tars._resumir_para_log({"text": "senha123"}) and "8 chars" in tars._resumir_para_log({"text": "senha123"})
|
| 149 |
+
assert (os.stat(tars.DIRETORIO_LOG).st_mode & 0o777) == 0o700
|
| 150 |
+
print("OK: o log só guarda o tamanho do texto digitado e a pasta é 0700")
|
| 151 |
+
|
| 152 |
+
# --- /modelo: horas e siglas não viram modelo -------------------------------------------------------------------------
|
| 153 |
+
for lixo in ("12:30", "pt:br", "note:x"):
|
| 154 |
+
antes = (tars.modo_modelo, tars.modelo_manual)
|
| 155 |
+
tars.modo_modelo, tars.modelo_manual = "AUTO", None
|
| 156 |
+
tars._tratar_comando_modelo(lixo) if hasattr(tars, "_tratar_comando_modelo") else None
|
| 157 |
+
assert tars.modo_modelo == "AUTO", lixo
|
| 158 |
+
tars.modo_modelo, tars.modelo_manual = antes
|
| 159 |
+
print("OK: mensagens como 12:30 não trocam o modelo")
|
| 160 |
+
|
| 161 |
+
# --- stream do Ollama que acaba sem 'done' não vira resposta -----------------------------------------------------------
|
| 162 |
+
class Resposta:
|
| 163 |
+
def __init__(self, linhas):
|
| 164 |
+
self._l = linhas
|
| 165 |
+
self.fechada = False
|
| 166 |
+
|
| 167 |
+
def iter_lines(self):
|
| 168 |
+
yield from self._l
|
| 169 |
+
|
| 170 |
+
def close(self):
|
| 171 |
+
self.fechada = True
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def corpo(msgs):
|
| 175 |
+
return [json.dumps({"message": {"content": m}, "done": False}).encode() for m in msgs]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
resp = Resposta(corpo(["Ola ", "mund"])) # acabou no meio, sem "done"
|
| 179 |
+
resp.status_code = 200
|
| 180 |
+
tars.ollama_request = lambda *a, **k: resp
|
| 181 |
+
r, erro = tars._executar_turno_streaming("qwen3:8b", [{"role": "user", "content": "oi"}], [], False)
|
| 182 |
+
assert r is None and erro, (r, erro)
|
| 183 |
+
assert resp.fechada, "a conexão é fechada em qualquer saída"
|
| 184 |
+
ok = Resposta(corpo(["Ola"]) + [json.dumps({"message": {"content": "!"}, "done": True}).encode()])
|
| 185 |
+
ok.status_code = 200
|
| 186 |
+
tars.ollama_request = lambda *a, **k: ok
|
| 187 |
+
r, erro = tars._executar_turno_streaming("qwen3:8b", [{"role": "user", "content": "oi"}], [], False)
|
| 188 |
+
assert erro is None and "Ola" in (r or {}).get("content", ""), (r, erro)
|
| 189 |
+
print("OK: stream sem 'done' é erro, não resposta pela metade")
|
| 190 |
+
|
| 191 |
+
print("\nTUDO OK.")
|
app/tests/test_robustez_v29.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""2.9: IAs menos propensas a falhas."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from _util import carregar_tars
|
| 5 |
+
|
| 6 |
+
tars = carregar_tars()
|
| 7 |
+
|
| 8 |
+
# 1. Conserto de chamadas
|
| 9 |
+
n, a, nota = tars.consertar_chamada("open_app", {"app_name": "firefox"})
|
| 10 |
+
assert (n, a) == ("open_application", {"app": "firefox"}) and "open_app" in nota, (n, a, nota)
|
| 11 |
+
n, a, _ = tars.consertar_chamada("click_mouse", {"x": "120", "y": 80.6})
|
| 12 |
+
assert a == {"x": 120, "y": 81}, a
|
| 13 |
+
n, a, _ = tars.consertar_chamada("hotkey", {"keys": "ctrl+s"})
|
| 14 |
+
assert (n, a) == ("press_key", {"key": "ctrl+s"}), (n, a) # 4.6: hotkey virou press_key
|
| 15 |
+
n, a, _ = tars.consertar_chamada("hotkey", {"keys": ["ctrl", "shift", "t"]})
|
| 16 |
+
assert (n, a) == ("press_key", {"key": "ctrl+shift+t"}), (n, a)
|
| 17 |
+
# 4.6: as ferramentas que saíram continuam funcionando pra quem ainda as conhece (Gargantua 1.1)
|
| 18 |
+
n, a, _ = tars.consertar_chamada("double_click", {"x": 10, "y": 20})
|
| 19 |
+
assert (n, a) == ("click_mouse", {"x": 10, "y": 20, "clicks": 2}), (n, a)
|
| 20 |
+
n, a, _ = tars.consertar_chamada("list_files", {"path": "~/Documentos"})
|
| 21 |
+
assert n == "execute_terminal" and a["command"] == "ls -la -- " + os.path.expanduser("~/Documentos"), (n, a) # o ~ é expandido: entre aspas o ls procurava uma pasta chamada "~"
|
| 22 |
+
n, a, _ = tars.consertar_chamada("wait_seconds", {"seconds": 999})
|
| 23 |
+
assert (n, a) == ("execute_terminal", {"command": "sleep 5"}), (n, a)
|
| 24 |
+
n, a, _ = tars.consertar_chamada("pc_info", {})
|
| 25 |
+
assert n == "execute_terminal" and "free -h" in a["command"], (n, a)
|
| 26 |
+
n, a, _ = tars.consertar_chamada("move_to_trash", {"paths": ["a b.txt"]})
|
| 27 |
+
assert n == "execute_terminal" and a["command"] == "gio trash -- 'a b.txt'", (n, a)
|
| 28 |
+
assert not {"double_click", "hotkey", "list_files", "pc_info", "wait_seconds", "move_to_trash", "undo_last_action",
|
| 29 |
+
"read_clipboard", "write_clipboard"} & {f["function"]["name"] for f in tars.TOOLS}
|
| 30 |
+
assert len(tars.TOOLS) == 19, len(tars.TOOLS)
|
| 31 |
+
n, a, _ = tars.consertar_chamada("type_in_element", {"text": "oi", "window": "Claude", "submit": "true"})
|
| 32 |
+
assert a["submit"] is True
|
| 33 |
+
n, a, _ = tars.consertar_chamada("serch_web", {"search_query": "lasanha"})
|
| 34 |
+
assert (n, a) == ("search_web", {"query": "lasanha"}), (n, a)
|
| 35 |
+
n, a, nota = tars.consertar_chamada("search_web", {"query": "x"})
|
| 36 |
+
assert nota is None
|
| 37 |
+
print("OK: nomes e argumentos inventados são corrigidos (open_app/app_name, '120', 'ctrl+s', 'true', erro de digitação)")
|
| 38 |
+
|
| 39 |
+
# 2. Ferramentas por tarefa
|
| 40 |
+
nomes = lambda tarefa: {f["function"]["name"] for f in tars.ferramentas_para(tarefa)} # noqa: E731
|
| 41 |
+
assert nomes("simples") == {"open_website", "search_web", "open_application", "close_application"}
|
| 42 |
+
assert "execute_terminal" in nomes("tecnico") and "click_mouse" not in nomes("tecnico")
|
| 43 |
+
# 3.0: memória e agenda só entram quando o pedido fala delas (ferramentas_do_pedido)
|
| 44 |
+
todas = len([f for f in tars.TOOLS if f["function"]["name"] not in tars._FERRAMENTAS_SO_SE_PEDIDAS])
|
| 45 |
+
assert len(nomes("acao")) == todas and len(nomes(None)) == todas
|
| 46 |
+
print(f"OK: cada tarefa recebe só as ferramentas que fazem sentido (simples: 4, ação: {todas})")
|
| 47 |
+
|
| 48 |
+
# 3. "Pronto!" depois de uma ferramenta que falhou -> conserta ou conta a verdade
|
| 49 |
+
tars.carregar_modelo = lambda m, silencioso=False: True
|
| 50 |
+
tars.contexto_ia = lambda: 8192
|
| 51 |
+
tars.modo_modelo = "MANUAL"
|
| 52 |
+
tars.catalogar_modelos = lambda forcar=False: {}
|
| 53 |
+
roteiro = iter([
|
| 54 |
+
("tool", "open_application", {"app": "photoshop"}),
|
| 55 |
+
("texto", "Pronto! Abri o Photoshop pra você."),
|
| 56 |
+
("texto", "Não consegui abrir: o Photoshop não está instalado neste PC."),
|
| 57 |
+
])
|
| 58 |
+
recebidas, ofertas = [], []
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def turno(modelo, msgs, tools, **kw):
|
| 62 |
+
recebidas.append(msgs[-1]["content"])
|
| 63 |
+
ofertas.append(len(tools or []))
|
| 64 |
+
tipo, *resto = next(roteiro)
|
| 65 |
+
if tipo == "texto":
|
| 66 |
+
return {"content": resto[0], "tool_calls": []}, None
|
| 67 |
+
return {"content": "", "tool_calls": [{"function": {"name": resto[0], "arguments": resto[1]}}]}, None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
tars._executar_turno_streaming = turno
|
| 71 |
+
tars._DISPATCH_FERRAMENTAS["open_application"] = lambda a: {"sucesso": False, "mensagem": "APP NOT FOUND: 'photoshop'."}
|
| 72 |
+
tars.conversa = []
|
| 73 |
+
resposta = tars.perguntar_modelo("qwen3:8b", "abre o photoshop", tarefa="simples")
|
| 74 |
+
assert resposta.startswith("Não consegui"), resposta
|
| 75 |
+
assert any("LAST tool call FAILED" in r for r in recebidas)
|
| 76 |
+
assert ofertas[0] == 4, ofertas
|
| 77 |
+
print("OK: 'Pronto, abri!' logo depois de uma falha é barrado; a IA conta a verdade")
|
| 78 |
+
print("\nTUDO OK.")
|
app/tests/test_rotinas.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0: rotinas agendadas (tars_rotinas.py): horários, pré-aprovação, perdidas, arquivo 0600."""
|
| 2 |
+
|
| 3 |
+
import datetime as dt
|
| 4 |
+
import os
|
| 5 |
+
import stat
|
| 6 |
+
import tempfile
|
| 7 |
+
|
| 8 |
+
from _util import RAIZ # noqa: F401
|
| 9 |
+
|
| 10 |
+
import tars_i18n
|
| 11 |
+
import tars_rotinas as R
|
| 12 |
+
|
| 13 |
+
D = dt.datetime
|
| 14 |
+
pasta = tempfile.mkdtemp(prefix="opentars-agenda-")
|
| 15 |
+
caminho = os.path.join(pasta, "agenda.json")
|
| 16 |
+
relogio = {"agora": D(2026, 10, 6, 7, 0)} # terça-feira
|
| 17 |
+
ag = R.Agenda(caminho, agora=lambda: relogio["agora"])
|
| 18 |
+
|
| 19 |
+
# --- ler hora e dias ---------------------------------------------------------
|
| 20 |
+
assert R.ler_hora("8") == (8, 0) and R.ler_hora("8h30") == (8, 30) and R.ler_hora("08:05") == (8, 5)
|
| 21 |
+
assert R.ler_hora("25:00") is None and R.ler_hora("abc") is None and R.ler_hora("") is None
|
| 22 |
+
assert R.ler_dias("segunda a sexta") == [0, 1, 2, 3, 4] and R.ler_dias("sáb, dom") == [5, 6]
|
| 23 |
+
assert R.ler_dias(["seg", "quarta", 4]) == [0, 2, 4] and R.ler_dias("fim de semana") == [5, 6]
|
| 24 |
+
assert R.ler_dias(None) == [] and R.ler_dias("nada") == []
|
| 25 |
+
|
| 26 |
+
# --- quando ------------------------------------------------------------------
|
| 27 |
+
assert R.normalizar_quando({"tipo": "diaria", "hora": "8h"}) == ({"tipo": "diaria", "hora": "08:00"}, None)
|
| 28 |
+
assert R.normalizar_quando({"tipo": "diaria", "hora": "99"})[1] == "hora"
|
| 29 |
+
assert R.normalizar_quando({"tipo": "semanal", "hora": "9", "dias": []})[1] == "dias"
|
| 30 |
+
assert R.normalizar_quando({"tipo": "intervalo", "cada_minutos": 2})[1] == "intervalo"
|
| 31 |
+
assert R.normalizar_quando({"tipo": "uma_vez", "data": "ontem"})[1] == "data"
|
| 32 |
+
assert R.normalizar_quando({"tipo": "mensal"})[1] == "tipo" and R.normalizar_quando("x")[1] == "tipo"
|
| 33 |
+
|
| 34 |
+
d = {"tipo": "diaria", "hora": "08:00"}
|
| 35 |
+
assert R.proxima_execucao(d, D(2026, 10, 6, 7, 0)) == D(2026, 10, 6, 8, 0)
|
| 36 |
+
assert R.proxima_execucao(d, D(2026, 10, 6, 8, 0)) == D(2026, 10, 7, 8, 0), "estritamente depois"
|
| 37 |
+
s = {"tipo": "semanal", "hora": "09:30", "dias": [0, 4]} # segunda e sexta
|
| 38 |
+
assert R.proxima_execucao(s, D(2026, 10, 6, 7, 0)) == D(2026, 10, 9, 9, 30) # terça -> sexta
|
| 39 |
+
assert R.proxima_execucao(s, D(2026, 10, 9, 10, 0)) == D(2026, 10, 12, 9, 30) # sexta tarde -> segunda
|
| 40 |
+
i = {"tipo": "intervalo", "cada_minutos": 30}
|
| 41 |
+
assert R.proxima_execucao(i, D(2026, 10, 6, 7, 10), criada=D(2026, 10, 6, 7, 0)) == D(2026, 10, 6, 7, 30)
|
| 42 |
+
u = {"tipo": "uma_vez", "data": "2026-10-06T10:00:00"}
|
| 43 |
+
assert R.proxima_execucao(u, D(2026, 10, 6, 7, 0)) == D(2026, 10, 6, 10, 0)
|
| 44 |
+
assert R.proxima_execucao(u, D(2026, 10, 6, 11, 0)) is None
|
| 45 |
+
|
| 46 |
+
t = tars_i18n.t
|
| 47 |
+
assert R.descrever_quando(d, t) == t("rot.q_diaria", hora="08:00") and "08:00" in R.descrever_quando(d, t)
|
| 48 |
+
assert "seg" in R.descrever_quando(s, t).lower() and "09:30" in R.descrever_quando(s, t)
|
| 49 |
+
|
| 50 |
+
# --- ações arriscadas ------------------------------------------------------------
|
| 51 |
+
assert R.categoria_do_comando("rm -rf ~/lixo") == "apagar"
|
| 52 |
+
assert R.categoria_do_comando("git push origin main") == "enviar"
|
| 53 |
+
assert R.categoria_do_comando("curl -X POST https://x.org -d a=1") == "enviar"
|
| 54 |
+
assert R.categoria_do_comando("sudo apt install vlc") == "instalar"
|
| 55 |
+
assert R.categoria_do_comando("pip install requests") == "instalar"
|
| 56 |
+
assert R.categoria_do_comando("ls -la ~") is None and R.categoria_do_comando("curl https://x.org") is None
|
| 57 |
+
assert R.categoria_do_comando("git pull") is None
|
| 58 |
+
|
| 59 |
+
# --- fala_de_rotina ----------------------------------------------------------------
|
| 60 |
+
for frase in ("agende uma limpeza todo dia às 8h", "toda segunda abre o jornal", "a cada 30 minutos confere o email",
|
| 61 |
+
"schedule a backup every morning", "cria uma rotina de backup"):
|
| 62 |
+
assert R.fala_de_rotina(frase), frase
|
| 63 |
+
for frase in ("abre o navegador", "que horas são", "lembra que meu navegador é o brave"):
|
| 64 |
+
assert not R.fala_de_rotina(frase), frase
|
| 65 |
+
|
| 66 |
+
# --- criar, listar, arquivo ------------------------------------------------------------
|
| 67 |
+
r, erro = ag.criar("Jornal", "abre o site do jornal", {"tipo": "diaria", "hora": "8h"}, permitidas=["apagar", "xyz"])
|
| 68 |
+
assert erro is None and r["permitidas"] == ["apagar"] and r["ativa"] and r["aprovada"]
|
| 69 |
+
assert r["proxima"] == "2026-10-06T08:00:00", r["proxima"]
|
| 70 |
+
assert stat.S_IMODE(os.stat(caminho).st_mode) == 0o600
|
| 71 |
+
assert ag.criar("", "x", d)[1] == "nome" and ag.criar("a", "", d)[1] == "pedido"
|
| 72 |
+
assert ag.criar("a", "x" * 501, d)[1] == "pedido" and ag.criar("a", "x", {"tipo": "diaria"})[1] == "hora"
|
| 73 |
+
for n in range(R.LIMITE_ROTINAS - 1):
|
| 74 |
+
assert ag.criar(f"r{n}", "pedido", i)[1] is None
|
| 75 |
+
assert ag.criar("a mais", "x", d)[1] == "limite"
|
| 76 |
+
for x in ag.listar()[1:]:
|
| 77 |
+
ag.apagar(x["id"])
|
| 78 |
+
assert [x["nome"] for x in ag.listar()] == ["Jornal"]
|
| 79 |
+
assert ag.achar_por_nome("jornal")["id"] == r["id"] and ag.achar_por_nome("zzz") is None
|
| 80 |
+
|
| 81 |
+
# --- autorização -------------------------------------------------------------------------
|
| 82 |
+
assert R.autorizado(r, "rm -rf ~/lixo") and not R.autorizado(r, "git push") and not R.autorizado(r, "ls")
|
| 83 |
+
nao_aprovada = dict(r, aprovada=False)
|
| 84 |
+
assert not R.autorizado(nao_aprovada, "rm -rf ~/lixo")
|
| 85 |
+
|
| 86 |
+
# --- devidas: ainda cedo, na hora, pulada --------------------------------------------------
|
| 87 |
+
assert ag.devidas() == ([], [])
|
| 88 |
+
relogio["agora"] = D(2026, 10, 6, 8, 2)
|
| 89 |
+
rodar, perdidas = ag.devidas()
|
| 90 |
+
assert [x["nome"] for x in rodar] == ["Jornal"] and perdidas == []
|
| 91 |
+
ag.registrar(r["id"], "ok", "feito", agora=relogio["agora"])
|
| 92 |
+
x = ag.obter(r["id"])
|
| 93 |
+
assert x["ultima"]["resultado"] == "ok" and x["proxima"] == "2026-10-07T08:00:00"
|
| 94 |
+
assert ag.devidas() == ([], []), "não roda duas vezes"
|
| 95 |
+
|
| 96 |
+
relogio["agora"] = D(2026, 10, 7, 9, 30) # o PC estava desligado às 8h
|
| 97 |
+
rodar, perdidas = ag.devidas()
|
| 98 |
+
assert rodar == [] and [p["nome"] for p in perdidas] == ["Jornal"]
|
| 99 |
+
x = ag.obter(r["id"])
|
| 100 |
+
assert x["ultima"]["resultado"] == "perdida" and x["proxima"] == "2026-10-08T08:00:00"
|
| 101 |
+
assert ag.devidas() == ([], []), "a perdida só é avisada uma vez"
|
| 102 |
+
|
| 103 |
+
# --- uma vez: some depois de rodar ---------------------------------------------------------
|
| 104 |
+
relogio["agora"] = D(2026, 10, 8, 7, 0)
|
| 105 |
+
u1, _ = ag.criar("Uma vez", "faz isso", {"tipo": "uma_vez", "data": "2026-10-08T07:30:00"})
|
| 106 |
+
relogio["agora"] = D(2026, 10, 8, 7, 31)
|
| 107 |
+
rodar, _ = ag.devidas()
|
| 108 |
+
assert [x["nome"] for x in rodar] == ["Uma vez"]
|
| 109 |
+
ag.registrar(u1["id"], "ok", agora=relogio["agora"])
|
| 110 |
+
x = ag.obter(u1["id"])
|
| 111 |
+
assert x["proxima"] is None and x["ativa"] is False
|
| 112 |
+
|
| 113 |
+
# --- criada pela IA: desligada e sem aprovação; só ligar depois de aprovar ----------------------
|
| 114 |
+
ia, _ = ag.criar("Da IA", "apaga o cache", d, permitidas=["apagar"], ativa=False, aprovada=False, origem="ia")
|
| 115 |
+
assert ia["ativa"] is False and ia["aprovada"] is False and ia["origem"] == "ia"
|
| 116 |
+
x, _ = ag.atualizar(ia["id"], ativa=True)
|
| 117 |
+
assert x["ativa"] is False, "ligar sem aprovar não liga"
|
| 118 |
+
x, _ = ag.atualizar(ia["id"], aprovada=True, ativa=True)
|
| 119 |
+
assert x["ativa"] and x["aprovada"]
|
| 120 |
+
x, _ = ag.atualizar(ia["id"], pedido="apaga tudo") # mudou o pedido: precisa aprovar de novo
|
| 121 |
+
assert x["aprovada"] is False and x["ativa"] is False
|
| 122 |
+
assert ag.atualizar("nada", ativa=True)[1] == "inexistente"
|
| 123 |
+
assert ag.atualizar(ia["id"], quando={"tipo": "x"})[1] == "tipo"
|
| 124 |
+
|
| 125 |
+
# --- apagar --------------------------------------------------------------------------------------------
|
| 126 |
+
assert ag.apagar(ia["id"]) and not ag.apagar(ia["id"])
|
| 127 |
+
n = len(ag.listar())
|
| 128 |
+
assert ag.apagar_tudo() == n and ag.listar() == []
|
| 129 |
+
|
| 130 |
+
# --- arquivo estragado não derruba ------------------------------------------------------------------------
|
| 131 |
+
open(caminho, "w").write("{ isso não é json")
|
| 132 |
+
assert ag.listar() == []
|
| 133 |
+
assert ag.criar("de novo", "x", d)[1] is None
|
| 134 |
+
|
| 135 |
+
# --- textos ------------------------------------------------------------------------------------------------
|
| 136 |
+
import json
|
| 137 |
+
pt = json.load(open(os.path.join(RAIZ, "idiomas", "pt_BR.json"), encoding="utf-8"))
|
| 138 |
+
en = json.load(open(os.path.join(RAIZ, "idiomas", "en.json"), encoding="utf-8"))
|
| 139 |
+
assert {k for k in pt if k.startswith("rot.")} == {k for k in en if k.startswith("rot.")}
|
| 140 |
+
print("test_rotinas: OK")
|
app/tests/test_rotinas_nucleo.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.0: as ferramentas de agenda no núcleo (só quando o pedido fala de agendar, nunca pro Gargantua), a pré-aprovação
|
| 2 |
+
no comando arriscado e o hook de confirmação."""
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
|
| 7 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 8 |
+
os.environ["HOME"] = CASA
|
| 9 |
+
os.environ["TARS_ARQUIVO_AGENDA"] = os.path.join(CASA, "agenda.json")
|
| 10 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 11 |
+
|
| 12 |
+
from _util import carregar_tars # noqa: E402
|
| 13 |
+
|
| 14 |
+
tars = carregar_tars()
|
| 15 |
+
nomes = lambda lista: {f["function"]["name"] for f in lista} # noqa: E731
|
| 16 |
+
|
| 17 |
+
# --- só aparecem quando o pedido fala de agendar ---------------------------------------------
|
| 18 |
+
assert "schedule_task" not in nomes(tars.ferramentas_do_pedido("acao", "abre o navegador"))
|
| 19 |
+
assert {"schedule_task", "list_tasks", "delete_task"} <= nomes(tars.ferramentas_do_pedido("acao", "agenda isso todo dia às 8h"))
|
| 20 |
+
assert "schedule_task" not in nomes(tars.TOOLS), "fora de TOOLS: o Gargantua não as vê"
|
| 21 |
+
assert "schedule_task" not in nomes(tars.ferramentas_gargantua())
|
| 22 |
+
assert "schedule_task" not in nomes(tars.ferramentas_do_modelo("ProjectOpenTARS/Gargantua:latest", "acao", "agenda todo dia"))
|
| 23 |
+
print("OK: ferramentas da agenda só quando o pedido fala de agendar, e nunca pro Gargantua")
|
| 24 |
+
|
| 25 |
+
# --- criar pela IA: nasce pendente ---------------------------------------------------------------
|
| 26 |
+
r = tars.executar_ferramenta("schedule_task", {"name": "Jornal", "request": "abre o site do jornal", "when_type": "daily", "time": "8h"})
|
| 27 |
+
assert r["sucesso"] and "PENDING" in r["mensagem"], r
|
| 28 |
+
rot = tars.agenda.listar()[0]
|
| 29 |
+
assert rot["nome"] == "Jornal" and rot["ativa"] is False and rot["aprovada"] is False and rot["origem"] == "ia"
|
| 30 |
+
assert rot["quando"] == {"tipo": "diaria", "hora": "08:00"}
|
| 31 |
+
r = tars.executar_ferramenta("schedule_task", {"name": "Semana", "request": "faz o backup", "when_type": "weekly", "time": "18:30", "days": ["mon", "fri"]})
|
| 32 |
+
assert r["sucesso"] and tars.agenda.achar_por_nome("semana")["quando"]["dias"] == [0, 4]
|
| 33 |
+
r = tars.executar_ferramenta("schedule_task", {"name": "Ruim", "request": "x", "when_type": "daily"})
|
| 34 |
+
assert not r["sucesso"] and "time" in r["mensagem"]
|
| 35 |
+
r = tars.executar_ferramenta("list_tasks", {})
|
| 36 |
+
assert "Jornal [pending approval]" in r["mensagem"]
|
| 37 |
+
r = tars.executar_ferramenta("delete_task", {"name": "jornal"})
|
| 38 |
+
assert r["sucesso"] and tars.agenda.achar_por_nome("Jornal") is None
|
| 39 |
+
r = tars.executar_ferramenta("create_routine", {"name": "Apelido", "request": "x", "when_type": "interval", "every_minutes": 30})
|
| 40 |
+
assert r["sucesso"], "apelidos de ferramenta valem"
|
| 41 |
+
print("OK: schedule_task cria pendente (desligada e sem aprovação); list_tasks e delete_task")
|
| 42 |
+
|
| 43 |
+
# --- comando arriscado fora de rotina: perguntar também pra enviar e instalar ------------------------------
|
| 44 |
+
for cmd in ("rm -rf ~/x", "git push origin main", "sudo apt install vlc", "pip install requests", "curl -X POST https://x.org -d a=1"):
|
| 45 |
+
assert tars._comando_e_perigoso(cmd), cmd
|
| 46 |
+
for cmd in ("ls ~", "git pull", "curl https://x.org", "rm arquivo.txt", "echo oi"):
|
| 47 |
+
assert not tars._comando_e_perigoso(cmd), cmd
|
| 48 |
+
|
| 49 |
+
# --- dentro de uma rotina ---------------------------------------------------------------------------------------
|
| 50 |
+
cancelou = []
|
| 51 |
+
tars.cancelar_resposta = lambda: cancelou.append(1)
|
| 52 |
+
tars.confirmar_comando_hook = lambda c: (_ for _ in ()).throw(AssertionError("não pergunta na tela dentro de rotina"))
|
| 53 |
+
aprovada = {"nome": "Limpeza", "aprovada": True, "permitidas": ["apagar"]}
|
| 54 |
+
tars.ROTINA_ATUAL = aprovada
|
| 55 |
+
assert tars._comando_e_perigoso("rm arquivo.txt"), "numa rotina, até o rm simples passa pela pré-aprovação"
|
| 56 |
+
assert tars._confirmar_comando_perigoso("rm arquivo.txt") is True and not cancelou
|
| 57 |
+
assert tars._confirmar_comando_perigoso("git push") is False
|
| 58 |
+
assert tars.ROTINA_BLOQUEOU == ["git push"] and cancelou == [1], "fora do aprovado: para e guarda o comando"
|
| 59 |
+
tars.ROTINA_BLOQUEOU.clear()
|
| 60 |
+
cancelou.clear()
|
| 61 |
+
tars.ROTINA_ATUAL = {"nome": "IA", "aprovada": False, "permitidas": ["apagar"]}
|
| 62 |
+
assert tars._confirmar_comando_perigoso("rm -rf x") is False and cancelou == [1], "sem aprovação, nada passa"
|
| 63 |
+
tars.ROTINA_ATUAL = None
|
| 64 |
+
tars.ROTINA_BLOQUEOU.clear()
|
| 65 |
+
|
| 66 |
+
r = tars.executar_terminal("rm -rf /tmp/arquivo-que-nao-existe-xyz") # fora de rotina volta a perguntar (hook levanta erro = nega)
|
| 67 |
+
assert not r["sucesso"], r
|
| 68 |
+
|
| 69 |
+
# --- textos ---------------------------------------------------------------------------------------------------------
|
| 70 |
+
print("test_rotinas_nucleo: OK")
|
app/tests/test_screenshot_resize.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys, types
|
| 2 |
+
|
| 3 |
+
sys.modules['psutil'] = types.ModuleType('psutil')
|
| 4 |
+
sys.modules['psutil'].process_iter = lambda *a, **k: []
|
| 5 |
+
|
| 6 |
+
# pyautogui de verdade precisa de um display (Xorg/Wayland), que não
|
| 7 |
+
# existe neste ambiente de teste — stub só o screenshot(), preservando
|
| 8 |
+
# o resto do módulo como stub também (move/clique/tecla não são
|
| 9 |
+
# exercitados por este teste).
|
| 10 |
+
pyautogui_stub = types.ModuleType('pyautogui')
|
| 11 |
+
pyautogui_stub.FAILSAFE = True
|
| 12 |
+
pyautogui_stub.PAUSE = 0.1
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _fake_screenshot():
|
| 16 |
+
from PIL import Image
|
| 17 |
+
return Image.new("RGB", (3840, 2160), color=(10, 20, 30))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
pyautogui_stub.screenshot = _fake_screenshot
|
| 21 |
+
sys.modules['pyautogui'] = pyautogui_stub
|
| 22 |
+
|
| 23 |
+
from _util import carregar_tars
|
| 24 |
+
tars = carregar_tars()
|
| 25 |
+
|
| 26 |
+
assert tars.pyautogui.PAUSE == tars.PYAUTOGUI_PAUSE_SEG, tars.pyautogui.PAUSE
|
| 27 |
+
print("OK: pyautogui.PAUSE reduzido:", tars.pyautogui.PAUSE)
|
| 28 |
+
|
| 29 |
+
# A captura real depende do display; aqui simula uma tela 4K.
|
| 30 |
+
tars._capturar_tela = _fake_screenshot
|
| 31 |
+
|
| 32 |
+
r = tars.tirar_print()
|
| 33 |
+
assert r["sucesso"]
|
| 34 |
+
print("mensagem:", r["mensagem"])
|
| 35 |
+
assert "3840x2160" in r["mensagem"]
|
| 36 |
+
assert f"{tars.LARGURA_MAXIMA_SCREENSHOT}x" in r["mensagem"]
|
| 37 |
+
|
| 38 |
+
import base64
|
| 39 |
+
import io
|
| 40 |
+
from PIL import Image
|
| 41 |
+
|
| 42 |
+
img = Image.open(io.BytesIO(base64.b64decode(r["_imagem_b64"])))
|
| 43 |
+
assert img.size[0] == tars.LARGURA_MAXIMA_SCREENSHOT, img.size
|
| 44 |
+
print("OK: imagem redimensionada de fato para", img.size)
|
| 45 |
+
|
| 46 |
+
print("\nTUDO OK.")
|
app/tests/test_search_web.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regressão do bug real reportado: 'pesquise rtx 5060 no google' foi
|
| 2 |
+
pro qwen3:0.6b, que repassou só site='rtx 5060' pro open_website (sem
|
| 3 |
+
o 'no google' nem o verbo), quebrando a busca.
|
| 4 |
+
|
| 5 |
+
A correção NÃO foi interceptar a busca no texto cru antes da IA entrar
|
| 6 |
+
em ação (isso seria só trocar um hardcode por outro, e a IA continuaria
|
| 7 |
+
sem aprender a raciocinar direito) — foi dar pra IA uma ferramenta
|
| 8 |
+
('search_web') onde o próprio formato já separa 'o que pesquisar'
|
| 9 |
+
(query) de 'onde pesquisar' (service), então não tem como ela cortar a
|
| 10 |
+
frase pela metade feito fazia com o campo genérico 'site' de
|
| 11 |
+
open_website. Este teste garante que a ferramenta está de fato
|
| 12 |
+
registrada e funcionando (antes dela, 'search_web' existia só na lista
|
| 13 |
+
de ferramentas oferecida à IA, mas não tinha handler nenhum — ou seja,
|
| 14 |
+
se qualquer modelo tentasse chamá-la, ia falhar com 'Ferramenta
|
| 15 |
+
desconhecida')."""
|
| 16 |
+
|
| 17 |
+
from _util import carregar_tars
|
| 18 |
+
tars = carregar_tars()
|
| 19 |
+
|
| 20 |
+
tars.webbrowser.open = lambda u: True
|
| 21 |
+
|
| 22 |
+
# a ferramenta precisa estar de fato ligada ao dispatch (antes não
|
| 23 |
+
# estava — só existia na lista TOOLS, sem handler nenhum)
|
| 24 |
+
assert "search_web" in tars._DISPATCH_FERRAMENTAS
|
| 25 |
+
print("OK: search_web está registrada no dispatch de ferramentas")
|
| 26 |
+
|
| 27 |
+
# caso real reportado: query e service member, sem depender de cortar
|
| 28 |
+
# frase nenhuma
|
| 29 |
+
r = tars.executar_ferramenta("search_web", {"query": "rtx 5060", "service": "google"})
|
| 30 |
+
assert r["sucesso"] and r["url"] == "https://www.google.com/search?q=rtx+5060", r
|
| 31 |
+
print("OK: busca com serviço explícito:", r["url"])
|
| 32 |
+
|
| 33 |
+
# sem 'service' -> padrão Google
|
| 34 |
+
r = tars.executar_ferramenta("search_web", {"query": "buracos negros"})
|
| 35 |
+
assert r["url"] == "https://www.google.com/search?q=buracos+negros", r
|
| 36 |
+
print("OK: busca sem serviço cai no Google por padrão:", r["url"])
|
| 37 |
+
|
| 38 |
+
# serviço youtube
|
| 39 |
+
r = tars.executar_ferramenta("search_web", {"query": "canal da anthropic", "service": "youtube"})
|
| 40 |
+
assert r["url"] == "https://www.youtube.com/results?search_query=canal+da+anthropic", r
|
| 41 |
+
print("OK: busca no youtube:", r["url"])
|
| 42 |
+
|
| 43 |
+
# query vazia é erro claro, não um chute
|
| 44 |
+
r = tars.executar_ferramenta("search_web", {"query": " "})
|
| 45 |
+
assert not r["sucesso"]
|
| 46 |
+
print("OK: query vazia retorna erro em vez de adivinhar algo")
|
| 47 |
+
|
| 48 |
+
# open_website continua funcionando como rede de segurança pra quando
|
| 49 |
+
# um modelo ainda assim usar ele pra uma frase de busca
|
| 50 |
+
tars._SESSION.post = lambda *a, **k: (_ for _ in ()).throw(
|
| 51 |
+
AssertionError("nao deveria precisar de nenhum modelo pra uma busca reconhecida")
|
| 52 |
+
)
|
| 53 |
+
r = tars.abrir_site("pesquisar sobre gatos siameses")
|
| 54 |
+
assert r["url"] == "https://www.google.com/search?q=gatos+siameses", r
|
| 55 |
+
print("OK: open_website ainda reconhece frase de busca como rede de segurança:", r["url"])
|
| 56 |
+
|
| 57 |
+
print("\nTUDO OK.")
|
app/tests/test_sem_falso_positivo_modelo.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from _util import carregar_tars
|
| 2 |
+
tars = carregar_tars()
|
| 3 |
+
|
| 4 |
+
catalogo_fake = {
|
| 5 |
+
"qwen3:0.6b": {"tag": "qwen3:0.6b", "familia": "qwen3", "categoria": "simples", "parametros_b": 0.6, "capacidades": {"tools"}, "capacidades_conhecidas": True},
|
| 6 |
+
"qwen3:8b": {"tag": "qwen3:8b", "familia": "qwen3", "categoria": "geral", "parametros_b": 8, "capacidades": {"tools"}, "capacidades_conhecidas": True},
|
| 7 |
+
"gemma3:12b": {"tag": "gemma3:12b", "familia": "gemma3", "categoria": "geral", "parametros_b": 12, "capacidades": set(), "capacidades_conhecidas": True},
|
| 8 |
+
"gemma3:270m": {"tag": "gemma3:270m", "familia": "gemma3", "categoria": "simples", "parametros_b": 0.27, "capacidades": set(), "capacidades_conhecidas": True},
|
| 9 |
+
}
|
| 10 |
+
tars.catalogar_modelos = lambda forcar=False: catalogo_fake
|
| 11 |
+
tars.listar_modelos_instalados = lambda forcar=False: set(catalogo_fake.keys())
|
| 12 |
+
tars._carregar_em_segundo_plano = lambda modelo: None
|
| 13 |
+
|
| 14 |
+
def ia_nao_deveria_ser_chamada(*a, **k):
|
| 15 |
+
raise AssertionError("nao deveria ter chamado a IA classificadora pra essa frase (sem sinal de troca de modelo)")
|
| 16 |
+
tars._SESSION.post = ia_nao_deveria_ser_chamada
|
| 17 |
+
|
| 18 |
+
# O bug relatado: frase de tarefa comum contendo "use", nao deveria trocar de modelo
|
| 19 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 20 |
+
resultado = tars.interpretar_comando_modelo("use suas ferramentas para abrir o youtube no canal da anthropic")
|
| 21 |
+
print("resultado:", resultado, "| modo:", tars.modo_modelo, "| modelo_manual:", tars.modelo_manual)
|
| 22 |
+
assert resultado is False, "nao deveria ter sido reconhecido como comando de troca de modelo"
|
| 23 |
+
assert tars.modo_modelo == "AUTO"
|
| 24 |
+
assert tars.modelo_manual is None
|
| 25 |
+
print("OK: 'use suas ferramentas...' nao dispara mais troca de modelo\n")
|
| 26 |
+
|
| 27 |
+
# Outras frases comuns com verbos ambiguos que tambem nao deveriam disparar
|
| 28 |
+
casos_normais = [
|
| 29 |
+
"quero abrir o navegador e ver meus emails",
|
| 30 |
+
"coloca a musica mais alta por favor",
|
| 31 |
+
"manda ver se tem espaco em disco",
|
| 32 |
+
"use a calculadora pra somar esses valores",
|
| 33 |
+
]
|
| 34 |
+
for frase in casos_normais:
|
| 35 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 36 |
+
resultado = tars.interpretar_comando_modelo(frase)
|
| 37 |
+
status = "OK" if resultado is False else "FALHOU"
|
| 38 |
+
print(f"{status:6} {frase!r}")
|
| 39 |
+
assert resultado is False, f"falso positivo em: {frase}"
|
| 40 |
+
|
| 41 |
+
print("\nOK: nenhum falso positivo nas frases comuns testadas.")
|
app/tests/test_setup_gargantua.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""O instalador não segura a instalação com o download de 2,5 GB quando não há terminal para mostrá-lo.
|
| 2 |
+
|
| 3 |
+
Pela loja de aplicativos (PackageKit) o script roda sem terminal: um `ollama pull` na frente deixava a barra da loja
|
| 4 |
+
parada em 0% por minutos enquanto o Ollama já estava em 50%. Sem terminal, o download vai pra segundo plano (o assistente
|
| 5 |
+
de primeiro uso acompanha o mesmo download). Com terminal, continua na frente, com a barra do Ollama."""
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import shutil
|
| 9 |
+
import subprocess
|
| 10 |
+
import tempfile
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
RAIZ = Path(__file__).resolve().parent.parent
|
| 15 |
+
setup = (RAIZ / "empacotamento" / "setup.sh").read_text(encoding="utf-8")
|
| 16 |
+
inicio = setup.index('MODELO_GARGANTUA="${TARS_MODELO_GARGANTUA:-ProjectOpenTARS/Gargantua-2}"')
|
| 17 |
+
fim = setup.index("# 4. Modelo de conversa")
|
| 18 |
+
bloco = setup[inicio:fim]
|
| 19 |
+
assert "gargantua_segundo_plano" in bloco and "[ -t 1 ]" in bloco
|
| 20 |
+
|
| 21 |
+
pasta = Path(tempfile.mkdtemp())
|
| 22 |
+
(pasta / "bin").mkdir()
|
| 23 |
+
registro = pasta / "chamadas.log"
|
| 24 |
+
fim_do_pull = pasta / "pull-terminou"
|
| 25 |
+
falso = pasta / "bin" / "ollama"
|
| 26 |
+
falso.write_text(f'#!/bin/bash\necho "ollama $*" >> "{registro}"\nsleep 2\ntouch "{fim_do_pull}"\n')
|
| 27 |
+
falso.chmod(0o755)
|
| 28 |
+
(pasta / "harness.sh").write_text(
|
| 29 |
+
'servico_pronto=1\n'
|
| 30 |
+
'msg() { echo "MSG: $*"; }\n'
|
| 31 |
+
'aviso() { echo "AVISO: $*"; }\n'
|
| 32 |
+
't() { echo "T:$1"; }\n'
|
| 33 |
+
'modelos_instalados() { echo ""; }\n' + bloco + '\necho "FIM-DO-BLOCO"\n')
|
| 34 |
+
env = dict(os.environ, PATH=f"{pasta / 'bin'}:{os.environ['PATH']}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def rodar(com_terminal):
|
| 38 |
+
fim_do_pull.unlink(missing_ok=True)
|
| 39 |
+
registro.unlink(missing_ok=True)
|
| 40 |
+
t0 = time.time()
|
| 41 |
+
if com_terminal:
|
| 42 |
+
r = subprocess.run(["script", "-qec", f"bash {pasta / 'harness.sh'}", "/dev/null"], env=env, capture_output=True,
|
| 43 |
+
text=True, timeout=60)
|
| 44 |
+
else:
|
| 45 |
+
r = subprocess.run(["bash", str(pasta / "harness.sh")], env=env, capture_output=True, text=True, timeout=60)
|
| 46 |
+
return r.stdout, time.time() - t0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# 1) SEM terminal: o bloco termina logo, o pull roda solto e nada de "não baixou" (ele ainda está baixando)
|
| 50 |
+
saida, segundos = rodar(com_terminal=False)
|
| 51 |
+
assert "FIM-DO-BLOCO" in saida and "T:gargantua_segundo_plano" in saida, saida
|
| 52 |
+
assert "T:nao_baixou" not in saida and "T:pode_demorar" not in saida, saida
|
| 53 |
+
assert segundos < 1.5, f"o instalador esperou o download ({segundos:.1f}s): a loja ficaria parada"
|
| 54 |
+
limite = time.time() + 10
|
| 55 |
+
while not fim_do_pull.exists() and time.time() < limite:
|
| 56 |
+
time.sleep(0.1)
|
| 57 |
+
assert fim_do_pull.exists(), "o download em segundo plano não rodou até o fim"
|
| 58 |
+
assert "ollama pull ProjectOpenTARS/Gargantua" in registro.read_text()
|
| 59 |
+
print(f"OK: sem terminal o instalador termina em {segundos:.1f}s e o download segue solto, em segundo plano")
|
| 60 |
+
|
| 61 |
+
# 2) COM terminal: baixa na frente (a barra do Ollama aparece), como sempre foi
|
| 62 |
+
if shutil.which("script"):
|
| 63 |
+
saida, segundos = rodar(com_terminal=True)
|
| 64 |
+
assert "T:pode_demorar" in saida and "T:gargantua_segundo_plano" not in saida, saida
|
| 65 |
+
assert segundos >= 2 and fim_do_pull.exists(), f"deveria esperar o download na frente ({segundos:.1f}s)"
|
| 66 |
+
assert "ollama pull ProjectOpenTARS/Gargantua" in registro.read_text()
|
| 67 |
+
print(f"OK: com terminal o download continua na frente ({segundos:.1f}s), com a barra do Ollama")
|
| 68 |
+
else:
|
| 69 |
+
print("PULADO (com terminal): comando 'script' não encontrado")
|
| 70 |
+
|
| 71 |
+
# 3) já instalado: não baixa nada (antes e depois da mudança)
|
| 72 |
+
(pasta / "harness.sh").write_text((pasta / "harness.sh").read_text().replace('modelos_instalados() { echo ""; }',
|
| 73 |
+
'modelos_instalados() { echo "ProjectOpenTARS/Gargantua:latest"; }'))
|
| 74 |
+
fim_do_pull.unlink(missing_ok=True)
|
| 75 |
+
registro.unlink(missing_ok=True)
|
| 76 |
+
saida = subprocess.run(["bash", str(pasta / "harness.sh")], env=env, capture_output=True, text=True, timeout=30).stdout
|
| 77 |
+
assert "T:gargantua_ja" in saida and not registro.exists(), saida
|
| 78 |
+
print("OK: com o Gargantua já instalado não baixa nada")
|
| 79 |
+
|
| 80 |
+
print("\nTUDO OK.")
|
app/tests/test_shell_portatil.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.2 (F5): o mini-shell em Python (tars_shell) contra o bash de verdade, com as formas de comando dos dados de treino.
|
| 2 |
+
Cada comando roda nos dois lados a partir da mesma casa; saída, erro, código e os arquivos que sobram têm de bater."""
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 11 |
+
import tars_shell as sh # noqa: E402
|
| 12 |
+
|
| 13 |
+
# a referência é o bash com o coreutils do GNU: no macOS o ls e o wc são outros, no Windows o bash é o do Git
|
| 14 |
+
COMPARAR = sys.platform.startswith("linux") and bool(shutil.which("bash"))
|
| 15 |
+
|
| 16 |
+
COMANDOS = [
|
| 17 |
+
"mkdir -p ~/Documents/Trabalho",
|
| 18 |
+
"echo 'study for the exam' > ~/Downloads/notes.txt",
|
| 19 |
+
"ls -1 ~/Downloads",
|
| 20 |
+
"ls -1 ~/Downloads | wc -l",
|
| 21 |
+
"mv ~/Downloads/notes.txt ~/Downloads/ideas.txt",
|
| 22 |
+
'mkdir -p "${HOME}/Documents/Jogos" && echo "llamar al dentista" > "${HOME}/Documents/Jogos/tareas.txt"',
|
| 23 |
+
"mkdir -p ~/Área\\ de\\ Trabalho && printf \"reunião às 15h\" > ~/Área\\ de\\ Trabalho/lembrete.txt",
|
| 24 |
+
"ls -1 '{casa}/Área de Trabalho'",
|
| 25 |
+
"rm -rf ~/Documents/Trabalho",
|
| 26 |
+
"echo 'pay the power bill' >> ~/Desktop/list.txt && echo 'call the dentist' >> ~/Desktop/list.txt",
|
| 27 |
+
"wc -l ~/Desktop/list.txt",
|
| 28 |
+
"cat ~/Desktop/list.txt",
|
| 29 |
+
"touch ~/Documents/a.txt && ls -1 ~/Documents",
|
| 30 |
+
*(['mkdir -p "$(xdg-user-dir DOCUMENTS)/Proyectos" && echo "x" > "$(xdg-user-dir DOCUMENTS)/Proyectos/a.txt"']
|
| 31 |
+
if shutil.which("xdg-user-dir") else []), # sem o programa (o runner do GitHub) o bash não serve de referência
|
| 32 |
+
"ls -1 /caminho/que/nao/existe",
|
| 33 |
+
"mkdir ~/Desktop",
|
| 34 |
+
"mkdir ~/Desktop || echo falhou",
|
| 35 |
+
"echo \"say \\\"hi\\\"\" > ~/q.txt && cat ~/q.txt",
|
| 36 |
+
"ls -1 ~/Documents/*.txt",
|
| 37 |
+
"ls -1 ~/Documents | grep -i a",
|
| 38 |
+
"find ~/Documents -name '*.txt' | sort",
|
| 39 |
+
"cat ~/nada.txt 2>/dev/null; echo fim",
|
| 40 |
+
"rm ~/Documents/a.txt && ls -1 ~/Documents | wc -l",
|
| 41 |
+
"cd ~/Downloads && ls -1",
|
| 42 |
+
"wc -l ~/Desktop/list.txt ~/q.txt",
|
| 43 |
+
"head -n 1 ~/Desktop/list.txt",
|
| 44 |
+
"mv ~/Downloads/ideas.txt ~/Documents/ && ls -1 ~/Documents",
|
| 45 |
+
"rm -rf ~/Documents/Jogos ~/Documents/Proyectos ~/Documentos/Proyectos",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def arvore(casa):
|
| 50 |
+
return sorted((str(p.relative_to(casa)), p.read_text(errors="replace") if p.is_file() else None)
|
| 51 |
+
for p in casa.rglob("*") if ".config" not in p.parts)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def nova_casa():
|
| 55 |
+
casa = Path(tempfile.mkdtemp(prefix="opentars-shell-"))
|
| 56 |
+
for pasta in ("Documents", "Downloads", "Desktop", "Documentos"):
|
| 57 |
+
(casa / pasta).mkdir()
|
| 58 |
+
(casa / ".config").mkdir()
|
| 59 |
+
(casa / ".config" / "user-dirs.dirs").write_text('XDG_DOCUMENTS_DIR="$HOME/Documentos"\n')
|
| 60 |
+
return casa
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
bash_casa, py_casa = nova_casa(), nova_casa()
|
| 64 |
+
diferencas = []
|
| 65 |
+
for modelo in (COMANDOS if COMPARAR else []):
|
| 66 |
+
resultados = []
|
| 67 |
+
for casa, lado in ((bash_casa, "bash"), (py_casa, "py")):
|
| 68 |
+
comando = modelo.replace("{casa}", str(casa))
|
| 69 |
+
os.environ.update(HOME=str(casa), USERPROFILE=str(casa), XDG_CONFIG_HOME=str(casa / ".config"))
|
| 70 |
+
if lado == "bash":
|
| 71 |
+
r = subprocess.run(["bash", "-c", comando], cwd=casa, capture_output=True, text=True,
|
| 72 |
+
env=dict(os.environ, LC_ALL="C.UTF-8"))
|
| 73 |
+
resultados.append((r.returncode, r.stdout.replace(str(casa), "CASA"), r.stderr.replace(str(casa), "CASA")))
|
| 74 |
+
else:
|
| 75 |
+
assert sh.suportado(comando), comando
|
| 76 |
+
r = sh.executar(comando, cwd=str(casa))
|
| 77 |
+
resultados.append((r["codigo"], r["stdout"].replace(str(casa), "CASA"), r["stderr"].replace(str(casa), "CASA")))
|
| 78 |
+
(cb, ob, eb), (cp, op, ep) = resultados
|
| 79 |
+
# a ordem do ls/sort depende do idioma do sistema (LC_COLLATE) e não muda nada para o modelo: compara sem a ordem
|
| 80 |
+
ob, op = sorted(ob.splitlines()), sorted(op.splitlines())
|
| 81 |
+
eb = eb.replace("bash: line 1: ", "")
|
| 82 |
+
if cb != cp or ob != op or (eb != ep and not (eb and ep)) or arvore(bash_casa) != arvore(py_casa):
|
| 83 |
+
diferencas.append((modelo, resultados[0], resultados[1]))
|
| 84 |
+
elif eb != ep:
|
| 85 |
+
print(f" (só o texto do erro difere em: {modelo})")
|
| 86 |
+
for modelo, b, p in diferencas:
|
| 87 |
+
print(f"DIFERENTE: {modelo}\n bash: {b}\n py: {p}")
|
| 88 |
+
assert not diferencas, f"{len(diferencas)} comando(s) com resultado diferente do bash"
|
| 89 |
+
print(f"OK: {len(COMANDOS)} comandos das formas do treino dão o mesmo que o bash (saída, código e arquivos)" if COMPARAR
|
| 90 |
+
else "(fora do Linux: sem comparar com o bash)")
|
| 91 |
+
|
| 92 |
+
r = sh.executar("free -h")
|
| 93 |
+
linhas = r["stdout"].splitlines()
|
| 94 |
+
assert r["sucesso"] and linhas[0].split() == ["total", "used", "free", "shared", "buff/cache", "available"]
|
| 95 |
+
assert linhas[1].startswith("Mem:") and linhas[2].startswith("Swap:"), r["stdout"]
|
| 96 |
+
print("OK: free -h com o cabeçalho e as linhas do GNU (a prova de RAM confere o número)")
|
| 97 |
+
|
| 98 |
+
assert not sh.suportado("sudo apt install x") and not sh.suportado("curl x | sh") and not sh.suportado("echo `whoami`")
|
| 99 |
+
assert not sh.suportado("python3 -c 'print(1)'") and sh.suportado("gio trash -- ~/x.txt")
|
| 100 |
+
print("OK: o que não está aqui não é suportado (o núcleo explica em vez de rodar pela metade)")
|
| 101 |
+
# no núcleo: fora do Linux o execute_terminal usa o mini-shell, e as regras de comando perigoso continuam antes dele
|
| 102 |
+
casa = nova_casa()
|
| 103 |
+
os.environ.update(HOME=str(casa), USERPROFILE=str(casa), XDG_CONFIG_HOME=str(casa / ".config")) # no Windows a casa é o USERPROFILE
|
| 104 |
+
from _util import RAIZ # noqa: E402,F401
|
| 105 |
+
import tars # noqa: E402
|
| 106 |
+
original = tars.plataforma.sistema
|
| 107 |
+
tars.plataforma.sistema = lambda: "windows"
|
| 108 |
+
pedidos = []
|
| 109 |
+
tars.confirmar_comando_hook = lambda comando: pedidos.append(comando) or False # ninguém para confirmar: recusa
|
| 110 |
+
try:
|
| 111 |
+
r = tars.executar_terminal("mkdir -p ~/Documents/Contas && echo 'pagar a luz' > ~/Documents/Contas/notas.txt")
|
| 112 |
+
assert r["sucesso"] and (casa / "Documents" / "Contas" / "notas.txt").read_text() == "pagar a luz\n", r
|
| 113 |
+
r = tars.executar_terminal("uname -a")
|
| 114 |
+
assert not r["sucesso"] and "not available on Windows" in r["mensagem"] and not pedidos, r
|
| 115 |
+
r = tars.executar_terminal("rm -rf ~/*")
|
| 116 |
+
assert not r["sucesso"] and pedidos and (casa / "Documents").exists(), r
|
| 117 |
+
finally:
|
| 118 |
+
tars.plataforma.sistema = original
|
| 119 |
+
print("OK: no Windows o execute_terminal roda no mini-shell; o resto é recusado; rm -rf ~/* ainda pede confirmação")
|
| 120 |
+
print("test_shell_portatil: TUDO OK.")
|
app/tests/test_sob_demanda.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tudo sob demanda: nada é carregado ao abrir, e o que fica ocioso sai da memória cedo."""
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import tempfile
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
CASA = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 8 |
+
os.environ["HOME"] = CASA
|
| 9 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(CASA, "sessoes.json")
|
| 10 |
+
for v in ("TARS_KEEP_ALIVE", "TARS_KEEP_ALIVE_VISAO", "TARS_KEEP_ALIVE_AJUDANTE", "TARS_PRECARGA"):
|
| 11 |
+
os.environ.pop(v, None)
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 14 |
+
from _util import carregar_tars # noqa: E402
|
| 15 |
+
|
| 16 |
+
tars = carregar_tars()
|
| 17 |
+
|
| 18 |
+
# 1. Tempos padrão: conversa 5 min; visão e ajudante 2 min (eram 30 min pra todos)
|
| 19 |
+
assert tars.KEEP_ALIVE == "5m" and tars.KEEP_ALIVE_VISAO == "2m" and tars.KEEP_ALIVE_AJUDANTE == "2m"
|
| 20 |
+
print("OK: modelo da conversa 5 min, visão e ajudante 2 min na memória")
|
| 21 |
+
|
| 22 |
+
# 2. Abrir o openTARS não carrega nada (nem Murph em fio, nem Cooper)
|
| 23 |
+
import threading # noqa: E402
|
| 24 |
+
THREAD_REAL = threading.Thread
|
| 25 |
+
fios = []
|
| 26 |
+
tars.threading.Thread = lambda *a, **k: fios.append(k) or type("T", (), {"start": lambda s: fios.append("start")})()
|
| 27 |
+
tars.aquecer_ajudante()
|
| 28 |
+
assert fios == [], fios
|
| 29 |
+
print("OK: aquecer_ajudante não dispara nada sem TARS_PRECARGA")
|
| 30 |
+
tars.PRECARGA = True
|
| 31 |
+
tars.aquecer_visao = lambda: fios.append("cooper")
|
| 32 |
+
tars.aquecer_ajudante()
|
| 33 |
+
assert "cooper" in fios and "start" in fios
|
| 34 |
+
tars.PRECARGA = False
|
| 35 |
+
print("OK: TARS_PRECARGA=1 devolve a pré-carga")
|
| 36 |
+
|
| 37 |
+
# 3. O Cooper usa o tempo curto de visão
|
| 38 |
+
tars.threading.Thread = THREAD_REAL
|
| 39 |
+
pedidos = []
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Resp:
|
| 43 |
+
status_code = 200
|
| 44 |
+
|
| 45 |
+
def json(self):
|
| 46 |
+
return {"message": {"content": '{"x": 500, "y": 500}'}}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
tars._SESSION.post = lambda url, json=None, timeout=None, **k: pedidos.append(json) or Resp()
|
| 50 |
+
tars._visao_estado.update(pausado_ate=0.0, quente=False, erro="")
|
| 51 |
+
tars.perguntar_visao("ProjectOpenTARS/cooper:latest", "onde?", imagem_b64="AAAA")
|
| 52 |
+
assert pedidos[-1]["keep_alive"] == "2m", pedidos[-1]
|
| 53 |
+
print("OK: o Cooper sai da memória 2 min depois do último uso")
|
| 54 |
+
|
| 55 |
+
# 4. Digitar aquece o modelo da conversa (1x a cada 30 s) e não toca em servidor externo
|
| 56 |
+
pedidos.clear()
|
| 57 |
+
tars.modo_modelo = "MANUAL"
|
| 58 |
+
tars.modelo_manual = "qwen3:4b"
|
| 59 |
+
tars._ultimo_aquecimento["em"] = 0.0
|
| 60 |
+
tars.aquecer_modelo_da_conversa()
|
| 61 |
+
tars.aquecer_modelo_da_conversa()
|
| 62 |
+
time.sleep(0.3)
|
| 63 |
+
assert len(pedidos) == 1 and pedidos[0]["model"] == "qwen3:4b" and pedidos[0]["prompt"] == "" and pedidos[0]["keep_alive"] == "5m", pedidos
|
| 64 |
+
print("OK: ao digitar, o modelo da conversa é aquecido uma única vez (a cada 30 s)")
|
| 65 |
+
print("\nTUDO OK.")
|
app/tests/test_superlativos.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from _util import carregar_tars
|
| 2 |
+
tars = carregar_tars()
|
| 3 |
+
|
| 4 |
+
catalogo_fake = {
|
| 5 |
+
"qwen3:0.6b": {"tag": "qwen3:0.6b", "familia": "qwen3", "categoria": "simples", "parametros_b": 0.6, "capacidades": {"tools"}, "capacidades_conhecidas": True},
|
| 6 |
+
"qwen3:32b": {"tag": "qwen3:32b", "familia": "qwen3", "categoria": "tecnico", "parametros_b": 32, "capacidades": {"tools"}, "capacidades_conhecidas": True},
|
| 7 |
+
}
|
| 8 |
+
tars.catalogar_modelos = lambda forcar=False: catalogo_fake
|
| 9 |
+
tars.listar_modelos_instalados = lambda forcar=False: set(catalogo_fake.keys())
|
| 10 |
+
tars._carregar_em_segundo_plano = lambda modelo: None
|
| 11 |
+
tars._SESSION.post = lambda *a, **k: (_ for _ in ()).throw(AssertionError("nao deveria precisar da IA"))
|
| 12 |
+
|
| 13 |
+
# Via "/modelo" — aceita a frase toda, com verbos e "por favor" e
|
| 14 |
+
# qualquer coisa em volta (substring é seguro aqui, porque o "/modelo"
|
| 15 |
+
# já deixou explícito que o pedido é sobre trocar de IA).
|
| 16 |
+
casos_com_modelo = {
|
| 17 |
+
"/modelo usa o mais leve": "qwen3:0.6b",
|
| 18 |
+
"/modelo quero o modelo mais leve": "qwen3:0.6b",
|
| 19 |
+
"/modelo troca pro maior modelo": "qwen3:32b",
|
| 20 |
+
}
|
| 21 |
+
for frase, esperado in casos_com_modelo.items():
|
| 22 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 23 |
+
ok = tars.interpretar_comando_modelo(frase)
|
| 24 |
+
status = "OK" if (ok and tars.modelo_manual == esperado) else "FALHOU"
|
| 25 |
+
print(f"{status:6} {frase!r:35} -> {tars.modelo_manual} (esperado {esperado})")
|
| 26 |
+
assert ok and tars.modelo_manual == esperado
|
| 27 |
+
|
| 28 |
+
# Sem "/modelo", só a frase EXATA do superlativo (mensagem inteira,
|
| 29 |
+
# sem nada em volta) ainda troca — nunca um pedaço dela dentro de uma
|
| 30 |
+
# frase qualquer, que é justamente o que causava falso positivo antes.
|
| 31 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 32 |
+
ok = tars.interpretar_comando_modelo("mais leve")
|
| 33 |
+
assert ok and tars.modelo_manual == "qwen3:0.6b"
|
| 34 |
+
print("OK: mensagem inteira == 'mais leve' troca sem precisar de /modelo")
|
| 35 |
+
|
| 36 |
+
# "o menor problema pode causar um erro grande no sistema" contém "o
|
| 37 |
+
# menor" como substring, mas não é a frase inteira "o menor" — não
|
| 38 |
+
# deve trocar de modelo (essa era exatamente a armadilha do bug
|
| 39 |
+
# original "isso foi meio burro").
|
| 40 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 41 |
+
ok = tars.interpretar_comando_modelo("o menor problema pode causar um erro grande no sistema")
|
| 42 |
+
print("frase-armadilha 'o menor problema...':", ok, tars.modelo_manual)
|
| 43 |
+
assert ok is False
|
| 44 |
+
assert tars.modelo_manual is None
|
| 45 |
+
|
| 46 |
+
# "usa o mais leve" SEM /modelo também não deve mais trocar — trocar a
|
| 47 |
+
# partir de um verbo solto foi removido de propósito.
|
| 48 |
+
tars.modo_modelo = "AUTO"; tars.modelo_manual = None
|
| 49 |
+
ok = tars.interpretar_comando_modelo("usa o mais leve")
|
| 50 |
+
assert ok is False
|
| 51 |
+
assert tars.modelo_manual is None
|
| 52 |
+
print("OK: 'usa o mais leve' sem /modelo não troca mais (por design)")
|
| 53 |
+
|
| 54 |
+
print("\nOK: superlativos via /modelo continuam flexíveis, e nada solto no meio de uma frase dispara troca.")
|
app/tests/test_tarefa_com_print.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tarefa que depende de print de tela ("abra a calculadora e clique nos
|
| 2 |
+
números"), reproduzindo o que um modelo de visão real fez:
|
| 3 |
+
|
| 4 |
+
1. O print era anexado como "(print da tela anexado acima — analise a
|
| 5 |
+
imagem)" numa mensagem de usuário: o modelo achava que o usuário
|
| 6 |
+
tinha mandado uma imagem nova, esquecia a tarefa e só descrevia a tela.
|
| 7 |
+
2. Depois disso ele "pensava" e não respondia nada — e o openTARS
|
| 8 |
+
encerrava a tarefa pela metade.
|
| 9 |
+
3. O print vai reduzido pra IA (ex: 2560 -> 1280 de largura), mas o
|
| 10 |
+
clique usava a coordenada da imagem direto na tela real: caía no
|
| 11 |
+
lugar errado.
|
| 12 |
+
|
| 13 |
+
A parte 3 roda de verdade num display (Xvfb) se houver; senão é pulada.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
|
| 19 |
+
from _util import carregar_tars
|
| 20 |
+
|
| 21 |
+
tars = carregar_tars()
|
| 22 |
+
|
| 23 |
+
PEDIDO = "abra a calculadora e clique em 7, +, 2 e ="
|
| 24 |
+
|
| 25 |
+
# ------------------------------------------------------------------
|
| 26 |
+
# 1 e 2: conversa com um "modelo" roteirizado
|
| 27 |
+
# ------------------------------------------------------------------
|
| 28 |
+
vistos = []
|
| 29 |
+
roteiro = iter([
|
| 30 |
+
{"content": "", "tool_calls": [{"function": {"name": "take_screenshot", "arguments": {}}}]},
|
| 31 |
+
{"content": "", "tool_calls": []}, # pensa e não responde
|
| 32 |
+
{"content": "", "tool_calls": [{"function": {"name": "click_mouse", "arguments": {"x": 10, "y": 20}}}]},
|
| 33 |
+
{"content": "", "tool_calls": [{"function": {"name": "take_screenshot", "arguments": {}}}]},
|
| 34 |
+
{"content": "Pronto: 7 + 2 = 9.", "tool_calls": []},
|
| 35 |
+
])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def turno_falso(modelo, mensagens, tools, **kw):
|
| 39 |
+
vistos.append([dict(m) for m in mensagens])
|
| 40 |
+
return next(roteiro), None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
cliques = []
|
| 44 |
+
tars._executar_turno_streaming = turno_falso
|
| 45 |
+
tars.carregar_modelo = lambda m: True
|
| 46 |
+
tars._DISPATCH_FERRAMENTAS["take_screenshot"] = lambda a: {
|
| 47 |
+
"sucesso": True, "mensagem": "print ok", "_imagem_b64": "AAAA"}
|
| 48 |
+
tars._DISPATCH_FERRAMENTAS["click_mouse"] = lambda a: cliques.append(a) or {
|
| 49 |
+
"sucesso": True, "mensagem": "clicou"}
|
| 50 |
+
tars.SESSAO_GRAFICA = "x11"
|
| 51 |
+
tars.modo_modelo = "MANUAL"
|
| 52 |
+
|
| 53 |
+
resposta = tars.perguntar_modelo("modelo-visao", PEDIDO)
|
| 54 |
+
|
| 55 |
+
msg_print = vistos[1][-1]
|
| 56 |
+
assert msg_print["role"] == "user" and msg_print.get("images") == ["AAAA"], msg_print
|
| 57 |
+
assert PEDIDO in msg_print["content"] and "not from the user" in msg_print["content"], msg_print["content"]
|
| 58 |
+
print("OK: o print chega junto com o pedido original, marcado como mensagem automática")
|
| 59 |
+
|
| 60 |
+
assert PEDIDO in vistos[2][-1]["content"] and not vistos[2][-1].get("images"), vistos[2][-1]
|
| 61 |
+
assert cliques == [{"x": 10, "y": 20}], cliques
|
| 62 |
+
assert resposta == "Pronto: 7 + 2 = 9.", resposta
|
| 63 |
+
com_imagem = [m for m in vistos[-1] if m.get("images")]
|
| 64 |
+
assert len(com_imagem) == 1 and com_imagem[0] is not None, len(com_imagem)
|
| 65 |
+
assert vistos[-1][-1].get("images"), "o print mais novo tem que ficar"
|
| 66 |
+
print("OK: só o print mais recente fica na conversa (economiza contexto)")
|
| 67 |
+
|
| 68 |
+
payloads = []
|
| 69 |
+
import importlib.util
|
| 70 |
+
spec = importlib.util.spec_from_file_location("tars2", tars.__file__)
|
| 71 |
+
t2 = importlib.util.module_from_spec(spec); spec.loader.exec_module(t2)
|
| 72 |
+
t2._chamar_ollama_stream = lambda p: (payloads.append(p), (None, "sem ollama"))[1]
|
| 73 |
+
t2.detectar_gpu = lambda forcar=False: {"vram_total_gb": 24.0, "vram_livre_gb": 20.0}
|
| 74 |
+
t2._executar_turno_streaming("m", [{"role": "user", "content": "oi"}], None)
|
| 75 |
+
assert payloads[-1]["options"]["num_ctx"] == 16384, payloads[-1]["options"]
|
| 76 |
+
t2.detectar_gpu = lambda forcar=False: None
|
| 77 |
+
t2._executar_turno_streaming("m", [{"role": "user", "content": "oi"}], None)
|
| 78 |
+
assert payloads[-1]["options"]["num_ctx"] == 8192, payloads[-1]["options"]
|
| 79 |
+
print("OK: contexto pedido ao Ollama cabe ferramentas + print (16k com GPU >=16GB, senão 8k)")
|
| 80 |
+
print("OK: quando a IA para sem responder, o openTARS pede pra continuar e a tarefa termina")
|
| 81 |
+
|
| 82 |
+
# Resposta vazia na PRIMEIRA mensagem (sem ferramenta antes) não cutuca.
|
| 83 |
+
roteiro = iter([{"content": "", "tool_calls": []}])
|
| 84 |
+
vistos.clear()
|
| 85 |
+
tars.perguntar_modelo("modelo-visao", "oi")
|
| 86 |
+
assert len(vistos) == 1
|
| 87 |
+
print("OK: resposta vazia sem tarefa em andamento não gera insistência")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------
|
| 91 |
+
# 3: clique de verdade, tela 2560x1440, print reduzido pra 1280x720
|
| 92 |
+
# ------------------------------------------------------------------
|
| 93 |
+
if not os.environ.get("DISPLAY"):
|
| 94 |
+
print("PULADO (parte real): sem DISPLAY")
|
| 95 |
+
print("\nTUDO OK.")
|
| 96 |
+
sys.exit(0)
|
| 97 |
+
|
| 98 |
+
os.environ.setdefault("XAUTHORITY", "/dev/null")
|
| 99 |
+
for nome in ("pyautogui", "psutil", "tars"):
|
| 100 |
+
sys.modules.pop(nome, None)
|
| 101 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 102 |
+
import tkinter as tk # noqa: E402
|
| 103 |
+
|
| 104 |
+
import tars as real # noqa: E402
|
| 105 |
+
|
| 106 |
+
if real.ERRO_PYAUTOGUI:
|
| 107 |
+
print(f"PULADO (parte real): {real.ERRO_PYAUTOGUI}")
|
| 108 |
+
sys.exit(0)
|
| 109 |
+
|
| 110 |
+
largura_tela, altura_tela = real.pyautogui.size()
|
| 111 |
+
if largura_tela <= real.LARGURA_MAXIMA_SCREENSHOT:
|
| 112 |
+
print(f"PULADO (parte real): tela {largura_tela}px não é reduzida no print")
|
| 113 |
+
sys.exit(0)
|
| 114 |
+
|
| 115 |
+
janela = tk.Tk()
|
| 116 |
+
janela.geometry("+900+500")
|
| 117 |
+
visor = tk.StringVar(value="")
|
| 118 |
+
botoes = {}
|
| 119 |
+
for i, rotulo in enumerate(["7", "+", "2", "="]):
|
| 120 |
+
b = tk.Button(janela, text=rotulo, width=6, height=3,
|
| 121 |
+
command=lambda r=rotulo: visor.set(visor.get() + r))
|
| 122 |
+
b.grid(row=0, column=i)
|
| 123 |
+
botoes[rotulo] = b
|
| 124 |
+
tk.Label(janela, textvariable=visor).grid(row=1, columnspan=4)
|
| 125 |
+
for _ in range(20):
|
| 126 |
+
janela.update()
|
| 127 |
+
|
| 128 |
+
r = real.tirar_print()
|
| 129 |
+
assert r["sucesso"] and "1280x720" in r["mensagem"], r["mensagem"]
|
| 130 |
+
fator = largura_tela / 1280
|
| 131 |
+
|
| 132 |
+
for rotulo in ["7", "+", "2", "="]:
|
| 133 |
+
b = botoes[rotulo]
|
| 134 |
+
# Onde o botão aparece NO PRINT (o que a IA vê e responde)
|
| 135 |
+
x_img = (b.winfo_rootx() + b.winfo_width() / 2) / fator
|
| 136 |
+
y_img = (b.winfo_rooty() + b.winfo_height() / 2) / fator
|
| 137 |
+
real.clicar_mouse("left", x_img, y_img)
|
| 138 |
+
for _ in range(10):
|
| 139 |
+
janela.update()
|
| 140 |
+
|
| 141 |
+
assert visor.get() == "7+2=", f"visor: {visor.get()!r}"
|
| 142 |
+
escala = real._escala_tela_inteira()
|
| 143 |
+
tam = (round(largura_tela / escala), round(altura_tela / escala))
|
| 144 |
+
assert tam == (1280, 720), tam
|
| 145 |
+
janela.destroy()
|
| 146 |
+
print(f"OK: tela real {largura_tela}x{altura_tela}, cliques com coordenadas do print 1280x720 acertaram os 4 botões")
|
| 147 |
+
|
| 148 |
+
print("\nTUDO OK.")
|
app/tests/test_tarefa_longa.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""4.1: tarefas longas. Antes todo pedido parava em 20 idas-e-voltas.
|
| 2 |
+
Agora:
|
| 3 |
+
- pedido com plano começa com mais (20 + 6 por etapa além da segunda, até 50);
|
| 4 |
+
- enquanto a IA faz progresso de verdade (ações que deram certo), ganha
|
| 5 |
+
mais 20 de fôlego, até o teto (TARS_LIMITE_ETAPAS, 120);
|
| 6 |
+
- andar em círculos só olhando a tela continua parando em 20;
|
| 7 |
+
- os resultados de ferramenta antigos do mesmo pedido são encurtados."""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
from _util import carregar_tars
|
| 12 |
+
|
| 13 |
+
tars = carregar_tars()
|
| 14 |
+
|
| 15 |
+
assert tars.limite_de_ciclos([]) == 20 and tars.limite_de_ciclos(["a", "b"]) == 20
|
| 16 |
+
assert tars.limite_de_ciclos(["a", "b", "c", "d"]) == 32 and tars.limite_de_ciclos(list("abcdefghij")) == 50
|
| 17 |
+
assert tars._merece_folego(20, 15, 0) and not tars._merece_folego(20, 3, 3) and not tars._merece_folego(120, 99, 0)
|
| 18 |
+
print("OK: limite inicial cresce com o plano; fôlego só com ação nova e abaixo do teto")
|
| 19 |
+
|
| 20 |
+
tars.catalogar_modelos = lambda forcar=False: {}
|
| 21 |
+
tars.carregar_modelo = lambda mod, silencioso=False: True
|
| 22 |
+
tars.ollama_online = lambda: True
|
| 23 |
+
tars.modo_modelo = "MANUAL"
|
| 24 |
+
tars.modelo_manual = "qwen3:8b"
|
| 25 |
+
tars.salvar_sessoes = lambda: None
|
| 26 |
+
tars._contexto_do_pedido = lambda *a, **k: None
|
| 27 |
+
feitos = []
|
| 28 |
+
tars._DISPATCH_FERRAMENTAS["open_application"] = lambda a: feitos.append(a["app"]) or {
|
| 29 |
+
"sucesso": True, "mensagem": "SUCCESS: opened " + a["app"] + " " + "x" * 2000}
|
| 30 |
+
tars._DISPATCH_FERRAMENTAS["list_windows"] = lambda a: {"sucesso": True, "mensagem": "1 window", "janelas": []}
|
| 31 |
+
assert "list_windows" in tars._FERRAMENTAS_SO_OLHAM
|
| 32 |
+
|
| 33 |
+
ACOES = 45
|
| 34 |
+
maior_conversa = [0]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def turno_trabalhador(modelo, msgs, tools, pensar=True, **kw):
|
| 38 |
+
maior_conversa[0] = max(maior_conversa[0], sum(len(m.get("content") or "") for m in msgs))
|
| 39 |
+
if len(feitos) < ACOES:
|
| 40 |
+
return {"content": "", "tool_calls": [{"function": {"name": "open_application",
|
| 41 |
+
"arguments": {"app": f"app{len(feitos)}"}}}]}, None
|
| 42 |
+
return {"content": f"Pronto: abri os {ACOES} apps.", "tool_calls": []}, None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
tars._executar_turno_streaming = turno_trabalhador
|
| 46 |
+
resposta = tars.perguntar_modelo("qwen3:8b", "abra os 45 apps da lista", tarefa="acao")
|
| 47 |
+
assert resposta == f"Pronto: abri os {ACOES} apps." and len(feitos) == ACOES, (resposta, len(feitos))
|
| 48 |
+
folegos = [m for m in tars.conversa if "Long task in progress" in (m.get("content") or "")]
|
| 49 |
+
assert len(folegos) == 2, len(folegos)
|
| 50 |
+
resultados = [m["content"] for m in tars.conversa if m.get("role") == "tool"]
|
| 51 |
+
assert all(len(r) < 700 for r in resultados[:-tars.RESULTADOS_INTEIROS]), [len(r) for r in resultados]
|
| 52 |
+
inteiros = resultados[-tars.RESULTADOS_INTEIROS:]
|
| 53 |
+
assert len(inteiros[-1]) > 2000, "o resultado mais novo fica inteiro"
|
| 54 |
+
assert sum(len(r) for r in inteiros) <= int(tars.contexto_ia() * 3 * 0.4) + len(inteiros[-1]), "os 'inteiros' cabem no orçamento do contexto"
|
| 55 |
+
print(f"OK: 45 ações seguidas terminam (2 fôlegos extras); resultados antigos encurtados "
|
| 56 |
+
f"(maior conversa: {maior_conversa[0] // 1000} mil caracteres)")
|
| 57 |
+
|
| 58 |
+
# Em círculos, só olhando: para no limite de sempre
|
| 59 |
+
tars.limpar_sessao()
|
| 60 |
+
olhadas = []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def turno_que_so_olha(modelo, msgs, tools, pensar=True, **kw):
|
| 64 |
+
olhadas.append(1)
|
| 65 |
+
return {"content": "", "tool_calls": [{"function": {"name": "list_windows",
|
| 66 |
+
"arguments": {"filtro": str(len(olhadas))}}}]}, None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
tars._executar_turno_streaming = turno_que_so_olha
|
| 70 |
+
resposta = tars.perguntar_modelo("qwen3:8b", "faz alguma coisa", tarefa="acao")
|
| 71 |
+
assert len(olhadas) == 20 and "20" in resposta, (len(olhadas), resposta)
|
| 72 |
+
print("OK: sem progresso (só olhando), para nas 20 etapas de sempre")
|
| 73 |
+
|
| 74 |
+
# Teto
|
| 75 |
+
tars.limpar_sessao()
|
| 76 |
+
feitos.clear()
|
| 77 |
+
ACOES = 10_000
|
| 78 |
+
tars._executar_turno_streaming = turno_trabalhador
|
| 79 |
+
resposta = tars.perguntar_modelo("qwen3:8b", "abra tudo", tarefa="acao")
|
| 80 |
+
assert len(feitos) == tars.LIMITE_CICLOS_MAX == 120 and "120" in resposta, (len(feitos), resposta)
|
| 81 |
+
print("OK: mesmo fazendo progresso, para no teto de 120 etapas")
|
| 82 |
+
|
| 83 |
+
print("\nTUDO OK.")
|
app/tests/test_terminal_tela_cheia.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""16/10: programas de tela cheia (htop, vim, less...) abrem numa janela de terminal em vez de rodar sem terminal, onde
|
| 2 |
+
falhavam com "Error opening terminal" e o modelo concluía que não estavam instalados. Comando com pipe, redirecionamento
|
| 3 |
+
ou 'top -b' continua rodando normal (é texto). Sem sessão gráfica, a mensagem sugere um comando sem tela. O comando
|
| 4 |
+
perigoso continua passando pela confirmação antes."""
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from _util import carregar_tars
|
| 8 |
+
|
| 9 |
+
tars = carregar_tars()
|
| 10 |
+
|
| 11 |
+
for comando, esperado in (("htop", "htop"), ("sudo nano /etc/hosts", "nano"), ("vim notas.txt", "vim"), ("man ls", "man"),
|
| 12 |
+
("TERM=xterm less x.log", "less"), ("/usr/bin/btop", "btop"), ("top", "top"),
|
| 13 |
+
("top -bn1", None), ("top -b -n 1", None), ("htop | head", None), ("less x > y", None),
|
| 14 |
+
("ls -la", None), ("free -h", None), ("echo htop", None), ("cd x && vim y", None)):
|
| 15 |
+
assert tars.programa_de_tela(comando) == esperado, (comando, tars.programa_de_tela(comando))
|
| 16 |
+
print("OK: reconhece programa de tela cheia sozinho, e não com pipe, redirecionamento ou 'top -b'")
|
| 17 |
+
|
| 18 |
+
abertos = []
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Popen:
|
| 22 |
+
def __init__(self, argv, **k):
|
| 23 |
+
abertos.append(argv)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
real_popen, real_which = tars.subprocess.Popen, tars.shutil.which
|
| 27 |
+
tars.subprocess.Popen = Popen
|
| 28 |
+
tars.shutil.which = lambda p: "/usr/bin/" + p if p in ("gnome-terminal", "xterm") else None
|
| 29 |
+
os.environ["DISPLAY"] = ":99"
|
| 30 |
+
r = tars.executar_terminal("htop")
|
| 31 |
+
assert r["sucesso"] and "terminal window" in r["mensagem"] and "gnome-terminal" in r["mensagem"], r
|
| 32 |
+
assert abertos[-1][:4] == ["gnome-terminal", "--", "bash", "-c"] and abertos[-1][4].startswith("htop;"), abertos
|
| 33 |
+
print("OK: htop abre numa janela de terminal e o modelo fica sabendo")
|
| 34 |
+
|
| 35 |
+
tars.shutil.which = lambda p: None
|
| 36 |
+
r = tars.executar_terminal("htop")
|
| 37 |
+
assert not r["sucesso"] and "top -bn1" in r["mensagem"]
|
| 38 |
+
os.environ.pop("DISPLAY", None)
|
| 39 |
+
os.environ.pop("WAYLAND_DISPLAY", None)
|
| 40 |
+
r = tars.executar_terminal("vim x")
|
| 41 |
+
assert not r["sucesso"] and "no graphical session" in r["mensagem"]
|
| 42 |
+
print("OK: sem terminal instalado ou sem sessão gráfica, a mensagem sugere um comando sem tela")
|
| 43 |
+
|
| 44 |
+
tars.subprocess.Popen, tars.shutil.which = real_popen, real_which
|
| 45 |
+
r = tars.executar_terminal("echo oi")
|
| 46 |
+
assert r["sucesso"] and "oi" in r["stdout"], r
|
| 47 |
+
print("OK: comando comum continua igual")
|
| 48 |
+
print("\nTUDO OK.")
|
app/tests/test_textos.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Travas contra defeitos que já aconteceram (16/10), para pegar antes de publicar:
|
| 2 |
+
* pt_BR e en com as mesmas chaves, os mesmos tipos e os mesmos {campos} (campo diferente derruba o format);
|
| 3 |
+
* toda chave t("...") escrita no código existe nos dois idiomas (chave faltando aparece crua na tela), e também as
|
| 4 |
+
famílias montadas com f-string (probes.r.sis_{condição} etc.);
|
| 5 |
+
* nenhum texto dos idiomas nem literal das telas Qt usa glifo fora da IBM Plex (o "●" derrubou a janela nos Probes);
|
| 6 |
+
* todo .py do app compila."""
|
| 7 |
+
import json
|
| 8 |
+
import py_compile
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from _util import RAIZ
|
| 15 |
+
|
| 16 |
+
RAIZ = Path(RAIZ)
|
| 17 |
+
pt = json.loads((RAIZ / "idiomas" / "pt_BR.json").read_text(encoding="utf-8"))
|
| 18 |
+
en = json.loads((RAIZ / "idiomas" / "en.json").read_text(encoding="utf-8"))
|
| 19 |
+
campos = lambda v: sorted(re.findall(r"\{(\w+)", v)) if isinstance(v, str) else None # noqa: E731
|
| 20 |
+
|
| 21 |
+
assert set(pt) == set(en), f"chaves só num idioma: {sorted(set(pt) ^ set(en))}"
|
| 22 |
+
assert not [k for k in pt if type(pt[k]) is not type(en[k])], "tipos diferentes entre idiomas"
|
| 23 |
+
diferentes = [k for k in pt if campos(pt[k]) != campos(en[k])]
|
| 24 |
+
assert not diferentes, f"{{campos}} diferentes entre pt_BR e en: {diferentes}"
|
| 25 |
+
print(f"OK: pt_BR e en com as mesmas {len(pt)} chaves, tipos e campos")
|
| 26 |
+
|
| 27 |
+
fontes_py = [p for p in RAIZ.rglob("*.py") if not set(p.relative_to(RAIZ).parts[:1]) & {"build", "dist", "tests", "gargantua"}]
|
| 28 |
+
usadas = {}
|
| 29 |
+
for p in fontes_py:
|
| 30 |
+
for m in re.finditer(r"""\bt\(\s*["']([\w.]+)["']""", p.read_text(encoding="utf-8")):
|
| 31 |
+
usadas.setdefault(m.group(1), p.name)
|
| 32 |
+
faltam = {k: arq for k, arq in usadas.items() if k not in pt}
|
| 33 |
+
assert not faltam, f"chaves usadas no código que não existem nos idiomas: {faltam}"
|
| 34 |
+
sys.path.insert(0, str(RAIZ))
|
| 35 |
+
import tars_modos # noqa: E402
|
| 36 |
+
import tars_probes as tp # noqa: E402
|
| 37 |
+
familias = ([f"probes.tipo.{x}" for x in ("pasta", "web", "feed", "sistema", "programa", "git", "palavras")]
|
| 38 |
+
+ [f"probes.tipo.{x}_ex" for x in ("pasta", "web", "feed", "sistema", "programa", "git", "palavras")]
|
| 39 |
+
+ [f"acoes.{g}.{x}" for g, xs in (("aba", ("reescrever", "resumir", "traduzir", "perguntar")),
|
| 40 |
+
("resultado", ("reescrever", "resumir", "traduzir", "perguntar")),
|
| 41 |
+
("opcao", ("corrigir", "formal", "informal", "para_en", "para_pt"))) for x in xs]
|
| 42 |
+
+ [f"acoes.atalho_{s}" for s in ("linux", "windows", "macos")]
|
| 43 |
+
+ [f"probes.r.sis_{c}" for c in tp.CONDICOES_DO_SISTEMA] + [f"probes.pp.q_sis_{c}" for c in tp.CONDICOES_DO_SISTEMA]
|
| 44 |
+
+ [f"probes.pp.n_sis_{c}" for c in tp.CONDICOES_DO_SISTEMA] + [f"probes.f.sis_{c}" for c in tp.CONDICOES_DO_SISTEMA]
|
| 45 |
+
+ [f"probes.r.prog_{e}" for e in tp.EVENTOS_DO_PROGRAMA] + [f"probes.pp.q_prog_{e}" for e in tp.EVENTOS_DO_PROGRAMA]
|
| 46 |
+
+ [f"probes.f.prog_{e}" for e in tp.EVENTOS_DO_PROGRAMA]
|
| 47 |
+
+ [f"probes.erro_{e}" for e in ("json", "ferramentas", "sem_modelo", "sem_resposta", "missao", "limite", "destino")]
|
| 48 |
+
+ [f"modo.{m}.{x}" for m in tars_modos.MODOS for x in ("nome", "desc")])
|
| 49 |
+
assert not [k for k in familias if k not in pt], f"chaves dinâmicas faltando: {[k for k in familias if k not in pt]}"
|
| 50 |
+
print(f"OK: as {len(usadas)} chaves do código e as {len(familias)} dinâmicas existem nos dois idiomas")
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
from PySide6.QtGui import QGuiApplication, QRawFont
|
| 54 |
+
except ImportError:
|
| 55 |
+
print("(sem PySide6: glifos não conferidos)")
|
| 56 |
+
else:
|
| 57 |
+
app = QGuiApplication.instance() or QGuiApplication(["teste", "-platform", "offscreen"])
|
| 58 |
+
plex = [QRawFont(str(f), 15) for f in sorted((RAIZ / "tars_qt" / "fontes").glob("*.ttf"))]
|
| 59 |
+
assert plex, "fontes da IBM Plex não encontradas"
|
| 60 |
+
fora = lambda texto: {c for c in texto if ord(c) > 0x7F and not any(f.supportsCharacter(ord(c)) for f in plex)} # noqa: E731
|
| 61 |
+
ruins = {f"{k}@{nome}": fora(v) for nome, d in (("pt_BR", pt), ("en", en)) for k, v in d.items()
|
| 62 |
+
if isinstance(v, str) and fora(v)}
|
| 63 |
+
assert not ruins, f"textos com glifo fora da IBM Plex (derruba a janela em algumas máquinas): {ruins}"
|
| 64 |
+
from tars_qt import tema
|
| 65 |
+
trocados = set(tema._TROCAS.keys()) # a tabela de trocas e quem procura esses sinais podem citá-los
|
| 66 |
+
ruins = {}
|
| 67 |
+
for p in list((RAIZ / "tars_qt").glob("*.py")) + [RAIZ / "tars_probes.py", RAIZ / "tars_modos.py"]:
|
| 68 |
+
for n, linha in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
| 69 |
+
if linha.lstrip().startswith("#"):
|
| 70 |
+
continue
|
| 71 |
+
f = {c for c in fora(linha) if ord(c) not in trocados}
|
| 72 |
+
if f:
|
| 73 |
+
ruins[f"{p.name}:{n}"] = f
|
| 74 |
+
assert not ruins, f"literais das telas com glifo fora da IBM Plex: {ruins}"
|
| 75 |
+
print("OK: nenhum texto dos idiomas nem literal das telas usa glifo fora da IBM Plex")
|
| 76 |
+
|
| 77 |
+
with tempfile.TemporaryDirectory() as pasta:
|
| 78 |
+
for p in fontes_py:
|
| 79 |
+
py_compile.compile(str(p), cfile=str(Path(pasta) / "x.pyc"), doraise=True)
|
| 80 |
+
print(f"OK: os {len(fontes_py)} arquivos .py do app compilam")
|
| 81 |
+
print("\nTUDO OK.")
|
app/tests/test_think_vazado.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""4.0: raciocínio que vaza no texto da resposta (print real: o Gargantua
|
| 2 |
+
escreveu o raciocínio em inglês como resposta, com um "</think>" solto no
|
| 3 |
+
fim, e o turno seguinte começou com "</think>").
|
| 4 |
+
|
| 5 |
+
O FiltroPensamento separa de novo: o raciocínio vai como pensamento (cinza
|
| 6 |
+
na janela), a resposta fica limpa, nenhuma tag aparece e o histórico guarda
|
| 7 |
+
só a resposta."""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
from _util import carregar_tars
|
| 14 |
+
|
| 15 |
+
tars = carregar_tars()
|
| 16 |
+
F = tars.FiltroPensamento
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def rodar(pedacos):
|
| 20 |
+
f = F()
|
| 21 |
+
eventos = []
|
| 22 |
+
for p in pedacos:
|
| 23 |
+
eventos += f.alimentar(p)
|
| 24 |
+
eventos += f.terminar()
|
| 25 |
+
return f, eventos
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def textos(eventos, tipo):
|
| 29 |
+
return "".join(t for k, t in eventos if k == tipo)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def picar(texto, n=7):
|
| 33 |
+
return [texto[i:i + n] for i in range(0, len(texto), n)]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# 1. O caso do print: raciocínio sem <think>, "</think>" no fim, depois a resposta
|
| 37 |
+
bruto = ('Okay, the user said "abre o claude". Let me figure out what they mean.\n\nSo, I should call '
|
| 38 |
+
'open_application.\n</think>\n\nAbri o Claude.')
|
| 39 |
+
f, ev = rodar(picar(bruto))
|
| 40 |
+
assert f.conteudo == "Abri o Claude.", f.conteudo
|
| 41 |
+
assert "Let me figure out" in f.texto_pensado and "think>" not in f.texto_pensado
|
| 42 |
+
assert "think>" not in json.dumps(ev) and "Let me" not in textos(ev, "resposta"), ev
|
| 43 |
+
assert "Let me figure out" in textos(ev, "pensamento") and textos(ev, "resposta") == "Abri o Claude.", ev
|
| 44 |
+
print("OK: raciocínio sem <think> e com '</think>' no fim vira pensamento; a resposta fica limpa")
|
| 45 |
+
|
| 46 |
+
# 2. Turno que começa com um "</think>" solto
|
| 47 |
+
f, ev = rodar(picar("</think>\n\nThe application \"Claude\" has been opened."))
|
| 48 |
+
assert f.conteudo == 'The application "Claude" has been opened.' and not f.texto_pensado, (f.conteudo, ev)
|
| 49 |
+
assert "think" not in json.dumps(ev)
|
| 50 |
+
print("OK: '</think>' solto no começo some")
|
| 51 |
+
|
| 52 |
+
# 3. <think> ... </think> normal dentro do texto (tags picadas em pedaços de 3)
|
| 53 |
+
f, ev = rodar(picar("<think>\npreciso abrir o app\n</think>\n\nPronto, abri.", 3))
|
| 54 |
+
assert f.conteudo == "Pronto, abri." and f.texto_pensado == "preciso abrir o app", (f.conteudo, f.texto_pensado)
|
| 55 |
+
assert "think>" not in json.dumps(ev) and "<" not in json.dumps(ev), ev
|
| 56 |
+
print("OK: <think>...</think> com as tags picadas entre pedaços vira pensamento")
|
| 57 |
+
|
| 58 |
+
# 4. Resposta normal: passa igual, sem atraso de tag
|
| 59 |
+
for resp in ("Oi! Tudo certo por aqui.", "12 x 8 = 96", "Abri a calculadora e fiz a conta: 96.",
|
| 60 |
+
"Okay, abri o Firefox pra você."[:0] or "Claro! Abri o Firefox."):
|
| 61 |
+
f, ev = rodar(picar(resp))
|
| 62 |
+
assert f.conteudo == resp and not f.texto_pensado and textos(ev, "resposta") == resp, (resp, ev)
|
| 63 |
+
print("OK: resposta comum passa igual")
|
| 64 |
+
|
| 65 |
+
# 5. Começa como 'Okay, ...' mas era a resposta (nunca fechou): vira resposta no fim
|
| 66 |
+
resp = "Okay, I opened the calculator for you and the result is 96."
|
| 67 |
+
f, ev = rodar(picar(resp))
|
| 68 |
+
assert f.conteudo == resp and not f.texto_pensado, (f.conteudo, f.texto_pensado)
|
| 69 |
+
assert textos(ev, "resposta") == resp
|
| 70 |
+
print("OK: 'Okay, ...' que nunca fecha era a resposta mesmo")
|
| 71 |
+
|
| 72 |
+
# 6. Pareceu resposta, mas veio '</think>': o que foi mostrado vira pensamento (era_pensamento)
|
| 73 |
+
bruto = "Preciso ver quais apps existem. Vou chamar a ferramenta certa pra isso agora.\n</think>\nAbri."
|
| 74 |
+
f, ev = rodar(picar(bruto))
|
| 75 |
+
assert f.conteudo == "Abri." and "Preciso ver" in f.texto_pensado, (f.conteudo, f.texto_pensado)
|
| 76 |
+
assert any(k == "era_pensamento" for k, _ in ev), ev
|
| 77 |
+
print("OK: texto mostrado como resposta que termina em '</think>' é repintado como pensamento")
|
| 78 |
+
|
| 79 |
+
# 7. De ponta a ponta no turno de streaming (Ollama falso): eventos e retorno
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class Resposta:
|
| 83 |
+
def __init__(self, pedacos):
|
| 84 |
+
self.linhas = [json.dumps({"message": {"content": p}, "done": False}).encode() for p in pedacos]
|
| 85 |
+
self.linhas.append(json.dumps({"message": {"content": ""}, "done": True}).encode())
|
| 86 |
+
|
| 87 |
+
def iter_lines(self):
|
| 88 |
+
return iter(self.linhas)
|
| 89 |
+
|
| 90 |
+
def close(self):
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
eventos = []
|
| 95 |
+
tars.emitir = lambda tipo, **dados: eventos.append((tipo, dados))
|
| 96 |
+
bruto = 'Okay, the user wants the calculator. I will call open_application.\n</think>\n\nAbri a calculadora.'
|
| 97 |
+
tars._chamar_ollama_stream = lambda payload: (Resposta(picar(bruto, 5)), None)
|
| 98 |
+
resultado, erro = tars._executar_turno_streaming("ProjectOpenTARS/gargantua:1.0", [{"role": "user", "content": "x"}],
|
| 99 |
+
None, pensar=False)
|
| 100 |
+
assert not erro and resultado["content"] == "Abri a calculadora." and "I will call" in resultado["thinking"], resultado
|
| 101 |
+
assert "think>" not in json.dumps(eventos), eventos
|
| 102 |
+
assert [e for e in eventos if e[0] == "pensamento"] and eventos[-1][0] == "fim_stream"
|
| 103 |
+
assert "".join(d["texto"] for k, d in eventos if k == "resposta") == "Abri a calculadora."
|
| 104 |
+
print("OK: no turno de verdade, a tela recebe pensamento + resposta limpa, e o histórico guarda só a resposta")
|
| 105 |
+
|
| 106 |
+
# 8. Janela: 'resposta_era_pensamento' repinta de cinza (só com tkinter e display)
|
| 107 |
+
try:
|
| 108 |
+
import tkinter as tk
|
| 109 |
+
sys.modules["tars"] = tars
|
| 110 |
+
import tars_gui
|
| 111 |
+
raiz = tk.Tk()
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"PULADO (janela): {e}")
|
| 114 |
+
else:
|
| 115 |
+
area = tars_gui.AreaConversa.__new__(tars_gui.AreaConversa)
|
| 116 |
+
texto = tk.Text(raiz)
|
| 117 |
+
for nome in ("rotulo_ia", "pensamento", "pensamento_rotulo"):
|
| 118 |
+
texto.tag_configure(nome)
|
| 119 |
+
area.t, area.ultimo_estilo, area.blocos, area.fontes = texto, None, [], None
|
| 120 |
+
area.escrever(tars_gui.formatar_evento("resposta", {"texto": "Let me think about it", "inicio": True}))
|
| 121 |
+
area.virar_pensamento()
|
| 122 |
+
conteudo = texto.get("1.0", "end-1c")
|
| 123 |
+
assert "openTARS" not in conteudo and "Let me think about it" in conteudo, conteudo
|
| 124 |
+
inicio = texto.search("Let me", "1.0")
|
| 125 |
+
assert "pensamento" in texto.tag_names(inicio), texto.tag_names(inicio)
|
| 126 |
+
raiz.destroy()
|
| 127 |
+
print("OK: na janela, a 'resposta' que era raciocínio vira texto cinza de pensamento")
|
| 128 |
+
|
| 129 |
+
print("\nTUDO OK.")
|
app/tests/test_troca_nao_desce.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.1 (item 7 do plano): quando o modelo falha no meio do pedido, a troca automática vai para um modelo do mesmo
|
| 2 |
+
tamanho ou maior, nunca para um bem menor (no print do Enzo, o Gargantua falhou e quem respondeu foi o qwen3:0.6b). Sem
|
| 3 |
+
modelo à altura, o erro aparece em vez de uma resposta ruim. Ollama de mentira."""
|
| 4 |
+
from _util import carregar_tars
|
| 5 |
+
|
| 6 |
+
tars = carregar_tars()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def m(tag, p):
|
| 10 |
+
return {"tag": tag, "parametros_b": p, "categoria": "geral", "vram_estimado_gb": p, "capacidades": {"tools"},
|
| 11 |
+
"capacidades_conhecidas": True, "nuvem": False}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def rodar(catalogo, fila):
|
| 15 |
+
tars.catalogar_modelos = lambda forcar=False: catalogo
|
| 16 |
+
tars.construir_cadeia_fallback = lambda tarefa, catalogo=None: list(fila)
|
| 17 |
+
tars.carregar_modelo = lambda *a, **k: True
|
| 18 |
+
chamados = []
|
| 19 |
+
|
| 20 |
+
def turno(modelo, mensagens, tools, **kw):
|
| 21 |
+
chamados.append(modelo)
|
| 22 |
+
if modelo == "ProjectOpenTARS/Gargantua:latest":
|
| 23 |
+
return None, "Erro ao comunicar com Ollama: out of memory"
|
| 24 |
+
return {"content": f"feito por {modelo}", "tool_calls": []}, None
|
| 25 |
+
|
| 26 |
+
tars._executar_turno_streaming = turno
|
| 27 |
+
tars.modo_modelo = "AUTO"
|
| 28 |
+
tars.conversa[:] = [{"role": "system", "content": "s"}]
|
| 29 |
+
return tars.perguntar_modelo("ProjectOpenTARS/Gargantua:latest", "oi, tudo bem?", tarefa="geral"), chamados
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
gargantua, pequeno, grande = m("ProjectOpenTARS/Gargantua:latest", 4.0), m("qwen3:0.6b", 0.75), m("qwen3:8b", 8.2)
|
| 33 |
+
catalogo = {x["tag"]: x for x in (gargantua, pequeno, grande)}
|
| 34 |
+
resposta, chamados = rodar(catalogo, ["ProjectOpenTARS/Gargantua:latest", "qwen3:0.6b", "qwen3:8b"])
|
| 35 |
+
assert chamados == ["ProjectOpenTARS/Gargantua:latest", "qwen3:8b"] and "qwen3:8b" in resposta, (chamados, resposta)
|
| 36 |
+
print("OK: depois da falha, pula o qwen3:0.6b e vai para o qwen3:8b")
|
| 37 |
+
|
| 38 |
+
catalogo = {x["tag"]: x for x in (gargantua, pequeno)}
|
| 39 |
+
resposta, chamados = rodar(catalogo, ["ProjectOpenTARS/Gargantua:latest", "qwen3:0.6b"])
|
| 40 |
+
assert chamados == ["ProjectOpenTARS/Gargantua:latest"] and "out of memory" in resposta, (chamados, resposta)
|
| 41 |
+
print("OK: sem modelo à altura, mostra o erro em vez de passar o pedido para o 0.6b")
|
| 42 |
+
print("\nTUDO OK.")
|
app/tests/test_url_site.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from _util import carregar_tars
|
| 2 |
+
tars = carregar_tars()
|
| 3 |
+
|
| 4 |
+
# 1. Pedido tipo "canal da anthropic no youtube" -> devia virar busca no youtube
|
| 5 |
+
r = tars.abrir_site("canal da anthropic no youtube")
|
| 6 |
+
assert "youtube.com/results" in r["url"], r
|
| 7 |
+
assert "anthropic" in r["url"]
|
| 8 |
+
print("OK: canal no youtube vira busca, não domínio inventado:", r["url"])
|
| 9 |
+
|
| 10 |
+
# 2. Domínio direto continua funcionando
|
| 11 |
+
tars.webbrowser.open = lambda u: True
|
| 12 |
+
r = tars.abrir_site("github.com")
|
| 13 |
+
assert r["url"] == "https://github.com", r
|
| 14 |
+
print("OK: dominio direto:", r["url"])
|
| 15 |
+
|
| 16 |
+
# 3. perguntar_url_site: IA responde "desconhecido" -> None (cai pra busca)
|
| 17 |
+
tars._consultar_modelo_leve = lambda p, formato=None: "desconhecido"
|
| 18 |
+
assert tars.perguntar_url_site("coisa qualquer") is None
|
| 19 |
+
print("OK: 'desconhecido' vira None")
|
| 20 |
+
|
| 21 |
+
# 4. perguntar_url_site: IA inventa domínio que não resolve -> None
|
| 22 |
+
tars._consultar_modelo_leve = lambda p, formato=None: "https://anthropic-canals.youtube.com"
|
| 23 |
+
tars._dominio_existe = lambda u: False # simula DNS falhando
|
| 24 |
+
assert tars.perguntar_url_site("canal da anthropic") is None
|
| 25 |
+
print("OK: dominio inventado (sem DNS) vira None")
|
| 26 |
+
|
| 27 |
+
# 5. perguntar_url_site: URL real, resolve -> aceita
|
| 28 |
+
tars._consultar_modelo_leve = lambda p, formato=None: "https://www.anthropic.com"
|
| 29 |
+
tars._dominio_existe = lambda u: True
|
| 30 |
+
assert tars.perguntar_url_site("anthropic") == "https://www.anthropic.com"
|
| 31 |
+
print("OK: dominio real aceito")
|
| 32 |
+
|
| 33 |
+
print("\nTUDO OK.")
|
app/tests/test_v2_janelas.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Controle de janelas (2.0), de verdade: precisa de um display X com
|
| 2 |
+
gerenciador de janelas (ex: Xvfb + openbox). Sem isso, é pulado.
|
| 3 |
+
|
| 4 |
+
Um app de teste roda em OUTRO processo (como um app real) e o openTARS:
|
| 5 |
+
abre e espera a janela, lista, traz pra frente, tira print só dela,
|
| 6 |
+
clica nos botões com as coordenadas DO PRINT RECORTADO e fecha a
|
| 7 |
+
janela pelo X (sem matar o processo)."""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import subprocess
|
| 12 |
+
import sys
|
| 13 |
+
import tempfile
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
if not os.environ.get("DISPLAY"):
|
| 17 |
+
print("PULADO: sem DISPLAY")
|
| 18 |
+
sys.exit(0)
|
| 19 |
+
|
| 20 |
+
os.environ.setdefault("XAUTHORITY", "/dev/null")
|
| 21 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 22 |
+
|
| 23 |
+
import tars # noqa: E402
|
| 24 |
+
|
| 25 |
+
if tars.ERRO_PYAUTOGUI:
|
| 26 |
+
print(f"PULADO: {tars.ERRO_PYAUTOGUI}")
|
| 27 |
+
sys.exit(0)
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from Xlib import display as _d
|
| 31 |
+
_x = _d.Display()
|
| 32 |
+
tem_wm = _x.screen().root.get_full_property(_x.intern_atom("_NET_CLIENT_LIST"), 0) is not None
|
| 33 |
+
_x.close()
|
| 34 |
+
except Exception:
|
| 35 |
+
tem_wm = False
|
| 36 |
+
if not tem_wm:
|
| 37 |
+
print("PULADO: sem gerenciador de janelas (EWMH)")
|
| 38 |
+
sys.exit(0)
|
| 39 |
+
|
| 40 |
+
pasta = tempfile.mkdtemp()
|
| 41 |
+
estado = os.path.join(pasta, "estado.json")
|
| 42 |
+
app = os.path.join(pasta, "calcteste.py")
|
| 43 |
+
open(app, "w").write(f'''
|
| 44 |
+
import json, tkinter as tk
|
| 45 |
+
r = tk.Tk(className="calcteste"); r.title("Calc Teste")
|
| 46 |
+
visor = tk.StringVar(value="")
|
| 47 |
+
def salvar(extra=None):
|
| 48 |
+
json.dump({{"visor": visor.get(), "fechada": extra == "fechada",
|
| 49 |
+
"botoes": {{b: [w.winfo_rootx() - r.winfo_rootx() + w.winfo_width() // 2,
|
| 50 |
+
w.winfo_rooty() - r.winfo_rooty() + w.winfo_height() // 2]
|
| 51 |
+
for b, w in botoes.items()}}}}, open({estado!r}, "w"))
|
| 52 |
+
botoes = {{}}
|
| 53 |
+
for i, b in enumerate(["7", "+", "2", "="]):
|
| 54 |
+
w = tk.Button(r, text=b, width=5, height=3, command=lambda b=b: (visor.set(visor.get() + b), salvar()))
|
| 55 |
+
w.grid(row=0, column=i); botoes[b] = w
|
| 56 |
+
tk.Label(r, textvariable=visor).grid(row=1, columnspan=4)
|
| 57 |
+
r.protocol("WM_DELETE_WINDOW", lambda: (salvar("fechada"), r.destroy()))
|
| 58 |
+
r.after(500, salvar)
|
| 59 |
+
r.mainloop()
|
| 60 |
+
''')
|
| 61 |
+
|
| 62 |
+
tars.encontrar_app = lambda nome: {
|
| 63 |
+
"tipo": "executavel", "nome": "calcteste",
|
| 64 |
+
"comando": [sys.executable, app], "origem": app,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
t0 = time.time()
|
| 68 |
+
r = tars.abrir_aplicativo("calcteste")
|
| 69 |
+
tempo = time.time() - t0
|
| 70 |
+
assert r["sucesso"] and r.get("janela", {}).get("titulo") == "Calc Teste", r
|
| 71 |
+
print(f"OK: abriu e esperou a janela ({tempo:.1f}s): {r['janela']}")
|
| 72 |
+
|
| 73 |
+
lista = tars.listar_janelas()
|
| 74 |
+
assert any(j["titulo"] == "Calc Teste" and j["em_foco"] for j in lista["janelas"]), lista
|
| 75 |
+
print("OK: list_windows mostra a janela, em foco")
|
| 76 |
+
|
| 77 |
+
time.sleep(0.6)
|
| 78 |
+
botoes = json.load(open(estado))["botoes"]
|
| 79 |
+
|
| 80 |
+
r = tars.tirar_print("calcteste")
|
| 81 |
+
assert r["sucesso"] and "Calc Teste" in r["mensagem"] and tars._escala_print["ox"] > 0, r["mensagem"]
|
| 82 |
+
print("OK: print só da janela:", r["mensagem"][:70])
|
| 83 |
+
|
| 84 |
+
for b in ["7", "+", "2", "="]:
|
| 85 |
+
x, y = botoes[b] # posição do botão DENTRO do print recortado
|
| 86 |
+
tars.clicar_mouse("left", x, y)
|
| 87 |
+
time.sleep(0.2)
|
| 88 |
+
time.sleep(0.3)
|
| 89 |
+
assert json.load(open(estado))["visor"] == "7+2=", json.load(open(estado))
|
| 90 |
+
print("OK: cliques com coordenadas do print recortado acertaram 7, +, 2, =")
|
| 91 |
+
|
| 92 |
+
assert tars.focar_janela("calc teste")["sucesso"]
|
| 93 |
+
r = tars.fechar_aplicativo("calcteste")
|
| 94 |
+
time.sleep(0.3)
|
| 95 |
+
assert r["sucesso"] and json.load(open(estado))["fechada"], r
|
| 96 |
+
print("OK: close_application fechou pela janela (o app recebeu o pedido de fechar)")
|
| 97 |
+
|
| 98 |
+
print("\nTUDO OK.")
|
app/tests/test_v2_nucleo.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mudanças da 2.0 no núcleo (sem display): cancelar resposta, fallback
|
| 2 |
+
de think/tools só quando o Ollama diz que não suporta, visão preferindo
|
| 3 |
+
modelos com ferramentas, terminal que não trava e comandos perigosos."""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from _util import carregar_tars
|
| 8 |
+
|
| 9 |
+
tars = carregar_tars()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ------------------------------------------------------------------
|
| 13 |
+
# Cancelar no meio do texto
|
| 14 |
+
# ------------------------------------------------------------------
|
| 15 |
+
class RespostaLenta:
|
| 16 |
+
status_code = 200
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.fechada = False
|
| 20 |
+
|
| 21 |
+
def iter_lines(self):
|
| 22 |
+
for i in range(100):
|
| 23 |
+
if i == 3:
|
| 24 |
+
tars.cancelar_resposta() # como o botão Parar
|
| 25 |
+
yield f'{{"message": {{"content": "p{i} "}}, "done": false}}'.encode()
|
| 26 |
+
|
| 27 |
+
def close(self):
|
| 28 |
+
self.fechada = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
lenta = RespostaLenta()
|
| 32 |
+
tars._chamar_ollama_stream = lambda payload: (lenta, None)
|
| 33 |
+
tars.carregar_modelo = lambda m: True
|
| 34 |
+
tars.modo_modelo = "MANUAL"
|
| 35 |
+
resposta = tars.perguntar_modelo("m", "conte uma história")
|
| 36 |
+
assert resposta == "(interrompido)" and lenta.fechada, resposta
|
| 37 |
+
assert tars.conversa[-1]["content"] == "(interrupted by the user)"
|
| 38 |
+
assert not tars._cancelar.is_set(), "o próximo pedido não pode nascer cancelado"
|
| 39 |
+
print("OK: Parar interrompe o texto na hora e deixa a conversa consistente")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ------------------------------------------------------------------
|
| 43 |
+
# think/tools: só desliga quando o Ollama diz que não suporta
|
| 44 |
+
# ------------------------------------------------------------------
|
| 45 |
+
class R:
|
| 46 |
+
def __init__(self, status, texto=""):
|
| 47 |
+
self.status_code, self.text = status, texto
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
import importlib.util # noqa: E402
|
| 51 |
+
|
| 52 |
+
spec = importlib.util.spec_from_file_location("t2", tars.__file__)
|
| 53 |
+
t2 = importlib.util.module_from_spec(spec)
|
| 54 |
+
spec.loader.exec_module(t2)
|
| 55 |
+
t2.catalogar_modelos = lambda forcar=False: {}
|
| 56 |
+
|
| 57 |
+
respostas = iter([R(500, "out of memory"), R(200)])
|
| 58 |
+
t2.ollama_request = lambda p, stream=False: next(respostas)
|
| 59 |
+
_, erro = t2._chamar_ollama_stream({"model": "x", "think": True, "tools": [1]})
|
| 60 |
+
assert erro and "out of memory" in erro and "x" not in t2._MODELOS_SEM_TOOLS, erro
|
| 61 |
+
print("OK: erro passageiro (falta de memória) não marca o modelo como 'sem ferramentas'")
|
| 62 |
+
|
| 63 |
+
enviados = []
|
| 64 |
+
respostas = iter([R(400, '"y" does not support tools'), R(200)])
|
| 65 |
+
t2.ollama_request = lambda p, stream=False: (enviados.append(dict(p)), next(respostas))[1]
|
| 66 |
+
resp, erro = t2._chamar_ollama_stream({"model": "y", "tools": [1]})
|
| 67 |
+
assert resp and not erro and "y" in t2._MODELOS_SEM_TOOLS and "tools" not in enviados[-1]
|
| 68 |
+
print("OK: 'does not support tools' → tenta de novo sem ferramentas e lembra")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ------------------------------------------------------------------
|
| 72 |
+
# Visão: prefere quem enxerga E chama ferramentas
|
| 73 |
+
# ------------------------------------------------------------------
|
| 74 |
+
def modelo(tag, params, caps):
|
| 75 |
+
return {"tag": tag, "parametros_b": params, "categoria": "visao",
|
| 76 |
+
"vram_estimado_gb": 3.0, "capacidades": set(caps), "capacidades_conhecidas": True}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
catalogo = {m["tag"]: m for m in (
|
| 80 |
+
modelo("so-ve:27b", 27, {"completion", "vision"}),
|
| 81 |
+
modelo("ve-e-age:12b", 12, {"completion", "vision", "tools"}),
|
| 82 |
+
)}
|
| 83 |
+
tars.detectar_gpu = lambda forcar=False: {"vram_total_gb": 48.0, "vram_livre_gb": 40.0}
|
| 84 |
+
assert tars.construir_cadeia_fallback("visao", catalogo)[0] == "ve-e-age:12b"
|
| 85 |
+
print("OK: tarefa de visão escolhe modelo que enxerga E clica")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ------------------------------------------------------------------
|
| 89 |
+
# Terminal: não trava esperando senha nem programa em segundo plano
|
| 90 |
+
# ------------------------------------------------------------------
|
| 91 |
+
t0 = time.time()
|
| 92 |
+
r = tars.executar_terminal("read resposta; echo \"lido:[$resposta]\"")
|
| 93 |
+
assert time.time() - t0 < 3 and "lido:[]" in r["stdout"], r
|
| 94 |
+
t0 = time.time()
|
| 95 |
+
r = tars.executar_terminal("sleep 20 &")
|
| 96 |
+
assert time.time() - t0 < 2 and r["sucesso"], r
|
| 97 |
+
print("OK: comandos que pedem entrada ou rodam em segundo plano não travam")
|
| 98 |
+
|
| 99 |
+
for cmd in ("rm -r ~/Documentos", "rm -R pasta", "find . -name '*.log' -delete", "git reset --hard HEAD~3"):
|
| 100 |
+
assert tars._comando_e_perigoso(cmd), cmd
|
| 101 |
+
for cmd in ("rm arquivo.txt", "ls -R", "git status", "find . -name x"):
|
| 102 |
+
assert not tars._comando_e_perigoso(cmd), cmd
|
| 103 |
+
print("OK: rm -r, find -delete e git reset --hard pedem confirmação")
|
| 104 |
+
|
| 105 |
+
# DNS não mexe mais no timeout global de sockets
|
| 106 |
+
import socket # noqa: E402
|
| 107 |
+
|
| 108 |
+
antes = socket.getdefaulttimeout()
|
| 109 |
+
tars._dominio_existe("https://dominio-que-nao-existe-opentars.invalid")
|
| 110 |
+
assert socket.getdefaulttimeout() == antes
|
| 111 |
+
print("OK: checagem de domínio não altera o timeout de rede do processo")
|
| 112 |
+
|
| 113 |
+
# Resposta do ajudante sem <think>
|
| 114 |
+
tars._SESSION.post = lambda *a, **k: type("Resp", (), {
|
| 115 |
+
"status_code": 200,
|
| 116 |
+
"json": lambda self: {"message": {"content": "<think>hmm</think>gnome-calculator"}},
|
| 117 |
+
})()
|
| 118 |
+
assert tars._chat_unico("m", "p") == "gnome-calculator"
|
| 119 |
+
print("OK: raciocínio <think> não vaza na resposta do modelo ajudante")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ------------------------------------------------------------------
|
| 123 |
+
# Correções da revisão independente
|
| 124 |
+
# ------------------------------------------------------------------
|
| 125 |
+
import threading # noqa: E402
|
| 126 |
+
|
| 127 |
+
# Ordem: [print, clique] na mesma resposta -> os dois resultados logo
|
| 128 |
+
# após as chamadas, e a imagem só depois deles.
|
| 129 |
+
spec3 = importlib.util.spec_from_file_location("t3", tars.__file__)
|
| 130 |
+
t3 = importlib.util.module_from_spec(spec3)
|
| 131 |
+
spec3.loader.exec_module(t3)
|
| 132 |
+
t3.SEM_INTERFACE = False # o teste vale com ou sem tela
|
| 133 |
+
t3.carregar_modelo = lambda m: True
|
| 134 |
+
t3.modo_modelo = "MANUAL"
|
| 135 |
+
t3._DISPATCH_FERRAMENTAS["take_screenshot"] = lambda a: {"sucesso": True, "mensagem": "ok", "_imagem_b64": "IMG"}
|
| 136 |
+
t3._DISPATCH_FERRAMENTAS["click_mouse"] = lambda a: {"sucesso": True, "mensagem": "ok"}
|
| 137 |
+
roteiro = iter([
|
| 138 |
+
{"content": "", "tool_calls": [{"function": {"name": "take_screenshot", "arguments": {}}},
|
| 139 |
+
{"function": {"name": "click_mouse", "arguments": {"x": 1, "y": 1}}}]},
|
| 140 |
+
{"content": "feito", "tool_calls": []},
|
| 141 |
+
])
|
| 142 |
+
vistos = []
|
| 143 |
+
t3._executar_turno_streaming = lambda m, msgs, tools, **kw: (vistos.append([dict(x) for x in msgs]), (next(roteiro), None))[1]
|
| 144 |
+
t3.perguntar_modelo("m", "tarefa")
|
| 145 |
+
papeis = [x["role"] for x in vistos[1][-4:]]
|
| 146 |
+
assert papeis == ["assistant", "tool", "tool", "user"] and vistos[1][-1].get("images") == ["IMG"], papeis
|
| 147 |
+
print("OK: vários resultados de ferramenta ficam juntos, e o print vem depois deles")
|
| 148 |
+
|
| 149 |
+
# Parar no meio de uma sequência de ferramentas: as que não rodaram
|
| 150 |
+
# ganham um resultado "cancelado" (a conversa continua válida).
|
| 151 |
+
roteiro = iter([{"content": "", "tool_calls": [
|
| 152 |
+
{"function": {"name": "click_mouse", "arguments": {}}},
|
| 153 |
+
{"function": {"name": "take_screenshot", "arguments": {}}},
|
| 154 |
+
{"function": {"name": "click_mouse", "arguments": {}}}]}])
|
| 155 |
+
t3._DISPATCH_FERRAMENTAS["click_mouse"] = lambda a: (t3.cancelar_resposta(), {"sucesso": True, "mensagem": "ok"})[1]
|
| 156 |
+
t3.perguntar_modelo("m", "três passos")
|
| 157 |
+
ultimas = t3.conversa[-5:]
|
| 158 |
+
assert [x["role"] for x in ultimas] == ["assistant", "tool", "tool", "tool", "assistant"], [x["role"] for x in ultimas]
|
| 159 |
+
assert "Cancelled" in ultimas[2]["content"] and "Cancelled" in ultimas[3]["content"]
|
| 160 |
+
print("OK: Parar no meio das ferramentas deixa um resultado 'cancelado' pra cada uma que não rodou")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# Parar enquanto o Ollama ainda lê a conversa (nenhuma linha chegou)
|
| 164 |
+
class Travada:
|
| 165 |
+
status_code = 200
|
| 166 |
+
|
| 167 |
+
def __init__(self):
|
| 168 |
+
self.fechou = threading.Event()
|
| 169 |
+
|
| 170 |
+
def iter_lines(self):
|
| 171 |
+
self.fechou.wait(10)
|
| 172 |
+
raise ConnectionError("fechada")
|
| 173 |
+
|
| 174 |
+
def close(self):
|
| 175 |
+
self.fechou.set()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
travada = Travada()
|
| 179 |
+
t4 = importlib.util.module_from_spec(spec3) # módulo novo, com o streaming de verdade
|
| 180 |
+
spec3.loader.exec_module(t4)
|
| 181 |
+
t4.carregar_modelo = lambda m: True
|
| 182 |
+
t4.modo_modelo = "MANUAL"
|
| 183 |
+
t4._chamar_ollama_stream = lambda p: (travada, None)
|
| 184 |
+
threading.Timer(0.5, t4.cancelar_resposta).start()
|
| 185 |
+
t0 = time.time()
|
| 186 |
+
assert t4.perguntar_modelo("m", "oi") == "(interrompido)"
|
| 187 |
+
assert time.time() - t0 < 3, time.time() - t0
|
| 188 |
+
print(f"OK: Parar funciona mesmo antes da IA começar a escrever ({time.time() - t0:.1f}s)")
|
| 189 |
+
|
| 190 |
+
# Comando de terminal longo também para
|
| 191 |
+
threading.Timer(0.5, tars.cancelar_resposta).start()
|
| 192 |
+
t0 = time.time()
|
| 193 |
+
r = tars.executar_terminal("sleep 20")
|
| 194 |
+
tars._cancelar.clear()
|
| 195 |
+
assert time.time() - t0 < 3 and "interrupted" in r["mensagem"], r
|
| 196 |
+
print("OK: Parar interrompe um comando de terminal demorado")
|
| 197 |
+
|
| 198 |
+
# DNS travado não segura o openTARS além do limite
|
| 199 |
+
import socket as _s # noqa: E402
|
| 200 |
+
tars.socket.gethostbyname = lambda h: time.sleep(20)
|
| 201 |
+
t0 = time.time()
|
| 202 |
+
assert tars._dominio_existe("https://qualquer.com") is False
|
| 203 |
+
assert time.time() - t0 < tars.TIMEOUT_DNS + 1.5, time.time() - t0
|
| 204 |
+
tars.socket.gethostbyname = _s.gethostbyname
|
| 205 |
+
print("OK: DNS travado respeita o limite de tempo")
|
| 206 |
+
|
| 207 |
+
# Mais de um modelo carregado: o novo só convive se TODOS couberem
|
| 208 |
+
tars._cache_catalogo["dados"] = {"a": {"vram_estimado_gb": 5.0}, "b": {"vram_estimado_gb": 5.0}, "c": {"vram_estimado_gb": 12.0}}
|
| 209 |
+
tars.detectar_gpu = lambda forcar=False: {"vram_total_gb": 24.0, "vram_livre_gb": 20.0}
|
| 210 |
+
tars.contexto_ia = lambda: 16384
|
| 211 |
+
assert tars._cabem_juntos(["a"], "b") and not tars._cabem_juntos(["a", "b"], "c")
|
| 212 |
+
print("OK: descarrega os modelos antigos quando o novo não cabe com todos eles")
|
| 213 |
+
|
| 214 |
+
print("\nTUDO OK.")
|
app/tests/test_variantes_gargantua.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Variantes do Gargantua (Lite, Code) no openTARS: o app nunca as confunde com o oficial e as usa só onde servem."""
|
| 2 |
+
from _util import carregar_tars
|
| 3 |
+
|
| 4 |
+
tars = carregar_tars()
|
| 5 |
+
import tars_primeiro_uso as pu
|
| 6 |
+
|
| 7 |
+
# 1) reconhecer a variante pelo nome OU pela etiqueta
|
| 8 |
+
casos = [("gargantua-code", "code"), ("gargantua-lite:latest", "lite"), ("ProjectOpenTARS/Gargantua:code", "code"),
|
| 9 |
+
("ProjectOpenTARS/Gargantua:1.2-lite", "lite"), ("ProjectOpenTARS/Gargantua:latest", ""), ("ProjectOpenTARS/Gargantua:1.2", ""),
|
| 10 |
+
("gargantua", ""), ("enzo/gargantua-teste:1.0", ""), ("qwen3:4b", ""), ("qwen2.5-coder:7b", ""), ("", ""), (None, "")]
|
| 11 |
+
for nome, esperado in casos:
|
| 12 |
+
assert tars.variante_do_gargantua(nome) == esperado, (nome, tars.variante_do_gargantua(nome))
|
| 13 |
+
assert pu.e_variante_do_gargantua(nome) == bool(esperado), nome # a cópia do primeiro uso concorda
|
| 14 |
+
print("OK: variante reconhecida pelo nome e pela etiqueta (e as duas cópias da regra concordam)")
|
| 15 |
+
|
| 16 |
+
# 2) 'o Gargantua instalado' é o oficial; variante só conta se não houver outro
|
| 17 |
+
cat = {"gargantua-code:latest": {}, "ProjectOpenTARS/Gargantua:code": {}, "gargantua-lite:latest": {}}
|
| 18 |
+
assert tars.variante_do_gargantua(tars.gargantua_instalado(cat)) # só variantes: devolve uma delas, nunca None
|
| 19 |
+
cat["ProjectOpenTARS/Gargantua:latest"] = {}
|
| 20 |
+
assert tars.gargantua_instalado(cat) == "ProjectOpenTARS/Gargantua:latest"
|
| 21 |
+
assert pu.tem_gargantua(["gargantua-code:latest", "ProjectOpenTARS/Gargantua:code", "gargantua-lite"]) is False # o primeiro uso baixa o oficial
|
| 22 |
+
assert pu.tem_gargantua(["ProjectOpenTARS/Gargantua:code", "ProjectOpenTARS/Gargantua:latest"]) is True
|
| 23 |
+
assert pu.nome_do_gargantua(["ProjectOpenTARS/Gargantua:code", "ProjectOpenTARS/Gargantua:latest"]) == "ProjectOpenTARS/Gargantua:latest"
|
| 24 |
+
assert pu.nome_do_gargantua(["ProjectOpenTARS/Gargantua:code"]) == pu.MODELO_GARGANTUA
|
| 25 |
+
print("OK: o Gargantua oficial nunca é trocado por uma variante (instalado, primeiro uso)")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def entrada(tag, params, caps=("completion", "tools"), categoria="geral"):
|
| 29 |
+
return {"tag": tag, "familia": tag.split(":")[0], "parametros_b": params, "quantizacao": "Q4_K_M",
|
| 30 |
+
"vram_estimado_gb": params * 0.6 + 0.6, "capacidades": set(caps), "capacidades_conhecidas": True,
|
| 31 |
+
"categoria": categoria, "emoji": "x", "nuvem": False}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
catalogo = {
|
| 35 |
+
"qwen3:8b": entrada("qwen3:8b", 8.2), "qwen3:4b": entrada("qwen3:4b", 4.0),
|
| 36 |
+
"qwen2.5-coder:7b": entrada("qwen2.5-coder:7b", 7.6, categoria="codigo"),
|
| 37 |
+
"ProjectOpenTARS/Gargantua:latest": entrada("ProjectOpenTARS/Gargantua:latest", 4.0),
|
| 38 |
+
"gargantua-code:latest": entrada("gargantua-code:latest", 4.0, categoria="codigo"),
|
| 39 |
+
"gargantua-lite:latest": entrada("gargantua-lite:latest", 1.7),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# 3) ação/busca: o oficial na frente; Lite só depois dele; o Code (que é de código) nem entra na frente
|
| 43 |
+
for tarefa in ("acao", "busca", "acao_complexa"):
|
| 44 |
+
fila = tars.construir_cadeia_fallback(tarefa, catalogo=catalogo)
|
| 45 |
+
assert fila[0] == "ProjectOpenTARS/Gargantua:latest", (tarefa, fila)
|
| 46 |
+
assert fila.index("gargantua-lite:latest") > 0
|
| 47 |
+
assert fila.index("gargantua-code:latest") > fila.index("qwen3:8b") or fila.index("gargantua-code:latest") > fila.index("gargantua-lite:latest"), (tarefa, fila)
|
| 48 |
+
assert fila.index("gargantua-code:latest") >= len(fila) - 2, (tarefa, fila) # especialista em código: no fim fora de código
|
| 49 |
+
print("OK: nas ações o oficial vai na frente; Lite depois; Code só como último recurso")
|
| 50 |
+
|
| 51 |
+
# 4) código: o Gargantua Code vai na frente do qwen2.5-coder e do oficial
|
| 52 |
+
fila = tars.construir_cadeia_fallback("codigo", catalogo=catalogo)
|
| 53 |
+
assert fila[0] == "gargantua-code:latest" and "ProjectOpenTARS/Gargantua:latest" in fila, fila
|
| 54 |
+
sem_code = {k: v for k, v in catalogo.items() if "code:" not in k}
|
| 55 |
+
assert tars.construir_cadeia_fallback("codigo", catalogo=sem_code)[0] == "qwen2.5-coder:7b" # sem ele, o de sempre
|
| 56 |
+
print("OK: em pedido de código o Gargantua Code vai na frente (e sem ele nada muda)")
|
| 57 |
+
|
| 58 |
+
# 5) só com Lite instalado (PC fraco): ele atende as ações
|
| 59 |
+
so_lite = {"gargantua-lite:latest": catalogo["gargantua-lite:latest"], "qwen3:0.6b": entrada("qwen3:0.6b", 0.6, categoria="simples")}
|
| 60 |
+
assert tars.construir_cadeia_fallback("acao", catalogo=so_lite)[0] == "gargantua-lite:latest"
|
| 61 |
+
print("OK: num PC fraco só com o Lite, ele atende as ações")
|
| 62 |
+
|
| 63 |
+
# 6) TARS_SEM_GARGANTUA desliga a preferência (também a do Code)
|
| 64 |
+
import os
|
| 65 |
+
os.environ["TARS_SEM_GARGANTUA"] = "1"
|
| 66 |
+
assert tars.construir_cadeia_fallback("codigo", catalogo=catalogo)[0] == "qwen2.5-coder:7b"
|
| 67 |
+
assert tars.construir_cadeia_fallback("acao", catalogo=catalogo)[0] != "ProjectOpenTARS/Gargantua:latest"
|
| 68 |
+
del os.environ["TARS_SEM_GARGANTUA"]
|
| 69 |
+
|
| 70 |
+
# 7) o formato (prompt curto + 19 ferramentas, sem raciocínio) vale pras variantes: foram treinadas assim
|
| 71 |
+
for v in ("gargantua-code:latest", "gargantua-lite:latest", "ProjectOpenTARS/Gargantua:code"):
|
| 72 |
+
assert tars.eh_gargantua(v), v
|
| 73 |
+
print("\nTUDO OK.")
|
app/tests/test_ver_imagem.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""take_screenshot(file=...): a IA "olha" um arquivo de imagem (o Cooper descreve se o modelo não enxerga).
|
| 2 |
+
Pasta pessoal temporária (antes gravava na de verdade e, se falhasse, deixava ~/Documentos/Foto.PNG), com Documents E
|
| 3 |
+
Documentos, como no PC do Enzo: "Documents/foto.png" tem de achar a foto que está em Documentos."""
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
os.environ["HOME"] = tempfile.mkdtemp(prefix="opentars-casa-")
|
| 7 |
+
from _util import carregar_tars # noqa: E402
|
| 8 |
+
from PIL import Image # noqa: E402
|
| 9 |
+
tars = carregar_tars()
|
| 10 |
+
|
| 11 |
+
d = tempfile.mkdtemp()
|
| 12 |
+
foto = os.path.join(d, "foto.png")
|
| 13 |
+
Image.new("RGB", (900, 500), (200, 30, 30)).save(foto)
|
| 14 |
+
|
| 15 |
+
r = tars.executar_ferramenta("take_screenshot", {"file": foto})
|
| 16 |
+
assert r["sucesso"] and r["_imagem_b64"] and r["arquivo"].endswith("foto.png"), r
|
| 17 |
+
print("OK: take_screenshot(file) carrega a imagem do arquivo")
|
| 18 |
+
|
| 19 |
+
r = tars.executar_ferramenta("look_at_image", {"image_path": foto}) # apelidos de nome e de argumento
|
| 20 |
+
assert r["sucesso"] and r["_imagem_b64"], r
|
| 21 |
+
print("OK: look_at_image(image_path=...) vira take_screenshot(file=...)")
|
| 22 |
+
|
| 23 |
+
r = tars.executar_ferramenta("take_screenshot", {"file": os.path.join(d, "nao_existe.png")})
|
| 24 |
+
assert not r["sucesso"] and "not found" in r["mensagem"].lower()
|
| 25 |
+
print("OK: arquivo que não existe dá erro claro")
|
| 26 |
+
|
| 27 |
+
# caminhos como a IA costuma escrever: relativo, pasta em outro idioma, maiúscula
|
| 28 |
+
casa = os.path.expanduser("~")
|
| 29 |
+
assert casa == os.environ["HOME"] and "opentars-casa-" in casa
|
| 30 |
+
os.makedirs(os.path.join(casa, "Documentos"), exist_ok=True)
|
| 31 |
+
os.makedirs(os.path.join(casa, "Documents"), exist_ok=True) # as duas: o resolvedor tem de voltar atrás
|
| 32 |
+
Image.new("RGB", (50, 50), (1, 2, 3)).save(os.path.join(casa, "Documentos", "Foto.PNG"))
|
| 33 |
+
for escrito in ("~/Documentos/Foto.PNG", "Documents/foto.png", "home/documentos/foto.png"):
|
| 34 |
+
r = tars.executar_ferramenta("take_screenshot", {"file": escrito})
|
| 35 |
+
assert r["sucesso"], (escrito, r["mensagem"])
|
| 36 |
+
print("OK: caminho relativo, pasta em inglês/português e maiúscula também funcionam")
|
| 37 |
+
os.remove(os.path.join(casa, "Documentos", "Foto.PNG"))
|
| 38 |
+
|
| 39 |
+
# modelo sem visão: o Cooper descreve (aqui, falso) e a mensagem diz que é uma foto, não a tela
|
| 40 |
+
tars.descrever_com_visao = lambda img, pedido: ("a red rectangle", "cooper:fake")
|
| 41 |
+
texto = tars._print_em_texto("x", "o que tem nessa imagem?", "gargantua", None, "/x/foto.png")
|
| 42 |
+
assert "a red rectangle" in texto and "don't click" in texto and "foto.png" in texto, texto
|
| 43 |
+
print("OK: modelo sem visão recebe a descrição do Cooper da foto")
|
| 44 |
+
print("TUDO OK.")
|
app/tests/test_visao_fallback.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""2.5.2: print de tela com um modelo que não enxerga.
|
| 2 |
+
|
| 3 |
+
Log real: qwen3:8b tirou um print; o openTARS mandou a imagem, o Ollama
|
| 4 |
+
respondeu 400 "model does not support multimodal", e o reenvio "só texto"
|
| 5 |
+
AINDA levava a imagem (erro de novo, pedido perdido)."""
|
| 6 |
+
|
| 7 |
+
from _util import carregar_tars
|
| 8 |
+
|
| 9 |
+
tars = carregar_tars()
|
| 10 |
+
|
| 11 |
+
import base64 as _b64, io as _io # noqa: E402
|
| 12 |
+
from PIL import Image as _Image # noqa: E402
|
| 13 |
+
_buf = _io.BytesIO()
|
| 14 |
+
_Image.new("RGB", (400, 400), "white").save(_buf, format="PNG")
|
| 15 |
+
PNG = _b64.b64encode(_buf.getvalue()).decode()
|
| 16 |
+
|
| 17 |
+
COOPER = "ProjectOpenTARS/cooper:latest"
|
| 18 |
+
ERRO_400 = ('Ollama error (400): {"error":"{\\"error\\":{\\"code\\":400,\\"message\\":\\"Multimodal data '
|
| 19 |
+
'provided, but model does not support multimodal requests.\\"}}"}')
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def catalogo(conhecidas):
|
| 23 |
+
return {
|
| 24 |
+
"qwen3:8b": {"tag": "qwen3:8b", "capacidades": {"tools", "thinking"}, "capacidades_conhecidas": conhecidas,
|
| 25 |
+
"categoria": "geral", "parametros_b": 8.2, "vram_estimado_gb": 5.5},
|
| 26 |
+
COOPER: {"tag": COOPER, "familia": "qwen3vl", "capacidades": {"vision", "completion"}, "capacidades_conhecidas": True,
|
| 27 |
+
"categoria": "visao", "parametros_b": 2.0, "vram_estimado_gb": 2.0},
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
descricoes = []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def chat_unico(modelo, prompt, imagens=None, **kw):
|
| 35 |
+
descricoes.append((modelo, bool(imagens)))
|
| 36 |
+
return "Calculator window. Display: 0. Button '1' at (40, 300), '=' at (200, 380)."
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
tars._chat_unico = chat_unico
|
| 40 |
+
tars.carregar_modelo = lambda m, silencioso=False: True
|
| 41 |
+
tars.contexto_ia = lambda: 8192
|
| 42 |
+
tars.detectar_gpu = lambda forcar=False: {"vram_total_gb": 12.0, "vram_livre_gb": 11.0}
|
| 43 |
+
tars.modo_modelo = "MANUAL"
|
| 44 |
+
tars._DISPATCH_FERRAMENTAS["take_screenshot"] = lambda a: {
|
| 45 |
+
"sucesso": True, "mensagem": "SUCCESS: screenshot taken.", "_imagem_b64": PNG}
|
| 46 |
+
|
| 47 |
+
enviados = []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def turno(modelo, msgs, tools, **kw):
|
| 51 |
+
com_imagem = any(m.get("images") for m in msgs)
|
| 52 |
+
enviados.append((modelo, com_imagem, bool(tools)))
|
| 53 |
+
if com_imagem and modelo == "qwen3:8b":
|
| 54 |
+
return None, ERRO_400
|
| 55 |
+
if not any(m["role"] == "tool" for m in msgs[-4:]) and not any("described the screenshot" in (m.get("content") or "") for m in msgs):
|
| 56 |
+
return {"content": "", "tool_calls": [{"function": {"name": "take_screenshot", "arguments": {"window": "calculator"}}}]}, None
|
| 57 |
+
return {"content": "The calculator shows 0.", "tool_calls": []}, None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
tars._executar_turno_streaming = turno
|
| 61 |
+
|
| 62 |
+
# 1. O catálogo já diz que o qwen3 não enxerga: a imagem nem vai
|
| 63 |
+
tars.catalogar_modelos = lambda forcar=False: catalogo(True)
|
| 64 |
+
tars.conversa = []
|
| 65 |
+
tars._sessoes_por_modelo = {} if hasattr(tars, "_sessoes_por_modelo") else None
|
| 66 |
+
r = tars.perguntar_modelo("qwen3:8b", "what does the calculator show?", tarefa="acao")
|
| 67 |
+
assert r == "The calculator shows 0.", r
|
| 68 |
+
assert not any(img for m, img, _ in enviados), enviados
|
| 69 |
+
assert descricoes == [(COOPER, True)], descricoes
|
| 70 |
+
print("OK: modelo sem visão -> o print não vai; o Cooper descreve a tela em texto e a tarefa continua")
|
| 71 |
+
|
| 72 |
+
# 2. Catálogo sem capacidades conhecidas: o Ollama recusa a imagem ->
|
| 73 |
+
# TODA imagem sai da conversa e o turno é refeito COM as ferramentas
|
| 74 |
+
enviados.clear(); descricoes.clear()
|
| 75 |
+
tars._MODELOS_SEM_VISAO.clear()
|
| 76 |
+
tars.catalogar_modelos = lambda forcar=False: catalogo(False)
|
| 77 |
+
tars.conversa = []
|
| 78 |
+
r = tars.perguntar_modelo("qwen3:8b", "what does the calculator show now?", tarefa="acao")
|
| 79 |
+
assert r == "The calculator shows 0.", (r, enviados)
|
| 80 |
+
com_imagem = [e for e in enviados if e[1]]
|
| 81 |
+
assert len(com_imagem) == 1 and enviados[enviados.index(com_imagem[0]) + 1] == ("qwen3:8b", False, True), enviados
|
| 82 |
+
assert "qwen3:8b" in tars._MODELOS_SEM_VISAO and not tars.modelo_enxerga("qwen3:8b")
|
| 83 |
+
print("OK: 400 'does not support multimodal' -> imagem trocada por descrição, repete com ferramentas, e lembra")
|
| 84 |
+
|
| 85 |
+
# 3. Sem nenhum modelo com visão: orienta a usar list_elements
|
| 86 |
+
enviados.clear(); descricoes.clear()
|
| 87 |
+
tars.catalogar_modelos = lambda forcar=False: {"qwen3:8b": catalogo(True)["qwen3:8b"]}
|
| 88 |
+
msg = tars._print_em_texto(PNG, "x", "qwen3:8b")
|
| 89 |
+
assert "list_elements" in msg and not descricoes
|
| 90 |
+
print("OK: sem modelo de visão instalado -> a IA é mandada pro list_elements")
|
| 91 |
+
print("\nTUDO OK.")
|
app/tests/test_visao_fallback_ferramentas.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from _util import carregar_tars
|
| 2 |
+
import io, json
|
| 3 |
+
tars = carregar_tars()
|
| 4 |
+
|
| 5 |
+
# Force AUTO mode, single candidate model, no fallback chain needed
|
| 6 |
+
tars.modo_modelo = "MANUAL"
|
| 7 |
+
tars.modelo_manual = "qwen3.8:27b"
|
| 8 |
+
|
| 9 |
+
tars.carregar_modelo = lambda *a, **k: True
|
| 10 |
+
|
| 11 |
+
calls = []
|
| 12 |
+
|
| 13 |
+
def fake_stream(modelo, mensagens, tools, **kw):
|
| 14 |
+
calls.append(tools)
|
| 15 |
+
imagem_no_turno = bool(mensagens) and mensagens[-1].get("images")
|
| 16 |
+
if imagem_no_turno and tools:
|
| 17 |
+
# simulate failure: model doesn't support tools+images together
|
| 18 |
+
return None, "Erro do Ollama (400): tools not supported with images"
|
| 19 |
+
if imagem_no_turno and not tools:
|
| 20 |
+
return {"content": "Vejo uma tela com um editor de texto aberto.", "thinking": "", "tool_calls": []}, None
|
| 21 |
+
# first turn: request a screenshot
|
| 22 |
+
return {"content": "", "thinking": "", "tool_calls": [
|
| 23 |
+
{"function": {"name": "take_screenshot", "arguments": {}}}
|
| 24 |
+
]}, None
|
| 25 |
+
|
| 26 |
+
tars._executar_turno_streaming = fake_stream
|
| 27 |
+
tars.executar_ferramenta = lambda nome, args: {"sucesso": True, "mensagem": "print tirado", "_imagem_b64": "AAAA"}
|
| 28 |
+
tars.obter_sessao = lambda modelo: [{"role": "system", "content": "sys"}]
|
| 29 |
+
|
| 30 |
+
resultado = tars.perguntar_modelo("qwen3.8:27b", "veja minha tela e faça uma pergunta simples", tarefa="visao")
|
| 31 |
+
print("RESULTADO FINAL:", repr(resultado))
|
| 32 |
+
assert resultado == "Vejo uma tela com um editor de texto aberto."
|
| 33 |
+
print("Numero de chamadas a _executar_turno_streaming:", len(calls))
|
| 34 |
+
assert len(calls) == 3 # 1) tool-call turn, 2) failed image+tools turn, 3) fallback image-only turn
|
| 35 |
+
print("OK: fallback funcionou e resposta final foi retornada corretamente.")
|
app/tests/test_voz_continua.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voz: "contínua" ouve sem o nome; "tars" só com o nome; gravador que falha vira erro explicado."""
|
| 2 |
+
import array, math, os, sys, time
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 5 |
+
import tars_voz as v
|
| 6 |
+
|
| 7 |
+
def tom(ms, amp):
|
| 8 |
+
n = int(v.TAXA * ms / 1000)
|
| 9 |
+
a = array.array("h", (int(amp * math.sin(2 * math.pi * 300 * i / v.TAXA)) for i in range(n)))
|
| 10 |
+
return a.tobytes()
|
| 11 |
+
|
| 12 |
+
def gravar():
|
| 13 |
+
quieto = tom(1, 0) # zeros
|
| 14 |
+
yield b"\x01\x00" * int(v.TAXA * 0.5) # ruído de fundo mínimo
|
| 15 |
+
for _ in range(3):
|
| 16 |
+
yield tom(600, 6000) # fala
|
| 17 |
+
yield b"\x01\x00" * int(v.TAXA * 0.9) # silêncio
|
| 18 |
+
time.sleep(0.3)
|
| 19 |
+
|
| 20 |
+
class Falso:
|
| 21 |
+
def __init__(self, texto): self.texto = texto
|
| 22 |
+
def transcrever(self, *a, **k): return self.texto
|
| 23 |
+
def aquecer(self): pass
|
| 24 |
+
|
| 25 |
+
def rodar(modo, texto):
|
| 26 |
+
pedidos = []
|
| 27 |
+
o = v.Ouvinte(pedidos.append, "pt_BR", modo=modo, gravar=gravar, transcritor=Falso(texto),
|
| 28 |
+
transcritor_ativacao=Falso(texto), aquecer=False)
|
| 29 |
+
o._rodar()
|
| 30 |
+
return pedidos
|
| 31 |
+
|
| 32 |
+
assert rodar("continua", "abre o firefox") , "contínua não ouviu"
|
| 33 |
+
print("OK: contínua: fala sem dizer TARS vira pedido")
|
| 34 |
+
assert rodar("continua", "TARS, abre o firefox")[0] == "abre o firefox"
|
| 35 |
+
print("OK: contínua: se disser TARS, o nome é tirado")
|
| 36 |
+
assert rodar("ativacao", "abre o firefox") == []
|
| 37 |
+
print("OK: palavra TARS: sem o nome, não age")
|
| 38 |
+
assert rodar("ativacao", "TARS, abre o firefox")[0] == "abre o firefox"
|
| 39 |
+
print("OK: palavra TARS: com o nome, age")
|
| 40 |
+
|
| 41 |
+
# gravador que falha: erro explicado em vez de "parado" mudo
|
| 42 |
+
estados = []
|
| 43 |
+
o = v.Ouvinte(lambda t: None, "pt_BR", modo="continua", ao_estado=estados.append, aquecer=False)
|
| 44 |
+
orig, v.GRAVADORES = v.GRAVADORES, (("false", ["false"]),)
|
| 45 |
+
try:
|
| 46 |
+
o._rodar()
|
| 47 |
+
finally:
|
| 48 |
+
v.GRAVADORES = orig
|
| 49 |
+
assert any(e.startswith("erro:microfone") for e in estados), estados
|
| 50 |
+
print("OK: gravador que morre vira erro:", [e for e in estados if e.startswith("erro")][0][:90])
|
| 51 |
+
print("TUDO OK.")
|
app/tests/test_voz_kokoro.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""4.0 Endurance: voz natural (Kokoro) e ouvir melhor (Whisper, "TARS").
|
| 2 |
+
|
| 3 |
+
- 5.0 Tesseract: voz em inglês e em português (Kokoro pf_dora/pm_alex/pm_santa, Whisper multilíngue);
|
| 4 |
+
- texto preparado pra fala (contas, datas, caminhos, unidades, "openTARS");
|
| 5 |
+
- fala frase a frase (a primeira toca enquanto as outras são geradas) e o
|
| 6 |
+
"Parar" corta no meio;
|
| 7 |
+
- voz escolhida e velocidade (config.json -> Kokoro);
|
| 8 |
+
- Whisper escolhido pelo hardware, "TARS" mais tolerante, frases fantasma descartadas.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import tempfile
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
for var in ("XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"):
|
| 20 |
+
os.environ[var] = tempfile.mkdtemp(prefix=f"opentars-{var.lower()}-")
|
| 21 |
+
os.environ["TARS_ARQUIVO_SESSOES"] = os.path.join(tempfile.mkdtemp(), "sessoes.json")
|
| 22 |
+
|
| 23 |
+
from _util import carregar_tars # noqa: E402
|
| 24 |
+
|
| 25 |
+
tars = carregar_tars()
|
| 26 |
+
voz = tars.voz_lib
|
| 27 |
+
|
| 28 |
+
# 1. Texto -> fala (Endurance Neo: a voz é só em inglês)
|
| 29 |
+
P = voz.preparar_para_fala
|
| 30 |
+
casos = {
|
| 31 |
+
"Opened the calculator: 12 x 8 = 96.": "Opened the calculator: 12 times 8 equals 96.",
|
| 32 |
+
"The result is 8,148.": "The result is 8148.",
|
| 33 |
+
"I created /home/sam/Desktop/notes.txt for you.": "I created notes.txt for you.",
|
| 34 |
+
"You have 3.5 GB free (45%).": "You have 3.5 gigabytes free (45 percent).",
|
| 35 |
+
"Today is 2026-10-01.": "Today is October 1, 2026.",
|
| 36 |
+
"Your screen is 1920x1080.": "Your screen is 1920 by 1080.",
|
| 37 |
+
"It takes 3-5 minutes.": "It takes 3 to 5 minutes.",
|
| 38 |
+
"281 - 54 = 227": "281 minus 54 equals 227",
|
| 39 |
+
"I am Gargantua, the openTARS AI. You can call me TARS.":
|
| 40 |
+
"I am Gargantua, the open Tars AI. You can call me Tars.",
|
| 41 |
+
}
|
| 42 |
+
for entrada, esperado in casos.items():
|
| 43 |
+
assert P(entrada, "en") == esperado, (entrada, P(entrada, "en"))
|
| 44 |
+
assert P("12 x 8 = 96, on 2026-10-01; 1,234 files", "en") == "12 times 8 equals 96, on October 1, 2026; 1234 files"
|
| 45 |
+
assert P("Done! Opened Firefox.") == "Done! Opened Firefox."
|
| 46 |
+
print("OK: math, dates, paths and units become natural English speech")
|
| 47 |
+
|
| 48 |
+
frases = voz.dividir_frases("Oi. Abri o Firefox e pesquisei o que você pediu, já está na tela. Pronto! "
|
| 49 |
+
"Quer que eu salve a página nos seus favoritos? " + "Uma frase bem comprida, " * 20 + "fim.")
|
| 50 |
+
assert len(frases) >= 3 and all(len(f) <= 270 for f in frases) and frases[0].startswith("Oi."), frases
|
| 51 |
+
assert " ".join(frases).replace(" ", " ").startswith("Oi. Abri o Firefox")
|
| 52 |
+
print(f"OK: resposta longa vira {len(frases)} trechos (o primeiro toca enquanto os outros são gerados)")
|
| 53 |
+
|
| 54 |
+
# 2. Voz escolhida e velocidade (Kokoro, só inglês)
|
| 55 |
+
assert set(voz.VOZES_DISPONIVEIS) == {"en", "pt_BR"}
|
| 56 |
+
assert set(voz.VOZES_DISPONIVEIS["en"]) == {"heart", "bella", "michael", "emma"}
|
| 57 |
+
assert set(voz.VOZES_DISPONIVEIS["pt_BR"]) == {"dora", "alex", "santa"}
|
| 58 |
+
assert voz.IDIOMAS_COM_VOZ == ("en", "pt_BR") and voz.tem_voz("en") and voz.tem_voz("pt_BR") and not voz.tem_voz("de")
|
| 59 |
+
assert voz.nome_da_voz("en") == "heart" and voz.voz_kokoro("en") == ("af_heart", "en-us")
|
| 60 |
+
assert voz.nome_da_voz("pt_BR") == "dora" and voz.voz_kokoro("pt_BR") == ("pf_dora", "pt-br")
|
| 61 |
+
assert voz.voz_kokoro("de") == ("af_heart", "en-us"), "idioma sem voz cai na voz em inglês"
|
| 62 |
+
assert voz.voz_kokoro("pt_BR", "santa") == ("pm_santa", "pt-br")
|
| 63 |
+
voz.configurar(vozes={"en": "michael", "pt_BR": "alex"}, velocidade=1.25)
|
| 64 |
+
assert voz.voz_kokoro("en") == ("am_michael", "en-us") and voz.voz_kokoro("pt_BR") == ("pm_alex", "pt-br")
|
| 65 |
+
assert voz.voz_kokoro("en", "emma") == ("bf_emma", "en-gb")
|
| 66 |
+
assert voz.PREFERENCIAS["velocidade"] == 1.25
|
| 67 |
+
voz.configurar(velocidade=9)
|
| 68 |
+
assert voz.PREFERENCIAS["velocidade"] == voz.VELOCIDADE_MAX
|
| 69 |
+
voz.configurar(vozes={"en": "nao-existe"}, velocidade=1.0)
|
| 70 |
+
assert voz.nome_da_voz("en") == "heart"
|
| 71 |
+
|
| 72 |
+
tars.i18n.definir_idioma("en")
|
| 73 |
+
ok, msg = tars.escolher_voz("dora")
|
| 74 |
+
assert not ok and "heart" in msg
|
| 75 |
+
for arquivo in voz.arquivos_kokoro(): # já baixado: não vai pra internet
|
| 76 |
+
arquivo.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
arquivo.write_bytes(b"x" * 2000)
|
| 78 |
+
ok, msg = tars.escolher_voz("Bella")
|
| 79 |
+
assert ok and tars.preferencias_voz()["vozes"]["en"] == "bella" and voz.voz_kokoro("en")[0] == "af_bella"
|
| 80 |
+
assert tars.definir_velocidade_voz("1,1")[0] and abs(voz.PREFERENCIAS["velocidade"] - 1.1) < 1e-9
|
| 81 |
+
assert not tars.definir_velocidade_voz("rápido")[0]
|
| 82 |
+
assert "Bella" in tars.frase_de_teste_voz()
|
| 83 |
+
print("OK: Kokoro voices (Heart, Bella, Michael, Emma); choice and speed go from config.json to Kokoro")
|
| 84 |
+
|
| 85 |
+
# 2a. Português (5.0): vozes pf_dora / pm_alex / pm_santa, Whisper multilíngue, texto falado em português
|
| 86 |
+
tars.i18n.definir_idioma("pt_BR")
|
| 87 |
+
assert tars.voz_no_idioma() and tars.voz_atual() == "dora"
|
| 88 |
+
ok, msg = tars.escolher_voz("heart")
|
| 89 |
+
assert not ok and "dora" in msg
|
| 90 |
+
ok, msg = tars.escolher_voz("Santa")
|
| 91 |
+
assert ok and tars.preferencias_voz()["vozes"]["pt_BR"] == "santa" and voz.voz_kokoro("pt_BR") == ("pm_santa", "pt-br")
|
| 92 |
+
assert voz._idioma_whisper["l"] == "pt_BR"
|
| 93 |
+
voz._config_whisper.clear()
|
| 94 |
+
assert voz.config_whisper(forcar=True, memoria=(2500, 6144))["pedido"] == ("small", "cpu", "int8")
|
| 95 |
+
assert voz.config_whisper(forcar=True, memoria=(5600, 6144))["ativacao"] == ("base", "cuda", "int8_float16")
|
| 96 |
+
voz._config_whisper.clear()
|
| 97 |
+
assert voz.CONFIRMACOES["pt_BR"] == "Sim?"
|
| 98 |
+
assert voz.preparar_para_fala("12 x 8 = 96 em 2026-10-01 (16GB, 1.234 arquivos, 50%)", "pt_BR") == \
|
| 99 |
+
"12 vezes 8 igual a 96 em primeiro de outubro de 2026 (16 gigabytes, 1234 arquivos, 50 por cento)"
|
| 100 |
+
assert voz.e_alucinacao("Legendas pela comunidade Amara.org") and voz.e_alucinacao("Obrigado por assistir!")
|
| 101 |
+
assert not voz.e_alucinacao("abre o navegador")
|
| 102 |
+
tars.i18n.definir_idioma("en")
|
| 103 |
+
voz._config_whisper.clear()
|
| 104 |
+
tars.aplicar_preferencias_voz()
|
| 105 |
+
print("OK: voz em português (Dora, Alex, Santa), Whisper multilíngue, números/datas/unidades falados em português")
|
| 106 |
+
|
| 107 |
+
# 2b. Whisper escolhido pelo hardware (modelos .en no processador)
|
| 108 |
+
voz._config_whisper.clear()
|
| 109 |
+
assert voz.config_whisper(forcar=True, memoria=(5600, 6144))["pedido"] == ("large-v3-turbo", "cuda", "int8_float16")
|
| 110 |
+
assert voz.config_whisper(forcar=True, memoria=(2500, 6144))["pedido"] == ("small.en", "cpu", "int8")
|
| 111 |
+
assert voz.config_whisper(forcar=True, memoria=(3000, 12288))["pedido"][1] == "cuda"
|
| 112 |
+
medir_placa = voz._memoria_da_placa
|
| 113 |
+
voz._memoria_da_placa = lambda: None # sem placa de vídeo (antes media a placa de verdade e dependia da VRAM livre)
|
| 114 |
+
assert voz.config_whisper(forcar=True, memoria=None)["ativacao"] == ("base.en", "cpu", "int8")
|
| 115 |
+
voz._memoria_da_placa = medir_placa
|
| 116 |
+
voz._config_whisper["placa_falhou"] = True
|
| 117 |
+
assert voz.config_whisper(forcar=True, memoria=(5600, 6144))["pedido"][1] == "cpu"
|
| 118 |
+
voz._config_whisper.clear()
|
| 119 |
+
print("OK: Whisper large-v3-turbo on the GPU when there is room, small.en on the CPU; GPU failure -> CPU")
|
| 120 |
+
|
| 121 |
+
# 2c. "TARS" mais tolerante, sem ligar à toa; frases inventadas pelo Whisper
|
| 122 |
+
for texto in ("Tars, open Firefox", "Tarz, what time is it", "hey Thars, close Discord",
|
| 123 |
+
"Dars, search for a recipe", "ok tares open spotify", "Tarzo open the calculator"):
|
| 124 |
+
chamou, resto = voz.achar_ativacao(texto)
|
| 125 |
+
assert chamou and resto, (texto, chamou, resto)
|
| 126 |
+
for texto in ("Tas, open Firefox", "stars", "we were talking about tars last night at dinner", "starship"):
|
| 127 |
+
assert not voz.achar_ativacao(texto)[0], texto
|
| 128 |
+
assert voz.parece_chamado("Taz, open firefox") and not voz.parece_chamado("open firefox")
|
| 129 |
+
for texto in ("Thanks for watching!", "Subtitles by the Amara.org community", "Please subscribe", "...", "♪♪"):
|
| 130 |
+
assert voz.e_alucinacao(texto), texto
|
| 131 |
+
assert not voz.e_alucinacao("open the browser and search thanks for watching")
|
| 132 |
+
print("OK: 'TARS' heard in more spellings (tarz, thars, dars...), not 'tas'; Whisper ghost phrases dropped")
|
| 133 |
+
|
| 134 |
+
# 2d. Transcrição do pedido: vocabulário dos apps, beam 5, detector de voz
|
| 135 |
+
class ModeloFalso:
|
| 136 |
+
def __init__(self, texto):
|
| 137 |
+
self.texto, self.opcoes = texto, []
|
| 138 |
+
|
| 139 |
+
def transcribe(self, dados, **opcoes):
|
| 140 |
+
self.opcoes.append(opcoes)
|
| 141 |
+
return iter([type("S", (), {"text": self.texto})()]), None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
voz.Transcritor._cache["pedido-teste"] = m = ModeloFalso(" open Steam")
|
| 145 |
+
tr = voz.Transcritor("pedido-teste")
|
| 146 |
+
assert tr.transcrever(b"\0\0" * 1600, "en", vocabulario=["Steam", "Discord"]) == "open Steam"
|
| 147 |
+
o = m.opcoes[-1]
|
| 148 |
+
assert o["beam_size"] == 5 and o["vad_filter"] is True and "Steam" in o["hotwords"] and "initial_prompt" not in o, o
|
| 149 |
+
voz.Transcritor._cache["fantasma"] = ModeloFalso(" Thanks for watching!")
|
| 150 |
+
assert voz.Transcritor("fantasma").transcrever(b"\0\0" * 1600, "en") == ""
|
| 151 |
+
print("OK: pedido transcrito com os nomes dos apps como vocabulário, busca mais cuidadosa e sem frases fantasma")
|
| 152 |
+
|
| 153 |
+
# 2e. Segunda chance: o modelo rápido ouviu "Taz", o bom confere e acha o "TARS"
|
| 154 |
+
class TranscritorFalso:
|
| 155 |
+
def __init__(self, texto):
|
| 156 |
+
self.texto, self.vocabularios = texto, []
|
| 157 |
+
|
| 158 |
+
def transcrever(self, audio, idioma=None, dica=None, rapido=False, vocabulario=None):
|
| 159 |
+
self.vocabularios.append(vocabulario)
|
| 160 |
+
return self.texto
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
rapido_ruim, bom = TranscritorFalso("Taz, open"), TranscritorFalso("Tars, open Firefox.")
|
| 164 |
+
ouv = voz.Ouvinte(lambda texto: None, "en", transcritor=bom, transcritor_ativacao=rapido_ruim, aquecer=False,
|
| 165 |
+
vocabulario=lambda: ["Firefox", "Steam"])
|
| 166 |
+
assert ouv._nome_no_trecho(b"\0\0" * 16000 * 2, None, "en") == (True, "open Firefox")
|
| 167 |
+
assert bom.vocabularios[-1] == ["Firefox", "Steam"]
|
| 168 |
+
rapido_nada = TranscritorFalso("Good morning everyone")
|
| 169 |
+
ouv2 = voz.Ouvinte(lambda texto: None, "en", transcritor=bom, transcritor_ativacao=rapido_nada, aquecer=False)
|
| 170 |
+
bom.vocabularios.clear()
|
| 171 |
+
assert ouv2._nome_no_trecho(b"\0\0" * 16000 * 2, None, "en") == (False, "") and not bom.vocabularios
|
| 172 |
+
print("OK: 'Taz' no modelo rápido -> o modelo bom confere e acha 'TARS'; fala comum nem passa pelo modelo bom")
|
| 173 |
+
|
| 174 |
+
# 3. Fala frase a frase, com Parar no meio
|
| 175 |
+
geradas, tocadas = [], []
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class FaladorTeste(voz.Falador):
|
| 179 |
+
def _gerar(self, texto, idioma, destino, cancelar=None):
|
| 180 |
+
time.sleep(0.05)
|
| 181 |
+
geradas.append(texto)
|
| 182 |
+
Path(destino).write_bytes(b"RIFF" + b"\0" * 100)
|
| 183 |
+
return True
|
| 184 |
+
|
| 185 |
+
def _tocar_arquivo(self, tocador, wav, cancelar):
|
| 186 |
+
if cancelar.is_set():
|
| 187 |
+
return
|
| 188 |
+
tocadas.append(geradas[len(tocadas)])
|
| 189 |
+
time.sleep(0.15)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
f = FaladorTeste(tocar=["true"])
|
| 193 |
+
texto = "Primeira frase, com um pouco de texto pra ter tamanho. Segunda frase, também com bastante texto. " \
|
| 194 |
+
"Terceira frase, a última, igualmente longa pra não juntar."
|
| 195 |
+
f._falar_de_fato(texto, "en")
|
| 196 |
+
assert tocadas == geradas and len(tocadas) == 3, (geradas, tocadas)
|
| 197 |
+
assert not f.falando.is_set()
|
| 198 |
+
assert not list(Path(tempfile.gettempdir()).glob("opentars-fala-*.wav")), "wav temporário ficou pra trás"
|
| 199 |
+
geradas.clear(), tocadas.clear()
|
| 200 |
+
t = threading.Thread(target=f._falar_de_fato, args=(texto, "en"))
|
| 201 |
+
t.start()
|
| 202 |
+
time.sleep(0.12)
|
| 203 |
+
f.parar()
|
| 204 |
+
t.join(3)
|
| 205 |
+
assert not t.is_alive() and len(tocadas) < 3 and not f.falando.is_set(), tocadas
|
| 206 |
+
print("OK: fala frase a frase, sem sobrar arquivo, e o Parar corta no meio")
|
| 207 |
+
|
| 208 |
+
print("\nTUDO OK.")
|
app/tests/test_voz_progresso.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""26/10: o download da voz tem barra de verdade: o Kokoro é medido em bytes (Content-Length), o resto fica em movimento.
|
| 2 |
+
Sem internet: `requests.get` é de mentira."""
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
| 9 |
+
os.environ["TARS_PASTA_KOKORO"] = tempfile.mkdtemp(prefix="opentars-kokoro-")
|
| 10 |
+
from _util import RAIZ # noqa: E402,F401
|
| 11 |
+
|
| 12 |
+
import requests # noqa: E402
|
| 13 |
+
import tars_voz as voz # noqa: E402
|
| 14 |
+
|
| 15 |
+
voz.pasta_kokoro = lambda: Path(os.environ["TARS_PASTA_KOKORO"])
|
| 16 |
+
TAMANHOS = {"kokoro-v1.0.onnx": 3_000_000, "voices-v1.0.bin": 1_000_000}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class _Resp:
|
| 20 |
+
def __init__(self, url):
|
| 21 |
+
self.tamanho = TAMANHOS[next(n for n in TAMANHOS if n in url)]
|
| 22 |
+
self.headers = {"Content-Length": str(self.tamanho)}
|
| 23 |
+
|
| 24 |
+
def __enter__(self):
|
| 25 |
+
return self
|
| 26 |
+
|
| 27 |
+
def __exit__(self, *a):
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
def raise_for_status(self):
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
def iter_content(self, bloco):
|
| 34 |
+
for i in range(0, self.tamanho, bloco):
|
| 35 |
+
yield b"x" * min(bloco, self.tamanho - i)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
requests.get = lambda url, **k: _Resp(url)
|
| 39 |
+
passos = []
|
| 40 |
+
ok, erro = voz.baixar_voz("en", lambda m: None, progresso=lambda f, t: passos.append((round(f, 3), t)))
|
| 41 |
+
assert ok, erro
|
| 42 |
+
fracoes = [f for f, _ in passos]
|
| 43 |
+
assert fracoes == sorted(fracoes) and fracoes[-1] > 0.99 and fracoes[0] < 0.1, fracoes[:3]
|
| 44 |
+
assert all("MB" in t for _, t in passos)
|
| 45 |
+
assert (voz.pasta_kokoro() / "kokoro-v1.0.onnx").stat().st_size == 3_000_000 and not list(voz.pasta_kokoro().glob("*.parte"))
|
| 46 |
+
print("OK: o download do Kokoro reporta a fração em bytes, do começo ao fim, sem sobra de arquivo parcial")
|
| 47 |
+
|
| 48 |
+
passos.clear()
|
| 49 |
+
ok, _ = voz.baixar_voz("en", lambda m: None, progresso=lambda f, t: passos.append(f))
|
| 50 |
+
assert ok and passos == [], "já baixado: nada a medir"
|
| 51 |
+
print("OK: o que já está baixado não baixa de novo")
|
| 52 |
+
|
| 53 |
+
# a janela: a barra mostra a fração e some no fim
|
| 54 |
+
from PySide6.QtWidgets import QApplication # noqa: E402
|
| 55 |
+
from tars_qt import configuracoes as cfgmod # noqa: E402
|
| 56 |
+
app = QApplication.instance() or QApplication([])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class _N:
|
| 60 |
+
def config_voz(self): return {"falar": False}
|
| 61 |
+
def preferencias_voz(self): return {"velocidade": 1.0}
|
| 62 |
+
def voz_atual(self): return "af_heart"
|
| 63 |
+
def o_que_falta_voz(self): return ["voz"]
|
| 64 |
+
def descrever_falta_voz(self, f): return "voz"
|
| 65 |
+
def salvar_config_voz(self, **k): pass
|
| 66 |
+
|
| 67 |
+
def instalar_voz(self, avisar=None, progresso=None):
|
| 68 |
+
progresso(None, "pip install faster-whisper")
|
| 69 |
+
progresso(0.5, "kokoro-v1.0.onnx: 160 / 310 MB")
|
| 70 |
+
return True, ""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class _C:
|
| 74 |
+
nucleo = _N()
|
| 75 |
+
def depois(self, f): f()
|
| 76 |
+
def em_segundo_plano(self, trabalho, pronto): pronto(trabalho())
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
pagina = cfgmod.PVoz(_C())
|
| 80 |
+
vistos = []
|
| 81 |
+
original = pagina.barra.setValue
|
| 82 |
+
pagina.barra.setValue = lambda v: (vistos.append(v), original(v))[1]
|
| 83 |
+
pagina._instalar()
|
| 84 |
+
assert 50 in vistos and not pagina.barra.isVisibleTo(pagina)
|
| 85 |
+
print("OK: a janela mostra a barra do download da voz e a esconde no fim")
|
| 86 |
+
print("test_voz_progresso: TUDO OK.")
|
app/tests/test_voz_v30.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""3.0: voz — microfone em trechos, palavra de ativação "TARS", texto pra
|
| 2 |
+
fala, e o Ouvinte de ponta a ponta com áudio sintético e um "Whisper" falso.
|
| 3 |
+
Não precisa de microfone, Whisper nem Piper instalados."""
|
| 4 |
+
|
| 5 |
+
import array
|
| 6 |
+
import math
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
|
| 10 |
+
os.environ.setdefault("XDG_DATA_HOME", tempfile.mkdtemp(prefix="opentars-dados-"))
|
| 11 |
+
from _util import carregar_tars # noqa: E402
|
| 12 |
+
|
| 13 |
+
tars = carregar_tars()
|
| 14 |
+
voz = tars.voz_lib
|
| 15 |
+
TAXA = voz.TAXA
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def tom(segundos, amplitude=6000, freq=220):
|
| 19 |
+
n = int(TAXA * segundos)
|
| 20 |
+
a = array.array("h", (int(amplitude * math.sin(2 * math.pi * freq * i / TAXA)) for i in range(n)))
|
| 21 |
+
return a.tobytes()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def silencio(segundos, amplitude=40):
|
| 25 |
+
n = int(TAXA * segundos)
|
| 26 |
+
a = array.array("h", ((amplitude if (i // 7) % 2 else -amplitude) for i in range(n)))
|
| 27 |
+
return a.tobytes()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def frequencia(audio):
|
| 31 |
+
"""Frequência do tom no áudio (ignora o silêncio): o 'Whisper' falso
|
| 32 |
+
reconhece a fala pela 'voz' (cada frase de teste tem um tom)."""
|
| 33 |
+
a = array.array("h")
|
| 34 |
+
a.frombytes(audio)
|
| 35 |
+
fortes = [x for x in a if abs(x) > 300]
|
| 36 |
+
if len(fortes) < 100:
|
| 37 |
+
return 0
|
| 38 |
+
trocas = sum(1 for x, y in zip(a, a[1:]) if abs(x) > 300 or abs(y) > 300 if (x < 0) != (y < 0))
|
| 39 |
+
return trocas / 2 / (len(fortes) / TAXA)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ------------------------------------------------------------
|
| 43 |
+
# 1. Segmentador: acha as falas no meio do silêncio
|
| 44 |
+
# ------------------------------------------------------------
|
| 45 |
+
audio = silencio(1.0) + tom(0.8) + silencio(1.0) + tom(0.1) + silencio(1.0) + tom(1.5, freq=330) + silencio(1.0)
|
| 46 |
+
seg = voz.Segmentador()
|
| 47 |
+
trechos = []
|
| 48 |
+
for i in range(0, len(audio), 1234): # pedaços de tamanho qualquer, como vêm do arecord
|
| 49 |
+
trechos += seg.alimentar(audio[i:i + 1234])
|
| 50 |
+
assert len(trechos) == 2, [len(t) for t in trechos] # o estalo de 0,1 s não conta
|
| 51 |
+
dur = [len(t) / 2 / TAXA for t in trechos]
|
| 52 |
+
assert 0.8 <= dur[0] <= 0.8 + (voz.SILENCIO_FIM_MS + voz.PRE_ROLL_MS) / 1000 + 0.1, dur
|
| 53 |
+
assert 1.5 <= dur[1] <= 1.5 + (voz.SILENCIO_FIM_MS + voz.PRE_ROLL_MS) / 1000 + 0.1, dur
|
| 54 |
+
assert seg.limiar == voz.LIMIAR_MINIMO, "ruído baixo: limiar mínimo"
|
| 55 |
+
print(f"OK: segmentador acha as 2 falas ({dur[0]:.2f}s e {dur[1]:.2f}s) e ignora o estalo de 0,1 s")
|
| 56 |
+
|
| 57 |
+
barulho = voz.Segmentador()
|
| 58 |
+
for _ in range(50):
|
| 59 |
+
barulho.alimentar(silencio(0.1, amplitude=900))
|
| 60 |
+
assert barulho.limiar > 2000 and not barulho.alimentar(silencio(2, amplitude=900)), "ruído de fundo alto não é fala"
|
| 61 |
+
print(f"OK: com ruído de fundo (ventoinha), o limiar sobe pra {barulho.limiar:.0f} e o ruído não vira fala")
|
| 62 |
+
|
| 63 |
+
# ------------------------------------------------------------
|
| 64 |
+
# 2. Palavra de ativação
|
| 65 |
+
# ------------------------------------------------------------
|
| 66 |
+
casos = {
|
| 67 |
+
"TARS, abre o Firefox.": (True, "abre o Firefox"),
|
| 68 |
+
"Ei Tars abre o spotify": (True, "abre o spotify"),
|
| 69 |
+
"Hey Tarz, what's the weather?": (True, "what's the weather"),
|
| 70 |
+
"ok thars": (True, ""),
|
| 71 |
+
"Tars.": (True, ""),
|
| 72 |
+
"OpenTARS, fecha a aba": (True, "fecha a aba"),
|
| 73 |
+
"Oye Tars, abre el navegador": (True, "abre el navegador"),
|
| 74 |
+
"eu tava vendo aquele filme com o tars ontem": (False, ""),
|
| 75 |
+
"abre o firefox": (False, ""),
|
| 76 |
+
"Mars is a planet": (False, ""),
|
| 77 |
+
"tarsila do amaral": (False, ""),
|
| 78 |
+
"Ei… TARS, abre o Firefox": (True, "abre o Firefox"), # "…" vira 3 caracteres no NFKD
|
| 79 |
+
"Olá TARS, finaliza o download": (True, "finaliza o download"),
|
| 80 |
+
}
|
| 81 |
+
for frase, esperado in casos.items():
|
| 82 |
+
assert voz.achar_ativacao(frase) == esperado, (frase, voz.achar_ativacao(frase))
|
| 83 |
+
print(f"OK: 'TARS' no começo (e variações do Whisper) chama; no meio da frase ou parecido, não ({len(casos)} casos)")
|
| 84 |
+
|
| 85 |
+
# ------------------------------------------------------------
|
| 86 |
+
# 3. Texto -> fala
|
| 87 |
+
# ------------------------------------------------------------
|
| 88 |
+
md = ("## Pronto!\n\nAbri o **Firefox** e pesquisei [RTX 5060](https://x.com/y).\n\n"
|
| 89 |
+
"```bash\nsudo apt update\n```\n- item um\n- item dois 🚀\n| a | b |\n")
|
| 90 |
+
fala = voz.limpar_para_fala(md)
|
| 91 |
+
assert fala == "Pronto!. Abri o Firefox e pesquisei RTX 5060. item um. item dois" or "sudo" not in fala, fala
|
| 92 |
+
assert "**" not in fala and "http" not in fala and "sudo" not in fala and "🚀" not in fala and "|" not in fala, fala
|
| 93 |
+
longo = voz.limpar_para_fala("Frase curta. " * 200)
|
| 94 |
+
assert len(longo) <= voz.LIMITE_FALA and longo.endswith("."), longo[-30:]
|
| 95 |
+
print(f"OK: markdown, código, links e emoji saem da fala: '{fala}'")
|
| 96 |
+
|
| 97 |
+
# ------------------------------------------------------------
|
| 98 |
+
# 4. Programas e o que falta
|
| 99 |
+
# ------------------------------------------------------------
|
| 100 |
+
assert voz.comando_gravar(lambda p: p == "parecord")[0] == "parecord"
|
| 101 |
+
assert voz.comando_gravar(lambda p: False) is None
|
| 102 |
+
assert voz.comando_tocar(lambda p: p in ("aplay", "paplay"))[0] == "paplay"
|
| 103 |
+
falta = voz.o_que_falta("pt_BR", existe=lambda p: False, tem_modulo=lambda m: False)
|
| 104 |
+
assert falta == ["faster-whisper", "kokoro-onnx", "voz", "gravador", "tocador"], falta
|
| 105 |
+
# 4.0: Kokoro (um modelo pra todas as vozes); Piper só de reserva
|
| 106 |
+
assert voz.arquivo_voz("pt_BR").name == voz.arquivo_voz("en").name == "kokoro-v1.0.onnx"
|
| 107 |
+
assert voz.voz_kokoro("pt_BR") == ("pf_dora", "pt-br") and voz.voz_kokoro("de") == ("af_heart", "en-us")
|
| 108 |
+
assert voz.urls_voz_piper("pt_BR")[0].endswith("en/en_US/lessac/medium/en_US-lessac-medium.onnx?download=true")
|
| 109 |
+
texto = tars.descrever_falta_voz(["kokoro-onnx", "gravador"])
|
| 110 |
+
assert "kokoro-onnx" in texto and "alsa-utils" in texto, texto
|
| 111 |
+
print("OK: acha gravador/tocador que houver, diz o que falta (em português) e de onde vem a voz de cada idioma")
|
| 112 |
+
|
| 113 |
+
# ------------------------------------------------------------
|
| 114 |
+
# 5. Ouvinte de ponta a ponta (áudio sintético, Whisper falso)
|
| 115 |
+
# ------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class WhisperFalso:
|
| 119 |
+
"""Devolve o texto 'falado' no trecho: pela frequência do tom (chaves
|
| 120 |
+
>= 100, em Hz) ou pela duração (chaves pequenas, em segundos)."""
|
| 121 |
+
|
| 122 |
+
def __init__(self, falas):
|
| 123 |
+
self.falas = falas
|
| 124 |
+
self.chamadas = 0
|
| 125 |
+
|
| 126 |
+
def transcrever(self, audio, idioma=None, dica="", rapido=False):
|
| 127 |
+
self.chamadas += 1
|
| 128 |
+
if min(self.falas) >= 100:
|
| 129 |
+
f = frequencia(audio)
|
| 130 |
+
return min(self.falas.items(), key=lambda kv: abs(kv[0] - f))[1]
|
| 131 |
+
dur = len(audio) / 2 / TAXA
|
| 132 |
+
return min(self.falas.items(), key=lambda kv: abs(kv[0] - dur))[1]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def rodar_ouvinte(modo, blocos, falas):
|
| 136 |
+
pedidos, estados = [], []
|
| 137 |
+
rapido, bom = WhisperFalso(falas), WhisperFalso(falas)
|
| 138 |
+
ouv = voz.Ouvinte(pedidos.append, "pt_BR", modo=modo, ao_estado=estados.append,
|
| 139 |
+
gravar=lambda: iter(blocos), transcritor=bom, transcritor_ativacao=rapido)
|
| 140 |
+
ouv._rodar()
|
| 141 |
+
return pedidos, estados, rapido, bom
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def picotar(audio, n=3200):
|
| 145 |
+
return [audio[i:i + n] for i in range(0, len(audio), n)]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
falas = {200: "o tempo está bom hoje", 300: "TARS, abre a calculadora", 400: "Tars", 500: "fecha o spotify"}
|
| 149 |
+
audio = (silencio(0.5) + tom(1.0, freq=200) + silencio(1) + tom(1.8, freq=300) + silencio(1) + tom(0.6, freq=400)
|
| 150 |
+
+ silencio(1) + tom(1.3, freq=500) + silencio(1))
|
| 151 |
+
pedidos, estados, rapido, bom = rodar_ouvinte("ativacao", picotar(audio), falas)
|
| 152 |
+
assert pedidos == ["abre a calculadora", "fecha o spotify"], (pedidos, falas)
|
| 153 |
+
assert "chamado" in estados and estados[-1] == "parado"
|
| 154 |
+
assert rapido.chamadas == 3 and bom.chamadas == 2, (rapido.chamadas, bom.chamadas)
|
| 155 |
+
print("OK: conversa normal é ignorada; 'TARS, abre a calculadora' vira pedido; 'TARS' + pausa + 'fecha o spotify' também")
|
| 156 |
+
|
| 157 |
+
pedidos, estados, _, bom = rodar_ouvinte("uma_vez", picotar(silencio(0.3) + tom(1.2) + silencio(1) + tom(1.2)),
|
| 158 |
+
{1.9: "abre o gimp", 1.8: "abre o gimp", 2.0: "abre o gimp"})
|
| 159 |
+
assert pedidos == ["abre o gimp"] and bom.chamadas == 1, (pedidos, bom.chamadas)
|
| 160 |
+
print("OK: 'Falar agora' (Ctrl+M): a primeira fala é o pedido, sem precisar dizer TARS, e para de ouvir")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class FaladorFalso:
|
| 164 |
+
def __init__(self):
|
| 165 |
+
import threading
|
| 166 |
+
self.falando = threading.Event()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
f = FaladorFalso()
|
| 170 |
+
f.falando.set()
|
| 171 |
+
pedidos = []
|
| 172 |
+
ouv = voz.Ouvinte(pedidos.append, "pt_BR", modo="uma_vez", falador=f,
|
| 173 |
+
gravar=lambda: iter(picotar(silencio(0.3) + tom(1.2) + silencio(1))),
|
| 174 |
+
transcritor=WhisperFalso({1.9: "TARS, desliga"}), transcritor_ativacao=WhisperFalso({1.9: "x"}))
|
| 175 |
+
ouv._rodar()
|
| 176 |
+
assert pedidos == [], "enquanto o openTARS fala, ele não ouve a própria voz"
|
| 177 |
+
print("OK: enquanto está falando, não escuta (a própria voz não vira pedido)")
|
| 178 |
+
|
| 179 |
+
# ------------------------------------------------------------
|
| 180 |
+
# 6. Responder falando só quando a pessoa quer
|
| 181 |
+
# ------------------------------------------------------------
|
| 182 |
+
falou = []
|
| 183 |
+
tars.falador = lambda: type("F", (), {"falar": lambda self, texto, idioma, ao_terminar=None: falou.append(texto) or True})()
|
| 184 |
+
tars.o_que_falta_voz = lambda: []
|
| 185 |
+
tars.salvar_config_voz(falar=False)
|
| 186 |
+
assert not tars.falar_resposta("Abri o Firefox.") and falou == []
|
| 187 |
+
assert tars.falar_resposta("Abri o Firefox.", forcar=True) and falou == ["Abri o Firefox."]
|
| 188 |
+
tars.salvar_config_voz(falar=True)
|
| 189 |
+
assert tars.config_voz()["falar"] and tars.falar_resposta("Pronto.")
|
| 190 |
+
assert not tars.falar_resposta(tars.RESPOSTA_INTERROMPIDA)
|
| 191 |
+
tars.o_que_falta_voz = lambda: ["voz"]
|
| 192 |
+
assert not tars.falar_resposta("Pronto.", forcar=True), "sem a voz instalada, fica quieto"
|
| 193 |
+
print("OK: fala a resposta se 'responder falando' estiver ligado ou se o pedido veio por voz")
|
| 194 |
+
|
| 195 |
+
# duas falas seguidas: a primeira acabar não pode "destravar" o microfone
|
| 196 |
+
fal = voz.Falador()
|
| 197 |
+
fal._comecou()
|
| 198 |
+
fal._comecou()
|
| 199 |
+
fal._acabou()
|
| 200 |
+
assert fal.falando.is_set(), "a segunda fala ainda está tocando"
|
| 201 |
+
fal._acabou()
|
| 202 |
+
assert not fal.falando.is_set()
|
| 203 |
+
print("OK: com duas falas sobrepostas, o microfone só volta quando a última acaba")
|
| 204 |
+
|
| 205 |
+
# ------------------------------------------------------------
|
| 206 |
+
# 7. Resposta rápida ao "TARS" (Endurance Neo: Whisper sempre em inglês)
|
| 207 |
+
# ------------------------------------------------------------
|
| 208 |
+
class ModeloFalso:
|
| 209 |
+
def __init__(self):
|
| 210 |
+
self.opcoes = []
|
| 211 |
+
|
| 212 |
+
def transcribe(self, dados, **opcoes):
|
| 213 |
+
self.opcoes.append(dict(opcoes))
|
| 214 |
+
if "without_timestamps" in opcoes:
|
| 215 |
+
raise TypeError("transcribe() got an unexpected keyword argument 'without_timestamps'")
|
| 216 |
+
return iter([type("S", (), {"text": " TARS, open the browser."})()]), None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
tr = voz.Transcritor("teste")
|
| 220 |
+
voz.Transcritor._cache["teste"] = modelo = ModeloFalso()
|
| 221 |
+
assert tr.transcrever(silencio(0.5), "pt_BR", rapido=True) == "TARS, open the browser."
|
| 222 |
+
o = modelo.opcoes[-1]
|
| 223 |
+
assert o["language"] == "pt" and o["initial_prompt"] == "TARS, openTARS." and o["temperature"] == 0.0
|
| 224 |
+
assert o["max_new_tokens"] == 12 and o["beam_size"] == 1 and "without_timestamps" not in o
|
| 225 |
+
assert tr.transcrever(silencio(0.5), "en", rapido=True) and modelo.opcoes[-1]["language"] == "en"
|
| 226 |
+
assert voz.achar_ativacao("Tas, open Firefox") == (False, "")
|
| 227 |
+
print("OK: Whisper no idioma da voz (pt/en), só com o nome de dica, sem refazer com outras temperaturas; "
|
| 228 |
+
"faster-whisper antigo funciona")
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class WhisperCronometrado(WhisperFalso):
|
| 232 |
+
def __init__(self, falas, nome, linha):
|
| 233 |
+
super().__init__(falas)
|
| 234 |
+
self.nome, self.linha, self.aquecido = nome, linha, False
|
| 235 |
+
|
| 236 |
+
def transcrever(self, audio, idioma=None, dica="", rapido=False):
|
| 237 |
+
self.linha.append((self.nome, round(len(audio) / 2 / TAXA, 2)))
|
| 238 |
+
return super().transcrever(audio, idioma, rapido=rapido)
|
| 239 |
+
|
| 240 |
+
def aquecer(self):
|
| 241 |
+
self.aquecido = True
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class FaladorConfirma:
|
| 245 |
+
def __init__(self):
|
| 246 |
+
self.falando = threading.Event()
|
| 247 |
+
self.confirmados = []
|
| 248 |
+
|
| 249 |
+
def confirmar(self, idioma):
|
| 250 |
+
self.confirmados.append(idioma)
|
| 251 |
+
|
| 252 |
+
def preparar_confirmacao(self, idioma):
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
import threading # noqa: E402
|
| 257 |
+
|
| 258 |
+
linha, estados, pedidos = [], [], []
|
| 259 |
+
audio = silencio(0.4) + tom(2.2) + silencio(1.0)
|
| 260 |
+
rapido = WhisperCronometrado({220: "TARS, abre"}, "tiny", linha)
|
| 261 |
+
bom = WhisperCronometrado({220: "TARS, abre a calculadora"}, "base", linha)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def estado(e):
|
| 265 |
+
estados.append((e, len(linha)))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
ouv = voz.Ouvinte(pedidos.append, "pt_BR", ao_estado=estado, gravar=lambda: iter(picotar(audio, 1600)),
|
| 269 |
+
transcritor=bom, transcritor_ativacao=rapido, falador=FaladorConfirma())
|
| 270 |
+
ouv._rodar()
|
| 271 |
+
assert rapido.aquecido and bom.aquecido, "os dois Whisper são aquecidos antes da primeira fala"
|
| 272 |
+
assert pedidos == ["abre a calculadora"], pedidos
|
| 273 |
+
assert [n for n, _ in linha] == ["tiny", "base"], linha # o tiny não roda de novo no fim
|
| 274 |
+
assert linha[0][1] <= voz.CEDO_MAX_S, linha # só o começo da fala
|
| 275 |
+
chamado = next(i for i, (e, _) in enumerate(estados) if e == "chamado")
|
| 276 |
+
transcrevendo = next(i for i, (e, _) in enumerate(estados) if e == "transcrevendo")
|
| 277 |
+
assert chamado < transcrevendo and estados[0][0] == "carregando"
|
| 278 |
+
print("OK: 'TARS, abre a calculadora' — o nome é conferido com a pessoa ainda falando (tela mostra na hora); "
|
| 279 |
+
"no fim, só o pedido é transcrito")
|
| 280 |
+
|
| 281 |
+
# "TARS" sozinho: responde "Sim?" e a próxima fala é o pedido; depois, conversa sem o nome
|
| 282 |
+
f = FaladorConfirma()
|
| 283 |
+
audio = silencio(0.4) + tom(0.5, freq=400) + silencio(1.0) + tom(1.2, freq=250) + silencio(1.0)
|
| 284 |
+
pedidos = []
|
| 285 |
+
ouv = voz.Ouvinte(pedidos.append, "pt_BR", gravar=lambda: iter(picotar(audio, 1600)), falador=f,
|
| 286 |
+
transcritor=WhisperFalso({250: "abre o gimp", 400: "Tars"}),
|
| 287 |
+
transcritor_ativacao=WhisperFalso({400: "Tars.", 250: "abre o gimp"}))
|
| 288 |
+
ouv._rodar()
|
| 289 |
+
assert f.confirmados == ["pt_BR"] and pedidos == ["abre o gimp"], (f.confirmados, pedidos)
|
| 290 |
+
ouv._thread = type("T", (), {"is_alive": lambda self: True})()
|
| 291 |
+
ouv.abrir_conversa()
|
| 292 |
+
assert ouv._esperando_pedido()
|
| 293 |
+
print("OK: 'TARS' sozinho responde 'Sim?' (na voz do idioma) e ouve o pedido; depois de responder, dá pra "
|
| 294 |
+
"continuar sem dizer o nome")
|
| 295 |
+
|
| 296 |
+
wav = voz.sininho_wav()
|
| 297 |
+
assert wav[:4] == b"RIFF" and len(wav) > 5000
|
| 298 |
+
print("OK: sininho de confirmação gerado na hora (sem arquivo nenhum pra baixar)")
|
| 299 |
+
|
| 300 |
+
# 5.0: "segurar para falar": soltar o botão fecha a fala em andamento sem esperar o silêncio
|
| 301 |
+
seg = voz.Segmentador()
|
| 302 |
+
seg.alimentar(b"\x00\x00" * (voz.TAXA * 1)) # 1 s de silêncio: aprende o ruído de fundo
|
| 303 |
+
fala_parcial = bytes(voz.array.array("h", [8000, -8000] * (voz.TAXA // 2))) # 1 s de "fala", sem silêncio depois
|
| 304 |
+
assert seg.alimentar(fala_parcial) == [] and seg.falando_ms > 0
|
| 305 |
+
trecho = seg.encerrar()
|
| 306 |
+
assert trecho and seg.falando_ms == 0 and seg.encerrar() is None
|
| 307 |
+
seg2 = voz.Segmentador()
|
| 308 |
+
seg2.alimentar(b"\x00\x00" * (voz.TAXA * 1))
|
| 309 |
+
seg2.alimentar(bytes(voz.array.array("h", [8000, -8000] * 800))) # 100 ms: curto demais
|
| 310 |
+
assert seg2.encerrar() is None, "estalo curtíssimo não vira pedido"
|
| 311 |
+
|
| 312 |
+
# ... e pelo Ouvinte: soltou o botão, o pedido sai
|
| 313 |
+
silencio_ini = b"\x00\x00" * (voz.TAXA * 1)
|
| 314 |
+
fala = bytes(voz.array.array("h", [8000, -8000] * (voz.TAXA // 2)))
|
| 315 |
+
pedidos5 = []
|
| 316 |
+
ouv5 = voz.Ouvinte(pedidos5.append, "en", modo="uma_vez", aquecer=False,
|
| 317 |
+
transcritor=type("T", (), {"transcrever": lambda self, a, i=None, dica=None, rapido=False, vocabulario=None: "open the browser",
|
| 318 |
+
"aquecer": lambda self: None})(),
|
| 319 |
+
transcritor_ativacao=type("T", (), {"transcrever": lambda self, *a, **k: "", "aquecer": lambda self: None})())
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def fonte5():
|
| 323 |
+
yield silencio_ini
|
| 324 |
+
yield fala
|
| 325 |
+
ouv5.terminar_fala() # soltou o botão
|
| 326 |
+
yield b"\x00\x00" * 480 # o próximo bloco já fecha o trecho
|
| 327 |
+
yield b"\x00\x00" * 480
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
ouv5._gravar = fonte5
|
| 331 |
+
ouv5.iniciar()
|
| 332 |
+
ouv5._thread.join(timeout=10)
|
| 333 |
+
assert pedidos5 == ["open the browser"], pedidos5
|
| 334 |
+
print("OK: soltar o botão fecha a fala em andamento (segurar para falar)")
|
| 335 |
+
|
| 336 |
+
print("\nTUDO OK.")
|
app/tests/test_wait_e_polling.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
|
| 3 |
+
from _util import carregar_tars
|
| 4 |
+
tars = carregar_tars()
|
| 5 |
+
|
| 6 |
+
# 4.6: wait_seconds saiu; quem ainda o chama cai no sleep do terminal, limitado a 5 s
|
| 7 |
+
inicio = time.time()
|
| 8 |
+
resultado = tars.executar_ferramenta("wait_seconds", {"seconds": 0.05})
|
| 9 |
+
assert resultado["sucesso"] and time.time() - inicio < 2
|
| 10 |
+
print("OK: wait_seconds antigo vira sleep no terminal:", resultado["mensagem"][:60])
|
| 11 |
+
|
| 12 |
+
# abrir_aplicativo confirma por polling (processo aparece rápido = retorno rápido)
|
| 13 |
+
tars.psutil.process_iter = lambda *a, **k: iter([
|
| 14 |
+
type("P", (), {"info": {"name": "minhaapp", "cmdline": ["minhaapp"]}})()
|
| 15 |
+
])
|
| 16 |
+
tars.shutil.which = lambda nome: "/usr/bin/minhaapp" if nome == "minhaapp" else None
|
| 17 |
+
tars.subprocess.Popen = lambda *a, **k: None
|
| 18 |
+
|
| 19 |
+
inicio = time.time()
|
| 20 |
+
r = tars.abrir_aplicativo("minhaapp")
|
| 21 |
+
decorrido = time.time() - inicio
|
| 22 |
+
assert r["sucesso"]
|
| 23 |
+
assert decorrido < tars.TIMEOUT_POLL_PROCESSO_SEG
|
| 24 |
+
print(f"OK: app confirmado rápido via polling ({decorrido:.2f}s, bem abaixo do teto de {tars.TIMEOUT_POLL_PROCESSO_SEG}s)")
|
| 25 |
+
|
| 26 |
+
print("\nTUDO OK.")
|
logo.svg
ADDED
|
|
opentars_all.deb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9171c5f46683e00be01c391364028b0abdec92610497140bdb394cc00381266a
|
| 3 |
+
size 1270260
|