primocode 10.14.1 → 10.15.0

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.
@@ -0,0 +1,236 @@
1
+ """conversa.py — a lógica da conversa, sem microfone, sem tela e sem som.
2
+
3
+ Tudo aqui é puro (entra texto/evento, sai decisão), de propósito: é o que o
4
+ teste mede. O que toca hardware (escuta.py, hud.py, __main__.py) só chama isto.
5
+
6
+ Maquina ouvindo → pensando → falando → (interrompido) → ouvindo
7
+ Fatiador texto em streaming → frases prontas para falar
8
+ resumo resposta longa → (fala curta, o que vai para o painel)
9
+ ativacao "Primo, abre o projeto" → (True, "abre o projeto")
10
+ comando "esconde o painel" → "esconder"
11
+ confirmacao ferramenta → "rodando os testes"
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import unicodedata
17
+ from typing import List, Optional, Tuple
18
+
19
+
20
+ def _sem_acento(s: str) -> str:
21
+ return "".join(ch for ch in unicodedata.normalize("NFD", s) if unicodedata.category(ch) != "Mn")
22
+
23
+
24
+ def normalizar(s: str) -> str:
25
+ """minúsculo, sem acento, sem pontuação — para comparar o que o Whisper deu."""
26
+ s = _sem_acento(str(s or "").lower())
27
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]+", " ", s)).strip()
28
+
29
+
30
+ # ── a máquina de estados ─────────────────────────────────────────────────
31
+
32
+ ESTADOS = ("dormindo", "ouvindo", "pensando", "falando", "executando", "interrompido")
33
+
34
+ # evento → {estado de origem → estado novo}. O que não está aqui é ignorado
35
+ # (um evento fora de hora não pode quebrar a conversa).
36
+ TRANSICOES = {
37
+ "ativar": {"dormindo": "ouvindo", "ouvindo": "ouvindo"},
38
+ "dormir": {"ouvindo": "dormindo", "interrompido": "dormindo"},
39
+ "ouviu": {"ouvindo": "pensando", "interrompido": "pensando", "dormindo": "pensando"},
40
+ "ferramenta": {"pensando": "executando", "executando": "executando", "falando": "falando"},
41
+ "resposta": {"pensando": "falando", "executando": "falando", "ouvindo": "falando"},
42
+ "fim_fala": {"falando": "ouvindo", "interrompido": "ouvindo"},
43
+ "sem_fala": {"pensando": "ouvindo", "executando": "ouvindo"},
44
+ "cortou": {"falando": "interrompido"},
45
+ "escutar": {"interrompido": "ouvindo"},
46
+ "parar": {"pensando": "ouvindo", "executando": "ouvindo", "falando": "ouvindo", "interrompido": "ouvindo"},
47
+ }
48
+
49
+
50
+ class Maquina:
51
+ """O estado da conversa. `mandar(evento)` devolve o estado novo."""
52
+
53
+ def __init__(self, inicial: str = "ouvindo") -> None:
54
+ self.estado = inicial
55
+ self.historico: List[str] = [inicial]
56
+
57
+ def mandar(self, evento: str) -> str:
58
+ novo = TRANSICOES.get(evento, {}).get(self.estado)
59
+ if novo and novo != self.estado:
60
+ self.estado = novo
61
+ self.historico.append(novo)
62
+ return self.estado
63
+
64
+ @property
65
+ def pode_cortar(self) -> bool:
66
+ """Falar por cima só corta se ele está falando."""
67
+ return self.estado == "falando"
68
+
69
+
70
+ # ── frase a frase ────────────────────────────────────────────────────────
71
+
72
+ _FIM = re.compile(r"(?<=[.!?…])\s+|(?<=\n)")
73
+ _ABREV = re.compile(r"\b(sr|sra|dr|dra|etc|ex|obs|vs|p\.ex)\.$", re.I)
74
+
75
+
76
+ class Fatiador:
77
+ """Recebe texto aos pedaços (streaming) e devolve frases completas.
78
+
79
+ Frase curta demais não sai sozinha (a voz picotada soa pior que a espera):
80
+ gruda na seguinte. A primeira frase sai mais cedo (mínimo menor), porque é
81
+ ela que decide quanto tempo a pessoa espera até ouvir algo."""
82
+
83
+ def __init__(self, minimo: int = 24, minimo_primeira: int = 12) -> None:
84
+ self.buf = ""
85
+ self.minimo = minimo
86
+ self.minimo_primeira = minimo_primeira
87
+ self.saiu = 0
88
+
89
+ def _alvo(self) -> int:
90
+ return self.minimo_primeira if self.saiu == 0 else self.minimo
91
+
92
+ def alimentar(self, pedaco: str) -> List[str]:
93
+ self.buf += pedaco or ""
94
+ return self._tirar(final=False)
95
+
96
+ def fechar(self) -> List[str]:
97
+ return self._tirar(final=True)
98
+
99
+ def _tirar(self, final: bool) -> List[str]:
100
+ saida: List[str] = []
101
+ partes = _FIM.split(self.buf)
102
+ resto = partes.pop() if partes else ""
103
+ acumulado = ""
104
+ for p in partes:
105
+ acumulado = (acumulado + " " + p).strip() if acumulado else p.strip()
106
+ if _ABREV.search(acumulado):
107
+ continue
108
+ if len(acumulado) >= self._alvo():
109
+ saida.append(acumulado)
110
+ self.saiu += 1
111
+ acumulado = ""
112
+ self.buf = (acumulado + " " + resto).strip() if acumulado else resto
113
+ if final and self.buf.strip():
114
+ saida.append(self.buf.strip())
115
+ self.saiu += 1
116
+ self.buf = ""
117
+ return saida
118
+
119
+
120
+ def dividir_frases(texto: str) -> List[str]:
121
+ f = Fatiador()
122
+ return f.alimentar(texto) + f.fechar()
123
+
124
+
125
+ # ── o que vai para o ouvido e o que vai para o painel ────────────────────
126
+
127
+ def limpar_para_voz(texto: str) -> str:
128
+ t = str(texto or "")
129
+ t = re.sub(r"```[\s\S]*?```", " ", t)
130
+ t = re.sub(r"\s+(em|no|na|de|do|da|para)\s+`[^`]*[/\\.][^`]*`", "", t)
131
+ t = re.sub(r"`([^`]*)`", lambda m: "" if re.search(r"[/\\.]", m.group(1)) else m.group(1), t)
132
+ t = re.sub(r"https?://\S+", "", t)
133
+ t = re.sub(r"(^|\s)[~.]?/[\w.\-/]+", " ", t)
134
+ t = re.sub(r"^\s*(#+|[-*•]|\d+[.)])\s+", "", t, flags=re.M)
135
+ t = re.sub(r"[*_>|]", "", t)
136
+ return re.sub(r"\s+", " ", t).strip()
137
+
138
+
139
+ def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
140
+ """(fala, painel). A voz resume; o texto inteiro (código, listas) vai ao painel."""
141
+ painel = str(texto or "").strip()
142
+ limpo = limpar_para_voz(painel)
143
+ partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
144
+ fala = ""
145
+ for p in partes[:frases]:
146
+ if len(fala) + len(p) > teto:
147
+ break
148
+ fala = (fala + " " + p).strip()
149
+ if not fala:
150
+ fala = limpo[:teto].strip()
151
+ tinha_mais = bool(re.search(r"```", painel)) or len(partes) > frases
152
+ if tinha_mais and fala and not re.search(r"painel|tela", fala, re.I):
153
+ fala += " O resto está no painel."
154
+ return fala, painel
155
+
156
+
157
+ # ── a palavra de ativação ────────────────────────────────────────────────
158
+
159
+ _NOMES = ("primo", "primu", "prima", "primocode", "primo code")
160
+ _ANTES = ("ei", "ey", "hey", "oi", "ola", "fala", "e ai", "opa", "salve")
161
+
162
+
163
+ def ativacao(texto: str, nome: str = "Primo") -> Tuple[bool, str]:
164
+ """Começa com "Primo" (ou "ei Primo")? Devolve (sim?, o resto do pedido).
165
+
166
+ Só vale no INÍCIO da fala: "o primo da Ana ligou" não acorda ninguém."""
167
+ cru = str(texto or "").strip()
168
+ n = normalizar(cru)
169
+ meus = _NOMES if normalizar(nome) in ("primo", "") else (normalizar(nome),)
170
+ for ant in ("",) + _ANTES:
171
+ for nm in meus:
172
+ pre = (ant + " " + nm).strip()
173
+ if n == pre or n.startswith(pre + " "):
174
+ resto = re.sub(r"^\W*(?:" + r"\W+".join(re.escape(w) for w in pre.split()) + r")\W*", "",
175
+ _sem_acento(cru), flags=re.I)
176
+ # o corte vale no texto sem acento; o tamanho é o mesmo
177
+ resto = cru[len(cru) - len(resto):] if len(resto) <= len(cru) else resto
178
+ return True, resto.strip(" ,.!?;:-")
179
+ return False, cru
180
+
181
+
182
+ # ── comandos de voz que o painel e o modo resolvem sozinhos ──────────────
183
+
184
+ _COMANDOS = [
185
+ ("esconder", r"(esconde|esconder|some com|fecha|oculta) (o )?(painel|hud)"),
186
+ ("mostrar", r"(mostra|mostrar|abre|exibe|volta com) (o )?(painel|hud)"),
187
+ ("parar", r"(para|pare|calma|chega|cala a boca|silencio|cancela)"),
188
+ ("olhar", r"(olha aqui|olha isso|olha a tela|veja a tela|ve a tela|olha pra tela|ve isso)"),
189
+ ("resumo_dia", r"(resumo do dia|como foi o dia|resumo de hoje|briefing|o que mudou hoje)"),
190
+ ]
191
+
192
+
193
+ def comando(texto: str) -> Optional[str]:
194
+ n = normalizar(texto)
195
+ if not n or len(n.split()) > 6: # comando é curto; frase longa é pedido
196
+ return None
197
+ for nome, rx in _COMANDOS:
198
+ if re.fullmatch(rx + r"( por favor| ai| agora)?", n):
199
+ return nome
200
+ return None
201
+
202
+
203
+ # ── confirmações curtas de ação ──────────────────────────────────────────
204
+
205
+ def confirmacao(ferramenta: str, args: Optional[dict] = None) -> str:
206
+ """O que o Primo diz ao começar uma ferramenta. Vazio = não diz nada."""
207
+ f = str(ferramenta or "")
208
+ cmd = str((args or {}).get("command") or "")
209
+ if f == "run_shell":
210
+ if re.search(r"\b(test|jest|pytest|vitest)\b", cmd):
211
+ return "Rodando os testes."
212
+ if re.search(r"\bgit\s+(push|commit)\b", cmd):
213
+ return "Mandando pro git."
214
+ if re.search(r"\b(build|tsc)\b", cmd):
215
+ return "Fazendo o build."
216
+ return "Rodando."
217
+ if f in ("desktop_open_app",):
218
+ return "Abrindo."
219
+ if f in ("write_file", "edit_file", "apply_patch"):
220
+ return "Editando."
221
+ if f in ("web_search", "pesquisar_referencia"):
222
+ return "Pesquisando."
223
+ return ""
224
+
225
+
226
+ def rotulo_ferramenta(ferramenta: str, args: Optional[dict] = None) -> str:
227
+ """O texto do painel: ferramenta + alvo curto."""
228
+ a = args or {}
229
+ alvo = a.get("path") or a.get("command") or a.get("query") or a.get("url") or ""
230
+ alvo = re.sub(r"\s+", " ", str(alvo)).strip()
231
+ return (str(ferramenta) + (" · " + alvo[:48] if alvo else ""))[:70]
232
+
233
+
234
+ def frase_final(texto: str) -> str:
235
+ """Termina a fala de ação com um 'feito' quando a resposta não tem fala."""
236
+ return texto if texto.strip() else "Feito."
@@ -49,6 +49,7 @@ PRE_ROLL = 4 # blocos guardados ANTES do início da fala
49
49
  RABO_SILENCIO = 2.0 # silêncio que encerra a frase
50
50
  RABO_SILENCIO_LONGA = 2.8 # …e quando a fala já vem longa
51
51
  FALA_LONGA = 6.0
52
+ RABO_VAD = 0.9 # silêncio que encerra a frase quando o VAD confirma
52
53
  ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
53
54
  MAX_FRASE = 45.0
54
55
  MAX_FRASE_ESTENDIDA = 100.0
@@ -93,6 +94,9 @@ class Gravador:
93
94
  self._ambiente = PISO_FALA
94
95
  self._amostras_ambiente: List[float] = []
95
96
  self.encerrou_por = ""
97
+ # VAD opcional (Silero, via pysilero-vad): bloco → True se for voz.
98
+ # Com ele, a frase fecha em 0,9 s de silêncio em vez de 2 s.
99
+ self.vad = None
96
100
 
97
101
  # O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
98
102
  # abrir o microfone daria o silêncio do processo, não o do cômodo.
@@ -114,6 +118,12 @@ class Gravador:
114
118
  self._relogio += BLOCO_MS / 1000.0
115
119
  agora = self._relogio
116
120
  nivel = rms(bloco)
121
+ voz = None
122
+ if self.vad is not None:
123
+ try:
124
+ voz = bool(self.vad(bloco))
125
+ except Exception:
126
+ self.vad = None
117
127
 
118
128
  if not self._falando:
119
129
  # Guarda os últimos blocos: sem eles, a frase começa cortada na
@@ -128,7 +138,7 @@ class Gravador:
128
138
  if len(self._amostras_ambiente) >= 5:
129
139
  self._ambiente = float(np.median(self._amostras_ambiente))
130
140
 
131
- if nivel > self._limiar():
141
+ if nivel > self._limiar() and voz is not False:
132
142
  self._falando = True
133
143
  self._blocos = list(self._pre)
134
144
  self._ultimo_som = agora
@@ -138,11 +148,13 @@ class Gravador:
138
148
  return False
139
149
 
140
150
  self._blocos.append(bloco)
141
- if nivel > self._limiar():
151
+ if voz is True or (voz is None and nivel > self._limiar()):
142
152
  self._ultimo_som = agora
143
153
 
144
154
  duracao = len(self._blocos) * BLOCO_MS / 1000.0
145
155
  rabo = RABO_SILENCIO_LONGA if duracao > FALA_LONGA else RABO_SILENCIO
156
+ if self.vad is not None:
157
+ rabo = RABO_VAD
146
158
 
147
159
  if agora - self._ultimo_som > rabo:
148
160
  self.encerrou_por = "silencio"
@@ -234,3 +246,21 @@ class Microfone:
234
246
  except Exception:
235
247
  pass
236
248
  self._fluxo = None
249
+
250
+
251
+ def criar_vad():
252
+ """O detector de voz Silero (MIT, pysilero-vad, só onnx/ggml) ou None se não instalou."""
253
+ try:
254
+ from pysilero_vad import SileroVoiceActivityDetector
255
+ det = SileroVoiceActivityDetector()
256
+ except Exception:
257
+ return None
258
+ passo = det.chunk_samples()
259
+
260
+ def vad(bloco: np.ndarray) -> bool:
261
+ pcm = bloco.astype(np.int16)
262
+ pico = 0.0
263
+ for i in range(0, len(pcm) - passo + 1, passo):
264
+ pico = max(pico, det(pcm[i:i + passo].tobytes()))
265
+ return pico > 0.5
266
+ return vad