primocode 10.15.0 → 10.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/primocode.js +18 -3
- package/lib/config.js +1 -1
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/tools.js +1 -0
- package/lib/voz.js +27 -3
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +201 -24
- package/voz/primo_voz/app.py +451 -0
- package/voz/primo_voz/appmac.py +125 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +17 -6
- package/voz/primo_voz/escuta.py +30 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +60 -26
- package/voz/primo_voz/hud.py +84 -12
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/tela.py +36 -54
- package/voz/primo_voz/transcrever.py +19 -2
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""app.py — o Primo como app (Finder, Launchpad, Dock): abre sem terminal e funciona inteiro.
|
|
2
|
+
|
|
3
|
+
O processo do app é o painel (hud.py). Daqui sobem os filhos, todos mortos junto com o app:
|
|
4
|
+
· o motor de voz (`primo_voz`: microfone, Whisper, voz) — o painel dele é este processo (PRIMOCODE_HUD_PONTE);
|
|
5
|
+
· o cérebro: cada frase vira `primocode -p "<frase>" --output-format json` na pasta de trabalho, e o
|
|
6
|
+
resultado volta falado. Node e primocode vêm de ~/.primocode/primo-app.json (o app não tem o PATH do terminal).
|
|
7
|
+
Log: ~/.primocode/logs/primo-app.log
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import unicodedata
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
HOME = Path.home()
|
|
23
|
+
PASTA_PC = HOME / ".primocode"
|
|
24
|
+
LOG = PASTA_PC / "logs" / "primo-app.log"
|
|
25
|
+
AQUI = Path(__file__).resolve().parent
|
|
26
|
+
PATH_BASE = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(msg: str) -> None:
|
|
30
|
+
try:
|
|
31
|
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with open(LOG, "a", encoding="utf-8") as f:
|
|
33
|
+
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + str(msg).rstrip() + "\n")
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _json(caminho: Path) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(caminho.read_text(encoding="utf-8"))
|
|
41
|
+
except Exception:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def logado() -> bool:
|
|
46
|
+
c = _json(PASTA_PC / "conta.json")
|
|
47
|
+
return bool(c.get("refreshToken") or c.get("idToken") or c.get("token"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sem_acento(t: str) -> str:
|
|
51
|
+
return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
RAIZ = HOME / "Primo" # a área do app: projetos novos por voz nascem em ~/Primo/<nome>
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pasta_atual() -> Path:
|
|
58
|
+
RAIZ.mkdir(exist_ok=True)
|
|
59
|
+
return RAIZ
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def gravar_pasta(d: Path) -> None:
|
|
63
|
+
"""Mesmo arquivo que o lib/config.js (lembrarProjeto) lê: a próxima sessão do PrimoCode cai aqui também."""
|
|
64
|
+
j = _json(PASTA_PC / "projeto-ativo.json")
|
|
65
|
+
rec = [str(d)] + [x for x in (j.get("recentes") or []) if x != str(d)][:9]
|
|
66
|
+
(PASTA_PC / "projeto-ativo.json").write_text(
|
|
67
|
+
json.dumps({"dir": str(d), "origem": "/dir", "quando": int(time.time() * 1000), "recentes": rec}, indent=2), encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def achar_projeto(nome: str) -> "Path | None":
|
|
71
|
+
palavras = _sem_acento(nome).replace("-", " ").split()
|
|
72
|
+
if not palavras:
|
|
73
|
+
return None
|
|
74
|
+
cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
|
|
75
|
+
for base in (RAIZ, HOME / "primocode", HOME):
|
|
76
|
+
try:
|
|
77
|
+
cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
for c in cand:
|
|
81
|
+
n = _sem_acento(c.name).replace("-", " ").replace("_", " ")
|
|
82
|
+
if c.is_dir() and all(p in n for p in palavras):
|
|
83
|
+
return c
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
PEDIDO_PASTA = re.compile(r"^(?:(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+|trabalha(?:r)?\s+(?:no|na|em)\s+(?:o\s+|a\s+)?(?:projeto\s+|pasta\s+)?)(.+?)[.!?]*$", re.I)
|
|
88
|
+
VERBO_ACAO = re.compile(r"^(?:e\s+|ent[aã]o\s+|agora\s+)?(?:cria|crie|criar|faz|fa[cç]a|fazer|monta|monte|gera|gere|escreve|escreva|edita|edite|muda|mude|corrige|corrija|conserta|conserte|instala|instale|roda|rode|executa|execute|abre|abra|apaga|apague|deleta|enxuga|enxugue|atualiza|atualize|adiciona|adicione|remove|remova|refatora|publica|publique|coloca|ponha|p[oõ]e|troca|continua|continue|pode ser|sim|isso|ok|beleza|manda|bora|vai)\b", re.I)
|
|
89
|
+
PEDE_NAVEGADOR = re.compile(r"\babr(?:e|a|ir)\b|\bmostr(?:a|e|ar)\b|navegador|visualiz|preview|pr[eé]-?visualiza", re.I)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _hora_falada(t: "time.struct_time") -> str:
|
|
93
|
+
from primo_voz.fala import hora_falada
|
|
94
|
+
return hora_falada(t)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_OBJETOS = {"site": "o site", "pagina": "a página", "landing": "a landing page", "app": "o app", "aplicativo": "o app", "video": "o vídeo",
|
|
98
|
+
"arquivo": "o arquivo", "pasta": "a pasta", "projeto": "o projeto", "script": "o script", "teste": "os testes", "testes": "os testes",
|
|
99
|
+
"bug": "o bug", "erro": "o erro", "commit": "o commit", "deploy": "o deploy", "codigo": "o código", "documento": "o documento",
|
|
100
|
+
"relatorio": "o relatório", "planilha": "a planilha", "logo": "a logo", "imagem": "a imagem", "motion": "o motion",
|
|
101
|
+
"servidor": "o servidor", "login": "o login", "cardapio": "o cardápio", "botao": "o botão", "layout": "o layout"}
|
|
102
|
+
_ACKS = [ # (verbos, variações; {o} = "o site", {de} = "do site", {em} = "no site")
|
|
103
|
+
(r"cri|mont|ger|faz|fa[cç]|escrev|constru|desenh", ["Bora, vou criar {o}.", "Beleza, já começo {o}.", "Fechado, vou montar {o}.", "Pode deixar, vou fazer {o}.", "Show, vou criar {o} agora."]),
|
|
104
|
+
(r"abr|mostr|exib", ["Deixa eu abrir {o}.", "Já abro {o}.", "Vou abrir {o} pra você.", "Um segundo, abrindo {o}."]),
|
|
105
|
+
(r"rod|execut|test|ver(?:ific)", ["Vou rodar isso agora.", "Rodando, já te falo.", "Deixa eu rodar {o}.", "Já executo e te conto."]),
|
|
106
|
+
(r"corrig|conserta|arrum|resolv|debug", ["Vou dar uma olhada {em} e consertar.", "Já vou atrás desse problema.", "Deixa eu ver o que aconteceu {em}."]),
|
|
107
|
+
(r"edit|mud|troc|atualiz|ajust|coloc|pon|adicion|remov|apag", ["Vou ajustar {o} agora.", "Beleza, já mexo {em}.", "Pode deixar, já faço essa mudança.", "Já altero isso."]),
|
|
108
|
+
(r"instal|public|sub[ae]|deploy|mand", ["Vou cuidar disso agora.", "Já começo por aqui.", "Deixa comigo, já faço."]),
|
|
109
|
+
(r"procur|busc|pesquis|analis|olh|le[ia]|explor", ["Vou dar uma olhada {em}.", "Deixa eu procurar isso.", "Já vou investigar."]),
|
|
110
|
+
]
|
|
111
|
+
_ACK_PADRAO = ["Já cuido disso.", "Deixa comigo.", "Pode deixar, já começo.", "Beleza, vou resolver isso."]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ack_contextual(texto: str, ultimo: str = "", sorteio=None) -> str:
|
|
115
|
+
"""O "já vou" de uma TAREFA demorada, escrito a partir do pedido (verbo e objeto) e nunca igual ao anterior."""
|
|
116
|
+
import random
|
|
117
|
+
sorte = sorteio or random.choice
|
|
118
|
+
t = _sem_acento(texto)
|
|
119
|
+
obj = next((_OBJETOS[w] for w in re.findall(r"[a-z]+", t) if w in _OBJETOS), "")
|
|
120
|
+
grupo = _ACK_PADRAO
|
|
121
|
+
primeira = re.findall(r"[a-z]+", t)[:4]
|
|
122
|
+
for rx, frases in _ACKS:
|
|
123
|
+
if any(re.match(rx, w) for w in primeira):
|
|
124
|
+
grupo = frases
|
|
125
|
+
break
|
|
126
|
+
pool = [f for f in grupo if f != ultimo and ("{" not in f or obj or True)] or grupo
|
|
127
|
+
f = sorte(pool)
|
|
128
|
+
if "{" in f and not obj:
|
|
129
|
+
f = sorte([x for x in grupo if "{" not in x and x != ultimo] or _ACK_PADRAO)
|
|
130
|
+
return f.format(o=obj, de=obj.replace("o ", "do ", 1).replace("a ", "da ", 1).replace("os ", "dos ", 1) if obj else "", em=("n" + obj) if obj else "disso")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class App:
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
self.cfg = _json(PASTA_PC / "primo-app.json")
|
|
136
|
+
self.node = self.cfg.get("node") or ""
|
|
137
|
+
self.primocode = self.cfg.get("primocode") or ""
|
|
138
|
+
self.pasta = pasta_atual()
|
|
139
|
+
self.pasta_modo = "raiz" # raiz (~/Primo, projetos novos) | projeto (o do último pedido) | /dir (escolhida por voz)
|
|
140
|
+
self.acao_antes = False
|
|
141
|
+
self.ultimo_ack = ""
|
|
142
|
+
self.motor: "subprocess.Popen | None" = None
|
|
143
|
+
self.cerebro: "subprocess.Popen | None" = None
|
|
144
|
+
self.entrada: "queue.Queue" = queue.Queue()
|
|
145
|
+
self.js = lambda m: None
|
|
146
|
+
self.turnos = 0
|
|
147
|
+
self.vivo = True
|
|
148
|
+
self.trava = threading.Lock()
|
|
149
|
+
self.cache_clima: dict = {}
|
|
150
|
+
|
|
151
|
+
# ── entrada do painel (iterável de linhas JSON) ──
|
|
152
|
+
def __iter__(self):
|
|
153
|
+
while True:
|
|
154
|
+
yield self.entrada.get()
|
|
155
|
+
|
|
156
|
+
def rotulo(self) -> str:
|
|
157
|
+
return "~/Primo" if self.pasta == RAIZ else self.pasta.name
|
|
158
|
+
|
|
159
|
+
def painel(self, msg: dict) -> None:
|
|
160
|
+
self.entrada.put(json.dumps(msg, ensure_ascii=False))
|
|
161
|
+
|
|
162
|
+
def saida_do_painel(self, obj: dict) -> None:
|
|
163
|
+
a = obj.get("acao")
|
|
164
|
+
if a == "ptt":
|
|
165
|
+
self.motor_envia({"tipo": "acao", "acao": "ptt"})
|
|
166
|
+
elif a == "entrar":
|
|
167
|
+
self.entrar()
|
|
168
|
+
|
|
169
|
+
# ── login ──
|
|
170
|
+
def entrar(self) -> None:
|
|
171
|
+
raiz = Path(self.primocode).resolve().parent.parent if self.primocode else None
|
|
172
|
+
if not (self.node and raiz and (raiz / "lib" / "conta.js").exists()):
|
|
173
|
+
log("entrar: node/primocode não registrados")
|
|
174
|
+
self.painel({"resposta": "Não achei o PrimoCode instalado. Rode `primocode voz instalar-app` no terminal."})
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
def trabalho():
|
|
178
|
+
log("login: abrindo o navegador")
|
|
179
|
+
r = subprocess.run([self.node, "-e", "require('./lib/conta.js').entrarPeloNavegador({aviso:()=>{}}).then(r=>process.exit(r&&r.ok?0:1))"],
|
|
180
|
+
cwd=str(raiz), env={**self.ambiente(), "PRIMOCODE_SEM_NAVEGADOR": "0"}, capture_output=True, timeout=600)
|
|
181
|
+
log(f"login: saiu com {r.returncode}")
|
|
182
|
+
self.painel({"login": logado()})
|
|
183
|
+
threading.Thread(target=trabalho, daemon=True).start()
|
|
184
|
+
|
|
185
|
+
def ambiente(self) -> dict:
|
|
186
|
+
env = dict(os.environ)
|
|
187
|
+
extra = [str(Path(self.node).parent)] if self.node else []
|
|
188
|
+
env["PATH"] = ":".join([x for x in extra + [self.cfg.get("path") or "", PATH_BASE, env.get("PATH", "")] if x])
|
|
189
|
+
env["HOME"] = str(HOME)
|
|
190
|
+
env["PRIMOCODE_SEM_NAVEGADOR"] = "1" # o cérebro nunca abre o navegador sozinho
|
|
191
|
+
return env
|
|
192
|
+
|
|
193
|
+
# ── motor de voz ──
|
|
194
|
+
def motor_envia(self, obj: dict) -> None:
|
|
195
|
+
try:
|
|
196
|
+
self.motor.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
197
|
+
self.motor.stdin.flush()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def subir_motor(self) -> None:
|
|
202
|
+
env = self.ambiente()
|
|
203
|
+
cfg_srv = self.cfg.get("servidor") or ""
|
|
204
|
+
conta = _json(PASTA_PC / "conta.json")
|
|
205
|
+
env.update({"PRIMOCODE_HUD_PONTE": "1", "PRIMOCODE_SERVER": cfg_srv or "https://primocode.web.app",
|
|
206
|
+
"PRIMOCODE_TOKEN": str(conta.get("idToken") or conta.get("token") or ""),
|
|
207
|
+
"PRIMOCODE_VOZ": str(self.cfg.get("voz") or "antonio"), "PRIMOCODE_CWD": str(self.pasta),
|
|
208
|
+
"PYTHONUNBUFFERED": "1", "PRIMOCODE_LAT": "1", "PRIMOCODE_SEM_PARCIAL": "1", **({"PRIMOCODE_TESTE_WAV": str(self.cfg["teste_wav"]), "PRIMOCODE_TESTE_ATRASO": str(self.cfg.get("teste_atraso", 12))} if self.cfg.get("teste_wav") else {}), "PYTHONPATH": os.environ.get("PYTHONPATH", str(AQUI.parent))})
|
|
209
|
+
log(f"motor de voz: subindo ({sys.executable} -m primo_voz) pasta={self.pasta}")
|
|
210
|
+
self.motor = subprocess.Popen([sys.executable, "-m", "primo_voz"], cwd=str(AQUI.parent), env=env, text=True,
|
|
211
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
212
|
+
threading.Thread(target=self.ler_motor, daemon=True).start()
|
|
213
|
+
threading.Thread(target=lambda: [log("motor: " + l) for l in self.motor.stderr], daemon=True).start()
|
|
214
|
+
|
|
215
|
+
def ler_motor(self) -> None:
|
|
216
|
+
for linha in self.motor.stdout:
|
|
217
|
+
try:
|
|
218
|
+
m = json.loads(linha)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
t = m.get("tipo")
|
|
222
|
+
if t == "hud":
|
|
223
|
+
self.painel(m.get("msg") or {})
|
|
224
|
+
elif t == "pronto":
|
|
225
|
+
log("motor de voz: PRONTO (microfone aberto)")
|
|
226
|
+
self.painel({"pasta": "~/Primo", "login": logado()})
|
|
227
|
+
elif t == "erro":
|
|
228
|
+
log("motor de voz: erro: " + str(m.get("texto")))
|
|
229
|
+
self.painel({"resposta": str(m.get("texto"))})
|
|
230
|
+
elif t == "parar":
|
|
231
|
+
self.matar_cerebro()
|
|
232
|
+
elif t == "ouvi" and str(m.get("texto") or "").strip():
|
|
233
|
+
threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
|
|
234
|
+
elif t == "fim":
|
|
235
|
+
break
|
|
236
|
+
log("motor de voz: saiu")
|
|
237
|
+
if self.vivo:
|
|
238
|
+
self.vivo = False
|
|
239
|
+
os._exit(0)
|
|
240
|
+
|
|
241
|
+
# ── cérebro ──
|
|
242
|
+
def matar_cerebro(self) -> None:
|
|
243
|
+
p = self.cerebro
|
|
244
|
+
if p and p.poll() is None:
|
|
245
|
+
p.terminate()
|
|
246
|
+
|
|
247
|
+
def dizer(self, texto: str) -> None:
|
|
248
|
+
self.motor_envia({"tipo": "falar", "texto": texto})
|
|
249
|
+
self.motor_envia({"tipo": "estado", "estado": "esperando"})
|
|
250
|
+
|
|
251
|
+
def responder_local(self, texto: str) -> "str | None":
|
|
252
|
+
"""Pergunta simples respondida aqui, sem passar pelo agente (hora, data, clima, música, agenda, volume)."""
|
|
253
|
+
from primo_voz import painel as pn, tela as td
|
|
254
|
+
t = _sem_acento(texto).strip(" .?!")
|
|
255
|
+
agora = time.time()
|
|
256
|
+
if re.search(r"\bque horas\b|\bhoras sao\b|\bme (?:da|diz|diga) a hora\b|\bque hora (?:e|eh)\b", t):
|
|
257
|
+
return _hora_falada(time.localtime(agora))
|
|
258
|
+
if re.search(r"\bque dia (?:e|eh)\b|\bdata de hoje\b|\bhoje e que dia\b", t):
|
|
259
|
+
dias = ["segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo"]
|
|
260
|
+
meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
|
|
261
|
+
lt = time.localtime(agora)
|
|
262
|
+
return f"Hoje é {dias[lt.tm_wday]}, {lt.tm_mday} de {meses[lt.tm_mon - 1]}."
|
|
263
|
+
if re.search(r"\bclima\b|\bvai chover\b|\btemperatura\b|\btempo (?:hoje|agora|la fora)\b", t):
|
|
264
|
+
c = td.clima_widget(self.cache_clima)
|
|
265
|
+
if c and c.get("temp") is not None:
|
|
266
|
+
return f"Em {c.get('cidade', 'sua cidade')} faz {c['temp']} graus, {str(c.get('cond', '')).lower()}. Máxima de {c['max']}, mínima de {c['min']}."
|
|
267
|
+
return None
|
|
268
|
+
if re.search(r"o que (?:esta|ta) tocando|que musica (?:e|eh) essa|qual (?:e )?a musica", t):
|
|
269
|
+
m = td.musica()
|
|
270
|
+
return f"Tocando {m['faixa']}, de {m['artista']}." if m and m.get("faixa") else "Não tem nada tocando agora."
|
|
271
|
+
if re.search(r"\b(?:pausa|pausar|pare|para) (?:a )?musica\b|\b(?:toca|tocar|continua|retoma) (?:a )?musica\b|\bpula (?:a )?musica\b|\bproxima musica\b|\bmusica anterior\b", t):
|
|
272
|
+
acao = "proxima" if re.search(r"pula|proxima", t) else "anterior" if "anterior" in t else "tocar"
|
|
273
|
+
pn.controle_midia(acao)
|
|
274
|
+
return {"proxima": "Pulei.", "anterior": "Voltei.", "tocar": "Feito."}[acao]
|
|
275
|
+
if re.search(r"\b(?:aumenta|sobe|aumentar|mais)\b.*\bvolume\b|\bvolume\b.*\b(?:mais alto|maior)\b", t):
|
|
276
|
+
pn.executar_acao({"acao": "volume", "arg": "mais"})
|
|
277
|
+
return "Aumentei."
|
|
278
|
+
if re.search(r"\b(?:abaixa|diminui|baixa|diminuir|menos)\b.*\bvolume\b|\bvolume\b.*\b(?:mais baixo|menor)\b", t):
|
|
279
|
+
pn.executar_acao({"acao": "volume", "arg": "menos"})
|
|
280
|
+
return "Abaixei."
|
|
281
|
+
if re.search(r"\bagenda\b|\bcompromissos?\b|o que (?:eu )?tenho hoje|\breunioes? hoje\b", t):
|
|
282
|
+
ev = td.eventos_do_mes()
|
|
283
|
+
if ev.get("permissao") != "ok":
|
|
284
|
+
return "Preciso do acesso ao Calendário. Clique em Liberar o Calendário no painel."
|
|
285
|
+
hj = td.hoje_lista(ev["eventos"], agora)
|
|
286
|
+
if not hj:
|
|
287
|
+
return "Você não tem mais nada marcado para hoje."
|
|
288
|
+
return "Hoje: " + "; ".join(f"{e['titulo']} às {e['hora']}" if e["hora"] != "dia todo" else f"{e['titulo']}, o dia todo" for e in hj[:4]) + "."
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def pensar(self, texto: str) -> None:
|
|
292
|
+
log(f"ouvi: {texto}")
|
|
293
|
+
m = PEDIDO_PASTA.match(texto.strip())
|
|
294
|
+
if m:
|
|
295
|
+
d = achar_projeto(m.group(1))
|
|
296
|
+
if d:
|
|
297
|
+
self.pasta, self.pasta_modo = d, "/dir"
|
|
298
|
+
gravar_pasta(d)
|
|
299
|
+
self.painel({"pasta": d.name})
|
|
300
|
+
log(f"pasta: {d}")
|
|
301
|
+
self.dizer(f"Fechado. Agora trabalho no {d.name}.")
|
|
302
|
+
else:
|
|
303
|
+
self.dizer(f"Não achei um projeto chamado {m.group(1)}.")
|
|
304
|
+
return
|
|
305
|
+
t_ini = time.time()
|
|
306
|
+
try:
|
|
307
|
+
local = self.responder_local(texto)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
log(f"resposta local falhou: {e}")
|
|
310
|
+
local = None
|
|
311
|
+
if local:
|
|
312
|
+
log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
|
|
313
|
+
self.dizer(local)
|
|
314
|
+
return
|
|
315
|
+
if not (self.node and self.primocode and Path(self.primocode).exists()):
|
|
316
|
+
log("cérebro: node/primocode não registrados em primo-app.json")
|
|
317
|
+
self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
|
|
318
|
+
return
|
|
319
|
+
if not logado():
|
|
320
|
+
self.painel({"login": False})
|
|
321
|
+
self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
|
|
322
|
+
return
|
|
323
|
+
acao = bool(VERBO_ACAO.match(_sem_acento(texto).strip())) or bool(re.search(r"\b(?:site|pagina|landing|projeto|arquivo|pasta|codigo|bug|teste|commit|deploy|app|aplicativo|script)s?\b", _sem_acento(texto))) or (self.acao_antes and not re.match(r"^(?:o que|qual|quem|como|por ?que|quando|onde|quanto)\b", _sem_acento(texto).strip()))
|
|
324
|
+
self.acao_antes = acao
|
|
325
|
+
pedido = texto
|
|
326
|
+
pedido += ('\n\n(Modo voz. Termine SEMPRE com uma única linha de JSON: {"falar": "...", "painel": "..."}. '
|
|
327
|
+
'"falar" é o que será DITO em voz alta: português natural de conversa, 1 a 4 frases curtas, sem markdown, sem código, '
|
|
328
|
+
'sem caminhos de arquivo, sem siglas nem abreviações; números e unidades por extenso como se fala '
|
|
329
|
+
'("trinta e dois segundos", "dez pixels", "noventa e nove reais", "versão dez ponto quinze"); nomes técnicos pronunciáveis; '
|
|
330
|
+
'diga o que você fez de fato. "painel" é a resposta escrita completa, com markdown e detalhes.)')
|
|
331
|
+
if acao:
|
|
332
|
+
pedido += ("\n\n(Pedido por VOZ. Execute de verdade com as ferramentas, sem pedir confirmação, a menos que seja apagar, publicar ou enviar algo. "
|
|
333
|
+
"Projetos novos ficam em ~/Primo/<nome curto>. Ao terminar, responda em português falado, 2 a 4 frases, dizendo o que fez DE FATO: "
|
|
334
|
+
"pastas e arquivos criados, onde ficaram e o que abriu. Não use listas nem código na resposta.)")
|
|
335
|
+
with self.trava:
|
|
336
|
+
env = self.ambiente()
|
|
337
|
+
env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
|
|
338
|
+
"PRIMOCODE_SEM_NAVEGADOR": "0" if (PEDE_NAVEGADOR.search(texto) or (acao and re.search(r"site|p[aá]gina|landing|html", texto, re.I))) else "1"})
|
|
339
|
+
if acao:
|
|
340
|
+
env["PRIMOCODE_MODO"] = "primocode"
|
|
341
|
+
if self.pasta != RAIZ:
|
|
342
|
+
env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.pasta), ("/dir" if self.pasta_modo == "/dir" else "projeto")
|
|
343
|
+
args = [self.node, self.primocode, "-p", pedido, "--output-format", "json"] + (["-c"] if self.turnos else [])
|
|
344
|
+
log(f"cérebro: modo={'AÇÃO' if acao else 'conversa'} cwd={self.pasta} pasta_modo={self.pasta_modo} navegador={'sim' if env['PRIMOCODE_SEM_NAVEGADOR'] == '0' else 'não'} continua={bool(self.turnos)}")
|
|
345
|
+
t0 = time.time()
|
|
346
|
+
|
|
347
|
+
def ack_tardio(): # tarefa: se o agente passou de ~1,2 s, diz o que vai fazer (conversa e pergunta rápida não ganham ack)
|
|
348
|
+
if acao and self.cerebro and self.cerebro.poll() is None:
|
|
349
|
+
a = ack_contextual(texto, self.ultimo_ack)
|
|
350
|
+
self.ultimo_ack = a
|
|
351
|
+
log(f"ack: {a!r}")
|
|
352
|
+
self.motor_envia({"tipo": "falar", "texto": a})
|
|
353
|
+
timer = threading.Timer(1.2, ack_tardio)
|
|
354
|
+
timer.start()
|
|
355
|
+
try:
|
|
356
|
+
self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=env, text=True,
|
|
357
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
358
|
+
start_new_session=True) # grupo próprio: nada do que o agente faça com processos atinge o app
|
|
359
|
+
out, err = self.cerebro.communicate(timeout=900)
|
|
360
|
+
log(f"cérebro: saiu com código {self.cerebro.returncode}")
|
|
361
|
+
timer.cancel()
|
|
362
|
+
except Exception as e:
|
|
363
|
+
timer.cancel()
|
|
364
|
+
log(f"cérebro: falhou: {e}")
|
|
365
|
+
self.dizer("O cérebro não respondeu.")
|
|
366
|
+
return
|
|
367
|
+
for l in (err or "").splitlines():
|
|
368
|
+
if l.startswith("ferramenta:"):
|
|
369
|
+
log(l[:300])
|
|
370
|
+
pj = _json(PASTA_PC / "projeto-ativo.json") # o agente criou/trocou de projeto? o painel e o próximo pedido seguem nele
|
|
371
|
+
if pj.get("dir") and pj.get("quando", 0) / 1000 >= t0 and Path(pj["dir"]).is_dir() and Path(pj["dir"]) not in (self.pasta, RAIZ):
|
|
372
|
+
self.pasta, self.pasta_modo = Path(pj["dir"]), "projeto"
|
|
373
|
+
self.painel({"pasta": self.rotulo()})
|
|
374
|
+
log(f"pasta do projeto agora: {self.pasta}")
|
|
375
|
+
self.turnos += 1
|
|
376
|
+
resp = ""
|
|
377
|
+
for l in reversed((out or "").strip().splitlines()):
|
|
378
|
+
try:
|
|
379
|
+
j = json.loads(l)
|
|
380
|
+
resp = str(j.get("result") or "")
|
|
381
|
+
break
|
|
382
|
+
except ValueError:
|
|
383
|
+
continue
|
|
384
|
+
sobra = "\n".join(l for l in (err or "").splitlines() if not l.startswith("ferramenta:")).strip()
|
|
385
|
+
if sobra:
|
|
386
|
+
log("cérebro stderr: " + sobra[-400:])
|
|
387
|
+
# tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
|
|
388
|
+
resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿") and not re.match(r"^\s*(?:Projeto novo:|[A-Z][A-Za-z_]{2,12}\s{2,}\S)", l)).replace("●", "").strip()
|
|
389
|
+
log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
|
|
390
|
+
if not resp.strip():
|
|
391
|
+
self.dizer("Não consegui uma resposta agora.")
|
|
392
|
+
return
|
|
393
|
+
falar, painel = self.separar(resp)
|
|
394
|
+
self.motor_envia({"tipo": "falar", "texto": falar, "painel": painel})
|
|
395
|
+
self.motor_envia({"tipo": "estado", "estado": "esperando"})
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def separar(resp: str) -> "tuple[str, str]":
|
|
399
|
+
"""O cérebro devolve {"falar","painel"} (o terminal quebra a linha no meio do JSON: junta e lê). Sem JSON, vale o texto todo."""
|
|
400
|
+
corpo = re.sub(r"\s*\n\s*", " ", resp)
|
|
401
|
+
i, j = corpo.find('{"falar"'), corpo.rfind("}")
|
|
402
|
+
if i >= 0 and j > i:
|
|
403
|
+
try:
|
|
404
|
+
d = json.loads(corpo[i:j + 1], strict=False)
|
|
405
|
+
if str(d.get("falar") or "").strip():
|
|
406
|
+
antes = resp[: resp.find('{"falar"')].strip() if '{"falar"' in resp else ""
|
|
407
|
+
return str(d["falar"]).strip(), (str(d.get("painel") or "").strip() or antes or str(d["falar"]).strip())
|
|
408
|
+
except ValueError:
|
|
409
|
+
pass
|
|
410
|
+
m = re.search(r'"falar"\s*:\s*"(.*?)"\s*(?:,\s*"painel"|\})', corpo, re.S)
|
|
411
|
+
if m:
|
|
412
|
+
return m.group(1).strip(), corpo[:i].strip() or m.group(1).strip()
|
|
413
|
+
return resp, resp
|
|
414
|
+
|
|
415
|
+
def sair(self) -> None:
|
|
416
|
+
self.vivo = False
|
|
417
|
+
self.matar_cerebro()
|
|
418
|
+
if self.motor and self.motor.poll() is None:
|
|
419
|
+
self.motor_envia({"tipo": "fim"})
|
|
420
|
+
try:
|
|
421
|
+
self.motor.wait(timeout=2)
|
|
422
|
+
except Exception:
|
|
423
|
+
self.motor.kill()
|
|
424
|
+
log("app: encerrado")
|
|
425
|
+
|
|
426
|
+
def iniciar(self, js, p) -> None:
|
|
427
|
+
self.js = js
|
|
428
|
+
threading.Timer(0.5, self.subir_motor).start()
|
|
429
|
+
threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main() -> int:
|
|
433
|
+
import fcntl
|
|
434
|
+
import atexit
|
|
435
|
+
PASTA_PC.mkdir(exist_ok=True)
|
|
436
|
+
os.environ["PATH"] = PATH_BASE + ":" + os.environ.get("PATH", "")
|
|
437
|
+
lock = open(PASTA_PC / "primo-app.lock", "w")
|
|
438
|
+
try:
|
|
439
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
440
|
+
except OSError:
|
|
441
|
+
return 0 # já aberto: o macOS só traz a janela de volta
|
|
442
|
+
sys.stderr = open(LOG, "a", buffering=1) if (LOG.parent.mkdir(parents=True, exist_ok=True) or True) else sys.stderr
|
|
443
|
+
log("app: abrindo")
|
|
444
|
+
app = App()
|
|
445
|
+
atexit.register(app.sair)
|
|
446
|
+
from primo_voz import hud
|
|
447
|
+
return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
sys.exit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""appmac.py — o Primo como app de verdade no macOS (Dock e Cmd+Tab dizem "Primo", com o ícone).
|
|
2
|
+
|
|
3
|
+
Gera sob demanda ~/.primocode/Primo.app (bundle leve: Info.plist, ícone .icns e um script que
|
|
4
|
+
roda o python do venv com `-m primo_voz.hud`). Quem abre o painel executa o binário do bundle
|
|
5
|
+
direto (spawn), então o canal stdin/stdout continua o mesmo.
|
|
6
|
+
|
|
7
|
+
python -m primo_voz.appmac # gera (se precisar) e imprime o caminho do executável
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import plistlib
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
AQUI = Path(__file__).resolve().parent
|
|
20
|
+
ICONE = AQUI / "vendor" / "primo-icone.png"
|
|
21
|
+
VERSAO = "7" # sobe quando o bundle muda: força regerar
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pasta_app() -> Path:
|
|
25
|
+
return Path(os.environ.get("PRIMOCODE_APP", Path.home() / "Applications" / "Primo.app"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _icns(destino: Path) -> bool:
|
|
29
|
+
with tempfile.TemporaryDirectory() as t:
|
|
30
|
+
iset = Path(t) / "Primo.iconset"
|
|
31
|
+
iset.mkdir()
|
|
32
|
+
for base in (16, 32, 128, 256, 512):
|
|
33
|
+
for k in (1, 2):
|
|
34
|
+
px = base * k
|
|
35
|
+
nome = f"icon_{base}x{base}{'@2x' if k == 2 else ''}.png"
|
|
36
|
+
subprocess.run(["sips", "-z", str(px), str(px), str(ICONE), "--out", str(iset / nome)],
|
|
37
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
38
|
+
r = subprocess.run(["iconutil", "-c", "icns", str(iset), "-o", str(destino)],
|
|
39
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
40
|
+
return r.returncode == 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _no_dock(app: Path) -> None:
|
|
44
|
+
"""Fixa o Primo no Dock (só com --dock)."""
|
|
45
|
+
item = ("<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>%s</string>"
|
|
46
|
+
"<key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>" % str(app))
|
|
47
|
+
subprocess.run(["defaults", "write", "com.apple.dock", "persistent-apps", "-array-add", item], check=False)
|
|
48
|
+
subprocess.run(["killall", "Dock"], check=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pythonpath() -> str:
|
|
52
|
+
"""O PYTHONPATH que o binário do bundle precisa (o primo_voz e os pacotes do venv, já que ele não é o python do venv)."""
|
|
53
|
+
import sysconfig
|
|
54
|
+
dirs = [str(AQUI.parent), sysconfig.get_path("purelib"), sysconfig.get_path("platlib")]
|
|
55
|
+
return os.pathsep.join(dict.fromkeys(d for d in dirs if d))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def preparar() -> str | None:
|
|
59
|
+
"""Devolve o caminho do executável do Primo.app (criando o bundle se faltar); None se não deu."""
|
|
60
|
+
if sys.platform != "darwin" or not ICONE.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
app = pasta_app()
|
|
64
|
+
exe = app / "Contents" / "MacOS" / "Primo" # o binário de verdade (o processo se chama Primo); o lançador só dá exec nele
|
|
65
|
+
lanc = app / "Contents" / "MacOS" / "Primo-iniciar"
|
|
66
|
+
marca = app / "Contents" / ".versao"
|
|
67
|
+
py = sys.executable
|
|
68
|
+
chave = f"{VERSAO}|{py}|{AQUI.parent}"
|
|
69
|
+
if exe.exists() and marca.exists() and marca.read_text() == chave and (app / "Contents/Resources/Primo.icns").exists():
|
|
70
|
+
return str(exe)
|
|
71
|
+
if app.exists():
|
|
72
|
+
shutil.rmtree(app)
|
|
73
|
+
antigo = Path.home() / ".primocode" / "Primo.app" # onde ficava até a 10.15.1
|
|
74
|
+
if antigo.exists():
|
|
75
|
+
shutil.rmtree(antigo, ignore_errors=True)
|
|
76
|
+
app.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
(app / "Contents" / "MacOS").mkdir(parents=True)
|
|
78
|
+
(app / "Contents" / "Resources").mkdir()
|
|
79
|
+
if not _icns(app / "Contents" / "Resources" / "Primo.icns"):
|
|
80
|
+
return None
|
|
81
|
+
info = {
|
|
82
|
+
"CFBundleName": "Primo", "CFBundleDisplayName": "Primo", "CFBundleIdentifier": "ai.primocode.primo",
|
|
83
|
+
"CFBundleExecutable": "Primo-iniciar", "CFBundleIconFile": "Primo", "CFBundlePackageType": "APPL",
|
|
84
|
+
"CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "LSMinimumSystemVersion": "11.0",
|
|
85
|
+
"NSHighResolutionCapable": True, "NSPrincipalClass": "NSApplication",
|
|
86
|
+
"NSSpeechRecognitionUsageDescription": "O Primo entende o que você fala usando o reconhecimento de voz do Mac, no próprio computador.",
|
|
87
|
+
"NSMicrophoneUsageDescription": "O Primo ouve você pelo microfone para atender os seus pedidos por voz.",
|
|
88
|
+
"NSCalendarsFullAccessUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
|
|
89
|
+
"NSCalendarsUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
|
|
90
|
+
"NSAppleEventsUsageDescription": "O Primo controla a música (Spotify/Music) pelo painel.",
|
|
91
|
+
}
|
|
92
|
+
with open(app / "Contents" / "Info.plist", "wb") as f:
|
|
93
|
+
plistlib.dump(info, f)
|
|
94
|
+
# binário de verdade (cópia do Python.app do framework): o macOS passa a chamar o processo de "Primo"
|
|
95
|
+
real = Path(os.path.realpath(py))
|
|
96
|
+
mach = next((c for c in (real.parents[1] / "Resources/Python.app/Contents/MacOS/Python",
|
|
97
|
+
real.parent / "../Resources/Python.app/Contents/MacOS/Python") if c.exists()), None)
|
|
98
|
+
if mach:
|
|
99
|
+
shutil.copy2(mach, exe)
|
|
100
|
+
# duplo clique no Finder/Launchpad: sem argumentos = sobe o app completo (voz + painel + cérebro)
|
|
101
|
+
lanc.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\nD="$(cd "$(dirname "$0")" && pwd)"\n'
|
|
102
|
+
f'[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "$D/Primo" "$@"\n')
|
|
103
|
+
lanc.chmod(0o755)
|
|
104
|
+
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
105
|
+
else:
|
|
106
|
+
exe.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\n[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "{py}" "$@"\n')
|
|
107
|
+
exe.chmod(0o755)
|
|
108
|
+
shutil.copy2(exe, lanc)
|
|
109
|
+
marca.write_text(chave)
|
|
110
|
+
if os.environ.get("PRIMOCODE_APP_DOCK") == "1":
|
|
111
|
+
_no_dock(app)
|
|
112
|
+
subprocess.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
|
113
|
+
"-f", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
114
|
+
return str(exe)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
sys.stderr.write(f"appmac: {e}\n")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
r = preparar()
|
|
122
|
+
if r:
|
|
123
|
+
print(r)
|
|
124
|
+
print(pythonpath())
|
|
125
|
+
sys.exit(0 if r else 1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -4 18 18" shape-rendering="crispEdges"><rect x="2" y="0" width="1" height="1" fill="#22d3ee"/><rect x="2" y="1" width="1" height="1" fill="#22d3ee"/><rect x="3" y="0" width="1" height="1" fill="#22d3ee"/><rect x="3" y="1" width="1" height="1" fill="#22d3ee"/><rect x="4" y="0" width="1" height="1" fill="#22d3ee"/><rect x="4" y="1" width="1" height="1" fill="#22d3ee"/><rect x="5" y="0" width="1" height="1" fill="#22d3ee"/><rect x="5" y="1" width="1" height="1" fill="#22d3ee"/><rect x="6" y="0" width="1" height="1" fill="#22d3ee"/><rect x="6" y="1" width="1" height="1" fill="#22d3ee"/><rect x="7" y="0" width="1" height="1" fill="#22d3ee"/><rect x="7" y="1" width="1" height="1" fill="#22d3ee"/><rect x="8" y="0" width="1" height="1" fill="#22d3ee"/><rect x="8" y="1" width="1" height="1" fill="#22d3ee"/><rect x="9" y="0" width="1" height="1" fill="#22d3ee"/><rect x="9" y="1" width="1" height="1" fill="#22d3ee"/><rect x="10" y="0" width="1" height="1" fill="#22d3ee"/><rect x="10" y="1" width="1" height="1" fill="#22d3ee"/><rect x="11" y="0" width="1" height="1" fill="#22d3ee"/><rect x="11" y="1" width="1" height="1" fill="#22d3ee"/><rect x="12" y="0" width="1" height="1" fill="#22d3ee"/><rect x="12" y="1" width="1" height="1" fill="#22d3ee"/><rect x="13" y="0" width="1" height="1" fill="#22d3ee"/><rect x="13" y="1" width="1" height="1" fill="#22d3ee"/><rect x="2" y="2" width="1" height="1" fill="#22d3ee"/><rect x="2" y="3" width="1" height="1" fill="#22d3ee"/><rect x="3" y="2" width="1" height="1" fill="#22d3ee"/><rect x="3" y="3" width="1" height="1" fill="#22d3ee"/><rect x="5" y="2" width="1" height="1" fill="#22d3ee"/><rect x="5" y="3" width="1" height="1" fill="#22d3ee"/><rect x="6" y="2" width="1" height="1" fill="#22d3ee"/><rect x="6" y="3" width="1" height="1" fill="#22d3ee"/><rect x="7" y="2" width="1" height="1" fill="#22d3ee"/><rect x="7" y="3" width="1" height="1" fill="#22d3ee"/><rect x="8" y="2" width="1" height="1" fill="#22d3ee"/><rect x="8" y="3" width="1" height="1" fill="#22d3ee"/><rect x="9" y="2" width="1" height="1" fill="#22d3ee"/><rect x="9" y="3" width="1" height="1" fill="#22d3ee"/><rect x="10" y="2" width="1" height="1" fill="#22d3ee"/><rect x="10" y="3" width="1" height="1" fill="#22d3ee"/><rect x="12" y="2" width="1" height="1" fill="#22d3ee"/><rect x="12" y="3" width="1" height="1" fill="#22d3ee"/><rect x="13" y="2" width="1" height="1" fill="#22d3ee"/><rect x="13" y="3" width="1" height="1" fill="#22d3ee"/><rect x="0" y="4" width="1" height="1" fill="#22d3ee"/><rect x="0" y="5" width="1" height="1" fill="#22d3ee"/><rect x="1" y="4" width="1" height="1" fill="#22d3ee"/><rect x="1" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="4" width="1" height="1" fill="#22d3ee"/><rect x="2" y="5" width="1" height="1" fill="#22d3ee"/><rect x="3" y="4" width="1" height="1" fill="#22d3ee"/><rect x="3" y="5" width="1" height="1" fill="#22d3ee"/><rect x="4" y="4" width="1" height="1" fill="#22d3ee"/><rect x="4" y="5" width="1" height="1" fill="#22d3ee"/><rect x="5" y="4" width="1" height="1" fill="#22d3ee"/><rect x="5" y="5" width="1" height="1" fill="#22d3ee"/><rect x="6" y="4" width="1" height="1" fill="#22d3ee"/><rect x="6" y="5" width="1" height="1" fill="#22d3ee"/><rect x="7" y="4" width="1" height="1" fill="#22d3ee"/><rect x="7" y="5" width="1" height="1" fill="#22d3ee"/><rect x="8" y="4" width="1" height="1" fill="#22d3ee"/><rect x="8" y="5" width="1" height="1" fill="#22d3ee"/><rect x="9" y="4" width="1" height="1" fill="#22d3ee"/><rect x="9" y="5" width="1" height="1" fill="#22d3ee"/><rect x="10" y="4" width="1" height="1" fill="#22d3ee"/><rect x="10" y="5" width="1" height="1" fill="#22d3ee"/><rect x="11" y="4" width="1" height="1" fill="#22d3ee"/><rect x="11" y="5" width="1" height="1" fill="#22d3ee"/><rect x="12" y="4" width="1" height="1" fill="#22d3ee"/><rect x="12" y="5" width="1" height="1" fill="#22d3ee"/><rect x="13" y="4" width="1" height="1" fill="#22d3ee"/><rect x="13" y="5" width="1" height="1" fill="#22d3ee"/><rect x="14" y="4" width="1" height="1" fill="#22d3ee"/><rect x="14" y="5" width="1" height="1" fill="#22d3ee"/><rect x="15" y="4" width="1" height="1" fill="#22d3ee"/><rect x="15" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="6" width="1" height="1" fill="#22d3ee"/><rect x="2" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="6" width="1" height="1" fill="#22d3ee"/><rect x="3" y="7" width="1" height="1" fill="#22d3ee"/><rect x="4" y="6" width="1" height="1" fill="#22d3ee"/><rect x="4" y="7" width="1" height="1" fill="#22d3ee"/><rect x="5" y="6" width="1" height="1" fill="#22d3ee"/><rect x="5" y="7" width="1" height="1" fill="#22d3ee"/><rect x="6" y="6" width="1" height="1" fill="#22d3ee"/><rect x="6" y="7" width="1" height="1" fill="#22d3ee"/><rect x="7" y="6" width="1" height="1" fill="#22d3ee"/><rect x="7" y="7" width="1" height="1" fill="#22d3ee"/><rect x="8" y="6" width="1" height="1" fill="#22d3ee"/><rect x="8" y="7" width="1" height="1" fill="#22d3ee"/><rect x="9" y="6" width="1" height="1" fill="#22d3ee"/><rect x="9" y="7" width="1" height="1" fill="#22d3ee"/><rect x="10" y="6" width="1" height="1" fill="#22d3ee"/><rect x="10" y="7" width="1" height="1" fill="#22d3ee"/><rect x="11" y="6" width="1" height="1" fill="#22d3ee"/><rect x="11" y="7" width="1" height="1" fill="#22d3ee"/><rect x="12" y="6" width="1" height="1" fill="#22d3ee"/><rect x="12" y="7" width="1" height="1" fill="#22d3ee"/><rect x="13" y="6" width="1" height="1" fill="#22d3ee"/><rect x="13" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="8" width="1" height="1" fill="#22d3ee"/><rect x="3" y="9" width="1" height="1" fill="#22d3ee"/><rect x="5" y="8" width="1" height="1" fill="#22d3ee"/><rect x="5" y="9" width="1" height="1" fill="#22d3ee"/><rect x="10" y="8" width="1" height="1" fill="#22d3ee"/><rect x="10" y="9" width="1" height="1" fill="#22d3ee"/><rect x="12" y="8" width="1" height="1" fill="#22d3ee"/><rect x="12" y="9" width="1" height="1" fill="#22d3ee"/></svg>
|
|
@@ -136,10 +136,22 @@ def limpar_para_voz(texto: str) -> str:
|
|
|
136
136
|
return re.sub(r"\s+", " ", t).strip()
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def resumo(texto: str, frases: int =
|
|
140
|
-
"""(fala, painel). A voz
|
|
139
|
+
def resumo(texto: str, frases: int = 60, teto: int = 3000) -> Tuple[str, str]:
|
|
140
|
+
"""(fala, painel). A voz diz a resposta COMPLETA (sem código, links nem caminhos); o painel guarda o texto
|
|
141
|
+
inteiro. Só código e lista enorme ficam fora da fala — e aí a prosa em volta já diz o que foi feito."""
|
|
141
142
|
painel = str(texto or "").strip()
|
|
142
|
-
|
|
143
|
+
sem_codigo = re.sub(r"```[\s\S]*?```", " ", painel)
|
|
144
|
+
# lista ENORME (mais de 8 itens seguidos) não é lida item a item
|
|
145
|
+
linhas, saida, run = sem_codigo.splitlines(), [], []
|
|
146
|
+
for l in linhas + [""]:
|
|
147
|
+
if re.match(r"^\s*(?:[-*•]|\d+[.)])\s+", l):
|
|
148
|
+
run.append(l)
|
|
149
|
+
continue
|
|
150
|
+
if run:
|
|
151
|
+
saida += run if len(run) <= 8 else [f"São {len(run)} itens, a lista completa está no painel."]
|
|
152
|
+
run = []
|
|
153
|
+
saida.append(l)
|
|
154
|
+
limpo = limpar_para_voz("\n".join(saida))
|
|
143
155
|
partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
|
|
144
156
|
fala = ""
|
|
145
157
|
for p in partes[:frases]:
|
|
@@ -148,9 +160,8 @@ def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
|
|
|
148
160
|
fala = (fala + " " + p).strip()
|
|
149
161
|
if not fala:
|
|
150
162
|
fala = limpo[:teto].strip()
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
fala += " O resto está no painel."
|
|
163
|
+
if not fala and re.search(r"```", painel):
|
|
164
|
+
fala = "Mandei o código para o painel."
|
|
154
165
|
return fala, painel
|
|
155
166
|
|
|
156
167
|
|