JuanCS-Dev commited on
Commit
dbe6486
·
verified ·
1 Parent(s): 7d5735a

Upload folder using huggingface_hub (Vértice Elite Deploy)

Browse files
Files changed (43) hide show
  1. README.md +48 -73
  2. bot/ascii_art_runtime.py +333 -0
  3. bot/byte_semantics.py +4 -0
  4. bot/byte_semantics_base.py +46 -0
  5. bot/byte_semantics_constants.py +1 -1
  6. bot/control_plane_config.py +18 -4
  7. bot/control_plane_config_helpers.py +36 -0
  8. bot/dashboard_parity_gate.py +22 -0
  9. bot/dashboard_server_routes.py +76 -0
  10. bot/logic_context.py +64 -0
  11. bot/logic_inference.py +43 -6
  12. bot/ops_playbooks.py +1 -1
  13. bot/persistence_layer.py +49 -0
  14. bot/persistence_persona_profile_repository.py +252 -0
  15. bot/prompt_flow.py +20 -0
  16. bot/prompt_runtime.py +4 -0
  17. bot/requirements.txt +3 -0
  18. bot/tests/scientific/suite_scene_irc.py +1 -1
  19. bot/tests/scientific/test_phase22_debt_free_validation.py +584 -0
  20. bot/tests/scientific/test_phase22_persona_routing.py +315 -0
  21. bot/tests/scientific_shared.py +0 -2
  22. bot/tests/test_control_plane_config.py +58 -0
  23. bot/tests/test_dashboard_routes_v3.py +95 -0
  24. bot/tests/test_irc_state_massive.py +1 -1
  25. bot/tests/test_irc_state_v3.py +2 -2
  26. bot/tests/test_prompt_flow_v2.py +4 -1
  27. dashboard/features/clips/view.js +84 -68
  28. dashboard/features/control-plane/api.js +32 -0
  29. dashboard/features/control-plane/controller.js +84 -0
  30. dashboard/features/control-plane/view.js +248 -0
  31. dashboard/partials/control_plane.html +157 -191
  32. dashboard/styles/clips.css +138 -67
  33. dashboard/tests/api_contract_parity.test.js +16 -0
  34. dashboard/tests/multi_channel_focus.test.js +10 -9
  35. docs/IMPLEMENTATION_PLAN_PERSISTENCIA_STATEFUL_SUPABASE.md +57 -47
  36. docs/NIGHTBOT_COMPETITIVE_REPORT.md +32 -0
  37. docs/OPERATIONS_GUIDE.md +78 -0
  38. docs/nebius/ASSET_CHECKLIST.md +121 -0
  39. docs/nebius/PARTNERSHIP_PROPOSAL.md +204 -0
  40. docs/nebius/TECHNICAL_BRIEF.md +134 -0
  41. docs/tactical_calendar_implementation_plan.md +54 -0
  42. test_ascii_quality.py +95 -0
  43. tests/test_ascii_art_runtime.py +106 -0
README.md CHANGED
@@ -1,94 +1,69 @@
1
- ---
2
- title: Byte Bot
3
- sdk: docker
4
- emoji: 🤖
5
- colorFrom: purple
6
- colorTo: blue
7
- ---
8
-
9
- # Byte - AI Agent Runtime para Operações de Streaming
10
-
11
- ![Byte Banner](assets/hero-banner-novo.png)
12
-
13
- [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Space-blue)](https://huggingface.co/spaces/JuanCS-Dev/twitch-byte-bot)
14
- [![Nebius Inference](https://img.shields.io/badge/Inference-Nebius%20AI-green)](https://nebius.ai/)
15
- [![Supabase Database](https://img.shields.io/badge/Database-Supabase-blueviolet)](https://supabase.com/)
16
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
17
-
18
- `Byte` é um agente de chat para Twitch de próxima geração, agora totalmente migrado para a stack soberana de 2026: **Nebius AI** para inferência MoE e **Supabase** para persistência de dados em tempo real.
19
-
20
- ## ✅ Posicionamento Certo
21
 
22
- **"AI Agent Runtime para Operações de Streaming"**
23
 
24
- - **Categoria:** infraestrutura de operações de streaming.
25
- - **Alternativa comparada:** gerenciar manualmente, contratar stream manager ou cobrir a operação com 5 ferramentas.
26
- - **Resultado:** Byte é o único produto nessa categoria.
27
- - **Diferencial operacional:** `action queue` como controle operacional profissional.
28
-
29
- ## 🏗️ Arquitetura
30
 
31
- ![Byte Architecture](assets/architecture-byte-flow-novo.png)
 
 
 
 
 
32
 
33
- O sistema opera como um "ML Operating System" distribuído:
34
- - **Runtime:** Hugging Face Spaces (Docker)
35
- - **Cérebro:** Nebius Token Factory (Kimi K2.5 / Llama 3.1 70B)
36
- - **Memória:** Supabase PostgreSQL (via Supavisor IPv4 Pooler)
37
- - **Busca:** DuckDuckGo Search para eventos em tempo real
38
 
39
- ## 🚀 O que o Byte Faz
40
 
41
- - **Integração Nativa:** Entra no chat da Twitch como viewer (`irc`) ou chatbot oficial (`eventsub`).
42
- - **Respostas Inteligentes:** Gatilhos naturais como `byte ...`, `@byte ...`, ou `!byte ...`.
43
- - **Concisão Extrema:** Hard limit de 8 linhas para não poluir a live.
44
- - **Grounding Real-time:** Pesquisa web automática para notícias e eventos atuais.
45
- - **Observabilidade:** Dashboard operacional integrada para monitoramento de saúde e custos.
46
 
47
- ## 🛠️ Comandos e Padrões de Gatilho
 
48
 
49
- - `byte help` - Lista ajuda.
50
- - `byte status` - Saúde do sistema e latência.
51
- - `byte movie fact sheet <movie>` - Ficha técnica de filmes.
52
- - `byte <pergunta livre>` - Inferência direta via Nebius.
53
 
54
- Também suporta prefixos: `@byte <pergunta>` ou `!byte <pergunta>`.
 
55
 
56
- ## 📦 Configuração de Produção (HF Secrets)
 
57
 
58
- O bot utiliza o cofre de segredos do Hugging Face (**Settings -> Variables and Secrets**):
59
 
60
- | Variável | Descrição |
61
- |----------|-----------|
62
- | `NEBIUS_API_KEY` | Chave de acesso ao Nebius AI Studio |
63
- | `SUPABASE_DB_URL` | DSN absoluto (Pooler IPv4 Porta 5432) |
64
- | `TWITCH_USER_TOKEN` | Token OAuth do bot |
65
- | `BYTE_DASHBOARD_ADMIN_TOKEN` | Token de acesso à Dashboard |
66
 
67
- ## 🧪 Quick Start Local
68
 
69
- 1. `cp .env.example .env`
70
- 2. `pip install -r bot/requirements.txt`
71
- 3. `python bot/main.py`
 
 
72
 
73
- ## 📑 Documentação
 
 
 
 
 
 
74
 
75
- - [Report de Cura (Forense)](docs/BYTE_AGENT_HEALING_REPORT_2026-02-24.md)
76
- - [Guia Geral de Operações](docs/DOCUMENTATION.md)
77
- - [Runbook de Deploy HF Spaces](docs/HF_SPACES_DEPLOY_RUNBOOK.md)
78
- - [Índice de Documentos](docs/INDEX.md)
 
79
 
80
  ---
81
 
82
- ## 🛡️ Segurança e Governança
83
-
84
- Este projeto segue a **Constituição Vértice v3.0**.
85
- - Nenhuma credencial é logada ou comitada.
86
- - Commits assinados via GPG.
87
- - Scanning automático de malware e segredos via HF HubOps.
88
-
89
- ## 📄 Licença
90
-
91
- Open source sob a licença **MIT**. Veja `LICENSE` para detalhes.
92
 
93
  ---
94
- *Desenvolvido por Juan Carlos (VÉRTICE Core Analytics x BYTE AI)*
 
1
+ # 🤖 BYTE AI — Agentic Stream Runtime (2026)
2
+ ## The First "Stream Co-Producer" for Twitch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ Byte is not a bot. It is an **Autonomous Agent Runtime** designed to manage, moderate, and grow Twitch channels using high-density intelligence. Powered by **Nebius AI (Kimi K2.5/K2-Thinking)** and **Supabase**, Byte bridges the gap between static commands and true real-time stream management.
5
 
6
+ ---
 
 
 
 
 
7
 
8
+ ## ⚡ Elite Performance Stats
9
+ - **Inference:** Powered by Nebius Token Factory (TTFT < 300ms).
10
+ - **Core:** 89 Python modules, 1,000+ Scientific Tests (100% Pass Rate).
11
+ - **Quality:** Structural Health 10.00/10 (Ruff/Pylint/McCabe).
12
+ - **Persistence:** Multi-tenant stateful layer via Supabase.
13
+ - **UI:** Zero-bloat Vanilla JS Dashboard with real-time telemetry.
14
 
15
+ ---
 
 
 
 
16
 
17
+ ## 🌟 Key Capabilities (Fases 1-25)
18
 
19
+ ### 1. Persona Studio & Model Routing
20
+ Define the "soul" of the agent. Switch between **DeepSeek-R1** for strategic coaching and **Kimi K2.5** for high-speed chat interactions. Customize tone, slang, and behavioral constraints in real-time.
 
 
 
21
 
22
+ ### 2. Tactical Calendar & Autonomy
23
+ Schedule strategic events via **Unix Cron** or **Fixed UTC Timestamps**. Byte monitors stream health, sentiment, and engagement goals, taking autonomous actions when necessary.
24
 
25
+ ### 3. High-Density Braille ASCII Art
26
+ A proprietary engine that converts images into **2x4 Braille Matrix** art. Deliver ultra-sharp silhouettes (Goku, Batman, Pikachu) directly in the Twitch chat without breaking layout.
 
 
27
 
28
+ ### 4. Real-time Clips Pipeline
29
+ Autonomous highlight detection + creation. The dashboard provides a visual pipeline with **real thumbnails** and live polling status for every clip job.
30
 
31
+ ### 5. Intelligence & Observability
32
+ Live sentiment tracking (Hype vs. Boredom), viewer churn risk assessment, and automated post-stream analytical reports.
33
 
34
+ ---
35
 
36
+ ## 🚀 Quick Start (Operation Guide)
 
 
 
 
 
37
 
38
+ For detailed instructions, see the [**Operations Guide (Antiburro)**](docs/OPERATIONS_GUIDE.md).
39
 
40
+ ### 1. Prerequisites
41
+ - Python 3.11+
42
+ - Supabase Project (PostgreSQL + pgvector)
43
+ - Nebius AI API Key
44
+ - Twitch Client ID & Secret
45
 
46
+ ### 2. Installation
47
+ ```bash
48
+ git clone https://github.com/JuanCS-Dev/twitch-byte-bot.git
49
+ cd twitch-byte-bot
50
+ pip install -r bot/requirements.txt
51
+ cp .env.example .env # Fill your keys
52
+ ```
53
 
54
+ ### 3. Running
55
+ ```bash
56
+ # Start the Agent + Dashboard Server
57
+ python -m bot.main
58
+ ```
59
 
60
  ---
61
 
62
+ ## 🛠 Tech Stack
63
+ - **Backend:** Python (AsyncIO), OpenAI SDK, Croniter, Pillow.
64
+ - **Frontend:** HTML5, CSS Variables, Vanilla JS (No Frameworks).
65
+ - **Storage:** Supabase (PostgREST, Auth, Realtime).
66
+ - **Inference:** Nebius AI Studio (Kimi K2.5, K2-Thinking, Qwen).
 
 
 
 
 
67
 
68
  ---
69
+ *Developed by Juan Carlos — VÉRTICE Core Analytics x BYTE AI*
bot/ascii_art_runtime.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ASCII Art Runtime - Geração de arte ASCII a partir de busca de imagens."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from io import BytesIO
9
+
10
+ import requests
11
+
12
+ logger = logging.getLogger("ByteBot")
13
+
14
+ # Configurações do contrato ASCII para Twitch 2026
15
+ ASCII_ART_MAX_LINES = 12
16
+ ASCII_ART_MAX_CHARS_PER_LINE = 80
17
+ ASCII_ART_COOLDOWN_SECONDS = 30.0
18
+ ASCII_ART_TIMEOUT_SECONDS = 10.0
19
+ ASCII_ART_IMAGE_MAX_SIZE = (400, 400)
20
+
21
+ # Caracteres ASCII do mais escuro ao mais claro (para melhor contraste)
22
+ ASCII_CHARS = "@#%*+=-:. "
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AsciiArtResult:
27
+ """Resultado da geração de arte ASCII."""
28
+
29
+ lines: list[str]
30
+ source: str
31
+ subject: str
32
+ success: bool
33
+ error_message: str = ""
34
+
35
+
36
+ class AsciiArtCooldown:
37
+ """Gerenciador de cooldown/throttle para comando ASCII."""
38
+
39
+ def __init__(self, cooldown_seconds: float = ASCII_ART_COOLDOWN_SECONDS):
40
+ self._cooldown_seconds = cooldown_seconds
41
+ self._last_usage: dict[str, float] = {}
42
+
43
+ def can_use(self, channel_id: str) -> tuple[bool, float]:
44
+ """Verifica se o comando pode ser usado no canal. Retorna (pode_usar, tempo_restante)."""
45
+ now = time.monotonic()
46
+ last = self._last_usage.get(channel_id, 0)
47
+ elapsed = now - last
48
+ if elapsed >= self._cooldown_seconds:
49
+ return True, 0.0
50
+ return False, self._cooldown_seconds - elapsed
51
+
52
+ def mark_used(self, channel_id: str) -> None:
53
+ """Marca o comando como usado no canal."""
54
+ self._last_usage[channel_id] = time.monotonic()
55
+
56
+
57
+ # Instância global de cooldown
58
+ _ascii_cooldown = AsciiArtCooldown()
59
+
60
+
61
+ def _search_image_sync(subject: str) -> str | None:
62
+ """Busca imagem usando DuckDuckGo (síncrono, roda em thread)."""
63
+ try:
64
+ from duckduckgo_search import DDGS
65
+
66
+ with DDGS() as ddgs:
67
+ # Busca por imagens
68
+ results = list(ddgs.images(f"{subject}", max_results=3))
69
+ for item in results:
70
+ url = item.get("image") or item.get("url") or item.get("thumbnail")
71
+ if url:
72
+ return url
73
+ except Exception as e:
74
+ logger.warning("Erro ao buscar imagem para ASCII art '%s': %s", subject, e)
75
+ return None
76
+
77
+
78
+ async def _search_image(subject: str) -> str | None:
79
+ """Busca imagem de forma assíncrona."""
80
+ try:
81
+ return await asyncio.wait_for(
82
+ asyncio.to_thread(_search_image_sync, subject),
83
+ timeout=ASCII_ART_TIMEOUT_SECONDS,
84
+ )
85
+ except TimeoutError:
86
+ logger.warning("Timeout ao buscar imagem para ASCII art: %s", subject)
87
+ return None
88
+ except Exception as e:
89
+ logger.warning("Erro na busca de imagem ASCII: %s", e)
90
+ return None
91
+
92
+
93
+ def _download_image_sync(url: str) -> bytes | None:
94
+ """Baixa imagem da URL (síncrono)."""
95
+ try:
96
+ response = requests.get(
97
+ url,
98
+ timeout=8.0,
99
+ headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.0"},
100
+ )
101
+ response.raise_for_status()
102
+ return response.content
103
+ except Exception as e:
104
+ logger.warning("Erro ao baixar imagem %s: %s", url, e)
105
+ return None
106
+
107
+
108
+ async def _download_image(url: str) -> bytes | None:
109
+ """Baixa imagem de forma assíncrona."""
110
+ try:
111
+ return await asyncio.wait_for(
112
+ asyncio.to_thread(_download_image_sync, url),
113
+ timeout=ASCII_ART_TIMEOUT_SECONDS,
114
+ )
115
+ except TimeoutError:
116
+ logger.warning("Timeout ao baixar imagem ASCII: %s", url)
117
+ return None
118
+ except Exception as e:
119
+ logger.warning("Erro no download de imagem ASCII: %s", e)
120
+ return None
121
+
122
+
123
+ def _generate_twitch_braille_true(image_bytes: bytes, width: int = 24) -> list[str]:
124
+ """Gera ASCII usando matriz Braille 2x4 (Verdadeira silhueta detalhada)."""
125
+ try:
126
+ from PIL import Image, ImageChops, ImageStat
127
+
128
+ img = Image.open(BytesIO(image_bytes)).convert("RGBA")
129
+
130
+ # Fundo branco para garantir que PNGs transparentes não fiquem invertidos
131
+ bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
132
+ img = Image.alpha_composite(bg, img).convert("L")
133
+
134
+ # Corta espaços em branco (Auto-crop)
135
+ inv = Image.eval(img, lambda p: 255 - p)
136
+ bbox = inv.getbbox()
137
+ if bbox:
138
+ img = img.crop(bbox)
139
+
140
+ pixel_width = width * 2
141
+ # Fator 0.75 para lidar com a altura de linha do chat da Twitch (que achata a arte)
142
+ aspect_ratio = img.height / img.width
143
+ pixel_height = int(pixel_width * aspect_ratio * 0.75)
144
+
145
+ img = img.resize((pixel_width, pixel_height), Image.Resampling.LANCZOS)
146
+
147
+ # Calcula um threshold dinâmico para pegar tons médios também
148
+ stat = ImageStat.Stat(img)
149
+ mean = stat.mean[0]
150
+ threshold = min(200, max(50, mean - 20))
151
+
152
+ braille_map = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]
153
+
154
+ lines = []
155
+ char_height = pixel_height // 4 + (1 if pixel_height % 4 != 0 else 0)
156
+
157
+ for y in range(min(char_height, ASCII_ART_MAX_LINES)):
158
+ line = ""
159
+ for x in range(width):
160
+ char_val = 0
161
+ for dy in range(4):
162
+ for dx in range(2):
163
+ px = x * 2 + dx
164
+ py = y * 4 + dy
165
+ if px < pixel_width and py < pixel_height:
166
+ if img.getpixel((px, py)) < threshold:
167
+ char_val += braille_map[dy][dx]
168
+
169
+ # Se não tem ponto nenhum, usa espaço comum para evitar glitches de layout
170
+ if char_val == 0:
171
+ line += " "
172
+ else:
173
+ line += chr(0x2800 + char_val)
174
+ lines.append(line.rstrip())
175
+
176
+ # Filtra linhas vazias
177
+ final_lines = [ln for ln in lines if ln.strip()]
178
+ return final_lines[:ASCII_ART_MAX_LINES]
179
+
180
+ except Exception as e:
181
+ logger.warning("Erro no gerador Braille ASCII: %s", e)
182
+ return []
183
+
184
+
185
+ def _generate_ascii_with_lib(image_bytes: bytes, width: int = 24) -> list[str]:
186
+ # Removido ascii_magic porque ele engasga com nulos e a renderização do Braille é infinitamente superior.
187
+ # Redirecionamos para a nossa própria engine de rendering nativa.
188
+ return _generate_twitch_braille_true(image_bytes, width)
189
+
190
+
191
+ def _sanitize_ascii_lines(lines: list[str]) -> list[str]:
192
+ """Sanitiza linhas ASCII para envio seguro no IRC."""
193
+ sanitized = []
194
+ for line in lines:
195
+ # Remove caracteres problemáticos para IRC
196
+ cleaned = line.replace("\r", "").replace("\n", "")
197
+ # Trunca se necessário
198
+ if len(cleaned) > ASCII_ART_MAX_CHARS_PER_LINE:
199
+ cleaned = cleaned[:ASCII_ART_MAX_CHARS_PER_LINE]
200
+ # Só adiciona se tiver conteúdo
201
+ if cleaned.strip():
202
+ sanitized.append(cleaned)
203
+ return sanitized[:ASCII_ART_MAX_LINES]
204
+
205
+
206
+ async def generate_ascii_art(
207
+ subject: str,
208
+ channel_id: str,
209
+ ) -> AsciiArtResult:
210
+ """Gera arte ASCII a partir de um assunto.
211
+
212
+ Args:
213
+ subject: Assunto da arte (ex: "gato", "cachorro", "Homer Simpson")
214
+ channel_id: ID do canal para controle de cooldown
215
+
216
+ Returns:
217
+ AsciiArtResult com linhas ASCII ou mensagem de erro
218
+ """
219
+ # Verifica cooldown
220
+ can_use, remaining = _ascii_cooldown.can_use(channel_id)
221
+ if not can_use:
222
+ return AsciiArtResult(
223
+ lines=[],
224
+ source="",
225
+ subject=subject,
226
+ success=False,
227
+ error_message=f"Aguarde {int(remaining)}s para usar arte ASCII novamente.",
228
+ )
229
+
230
+ # Busca imagem
231
+ image_url = await _search_image(subject)
232
+ if not image_url:
233
+ return AsciiArtResult(
234
+ lines=[],
235
+ source="",
236
+ subject=subject,
237
+ success=False,
238
+ error_message=f"Não encontrei imagem para '{subject}'. Tente outro termo!",
239
+ )
240
+
241
+ # Baixa imagem
242
+ image_bytes = await _download_image(image_url)
243
+ if not image_bytes:
244
+ return AsciiArtResult(
245
+ lines=[],
246
+ source="",
247
+ subject=subject,
248
+ success=False,
249
+ error_message="Erro ao baixar imagem. Tente novamente!",
250
+ )
251
+
252
+ # Gera ASCII usando a nova engine de silhueta Braille (Fase 23 Upgrade)
253
+ # Largura padrão para chat Twitch é idealmente entre 24 e 28 chars
254
+ lines = _generate_ascii_with_lib(image_bytes, width=28)
255
+
256
+ if not lines:
257
+ return AsciiArtResult(
258
+ lines=[],
259
+ source=image_url,
260
+ subject=subject,
261
+ success=False,
262
+ error_message="Não consegui converter a imagem em arte ASCII.",
263
+ )
264
+
265
+ # Sanitiza e marca uso
266
+ sanitized_lines = _sanitize_ascii_lines(lines)
267
+ _ascii_cooldown.mark_used(channel_id)
268
+
269
+ # Extrai domínio da fonte
270
+ source_domain = image_url.split("/")[2] if "/" in image_url else "desconhecida"
271
+
272
+ return AsciiArtResult(
273
+ lines=sanitized_lines,
274
+ source=source_domain,
275
+ subject=subject,
276
+ success=True,
277
+ )
278
+
279
+
280
+ async def handle_ascii_art_prompt(
281
+ subject: str,
282
+ author_name: str,
283
+ reply_raw_fn: Callable[[str], object],
284
+ channel_id: str,
285
+ ) -> bool:
286
+ """Handler de alto nível para comando de arte ASCII.
287
+
288
+ Args:
289
+ subject: Assunto da arte
290
+ author_name: Nome do autor da mensagem
291
+ reply_raw_fn: Função para enviar linha raw (preserva espaços)
292
+ channel_id: ID do canal
293
+
294
+ Returns:
295
+ True se arte foi enviada, False se erro/cooldown
296
+ """
297
+ result = await generate_ascii_art(subject, channel_id)
298
+
299
+ if not result.success:
300
+ # Envia mensagem de erro normal
301
+ error_reply = f"@{author_name} {result.error_message}"
302
+ await reply_raw_fn(error_reply)
303
+ return False
304
+
305
+ # Envia arte linha por linha preservando formatação
306
+ header = f"@{author_name} Arte ASCII: {result.subject}"
307
+ await reply_raw_fn(header)
308
+ await asyncio.sleep(0.45) # Throttle inicial
309
+
310
+ for line in result.lines:
311
+ await reply_raw_fn(line)
312
+ await asyncio.sleep(0.45) # Throttle entre linhas para evitar rate-limit
313
+
314
+ # Fonte em linha separada
315
+ if result.source:
316
+ await reply_raw_fn(f"(fonte: {result.source})")
317
+
318
+ return True
319
+
320
+
321
+ def get_cooldown_status(channel_id: str) -> tuple[bool, float]:
322
+ """Retorna status do cooldown para um canal.
323
+
324
+ Returns:
325
+ (pode_usar, segundos_restantes)
326
+ """
327
+ return _ascii_cooldown.can_use(channel_id)
328
+
329
+
330
+ def reset_cooldown(channel_id: str) -> None:
331
+ """Reseta o cooldown para um canal (útil para testes)."""
332
+ if channel_id in _ascii_cooldown._last_usage:
333
+ del _ascii_cooldown._last_usage[channel_id]
bot/byte_semantics.py CHANGED
@@ -1,7 +1,9 @@
1
  from bot.byte_semantics_base import (
2
  build_intro_reply,
3
  compact_message,
 
4
  format_chat_reply,
 
5
  is_current_events_prompt,
6
  is_follow_up_prompt,
7
  is_high_risk_current_events_prompt,
@@ -64,9 +66,11 @@ __all__ = [
64
  "build_server_time_anchor_instruction",
65
  "build_verifiable_prompt",
66
  "compact_message",
 
67
  "extract_movie_title",
68
  "extract_multi_reply_parts",
69
  "format_chat_reply",
 
70
  "is_current_events_prompt",
71
  "is_follow_up_prompt",
72
  "is_high_risk_current_events_prompt",
 
1
  from bot.byte_semantics_base import (
2
  build_intro_reply,
3
  compact_message,
4
+ extract_ascii_subject,
5
  format_chat_reply,
6
+ is_ascii_art_prompt,
7
  is_current_events_prompt,
8
  is_follow_up_prompt,
9
  is_high_risk_current_events_prompt,
 
66
  "build_server_time_anchor_instruction",
67
  "build_verifiable_prompt",
68
  "compact_message",
69
+ "extract_ascii_subject",
70
  "extract_movie_title",
71
  "extract_multi_reply_parts",
72
  "format_chat_reply",
73
+ "is_ascii_art_prompt",
74
  "is_current_events_prompt",
75
  "is_follow_up_prompt",
76
  "is_high_risk_current_events_prompt",
bot/byte_semantics_base.py CHANGED
@@ -1,4 +1,5 @@
1
  import re
 
2
 
3
  from bot.byte_semantics_constants import (
4
  BYTE_INTRO_TEMPLATES,
@@ -61,6 +62,51 @@ def parse_byte_prompt(message_text: str) -> str | None:
61
  return match.group(1).strip()
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def is_movie_fact_sheet_prompt(prompt: str) -> bool:
65
  lowered = (prompt or "").lower()
66
  return bool(MOVIE_FACT_SHEET_PATTERN.search(lowered))
 
1
  import re
2
+ import unicodedata
3
 
4
  from bot.byte_semantics_constants import (
5
  BYTE_INTRO_TEMPLATES,
 
62
  return match.group(1).strip()
63
 
64
 
65
+ def _normalize_ascii_prompt(prompt: str) -> str:
66
+ lowered = (prompt or "").strip().lower()
67
+ if not lowered:
68
+ return ""
69
+ folded = unicodedata.normalize("NFKD", lowered)
70
+ without_accents = "".join(char for char in folded if not unicodedata.combining(char))
71
+ return " ".join(without_accents.split())
72
+
73
+
74
+ def is_ascii_art_prompt(prompt: str) -> bool:
75
+ normalized = _normalize_ascii_prompt(prompt)
76
+ if not normalized:
77
+ return False
78
+ if "arte ascii" in normalized or "ascii art" in normalized:
79
+ return True
80
+ # Detecta "ascii" como palavra isolada (ex: "me faz uma ascii de ...")
81
+ if " ascii " in normalized or normalized.startswith("ascii "):
82
+ return True
83
+ return False
84
+
85
+
86
+ def extract_ascii_subject(prompt: str) -> str:
87
+ normalized = _normalize_ascii_prompt(prompt)
88
+ if not normalized or not is_ascii_art_prompt(normalized):
89
+ return ""
90
+
91
+ # Remove prefixo "byte" se presente
92
+ candidate = re.sub(r"^byte\s+", "", normalized, count=1)
93
+ # Remove verbos de pedido
94
+ candidate = re.sub(
95
+ r"^(?:me\s+)?(?:faz(?:er)?|faca|gera(?:r)?|manda|quero)\s+(?:uma\s+)?",
96
+ "",
97
+ candidate,
98
+ count=1,
99
+ )
100
+ # Remove marcadores de arte ASCII
101
+ candidate = re.sub(r"^(?:arte\s+ascii|ascii\s+art|ascii)\b", "", candidate, count=1).strip()
102
+ # Remove preposições
103
+ candidate = re.sub(r"^(?:de|do|da|dos|das|sobre)\b", "", candidate, count=1).strip()
104
+ candidate = candidate.strip(" ?!.,:;")
105
+ if candidate in {"", "pfv", "por favor"}:
106
+ return ""
107
+ return candidate
108
+
109
+
110
  def is_movie_fact_sheet_prompt(prompt: str) -> bool:
111
  lowered = (prompt or "").lower()
112
  return bool(MOVIE_FACT_SHEET_PATTERN.search(lowered))
bot/byte_semantics_constants.py CHANGED
@@ -6,7 +6,7 @@ from bot.logic_constants import BOT_BRAND, MAX_REPLY_LINES
6
  BYTE_TRIGGER = os.environ.get("BYTE_TRIGGER", BOT_BRAND).strip().lower() or "byte"
7
  BYTE_HELP_MESSAGE = (
8
  "Byte entende chat natural. Acione com byte/@byte/!byte + pergunta. "
9
- "Atalhos: ajuda | se apresente | ficha tecnica <filme> | status. "
10
  "Dono (IRC): canais | join <canal> | part <canal>."
11
  )
12
  BYTE_TRIGGER_PATTERN = re.compile(
 
6
  BYTE_TRIGGER = os.environ.get("BYTE_TRIGGER", BOT_BRAND).strip().lower() or "byte"
7
  BYTE_HELP_MESSAGE = (
8
  "Byte entende chat natural. Acione com byte/@byte/!byte + pergunta. "
9
+ "Atalhos: ajuda | se apresente | ficha tecnica <filme> | arte ascii de <tema> | status. "
10
  "Dono (IRC): canais | join <canal> | part <canal>."
11
  )
12
  BYTE_TRIGGER_PATTERN = re.compile(
bot/control_plane_config.py CHANGED
@@ -2,10 +2,14 @@ import copy
2
  import threading
3
  import time
4
  from collections import deque
 
5
  from typing import Any, ClassVar
6
 
 
 
7
  from bot.control_plane_config_helpers import (
8
  budget_usage,
 
9
  goal_target_met,
10
  infer_goal_observed_value,
11
  normalize_goals,
@@ -416,20 +420,30 @@ class ControlPlaneConfigRuntime:
416
  if not bool(goal.get("enabled", True)):
417
  continue
418
 
419
- interval = max(60, int(goal.get("interval_seconds", 600) or 600))
 
420
  if force:
421
  due_goals.append(copy.deepcopy(goal))
422
- self._next_goal_due_at[goal_id] = now + interval
 
 
423
  continue
424
 
425
  due_at = self._next_goal_due_at.get(goal_id)
426
  if due_at is None:
427
- self._next_goal_due_at[goal_id] = now + interval
 
 
 
 
428
  continue
429
 
430
  if now >= due_at:
431
  due_goals.append(copy.deepcopy(goal))
432
- self._next_goal_due_at[goal_id] = now + interval
 
 
 
433
 
434
  self._next_goal_due_at = {
435
  goal_id: due_at
 
2
  import threading
3
  import time
4
  from collections import deque
5
+ from datetime import datetime, timezone
6
  from typing import Any, ClassVar
7
 
8
+ from croniter import croniter
9
+
10
  from bot.control_plane_config_helpers import (
11
  budget_usage,
12
+ compute_next_goal_due_at,
13
  goal_target_met,
14
  infer_goal_observed_value,
15
  normalize_goals,
 
420
  if not bool(goal.get("enabled", True)):
421
  continue
422
 
423
+ schedule_type = str(goal.get("schedule_type", "interval")).strip().lower()
424
+
425
  if force:
426
  due_goals.append(copy.deepcopy(goal))
427
+ self._next_goal_due_at[goal_id] = compute_next_goal_due_at(goal, now)
428
+ if schedule_type == "fixed_time":
429
+ goal["enabled"] = False
430
  continue
431
 
432
  due_at = self._next_goal_due_at.get(goal_id)
433
  if due_at is None:
434
+ initial_due = compute_next_goal_due_at(goal, now)
435
+ if schedule_type == "fixed_time" and initial_due < now - 60:
436
+ goal["enabled"] = False
437
+ continue
438
+ self._next_goal_due_at[goal_id] = initial_due
439
  continue
440
 
441
  if now >= due_at:
442
  due_goals.append(copy.deepcopy(goal))
443
+ if schedule_type == "fixed_time":
444
+ goal["enabled"] = False
445
+ else:
446
+ self._next_goal_due_at[goal_id] = compute_next_goal_due_at(goal, now)
447
 
448
  self._next_goal_due_at = {
449
  goal_id: due_at
bot/control_plane_config_helpers.py CHANGED
@@ -1,6 +1,9 @@
1
  from collections import deque
 
2
  from typing import Any
3
 
 
 
4
  from bot.control_plane_constants import (
5
  RISK_SUGGEST_STREAMER,
6
  SUPPORTED_GOAL_KPI_COMPARISONS,
@@ -21,6 +24,28 @@ GOAL_KPI_SUCCESS_OUTCOMES: dict[str, set[str]] = {
21
  }
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  def sanitize_goal_comparison(raw_value: Any) -> str:
25
  comparison = str(raw_value or "").strip().lower()
26
  if comparison not in SUPPORTED_GOAL_KPI_COMPARISONS:
@@ -91,6 +116,14 @@ def normalize_goal(raw_goal: Any, index: int) -> dict[str, Any]:
91
  maximum=86_400,
92
  fallback=600,
93
  )
 
 
 
 
 
 
 
 
94
  enabled = bool(goal.get("enabled", True))
95
  default_kpi_name = default_goal_kpi_name(risk)
96
  kpi_name = str(goal.get("kpi_name", "") or default_kpi_name).strip().lower()
@@ -121,6 +154,9 @@ def normalize_goal(raw_goal: Any, index: int) -> dict[str, Any]:
121
  "prompt": prompt,
122
  "risk": risk,
123
  "interval_seconds": interval_seconds,
 
 
 
124
  "enabled": enabled,
125
  "kpi_name": kpi_name,
126
  "target_value": target_value,
 
1
  from collections import deque
2
+ from datetime import datetime
3
  from typing import Any
4
 
5
+ from croniter import croniter
6
+
7
  from bot.control_plane_constants import (
8
  RISK_SUGGEST_STREAMER,
9
  SUPPORTED_GOAL_KPI_COMPARISONS,
 
24
  }
25
 
26
 
27
+ def compute_next_goal_due_at(goal: dict[str, Any], current_time: float) -> float:
28
+ schedule_type = str(goal.get("schedule_type", "interval")).strip().lower()
29
+ interval = max(60, int(goal.get("interval_seconds", 600) or 600))
30
+
31
+ if schedule_type == "cron":
32
+ cron_expr = str(goal.get("cron_expression", "") or "").strip()
33
+ if cron_expr and croniter.is_valid(cron_expr):
34
+ try:
35
+ return croniter(cron_expr, current_time).get_next(float)
36
+ except Exception:
37
+ pass
38
+ elif schedule_type == "fixed_time":
39
+ scheduled_at_str = str(goal.get("scheduled_at", "") or "").strip()
40
+ if scheduled_at_str:
41
+ try:
42
+ dt = datetime.fromisoformat(scheduled_at_str.replace("Z", "+00:00"))
43
+ return dt.timestamp()
44
+ except ValueError:
45
+ pass
46
+ return current_time + interval
47
+
48
+
49
  def sanitize_goal_comparison(raw_value: Any) -> str:
50
  comparison = str(raw_value or "").strip().lower()
51
  if comparison not in SUPPORTED_GOAL_KPI_COMPARISONS:
 
116
  maximum=86_400,
117
  fallback=600,
118
  )
119
+
120
+ schedule_type = str(goal.get("schedule_type", "interval")).strip().lower()
121
+ if schedule_type not in {"interval", "fixed_time", "cron"}:
122
+ schedule_type = "interval"
123
+
124
+ scheduled_at = str(goal.get("scheduled_at", "") or "").strip()
125
+ cron_expression = str(goal.get("cron_expression", "") or "").strip()
126
+
127
  enabled = bool(goal.get("enabled", True))
128
  default_kpi_name = default_goal_kpi_name(risk)
129
  kpi_name = str(goal.get("kpi_name", "") or default_kpi_name).strip().lower()
 
154
  "prompt": prompt,
155
  "risk": risk,
156
  "interval_seconds": interval_seconds,
157
+ "schedule_type": schedule_type,
158
+ "scheduled_at": scheduled_at,
159
+ "cron_expression": cron_expression,
160
  "enabled": enabled,
161
  "kpi_name": kpi_name,
162
  "target_value": target_value,
bot/dashboard_parity_gate.py CHANGED
@@ -428,6 +428,28 @@ PARITY_CONTRACT: tuple[ParityContractEntry, ...] = (
428
  dashboard_test_files=("dashboard/tests/api_contract_parity.test.js",),
429
  route_snippet="/api/webhooks/test",
430
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  )
432
 
433
 
 
428
  dashboard_test_files=("dashboard/tests/api_contract_parity.test.js",),
429
  route_snippet="/api/webhooks/test",
430
  ),
431
+ ParityContractEntry(
432
+ method="GET",
433
+ backend_route="/api/persona-profile",
434
+ domain="channel_governance",
435
+ dashboard_surface="control_plane",
436
+ status="integrated",
437
+ dashboard_route_prefix="/api/persona-profile",
438
+ backend_test_files=("bot/tests/test_dashboard_routes_v3.py",),
439
+ dashboard_test_files=("dashboard/tests/api_contract_parity.test.js",),
440
+ route_snippet="/api/persona-profile",
441
+ ),
442
+ ParityContractEntry(
443
+ method="PUT",
444
+ backend_route="/api/persona-profile",
445
+ domain="channel_governance",
446
+ dashboard_surface="control_plane",
447
+ status="integrated",
448
+ dashboard_route_prefix="/api/persona-profile",
449
+ backend_test_files=("bot/tests/test_dashboard_routes_v3.py",),
450
+ dashboard_test_files=("dashboard/tests/api_contract_parity.test.js",),
451
+ route_snippet="/api/persona-profile",
452
+ ),
453
  )
454
 
455
 
bot/dashboard_server_routes.py CHANGED
@@ -258,6 +258,11 @@ def build_channel_context_payload(channel_id: str | None = None) -> dict[str, An
258
  ),
259
  "has_persisted_history": bool(persisted_history),
260
  }
 
 
 
 
 
261
  return {
262
  "ok": True,
263
  "mode": TWITCH_CHAT_MODE,
@@ -713,6 +718,23 @@ def _handle_get_dashboard_config(handler: Any, _query: dict[str, list[str]]) ->
713
  handle_get_config_js(handler)
714
 
715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  _GET_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]]], None]] = {
717
  "/api/observability": _handle_get_observability,
718
  "/api/channel-context": _handle_get_channel_context,
@@ -730,6 +752,7 @@ _GET_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]]], None]] = {
730
  "/api/vision/status": _handle_get_vision_status,
731
  "/api/observability/conversions": _handle_get_revenue_conversions,
732
  "/api/webhooks": _handle_get_webhooks,
 
733
  "/dashboard/config.js": _handle_get_dashboard_config,
734
  }
735
 
@@ -916,12 +939,65 @@ def _handle_put_webhooks(
916
  )
917
 
918
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  _PUT_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]], dict[str, Any]], None]] = {
920
  "/api/control-plane": _handle_put_control_plane,
921
  "/api/channel-config": _handle_put_channel_config,
922
  "/api/agent-notes": _handle_put_agent_notes,
923
  "/api/semantic-memory": _handle_put_semantic_memory,
924
  "/api/webhooks": _handle_put_webhooks,
 
925
  }
926
 
927
 
 
258
  ),
259
  "has_persisted_history": bool(persisted_history),
260
  }
261
+ persisted_persona_profile = persistence.load_persona_profile_sync(safe_channel_id)
262
+ channel_payload["persisted_persona_profile"] = dict(persisted_persona_profile or {})
263
+ channel_payload["has_persisted_persona_profile"] = bool(
264
+ persisted_persona_profile and persisted_persona_profile.get("has_profile")
265
+ )
266
  return {
267
  "ok": True,
268
  "mode": TWITCH_CHAT_MODE,
 
718
  handle_get_config_js(handler)
719
 
720
 
721
+ def _handle_get_persona_profile(handler: Any, query: dict[str, list[str]]) -> None:
722
+ try:
723
+ channel_id = _resolve_channel_id(query)
724
+ except ValueError as error:
725
+ send_invalid_request(handler, str(error))
726
+ return
727
+ profile = persistence.load_persona_profile_sync(channel_id)
728
+ handler._send_json(
729
+ {
730
+ "ok": True,
731
+ "mode": TWITCH_CHAT_MODE,
732
+ "profile": profile,
733
+ },
734
+ status_code=200,
735
+ )
736
+
737
+
738
  _GET_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]]], None]] = {
739
  "/api/observability": _handle_get_observability,
740
  "/api/channel-context": _handle_get_channel_context,
 
752
  "/api/vision/status": _handle_get_vision_status,
753
  "/api/observability/conversions": _handle_get_revenue_conversions,
754
  "/api/webhooks": _handle_get_webhooks,
755
+ "/api/persona-profile": _handle_get_persona_profile,
756
  "/dashboard/config.js": _handle_get_dashboard_config,
757
  }
758
 
 
939
  )
940
 
941
 
942
+ def _handle_put_persona_profile(
943
+ handler: Any,
944
+ query: dict[str, list[str]],
945
+ payload: dict[str, Any],
946
+ ) -> None:
947
+ try:
948
+ channel_id = _resolve_channel_id(query, payload)
949
+ current = persistence.load_persona_profile_sync(channel_id)
950
+ profile = persistence.save_persona_profile_sync(
951
+ channel_id,
952
+ base_identity=(
953
+ payload.get("base_identity")
954
+ if "base_identity" in payload
955
+ else current.get("base_identity")
956
+ ),
957
+ tonality_engine=(
958
+ payload.get("tonality_engine")
959
+ if "tonality_engine" in payload
960
+ else current.get("tonality_engine")
961
+ ),
962
+ behavioral_constraints=(
963
+ payload.get("behavioral_constraints")
964
+ if "behavioral_constraints" in payload
965
+ else current.get("behavioral_constraints")
966
+ ),
967
+ model_routing=(
968
+ payload.get("model_routing")
969
+ if "model_routing" in payload
970
+ else current.get("model_routing")
971
+ ),
972
+ )
973
+ context_manager.apply_persona_profile(
974
+ channel_id,
975
+ base_identity=profile.get("base_identity"),
976
+ tonality_engine=profile.get("tonality_engine"),
977
+ behavioral_constraints=profile.get("behavioral_constraints"),
978
+ model_routing=profile.get("model_routing"),
979
+ )
980
+ except ValueError as error:
981
+ send_invalid_request(handler, str(error))
982
+ return
983
+
984
+ handler._send_json(
985
+ {
986
+ "ok": True,
987
+ "mode": TWITCH_CHAT_MODE,
988
+ "profile": profile,
989
+ },
990
+ status_code=200,
991
+ )
992
+
993
+
994
  _PUT_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]], dict[str, Any]], None]] = {
995
  "/api/control-plane": _handle_put_control_plane,
996
  "/api/channel-config": _handle_put_channel_config,
997
  "/api/agent-notes": _handle_put_agent_notes,
998
  "/api/semantic-memory": _handle_put_semantic_memory,
999
  "/api/webhooks": _handle_put_webhooks,
1000
+ "/api/persona-profile": _handle_put_persona_profile,
1001
  }
1002
 
1003
 
bot/logic_context.py CHANGED
@@ -47,6 +47,10 @@ class StreamContext:
47
  self.persona_tone = ""
48
  self.persona_emote_vocab: list[str] = []
49
  self.persona_lore = ""
 
 
 
 
50
  self.live_observability: dict[str, str] = {
51
  "game": "",
52
  "movie": "",
@@ -220,6 +224,22 @@ class ContextManager:
220
  ctx.persona_tone = str(channel_identity.get("tone") or "")
221
  ctx.persona_emote_vocab = list(channel_identity.get("emote_vocab") or [])
222
  ctx.persona_lore = str(channel_identity.get("lore") or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  ctx.channel_config_loaded = True
224
 
225
  try:
@@ -278,6 +298,14 @@ class ContextManager:
278
  emote_vocab=list(channel_identity.get("emote_vocab") or []),
279
  lore=str(channel_identity.get("lore") or ""),
280
  )
 
 
 
 
 
 
 
 
281
  return ctx
282
 
283
  def apply_agent_notes(self, channel_id: str, *, notes: str) -> None:
@@ -309,6 +337,42 @@ class ContextManager:
309
  ]
310
  ctx.persona_lore = str(lore or "")
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  async def cleanup(self, channel_id: str) -> None:
313
  """Remove contexto da RAM (Async para manter assinatura onde esperado)."""
314
  key = channel_id.strip().lower()
 
47
  self.persona_tone = ""
48
  self.persona_emote_vocab: list[str] = []
49
  self.persona_lore = ""
50
+ self.persona_sentence_style = ""
51
+ self.persona_banned_topics: list[str] = []
52
+ self.persona_cta_triggers: list[str] = []
53
+ self.channel_model_routing: dict[str, str | None] = {}
54
  self.live_observability: dict[str, str] = {
55
  "game": "",
56
  "movie": "",
 
224
  ctx.persona_tone = str(channel_identity.get("tone") or "")
225
  ctx.persona_emote_vocab = list(channel_identity.get("emote_vocab") or [])
226
  ctx.persona_lore = str(channel_identity.get("lore") or "")
227
+
228
+ persona_profile = await persistence.load_persona_profile(channel_id)
229
+ if persona_profile.get("has_profile"):
230
+ base = persona_profile.get("base_identity") or {}
231
+ tonality = persona_profile.get("tonality_engine") or {}
232
+ constraints = persona_profile.get("behavioral_constraints") or {}
233
+ routing = persona_profile.get("model_routing") or {}
234
+ ctx.persona_name = str(base.get("name") or "") or ctx.persona_name
235
+ ctx.persona_lore = str(base.get("lore") or "") or ctx.persona_lore
236
+ ctx.persona_tone = str(tonality.get("tone") or "") or ctx.persona_tone
237
+ vocab = list(tonality.get("emote_vocab") or [])
238
+ ctx.persona_emote_vocab = vocab or ctx.persona_emote_vocab
239
+ ctx.persona_sentence_style = str(tonality.get("sentence_style") or "")
240
+ ctx.persona_banned_topics = list(constraints.get("banned_topics") or [])
241
+ ctx.persona_cta_triggers = list(constraints.get("cta_triggers") or [])
242
+ ctx.channel_model_routing = dict(routing)
243
  ctx.channel_config_loaded = True
244
 
245
  try:
 
298
  emote_vocab=list(channel_identity.get("emote_vocab") or []),
299
  lore=str(channel_identity.get("lore") or ""),
300
  )
301
+ persona_profile = persistence.load_persona_profile_sync(key)
302
+ self.apply_persona_profile(
303
+ key,
304
+ base_identity=persona_profile.get("base_identity"),
305
+ tonality_engine=persona_profile.get("tonality_engine"),
306
+ behavioral_constraints=persona_profile.get("behavioral_constraints"),
307
+ model_routing=persona_profile.get("model_routing"),
308
+ )
309
  return ctx
310
 
311
  def apply_agent_notes(self, channel_id: str, *, notes: str) -> None:
 
337
  ]
338
  ctx.persona_lore = str(lore or "")
339
 
340
+ def apply_persona_profile(
341
+ self,
342
+ channel_id: str,
343
+ *,
344
+ base_identity: dict | None = None,
345
+ tonality_engine: dict | None = None,
346
+ behavioral_constraints: dict | None = None,
347
+ model_routing: dict | None = None,
348
+ ) -> None:
349
+ key = (channel_id or "default").strip().lower()
350
+ with self._lock:
351
+ ctx = self._contexts.get(key)
352
+ if ctx is None:
353
+ return
354
+ base = base_identity if isinstance(base_identity, dict) else {}
355
+ tonality = tonality_engine if isinstance(tonality_engine, dict) else {}
356
+ constraints = behavioral_constraints if isinstance(behavioral_constraints, dict) else {}
357
+ routing = model_routing if isinstance(model_routing, dict) else {}
358
+ if base.get("name"):
359
+ ctx.persona_name = str(base["name"])
360
+ if base.get("lore"):
361
+ ctx.persona_lore = str(base["lore"])
362
+ if tonality.get("tone"):
363
+ ctx.persona_tone = str(tonality["tone"])
364
+ vocab = list(tonality.get("emote_vocab") or [])
365
+ if vocab:
366
+ ctx.persona_emote_vocab = [str(v).strip() for v in vocab if str(v).strip()]
367
+ ctx.persona_sentence_style = str(tonality.get("sentence_style") or "")
368
+ ctx.persona_banned_topics = [
369
+ str(t).strip() for t in list(constraints.get("banned_topics") or []) if str(t).strip()
370
+ ]
371
+ ctx.persona_cta_triggers = [
372
+ str(t).strip() for t in list(constraints.get("cta_triggers") or []) if str(t).strip()
373
+ ]
374
+ ctx.channel_model_routing = {k: (str(v) if v else None) for k, v in dict(routing).items()}
375
+
376
  async def cleanup(self, channel_id: str) -> None:
377
  """Remove contexto da RAM (Async para manter assinatura onde esperado)."""
378
  key = channel_id.strip().lower()
bot/logic_inference.py CHANGED
@@ -65,15 +65,30 @@ def _normalize_generation_override(
65
  return parsed
66
 
67
 
68
- def _select_model(enable_grounding: bool, is_serious: bool) -> str:
69
- """Select the optimal Nebius model for the request type."""
 
 
 
 
 
 
 
 
 
70
  from bot.runtime_config import NEBIUS_MODEL_DEFAULT, NEBIUS_MODEL_REASONING, NEBIUS_MODEL_SEARCH
71
 
 
 
72
  if enable_grounding:
73
- return NEBIUS_MODEL_SEARCH
 
74
  if is_serious:
75
- return NEBIUS_MODEL_REASONING
76
- return NEBIUS_MODEL_DEFAULT
 
 
 
77
 
78
 
79
  def _extract_search_query(user_msg: str) -> str:
@@ -207,6 +222,28 @@ def _build_identity_instruction(context: Any) -> str:
207
  if lore_lines:
208
  lines.append("- Lore/continuidade:")
209
  lines.extend(f" - {line}" for line in lore_lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  return "\n".join(lines)
211
 
212
 
@@ -391,7 +428,7 @@ async def agent_inference(
391
  search_results, grounding_metadata = await _fetch_search_results(user_msg, enable_grounding)
392
 
393
  is_serious = not enable_grounding and len(user_msg) >= 40
394
- model = _select_model(enable_grounding, is_serious)
395
 
396
  # CURA: O contexto agora é resolvido síncronamente
397
  if context is None:
 
65
  return parsed
66
 
67
 
68
+ def _select_model(
69
+ enable_grounding: bool,
70
+ is_serious: bool,
71
+ *,
72
+ context: Any = None,
73
+ ) -> str:
74
+ """Select the optimal Nebius model for the request type.
75
+
76
+ Per-channel model routing overrides from persona profiles take
77
+ precedence when available in the context.
78
+ """
79
  from bot.runtime_config import NEBIUS_MODEL_DEFAULT, NEBIUS_MODEL_REASONING, NEBIUS_MODEL_SEARCH
80
 
81
+ routing = getattr(context, "channel_model_routing", None) or {}
82
+
83
  if enable_grounding:
84
+ override = routing.get("search")
85
+ return override if override else NEBIUS_MODEL_SEARCH
86
  if is_serious:
87
+ override = routing.get("reasoning")
88
+ return override if override else NEBIUS_MODEL_REASONING
89
+
90
+ override = routing.get("chat")
91
+ return override if override else NEBIUS_MODEL_DEFAULT
92
 
93
 
94
  def _extract_search_query(user_msg: str) -> str:
 
222
  if lore_lines:
223
  lines.append("- Lore/continuidade:")
224
  lines.extend(f" - {line}" for line in lore_lines)
225
+
226
+ sentence_style = str(getattr(context, "persona_sentence_style", "") or "").strip()
227
+ if sentence_style:
228
+ style_label = {
229
+ "short_punchy": "curto e direto",
230
+ "long_analytical": "analitico e detalhado",
231
+ "balanced": "equilibrado",
232
+ }.get(sentence_style, sentence_style)
233
+ lines.append(f"- Estilo de frase: {style_label}")
234
+
235
+ banned_topics = list(getattr(context, "persona_banned_topics", []) or [])
236
+ if banned_topics:
237
+ safe_topics = [str(t)[:80] for t in banned_topics[:10] if str(t).strip()]
238
+ if safe_topics:
239
+ lines.append(f"- Topicos proibidos (NUNCA aborde): {', '.join(safe_topics)}")
240
+
241
+ cta_triggers = list(getattr(context, "persona_cta_triggers", []) or [])
242
+ if cta_triggers:
243
+ safe_cta = [str(t)[:80] for t in cta_triggers[:10] if str(t).strip()]
244
+ if safe_cta:
245
+ lines.append(f"- Gatilhos de CTA: {', '.join(safe_cta)}")
246
+
247
  return "\n".join(lines)
248
 
249
 
 
428
  search_results, grounding_metadata = await _fetch_search_results(user_msg, enable_grounding)
429
 
430
  is_serious = not enable_grounding and len(user_msg) >= 40
431
+ model = _select_model(enable_grounding, is_serious, context=context)
432
 
433
  # CURA: O contexto agora é resolvido síncronamente
434
  if context is None:
bot/ops_playbooks.py CHANGED
@@ -502,7 +502,7 @@ class OpsPlaybookRuntime:
502
  for item in self._runtime.values()
503
  )
504
  return {
505
- "total": int(len(self._runtime)),
506
  "idle": int(states.get(PLAYBOOK_STATE_IDLE, 0)),
507
  "awaiting_decision": int(states.get(PLAYBOOK_STATE_AWAITING_DECISION, 0)),
508
  "cooldown": int(states.get(PLAYBOOK_STATE_COOLDOWN, 0)),
 
502
  for item in self._runtime.values()
503
  )
504
  return {
505
+ "total": len(self._runtime),
506
  "idle": int(states.get(PLAYBOOK_STATE_IDLE, 0)),
507
  "awaiting_decision": int(states.get(PLAYBOOK_STATE_AWAITING_DECISION, 0)),
508
  "cooldown": int(states.get(PLAYBOOK_STATE_COOLDOWN, 0)),
bot/persistence_layer.py CHANGED
@@ -8,6 +8,7 @@ from bot.persistence_agent_notes_repository import AgentNotesRepository
8
  from bot.persistence_channel_config_repository import ChannelConfigRepository
9
  from bot.persistence_channel_identity_repository import ChannelIdentityRepository
10
  from bot.persistence_observability_history_repository import ObservabilityHistoryRepository
 
11
  from bot.persistence_post_stream_report_repository import PostStreamReportRepository
12
  from bot.persistence_revenue_attribution_repository import RevenueAttributionRepository
13
  from bot.persistence_semantic_memory_repository import SemanticMemoryRepository
@@ -37,6 +38,7 @@ class PersistenceLayer:
37
  self._semantic_memory_cache: dict[str, list[dict[str, Any]]] = {}
38
  self._revenue_cache: dict[str, list[dict[str, Any]]] = {}
39
  self._webhook_cache: dict[str, list[dict[str, Any]]] = {}
 
40
 
41
  if self._url and self._key:
42
  try:
@@ -88,6 +90,11 @@ class PersistenceLayer:
88
  client=self._client,
89
  cache=self._webhook_cache,
90
  )
 
 
 
 
 
91
 
92
  @property
93
  def is_enabled(self) -> bool:
@@ -696,5 +703,47 @@ class PersistenceLayer:
696
  ) -> None:
697
  self.save_webhook_delivery_sync(webhook_id, channel_id, delivery)
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
 
700
  persistence = PersistenceLayer()
 
8
  from bot.persistence_channel_config_repository import ChannelConfigRepository
9
  from bot.persistence_channel_identity_repository import ChannelIdentityRepository
10
  from bot.persistence_observability_history_repository import ObservabilityHistoryRepository
11
+ from bot.persistence_persona_profile_repository import PersonaProfileRepository
12
  from bot.persistence_post_stream_report_repository import PostStreamReportRepository
13
  from bot.persistence_revenue_attribution_repository import RevenueAttributionRepository
14
  from bot.persistence_semantic_memory_repository import SemanticMemoryRepository
 
38
  self._semantic_memory_cache: dict[str, list[dict[str, Any]]] = {}
39
  self._revenue_cache: dict[str, list[dict[str, Any]]] = {}
40
  self._webhook_cache: dict[str, list[dict[str, Any]]] = {}
41
+ self._persona_profile_cache: dict[str, dict[str, Any]] = {}
42
 
43
  if self._url and self._key:
44
  try:
 
90
  client=self._client,
91
  cache=self._webhook_cache,
92
  )
93
+ self._persona_profile_repo = PersonaProfileRepository(
94
+ enabled=self._enabled,
95
+ client=self._client,
96
+ cache=self._persona_profile_cache,
97
+ )
98
 
99
  @property
100
  def is_enabled(self) -> bool:
 
703
  ) -> None:
704
  self.save_webhook_delivery_sync(webhook_id, channel_id, delivery)
705
 
706
+ # --- Persona Profiles ---
707
+
708
+ def load_persona_profile_sync(self, channel_id: str) -> dict[str, Any]:
709
+ return self._persona_profile_repo.load_sync(channel_id)
710
+
711
+ async def load_persona_profile(self, channel_id: str) -> dict[str, Any]:
712
+ return self.load_persona_profile_sync(channel_id)
713
+
714
+ def save_persona_profile_sync(
715
+ self,
716
+ channel_id: str,
717
+ *,
718
+ base_identity: Any = None,
719
+ tonality_engine: Any = None,
720
+ behavioral_constraints: Any = None,
721
+ model_routing: Any = None,
722
+ ) -> dict[str, Any]:
723
+ return self._persona_profile_repo.save_sync(
724
+ channel_id,
725
+ base_identity=base_identity,
726
+ tonality_engine=tonality_engine,
727
+ behavioral_constraints=behavioral_constraints,
728
+ model_routing=model_routing,
729
+ )
730
+
731
+ async def save_persona_profile(
732
+ self,
733
+ channel_id: str,
734
+ *,
735
+ base_identity: Any = None,
736
+ tonality_engine: Any = None,
737
+ behavioral_constraints: Any = None,
738
+ model_routing: Any = None,
739
+ ) -> dict[str, Any]:
740
+ return self.save_persona_profile_sync(
741
+ channel_id,
742
+ base_identity=base_identity,
743
+ tonality_engine=tonality_engine,
744
+ behavioral_constraints=behavioral_constraints,
745
+ model_routing=model_routing,
746
+ )
747
+
748
 
749
  persistence = PersistenceLayer()
bot/persistence_persona_profile_repository.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from bot.persistence_cached_channel_repository import CachedChannelRepository
4
+ from bot.persistence_utils import normalize_optional_text, utc_iso_now
5
+
6
+ _MAX_NAME_LENGTH = 80
7
+ _MAX_PRONOUNS_LENGTH = 40
8
+ _MAX_TONE_LENGTH = 160
9
+ _MAX_LORE_LENGTH = 1200
10
+ _MAX_SENTENCE_STYLE_LENGTH = 20
11
+ _MAX_LIST_ITEMS = 10
12
+ _MAX_LIST_ITEM_LENGTH = 80
13
+ _MAX_MODEL_NAME_LENGTH = 120
14
+
15
+ _VALID_SENTENCE_STYLES = {"short_punchy", "long_analytical", "balanced", ""}
16
+
17
+ _VALID_ROUTING_ACTIVITIES = {"chat", "coaching", "search", "reasoning"}
18
+
19
+
20
+ def _normalize_single_line(text: str) -> str:
21
+ return " ".join(str(text or "").split()).strip()
22
+
23
+
24
+ def _normalize_string_list(
25
+ value: Any,
26
+ *,
27
+ max_items: int = _MAX_LIST_ITEMS,
28
+ max_item_length: int = _MAX_LIST_ITEM_LENGTH,
29
+ strict: bool = False,
30
+ ) -> list[str]:
31
+ if value in (None, ""):
32
+ return []
33
+
34
+ raw_items: list[Any]
35
+ if isinstance(value, str):
36
+ raw_items = value.replace("\n", ",").split(",")
37
+ elif isinstance(value, list | tuple | set):
38
+ raw_items = list(value)
39
+ else:
40
+ if strict:
41
+ raise ValueError("lista invalida.")
42
+ return []
43
+
44
+ normalized: list[str] = []
45
+ seen: set[str] = set()
46
+ for item in raw_items:
47
+ token = _normalize_single_line(str(item or ""))
48
+ if not token:
49
+ continue
50
+ if len(token) > max_item_length:
51
+ if strict:
52
+ raise ValueError("item da lista excede o tamanho permitido.")
53
+ token = token[:max_item_length].strip()
54
+ if not token:
55
+ continue
56
+ token_key = token.lower()
57
+ if token_key in seen:
58
+ continue
59
+ seen.add(token_key)
60
+ normalized.append(token)
61
+
62
+ if strict and len(normalized) > max_items:
63
+ raise ValueError("lista excede o limite permitido.")
64
+ return normalized[:max_items]
65
+
66
+
67
+ def _safe_dict(value: Any) -> dict[str, Any]:
68
+ if isinstance(value, dict):
69
+ return dict(value)
70
+ return {}
71
+
72
+
73
+ def _extract_base_identity(raw: Any) -> dict[str, str]:
74
+ d = _safe_dict(raw)
75
+ return {
76
+ "name": _normalize_single_line(str(d.get("name") or "")),
77
+ "pronouns": _normalize_single_line(str(d.get("pronouns") or "")),
78
+ "lore": str(d.get("lore") or ""),
79
+ }
80
+
81
+
82
+ def _extract_tonality_engine(raw: Any) -> dict[str, Any]:
83
+ d = _safe_dict(raw)
84
+ style_raw = _normalize_single_line(str(d.get("sentence_style") or "")).lower()
85
+ return {
86
+ "tone": _normalize_single_line(str(d.get("tone") or "")),
87
+ "emote_vocab": _normalize_string_list(d.get("emote_vocab"), strict=False),
88
+ "sentence_style": style_raw if style_raw in _VALID_SENTENCE_STYLES else "",
89
+ }
90
+
91
+
92
+ def _extract_behavioral_constraints(raw: Any) -> dict[str, Any]:
93
+ d = _safe_dict(raw)
94
+ return {
95
+ "banned_topics": _normalize_string_list(d.get("banned_topics"), strict=False),
96
+ "cta_triggers": _normalize_string_list(d.get("cta_triggers"), strict=False),
97
+ }
98
+
99
+
100
+ def _extract_model_routing(raw: Any) -> dict[str, str | None]:
101
+ d = _safe_dict(raw)
102
+ routing: dict[str, str | None] = {}
103
+ for activity in _VALID_ROUTING_ACTIVITIES:
104
+ val = _normalize_single_line(str(d.get(activity) or ""))
105
+ routing[activity] = val if val else None
106
+ return routing
107
+
108
+
109
+ def _has_profile(
110
+ base: dict[str, str],
111
+ tonality: dict[str, Any],
112
+ constraints: dict[str, Any],
113
+ routing: dict[str, str | None],
114
+ ) -> bool:
115
+ if base.get("name") or base.get("pronouns") or (base.get("lore") or "").strip():
116
+ return True
117
+ if tonality.get("tone") or tonality.get("emote_vocab") or tonality.get("sentence_style"):
118
+ return True
119
+ if constraints.get("banned_topics") or constraints.get("cta_triggers"):
120
+ return True
121
+ if any(v for v in routing.values()):
122
+ return True
123
+ return False
124
+
125
+
126
+ class PersonaProfileRepository(CachedChannelRepository):
127
+ @property
128
+ def table_name(self) -> str:
129
+ return "persona_profiles"
130
+
131
+ @property
132
+ def select_columns(self) -> str:
133
+ return (
134
+ "channel_id, base_identity, tonality_engine, "
135
+ "behavioral_constraints, model_routing, updated_at"
136
+ )
137
+
138
+ @property
139
+ def entity_name(self) -> str:
140
+ return "persona_profile"
141
+
142
+ def _default_payload_from_cache(
143
+ self,
144
+ channel_id: str,
145
+ cached: dict[str, Any],
146
+ ) -> dict[str, Any]:
147
+ base = _extract_base_identity(cached.get("base_identity"))
148
+ tonality = _extract_tonality_engine(cached.get("tonality_engine"))
149
+ constraints = _extract_behavioral_constraints(cached.get("behavioral_constraints"))
150
+ routing = _extract_model_routing(cached.get("model_routing"))
151
+ return {
152
+ "channel_id": channel_id,
153
+ "base_identity": base,
154
+ "tonality_engine": tonality,
155
+ "behavioral_constraints": constraints,
156
+ "model_routing": routing,
157
+ "has_profile": _has_profile(base, tonality, constraints, routing),
158
+ "updated_at": str(cached.get("updated_at") or ""),
159
+ "source": str(cached.get("source") or "memory"),
160
+ }
161
+
162
+ def _row_to_payload(self, channel_id: str, row: dict[str, Any]) -> dict[str, Any]:
163
+ base = _extract_base_identity(row.get("base_identity"))
164
+ tonality = _extract_tonality_engine(row.get("tonality_engine"))
165
+ constraints = _extract_behavioral_constraints(row.get("behavioral_constraints"))
166
+ routing = _extract_model_routing(row.get("model_routing"))
167
+ return {
168
+ "channel_id": channel_id,
169
+ "base_identity": base,
170
+ "tonality_engine": tonality,
171
+ "behavioral_constraints": constraints,
172
+ "model_routing": routing,
173
+ "has_profile": _has_profile(base, tonality, constraints, routing),
174
+ "updated_at": str(row.get("updated_at") or ""),
175
+ "source": "supabase",
176
+ }
177
+
178
+ def _build_memory_payload(self, channel_id: str, **kwargs: Any) -> dict[str, Any]:
179
+ raw_base = _safe_dict(kwargs.get("base_identity"))
180
+ safe_name = _normalize_single_line(
181
+ normalize_optional_text(
182
+ raw_base.get("name"),
183
+ field_name="base_identity.name",
184
+ max_length=_MAX_NAME_LENGTH,
185
+ )
186
+ )
187
+ safe_pronouns = _normalize_single_line(
188
+ normalize_optional_text(
189
+ raw_base.get("pronouns"),
190
+ field_name="base_identity.pronouns",
191
+ max_length=_MAX_PRONOUNS_LENGTH,
192
+ )
193
+ )
194
+ safe_lore = normalize_optional_text(
195
+ raw_base.get("lore"),
196
+ field_name="base_identity.lore",
197
+ max_length=_MAX_LORE_LENGTH,
198
+ )
199
+ base = {"name": safe_name, "pronouns": safe_pronouns, "lore": safe_lore}
200
+
201
+ raw_tonality = _safe_dict(kwargs.get("tonality_engine"))
202
+ safe_tone = _normalize_single_line(
203
+ normalize_optional_text(
204
+ raw_tonality.get("tone"),
205
+ field_name="tonality_engine.tone",
206
+ max_length=_MAX_TONE_LENGTH,
207
+ )
208
+ )
209
+ safe_emote_vocab = _normalize_string_list(raw_tonality.get("emote_vocab"), strict=True)
210
+ style_raw = _normalize_single_line(str(raw_tonality.get("sentence_style") or "")).lower()
211
+ if style_raw and style_raw not in _VALID_SENTENCE_STYLES:
212
+ raise ValueError("tonality_engine.sentence_style invalido.")
213
+ tonality = {
214
+ "tone": safe_tone,
215
+ "emote_vocab": safe_emote_vocab,
216
+ "sentence_style": style_raw,
217
+ }
218
+
219
+ raw_constraints = _safe_dict(kwargs.get("behavioral_constraints"))
220
+ safe_banned = _normalize_string_list(raw_constraints.get("banned_topics"), strict=True)
221
+ safe_cta = _normalize_string_list(raw_constraints.get("cta_triggers"), strict=True)
222
+ constraints = {"banned_topics": safe_banned, "cta_triggers": safe_cta}
223
+
224
+ routing = _extract_model_routing(kwargs.get("model_routing"))
225
+ for activity, model_name in routing.items():
226
+ if model_name and len(model_name) > _MAX_MODEL_NAME_LENGTH:
227
+ raise ValueError(f"model_routing.{activity} excede o tamanho permitido.")
228
+
229
+ return {
230
+ "channel_id": channel_id,
231
+ "base_identity": base,
232
+ "tonality_engine": tonality,
233
+ "behavioral_constraints": constraints,
234
+ "model_routing": routing,
235
+ "has_profile": _has_profile(base, tonality, constraints, routing),
236
+ "updated_at": utc_iso_now(),
237
+ "source": "memory",
238
+ }
239
+
240
+ def _build_upsert_payload(
241
+ self,
242
+ channel_id: str,
243
+ payload: dict[str, Any],
244
+ ) -> dict[str, Any]:
245
+ return {
246
+ "channel_id": channel_id,
247
+ "base_identity": payload.get("base_identity", {}),
248
+ "tonality_engine": payload.get("tonality_engine", {}),
249
+ "behavioral_constraints": payload.get("behavioral_constraints", {}),
250
+ "model_routing": payload.get("model_routing", {}),
251
+ "updated_at": "now()",
252
+ }
bot/prompt_flow.py CHANGED
@@ -4,6 +4,7 @@ from dataclasses import dataclass
4
  from typing import Any
5
 
6
  ReplyFn = Callable[[str], Awaitable[None]]
 
7
  InferenceFn = Callable[..., Awaitable[Any]]
8
 
9
 
@@ -31,6 +32,8 @@ class BytePromptRuntime:
31
  is_movie_fact_sheet_prompt: Callable[[str], bool]
32
  extract_movie_title: Callable[[str], str]
33
  build_movie_fact_sheet_query: Callable[[str], str]
 
 
34
  build_llm_enhanced_prompt: Callable[..., str]
35
  has_grounding_signal: Callable[[Mapping[str, Any] | None], bool]
36
  normalize_current_events_reply_contract: Callable[..., str]
@@ -152,6 +155,23 @@ async def handle_byte_prompt_text(
152
  log_interaction("intro")
153
  return
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  if lowered_prompt.startswith("status"):
156
  status_text = "status indisponivel"
157
  if callable(status_line_factory):
 
4
  from typing import Any
5
 
6
  ReplyFn = Callable[[str], Awaitable[None]]
7
+ ReplyRawFn = Callable[[str], Awaitable[None]]
8
  InferenceFn = Callable[..., Awaitable[Any]]
9
 
10
 
 
32
  is_movie_fact_sheet_prompt: Callable[[str], bool]
33
  extract_movie_title: Callable[[str], str]
34
  build_movie_fact_sheet_query: Callable[[str], str]
35
+ is_ascii_art_prompt: Callable[[str], bool]
36
+ extract_ascii_subject: Callable[[str], str]
37
  build_llm_enhanced_prompt: Callable[..., str]
38
  has_grounding_signal: Callable[[Mapping[str, Any] | None], bool]
39
  normalize_current_events_reply_contract: Callable[..., str]
 
155
  log_interaction("intro")
156
  return
157
 
158
+ if runtime.is_ascii_art_prompt(normalized_prompt):
159
+ subject = runtime.extract_ascii_subject(normalized_prompt)
160
+ if not subject:
161
+ await tracked_reply("Qual arte ASCII você quer? Ex: byte arte ascii de gato")
162
+ log_interaction("ascii_art_no_subject")
163
+ return
164
+ # Importa runtime ASCII e envia linha por linha
165
+ from bot.ascii_art_runtime import handle_ascii_art_prompt
166
+
167
+ async def reply_raw(text: str) -> None:
168
+ # Envia sem formatação para preservar espaços da arte
169
+ await reply_fn(text)
170
+
171
+ success = await handle_ascii_art_prompt(subject, author_name, reply_raw, channel_id)
172
+ log_interaction("ascii_art_success" if success else "ascii_art_error")
173
+ return
174
+
175
  if lowered_prompt.startswith("status"):
176
  status_text = "status indisponivel"
177
  if callable(status_line_factory):
bot/prompt_runtime.py CHANGED
@@ -39,6 +39,8 @@ is_high_risk_current_events_prompt = byte_semantics.is_high_risk_current_events_
39
  build_verifiable_prompt = byte_semantics.build_verifiable_prompt
40
  is_serious_technical_prompt = byte_semantics.is_serious_technical_prompt
41
  is_follow_up_prompt = byte_semantics.is_follow_up_prompt
 
 
42
  build_direct_answer_instruction = byte_semantics.build_direct_answer_instruction
43
  build_llm_enhanced_prompt = byte_semantics.build_llm_enhanced_prompt
44
  is_low_quality_answer = byte_semantics.is_low_quality_answer
@@ -121,6 +123,8 @@ def build_prompt_runtime(ctx: Any = None) -> BytePromptRuntime:
121
  is_movie_fact_sheet_prompt=is_movie_fact_sheet_prompt,
122
  extract_movie_title=extract_movie_title,
123
  build_movie_fact_sheet_query=build_movie_fact_sheet_query,
 
 
124
  build_llm_enhanced_prompt=build_llm_enhanced_prompt,
125
  has_grounding_signal=has_grounding_signal,
126
  normalize_current_events_reply_contract=normalize_current_events_reply_contract,
 
39
  build_verifiable_prompt = byte_semantics.build_verifiable_prompt
40
  is_serious_technical_prompt = byte_semantics.is_serious_technical_prompt
41
  is_follow_up_prompt = byte_semantics.is_follow_up_prompt
42
+ is_ascii_art_prompt = byte_semantics.is_ascii_art_prompt
43
+ extract_ascii_subject = byte_semantics.extract_ascii_subject
44
  build_direct_answer_instruction = byte_semantics.build_direct_answer_instruction
45
  build_llm_enhanced_prompt = byte_semantics.build_llm_enhanced_prompt
46
  is_low_quality_answer = byte_semantics.is_low_quality_answer
 
123
  is_movie_fact_sheet_prompt=is_movie_fact_sheet_prompt,
124
  extract_movie_title=extract_movie_title,
125
  build_movie_fact_sheet_query=build_movie_fact_sheet_query,
126
+ is_ascii_art_prompt=is_ascii_art_prompt,
127
+ extract_ascii_subject=extract_ascii_subject,
128
  build_llm_enhanced_prompt=build_llm_enhanced_prompt,
129
  has_grounding_signal=has_grounding_signal,
130
  normalize_current_events_reply_contract=normalize_current_events_reply_contract,
bot/requirements.txt CHANGED
@@ -5,3 +5,6 @@ asqlite
5
  duckduckgo-search
6
  python-dotenv
7
  supabase
 
 
 
 
5
  duckduckgo-search
6
  python-dotenv
7
  supabase
8
+ ascii-magic>=2.7.2
9
+ Pillow>=10.0.0
10
+ croniter>=2.0.0
bot/tests/scientific/suite_scene_irc.py CHANGED
@@ -133,7 +133,7 @@ class ScientificSceneAndIrcTestsMixin(ScientificTestCase):
133
  mock_auto_scene.return_value = []
134
 
135
  async def fake_handle(
136
- prompt, author_name, reply_fn, status_line_factory=None, channel_id=None
137
  ):
138
  await reply_fn("ok no canal certo")
139
 
 
133
  mock_auto_scene.return_value = []
134
 
135
  async def fake_handle(
136
+ prompt, author_name, reply_fn, status_line_factory=None, channel_id=None, **kwargs
137
  ):
138
  await reply_fn("ok no canal certo")
139
 
bot/tests/scientific/test_phase22_debt_free_validation.py ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Expanded scientific test suite for Phase 22 — Debt-Free Validation.
2
+
3
+ These tests prove behavioral correctness at the edges:
4
+ 1. Repository: validation boundaries, XSS sanitization, idempotency
5
+ 2. Routing: falsy values, coaching activity, unknown activities
6
+ 3. Identity instruction: XSS injection, max length enforcement
7
+ 4. Facade: async parity, isolation between channels
8
+ 5. Context: partial updates, type coercion, None resilience
9
+ 6. Route handlers: channel_id resolution, error paths, merge semantics
10
+ """
11
+
12
+ from types import SimpleNamespace
13
+ from unittest.mock import MagicMock, patch
14
+
15
+ import pytest
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # 1. Repository Validation Boundaries
20
+ # ---------------------------------------------------------------------------
21
+ class TestRepoValidationEdges:
22
+ """Repo must reject invalid data and accept boundary-valid data."""
23
+
24
+ def _make_repo(self):
25
+ from bot.persistence_persona_profile_repository import PersonaProfileRepository
26
+
27
+ return PersonaProfileRepository(enabled=False, client=None, cache={})
28
+
29
+ def test_name_at_max_length_accepted(self):
30
+ repo = self._make_repo()
31
+ name = "A" * 80
32
+ saved = repo.save_sync("ch", base_identity={"name": name})
33
+ assert saved["base_identity"]["name"] == name
34
+
35
+ def test_name_over_max_length_rejected(self):
36
+ repo = self._make_repo()
37
+ with pytest.raises(ValueError, match="tamanho"):
38
+ repo.save_sync("ch", base_identity={"name": "B" * 81})
39
+
40
+ def test_pronouns_at_max_length_accepted(self):
41
+ repo = self._make_repo()
42
+ pronouns = "P" * 40
43
+ saved = repo.save_sync("ch", base_identity={"pronouns": pronouns})
44
+ assert saved["base_identity"]["pronouns"] == pronouns
45
+
46
+ def test_pronouns_over_max_length_rejected(self):
47
+ repo = self._make_repo()
48
+ with pytest.raises(ValueError, match="tamanho"):
49
+ repo.save_sync("ch", base_identity={"pronouns": "P" * 41})
50
+
51
+ def test_tone_at_max_length_accepted(self):
52
+ repo = self._make_repo()
53
+ tone = "T" * 160
54
+ saved = repo.save_sync("ch", tonality_engine={"tone": tone})
55
+ assert saved["tonality_engine"]["tone"] == tone
56
+
57
+ def test_lore_at_max_length_accepted(self):
58
+ repo = self._make_repo()
59
+ lore = "L" * 1200
60
+ saved = repo.save_sync("ch", base_identity={"lore": lore})
61
+ assert len(saved["base_identity"]["lore"]) == 1200
62
+
63
+ def test_model_name_at_max_length_accepted(self):
64
+ repo = self._make_repo()
65
+ model = "M" * 120
66
+ saved = repo.save_sync("ch", model_routing={"chat": model})
67
+ assert saved["model_routing"]["chat"] == model
68
+
69
+ def test_model_name_over_max_length_rejected(self):
70
+ repo = self._make_repo()
71
+ with pytest.raises(ValueError, match="tamanho"):
72
+ repo.save_sync("ch", model_routing={"chat": "M" * 121})
73
+
74
+ def test_invalid_routing_activity_silently_ignored(self):
75
+ repo = self._make_repo()
76
+ # Unknown activities are silently ignored by _extract_model_routing
77
+ saved = repo.save_sync("ch", model_routing={"invalid_activity": "model", "chat": "valid"})
78
+ assert "invalid_activity" not in saved["model_routing"]
79
+ assert saved["model_routing"]["chat"] == "valid"
80
+
81
+ def test_banned_topics_max_items_enforced_strict(self):
82
+ repo = self._make_repo()
83
+ topics = [f"topic_{i}" for i in range(15)]
84
+ # strict=True in _build_memory_payload raises when > _MAX_LIST_ITEMS
85
+ with pytest.raises(ValueError, match="lista excede"):
86
+ repo.save_sync("ch", behavioral_constraints={"banned_topics": topics})
87
+
88
+ def test_emote_vocab_max_items_enforced_strict(self):
89
+ repo = self._make_repo()
90
+ emotes = [f"emote_{i}" for i in range(15)]
91
+ # strict=True in _build_memory_payload raises when > 10
92
+ with pytest.raises(ValueError, match="lista excede"):
93
+ repo.save_sync("ch", tonality_engine={"emote_vocab": emotes})
94
+
95
+ def test_xss_in_name_stored_as_is(self):
96
+ """Repo stores text for DB, not HTML rendering. No sanitization needed."""
97
+ repo = self._make_repo()
98
+ xss = '<script>alert("xss")</script>'
99
+ saved = repo.save_sync("ch", base_identity={"name": xss})
100
+ # _normalize_single_line collapses whitespace but doesn't strip HTML
101
+ assert "alert" in saved["base_identity"]["name"]
102
+
103
+ def test_xss_in_lore_stored_as_is(self):
104
+ """Lore goes to Supabase, not to HTML. No sanitization needed."""
105
+ repo = self._make_repo()
106
+ saved = repo.save_sync("ch", base_identity={"lore": '<img onerror="hack()" />'})
107
+ assert "onerror" in saved["base_identity"]["lore"]
108
+
109
+ def test_sql_injection_in_name_stored_as_is(self):
110
+ """Supabase client handles SQL injection via parameterized queries."""
111
+ repo = self._make_repo()
112
+ saved = repo.save_sync(
113
+ "ch",
114
+ base_identity={"name": "'; DROP TABLE users; --"},
115
+ )
116
+ assert "DROP TABLE" in saved["base_identity"]["name"]
117
+
118
+ def test_all_sentence_styles_accepted(self):
119
+ repo = self._make_repo()
120
+ for style in ("short_punchy", "long_analytical", "balanced", ""):
121
+ saved = repo.save_sync(
122
+ f"ch_{style or 'empty'}",
123
+ tonality_engine={"sentence_style": style},
124
+ )
125
+ assert saved["tonality_engine"]["sentence_style"] == style
126
+
127
+ def test_idempotent_save_returns_same_data(self):
128
+ repo = self._make_repo()
129
+ data = {
130
+ "base_identity": {"name": "Test"},
131
+ "tonality_engine": {"tone": "fun"},
132
+ }
133
+ first = repo.save_sync("ch", **data)
134
+ second = repo.save_sync("ch", **data)
135
+ assert first["base_identity"] == second["base_identity"]
136
+ assert first["tonality_engine"] == second["tonality_engine"]
137
+
138
+ def test_channel_id_normalization(self):
139
+ repo = self._make_repo()
140
+ repo.save_sync(" Canal_A ", base_identity={"name": "Test"})
141
+ loaded = repo.load_sync("canal_a")
142
+ assert loaded["base_identity"]["name"] == "Test"
143
+
144
+ def test_none_inputs_default_to_empty(self):
145
+ repo = self._make_repo()
146
+ saved = repo.save_sync(
147
+ "ch",
148
+ base_identity=None,
149
+ tonality_engine=None,
150
+ behavioral_constraints=None,
151
+ model_routing=None,
152
+ )
153
+ assert saved["base_identity"]["name"] == ""
154
+ assert saved["tonality_engine"]["tone"] == ""
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # 2. Model Routing Edge Cases
159
+ # ---------------------------------------------------------------------------
160
+ class TestModelRoutingEdges:
161
+ """Routing must handle all edge cases without crashing."""
162
+
163
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
164
+ @patch("bot.runtime_config.NEBIUS_MODEL_SEARCH", "global-search")
165
+ @patch("bot.runtime_config.NEBIUS_MODEL_REASONING", "global-reasoning")
166
+ def test_none_context_uses_global(self):
167
+ from bot.logic_inference import _select_model
168
+
169
+ assert _select_model(False, False, context=None) == "global-default"
170
+
171
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
172
+ def test_context_without_routing_attr_uses_global(self):
173
+ from bot.logic_inference import _select_model
174
+
175
+ ctx = object() # no channel_model_routing attribute
176
+ assert _select_model(False, False, context=ctx) == "global-default"
177
+
178
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
179
+ def test_empty_string_routing_falls_back(self):
180
+ from bot.logic_inference import _select_model
181
+
182
+ ctx = SimpleNamespace(channel_model_routing={"chat": ""})
183
+ assert _select_model(False, False, context=ctx) == "global-default"
184
+
185
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
186
+ def test_whitespace_routing_falls_back(self):
187
+ from bot.logic_inference import _select_model
188
+
189
+ ctx = SimpleNamespace(channel_model_routing={"chat": " "})
190
+ # Whitespace string is truthy; model selection should return it
191
+ result = _select_model(False, False, context=ctx)
192
+ # This is technically valid — the model name is " "
193
+ # The important thing is it doesn't crash
194
+ assert isinstance(result, str)
195
+
196
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
197
+ @patch("bot.runtime_config.NEBIUS_MODEL_SEARCH", "global-search")
198
+ @patch("bot.runtime_config.NEBIUS_MODEL_REASONING", "global-reasoning")
199
+ def test_both_grounding_and_serious_prefers_grounding(self):
200
+ from bot.logic_inference import _select_model
201
+
202
+ ctx = SimpleNamespace(
203
+ channel_model_routing={
204
+ "search": "override-search",
205
+ "reasoning": "override-reasoning",
206
+ }
207
+ )
208
+ # enable_grounding is checked first, so search takes precedence
209
+ result = _select_model(True, True, context=ctx)
210
+ assert result == "override-search"
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # 3. Identity Instruction Edge Cases
215
+ # ---------------------------------------------------------------------------
216
+ class TestIdentityInstructionEdges:
217
+ """Prompt builder must handle adversarial inputs gracefully."""
218
+
219
+ def test_xss_in_persona_name_not_in_prompt(self):
220
+ from bot.logic_inference import _build_identity_instruction
221
+
222
+ ctx = SimpleNamespace(
223
+ persona_name='<script>alert("xss")</script>',
224
+ persona_tone="",
225
+ persona_lore="",
226
+ persona_emote_vocab=[],
227
+ persona_sentence_style="",
228
+ persona_banned_topics=[],
229
+ persona_cta_triggers=[],
230
+ )
231
+ result = _build_identity_instruction(ctx)
232
+ assert "<script>" in result # The function doesn't sanitize — it's prompt, not HTML
233
+ assert "alert" in result # It SHOULD appear in the prompt as-is
234
+
235
+ def test_long_name_truncated_to_80(self):
236
+ from bot.logic_inference import _build_identity_instruction
237
+
238
+ ctx = SimpleNamespace(
239
+ persona_name="A" * 200,
240
+ persona_tone="",
241
+ persona_lore="",
242
+ persona_emote_vocab=[],
243
+ )
244
+ result = _build_identity_instruction(ctx)
245
+ # The prompt should truncate to 80 chars
246
+ name_line = next(line for line in result.split("\n") if "Persona principal" in line)
247
+ assert len(name_line) < 120 # "- Persona principal: " + 80 chars
248
+
249
+ def test_long_tone_truncated_to_160(self):
250
+ from bot.logic_inference import _build_identity_instruction
251
+
252
+ ctx = SimpleNamespace(
253
+ persona_name="Test",
254
+ persona_tone="T" * 300,
255
+ persona_lore="",
256
+ persona_emote_vocab=[],
257
+ )
258
+ result = _build_identity_instruction(ctx)
259
+ tone_line = next(line for line in result.split("\n") if "Tom de voz" in line)
260
+ assert len(tone_line) < 200 # "- Tom de voz: " + 160 chars
261
+
262
+ def test_max_4_lore_lines(self):
263
+ from bot.logic_inference import _build_identity_instruction
264
+
265
+ ctx = SimpleNamespace(
266
+ persona_name="Test",
267
+ persona_tone="",
268
+ persona_lore="line1\nline2\nline3\nline4\nline5\nline6",
269
+ persona_emote_vocab=[],
270
+ )
271
+ result = _build_identity_instruction(ctx)
272
+ lore_lines = [line for line in result.split("\n") if line.startswith(" - ")]
273
+ assert len(lore_lines) <= 4
274
+
275
+ def test_max_12_emotes(self):
276
+ from bot.logic_inference import _build_identity_instruction
277
+
278
+ ctx = SimpleNamespace(
279
+ persona_name="Test",
280
+ persona_tone="",
281
+ persona_lore="",
282
+ persona_emote_vocab=[f"Emote{i}" for i in range(20)],
283
+ )
284
+ result = _build_identity_instruction(ctx)
285
+ vocab_line = next(line for line in result.split("\n") if "Vocabulario" in line)
286
+ emote_count = vocab_line.count(",") + 1
287
+ assert emote_count <= 12
288
+
289
+ def test_banned_topics_max_10(self):
290
+ from bot.logic_inference import _build_identity_instruction
291
+
292
+ ctx = SimpleNamespace(
293
+ persona_name="Test",
294
+ persona_tone="",
295
+ persona_lore="",
296
+ persona_emote_vocab=[],
297
+ persona_sentence_style="",
298
+ persona_banned_topics=[f"topic_{i}" for i in range(15)],
299
+ persona_cta_triggers=[],
300
+ )
301
+ result = _build_identity_instruction(ctx)
302
+ banned_line = next(line for line in result.split("\n") if "NUNCA aborde" in line)
303
+ topic_count = banned_line.count(",") + 1
304
+ assert topic_count <= 10
305
+
306
+ def test_empty_string_items_filtered_out(self):
307
+ from bot.logic_inference import _build_identity_instruction
308
+
309
+ ctx = SimpleNamespace(
310
+ persona_name="Test",
311
+ persona_tone="",
312
+ persona_lore="",
313
+ persona_emote_vocab=["", " ", "PogChamp"],
314
+ persona_sentence_style="",
315
+ persona_banned_topics=["", "politics"],
316
+ persona_cta_triggers=["", " "],
317
+ )
318
+ result = _build_identity_instruction(ctx)
319
+ if "Vocabulario" in result:
320
+ vocab_line = next(line for line in result.split("\n") if "Vocabulario" in line)
321
+ assert "PogChamp" in vocab_line
322
+ if "NUNCA aborde" in result:
323
+ banned_line = next(line for line in result.split("\n") if "NUNCA aborde" in line)
324
+ assert "politics" in banned_line
325
+ # Empty CTA triggers should NOT produce a CTA line
326
+ assert "Gatilhos de CTA" not in result
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # 4. Facade: Channel Isolation
331
+ # ---------------------------------------------------------------------------
332
+ class TestFacadeChannelIsolation:
333
+ """Saving profile for channel A must not affect channel B."""
334
+
335
+ @patch.dict("os.environ", {"SUPABASE_URL": "", "SUPABASE_KEY": ""})
336
+ def test_channels_isolated(self):
337
+ from bot.persistence_layer import PersistenceLayer
338
+
339
+ pl = PersistenceLayer()
340
+ pl.save_persona_profile_sync(
341
+ "canal_a",
342
+ base_identity={"name": "Alpha Coach"},
343
+ model_routing={"chat": "alpha-model"},
344
+ )
345
+ pl.save_persona_profile_sync(
346
+ "canal_b",
347
+ base_identity={"name": "Beta Coach"},
348
+ model_routing={"chat": "beta-model"},
349
+ )
350
+
351
+ loaded_a = pl.load_persona_profile_sync("canal_a")
352
+ loaded_b = pl.load_persona_profile_sync("canal_b")
353
+
354
+ assert loaded_a["base_identity"]["name"] == "Alpha Coach"
355
+ assert loaded_b["base_identity"]["name"] == "Beta Coach"
356
+ assert loaded_a["model_routing"]["chat"] == "alpha-model"
357
+ assert loaded_b["model_routing"]["chat"] == "beta-model"
358
+
359
+ @patch.dict("os.environ", {"SUPABASE_URL": "", "SUPABASE_KEY": ""})
360
+ def test_overwrite_preserves_cache_consistency(self):
361
+ from bot.persistence_layer import PersistenceLayer
362
+
363
+ pl = PersistenceLayer()
364
+ pl.save_persona_profile_sync("ch", base_identity={"name": "V1"})
365
+ pl.save_persona_profile_sync("ch", base_identity={"name": "V2"})
366
+
367
+ loaded = pl.load_persona_profile_sync("ch")
368
+ assert loaded["base_identity"]["name"] == "V2"
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # 5. Context: Partial Updates & Type Coercion
373
+ # ---------------------------------------------------------------------------
374
+ class TestContextPartialUpdates:
375
+ """Apply must handle partial payloads without losing existing data."""
376
+
377
+ def test_partial_update_preserves_existing_fields(self):
378
+ from bot.logic_context import ContextManager
379
+
380
+ cm = ContextManager()
381
+ cm.get("ch")
382
+
383
+ # First apply: full profile
384
+ cm.apply_persona_profile(
385
+ "ch",
386
+ base_identity={"name": "Original", "lore": "Original Lore"},
387
+ tonality_engine={"tone": "original-tone", "emote_vocab": ["Kappa"]},
388
+ behavioral_constraints={"banned_topics": ["a"]},
389
+ )
390
+
391
+ # Second apply: only change name (tone/lore should persist in context)
392
+ cm.apply_persona_profile(
393
+ "ch",
394
+ base_identity={"name": "Updated"},
395
+ )
396
+
397
+ ctx = cm.get("ch")
398
+ assert ctx.persona_name == "Updated"
399
+ # These should still have original values
400
+ assert ctx.persona_lore == "Original Lore"
401
+ assert ctx.persona_tone == "original-tone"
402
+
403
+ def test_none_model_routing_does_not_crash(self):
404
+ from bot.logic_context import ContextManager
405
+
406
+ cm = ContextManager()
407
+ cm.get("ch")
408
+ cm.apply_persona_profile("ch", model_routing=None)
409
+ ctx = cm.get("ch")
410
+ assert ctx.channel_model_routing == {}
411
+
412
+ def test_integer_in_emote_vocab_coerced_to_string(self):
413
+ from bot.logic_context import ContextManager
414
+
415
+ cm = ContextManager()
416
+ cm.get("ch")
417
+ cm.apply_persona_profile(
418
+ "ch",
419
+ tonality_engine={"emote_vocab": [123, "LUL", None]},
420
+ )
421
+ ctx = cm.get("ch")
422
+ assert "123" in ctx.persona_emote_vocab
423
+ assert "LUL" in ctx.persona_emote_vocab
424
+
425
+ def test_model_routing_none_values_preserved(self):
426
+ from bot.logic_context import ContextManager
427
+
428
+ cm = ContextManager()
429
+ cm.get("ch")
430
+ cm.apply_persona_profile(
431
+ "ch",
432
+ model_routing={"chat": "custom", "coaching": None, "search": ""},
433
+ )
434
+ ctx = cm.get("ch")
435
+ assert ctx.channel_model_routing["chat"] == "custom"
436
+ assert ctx.channel_model_routing["coaching"] is None
437
+ # Empty string is coerced to None by apply_persona_profile
438
+ assert ctx.channel_model_routing.get("search") is None
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # 6. Route Handler: Merge Semantics & Error Paths
443
+ # ---------------------------------------------------------------------------
444
+ class TestRouteHandlerMergeSemantics:
445
+ """PUT handler must merge, not overwrite entire profile."""
446
+
447
+ @patch("bot.dashboard_server_routes.context_manager")
448
+ @patch("bot.dashboard_server_routes.persistence")
449
+ def test_partial_payload_merges_with_existing(self, mock_persistence, mock_cm):
450
+ from bot.dashboard_server_routes import _handle_put_persona_profile
451
+
452
+ # Existing profile has name and routing
453
+ mock_persistence.load_persona_profile_sync.return_value = {
454
+ "base_identity": {"name": "Existing", "pronouns": "ela/dela", "lore": "bg"},
455
+ "tonality_engine": {"tone": "soft", "emote_vocab": [], "sentence_style": ""},
456
+ "behavioral_constraints": {"banned_topics": ["a"], "cta_triggers": []},
457
+ "model_routing": {"chat": "existing-chat", "coaching": None},
458
+ }
459
+ mock_persistence.save_persona_profile_sync.return_value = {
460
+ "base_identity": {"name": "Updated", "pronouns": "ela/dela", "lore": "bg"},
461
+ "tonality_engine": {"tone": "soft", "emote_vocab": [], "sentence_style": ""},
462
+ "behavioral_constraints": {"banned_topics": ["a"], "cta_triggers": ["follow"]},
463
+ "model_routing": {"chat": "existing-chat", "coaching": None},
464
+ "has_profile": True,
465
+ }
466
+
467
+ handler = MagicMock()
468
+ # Only updating name + adding cta_triggers
469
+ payload = {
470
+ "channel_id": "Canal_A",
471
+ "base_identity": {"name": "Updated"},
472
+ "behavioral_constraints": {"cta_triggers": ["follow"]},
473
+ }
474
+ _handle_put_persona_profile(handler, {"channel": ["Canal_A"]}, payload)
475
+
476
+ # Verify save was called with merged data
477
+ call_kwargs = mock_persistence.save_persona_profile_sync.call_args
478
+ # base_identity from payload (partial)
479
+ assert call_kwargs[1]["base_identity"]["name"] == "Updated"
480
+ # tonality_engine from existing (not in payload)
481
+ assert call_kwargs[1]["tonality_engine"]["tone"] == "soft"
482
+ # behavioral_constraints from payload
483
+ assert call_kwargs[1]["behavioral_constraints"]["cta_triggers"] == ["follow"]
484
+ # model_routing from existing (not in payload)
485
+ assert call_kwargs[1]["model_routing"]["chat"] == "existing-chat"
486
+
487
+ handler._send_json.assert_called_once()
488
+ assert handler._send_json.call_args[0][0]["ok"] is True
489
+
490
+ @patch("bot.dashboard_server_routes.context_manager")
491
+ @patch("bot.dashboard_server_routes.persistence")
492
+ def test_validation_error_returns_400(self, mock_persistence, mock_cm):
493
+ from bot.dashboard_server_routes import _handle_put_persona_profile
494
+
495
+ mock_persistence.load_persona_profile_sync.return_value = {}
496
+ mock_persistence.save_persona_profile_sync.side_effect = ValueError(
497
+ "Nome excede tamanho maximo"
498
+ )
499
+
500
+ handler = MagicMock()
501
+ payload = {"channel_id": "canal_a", "base_identity": {"name": "X" * 200}}
502
+ _handle_put_persona_profile(handler, {}, payload)
503
+
504
+ # Should send 400
505
+ handler._send_json.assert_called_once()
506
+ response = handler._send_json.call_args[0][0]
507
+ assert response["ok"] is False
508
+
509
+ @patch("bot.dashboard_server_routes.send_invalid_request")
510
+ @patch("bot.dashboard_server_routes.persistence")
511
+ def test_get_without_channel_returns_error(self, mock_persistence, mock_send_invalid):
512
+ from bot.dashboard_server_routes import _handle_get_persona_profile
513
+
514
+ handler = MagicMock()
515
+ _handle_get_persona_profile(handler, {})
516
+ # _resolve_channel_id(required=True) raises ValueError -> send_invalid_request
517
+ mock_send_invalid.assert_called_once()
518
+ mock_persistence.load_persona_profile_sync.assert_not_called()
519
+
520
+
521
+ # ---------------------------------------------------------------------------
522
+ # 7. Dashboard Payload: Persona Profile Field Existence
523
+ # ---------------------------------------------------------------------------
524
+ class TestBuildChannelContextPayloadPersonaProfile:
525
+ """Prove build_channel_context_payload includes persona profile fields."""
526
+
527
+ @patch("bot.dashboard_server_routes.persistence")
528
+ @patch("bot.dashboard_server_routes.context_manager")
529
+ def test_payload_includes_persona_profile_when_present(self, mock_cm, mock_persistence):
530
+ from bot.dashboard_server_routes import build_channel_context_payload
531
+
532
+ mock_cm.list_active_channels.return_value = []
533
+ mock_cm.get.return_value = MagicMock(
534
+ channel_id="ch",
535
+ current_game="",
536
+ stream_vibe="",
537
+ last_event="",
538
+ style_profile="",
539
+ agent_notes="",
540
+ last_byte_reply="",
541
+ live_observability={},
542
+ recent_chat_entries=[],
543
+ )
544
+ mock_persistence.load_channel_state_sync.return_value = None
545
+ mock_persistence.load_agent_notes_sync.return_value = None
546
+ mock_persistence.load_channel_identity_sync.return_value = None
547
+ mock_persistence.load_recent_history_sync.return_value = []
548
+ mock_persistence.load_persona_profile_sync.return_value = {
549
+ "has_profile": True,
550
+ "base_identity": {"name": "Prof"},
551
+ }
552
+
553
+ payload = build_channel_context_payload("ch")
554
+
555
+ assert payload["channel"]["has_persisted_persona_profile"] is True
556
+ assert payload["channel"]["persisted_persona_profile"]["base_identity"]["name"] == "Prof"
557
+
558
+ @patch("bot.dashboard_server_routes.persistence")
559
+ @patch("bot.dashboard_server_routes.context_manager")
560
+ def test_payload_handles_none_persona_profile(self, mock_cm, mock_persistence):
561
+ from bot.dashboard_server_routes import build_channel_context_payload
562
+
563
+ mock_cm.list_active_channels.return_value = []
564
+ mock_cm.get.return_value = MagicMock(
565
+ channel_id="ch",
566
+ current_game="",
567
+ stream_vibe="",
568
+ last_event="",
569
+ style_profile="",
570
+ agent_notes="",
571
+ last_byte_reply="",
572
+ live_observability={},
573
+ recent_chat_entries=[],
574
+ )
575
+ mock_persistence.load_channel_state_sync.return_value = None
576
+ mock_persistence.load_agent_notes_sync.return_value = None
577
+ mock_persistence.load_channel_identity_sync.return_value = None
578
+ mock_persistence.load_recent_history_sync.return_value = []
579
+ mock_persistence.load_persona_profile_sync.return_value = None
580
+
581
+ payload = build_channel_context_payload("ch")
582
+
583
+ assert payload["channel"]["has_persisted_persona_profile"] is False
584
+ assert payload["channel"]["persisted_persona_profile"] == {}
bot/tests/scientific/test_phase22_persona_routing.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scientific test suite for Phase 22: Persona Studio & Nebius Model Routing.
2
+
3
+ Tests validate behavioral contracts, NOT implementation details:
4
+ 1. PersonaProfileRepository round-trip and validation
5
+ 2. _select_model routing with per-channel overrides
6
+ 3. _build_identity_instruction with expanded persona fields
7
+ 4. PersistenceLayer facade shares cache with repository
8
+ 5. ContextManager.apply_persona_profile propagation
9
+ """
10
+
11
+ from types import SimpleNamespace
12
+ from unittest.mock import MagicMock, patch
13
+
14
+ import pytest
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # 1. PersonaProfileRepository: Memory Round-Trip & Validation
19
+ # ---------------------------------------------------------------------------
20
+ class TestPersonaProfileRepositoryRoundTrip:
21
+ """Prove the repository normalizes, stores, and retrieves persona profiles correctly."""
22
+
23
+ def _make_repo(self):
24
+ from bot.persistence_persona_profile_repository import PersonaProfileRepository
25
+
26
+ cache: dict = {}
27
+ return PersonaProfileRepository(enabled=False, client=None, cache=cache)
28
+
29
+ def test_save_and_load_full_profile(self):
30
+ repo = self._make_repo()
31
+ saved = repo.save_sync(
32
+ "test_channel",
33
+ base_identity={
34
+ "name": "Byte Coach",
35
+ "pronouns": "ele/dele",
36
+ "lore": "Digital coaching entity",
37
+ },
38
+ tonality_engine={
39
+ "tone": "tatico e direto",
40
+ "emote_vocab": ["PogChamp", "LUL"],
41
+ "sentence_style": "short_punchy",
42
+ },
43
+ behavioral_constraints={
44
+ "banned_topics": ["politics", "religion"],
45
+ "cta_triggers": ["follow", "subscribe"],
46
+ },
47
+ model_routing={
48
+ "chat": "deepseek-r1",
49
+ "coaching": None,
50
+ "search": "qwen-72b",
51
+ "reasoning": None,
52
+ },
53
+ )
54
+
55
+ assert saved["has_profile"] is True
56
+ assert saved["base_identity"]["name"] == "Byte Coach"
57
+ assert saved["base_identity"]["pronouns"] == "ele/dele"
58
+ assert saved["tonality_engine"]["sentence_style"] == "short_punchy"
59
+ assert saved["behavioral_constraints"]["banned_topics"] == ["politics", "religion"]
60
+ assert saved["model_routing"]["chat"] == "deepseek-r1"
61
+ assert saved["model_routing"]["coaching"] is None
62
+
63
+ loaded = repo.load_sync("test_channel")
64
+ assert loaded["has_profile"] is True
65
+ assert loaded["base_identity"]["name"] == "Byte Coach"
66
+ assert loaded["tonality_engine"]["emote_vocab"] == ["PogChamp", "LUL"]
67
+ assert loaded["model_routing"]["search"] == "qwen-72b"
68
+
69
+ def test_empty_profile_has_profile_false(self):
70
+ repo = self._make_repo()
71
+ saved = repo.save_sync(
72
+ "empty_channel",
73
+ base_identity={},
74
+ tonality_engine={},
75
+ behavioral_constraints={},
76
+ model_routing={},
77
+ )
78
+ assert saved["has_profile"] is False
79
+
80
+ def test_load_before_save_returns_default(self):
81
+ repo = self._make_repo()
82
+ loaded = repo.load_sync("nonexistent")
83
+ assert loaded["has_profile"] is False
84
+ assert loaded["base_identity"] == {"name": "", "pronouns": "", "lore": ""}
85
+ assert loaded["channel_id"] == "nonexistent"
86
+
87
+ def test_name_length_validation(self):
88
+ repo = self._make_repo()
89
+ with pytest.raises(ValueError, match="tamanho"):
90
+ repo.save_sync(
91
+ "test_channel",
92
+ base_identity={"name": "A" * 200},
93
+ )
94
+
95
+ def test_invalid_sentence_style_rejected(self):
96
+ repo = self._make_repo()
97
+ with pytest.raises(ValueError, match="invalido"):
98
+ repo.save_sync(
99
+ "test_channel",
100
+ tonality_engine={"sentence_style": "invalid_style"},
101
+ )
102
+
103
+ def test_banned_topics_dedup_and_limit(self):
104
+ repo = self._make_repo()
105
+ saved = repo.save_sync(
106
+ "test_channel",
107
+ behavioral_constraints={"banned_topics": ["dup", "DUP", "unique"]},
108
+ )
109
+ assert saved["behavioral_constraints"]["banned_topics"] == ["dup", "unique"]
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # 2. _select_model: Per-Channel Model Routing
114
+ # ---------------------------------------------------------------------------
115
+ class TestSelectModelRouting:
116
+ """Prove model selection respects per-channel overrides when context has routing."""
117
+
118
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
119
+ @patch("bot.runtime_config.NEBIUS_MODEL_SEARCH", "global-search")
120
+ @patch("bot.runtime_config.NEBIUS_MODEL_REASONING", "global-reasoning")
121
+ def test_no_routing_uses_global_defaults(self):
122
+ from bot.logic_inference import _select_model
123
+
124
+ assert _select_model(False, False) == "global-default"
125
+ assert _select_model(True, False) == "global-search"
126
+ assert _select_model(False, True) == "global-reasoning"
127
+
128
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
129
+ @patch("bot.runtime_config.NEBIUS_MODEL_SEARCH", "global-search")
130
+ @patch("bot.runtime_config.NEBIUS_MODEL_REASONING", "global-reasoning")
131
+ def test_routing_overrides_global(self):
132
+ from bot.logic_inference import _select_model
133
+
134
+ ctx = SimpleNamespace(
135
+ channel_model_routing={
136
+ "chat": "custom-chat",
137
+ "search": "custom-search",
138
+ "reasoning": "custom-reasoning",
139
+ }
140
+ )
141
+ assert _select_model(False, False, context=ctx) == "custom-chat"
142
+ assert _select_model(True, False, context=ctx) == "custom-search"
143
+ assert _select_model(False, True, context=ctx) == "custom-reasoning"
144
+
145
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
146
+ @patch("bot.runtime_config.NEBIUS_MODEL_SEARCH", "global-search")
147
+ def test_partial_routing_falls_back_to_global(self):
148
+ from bot.logic_inference import _select_model
149
+
150
+ ctx = SimpleNamespace(
151
+ channel_model_routing={
152
+ "chat": "custom-chat",
153
+ "search": None,
154
+ }
155
+ )
156
+ assert _select_model(False, False, context=ctx) == "custom-chat"
157
+ assert _select_model(True, False, context=ctx) == "global-search"
158
+
159
+ @patch("bot.runtime_config.NEBIUS_MODEL_DEFAULT", "global-default")
160
+ def test_empty_routing_dict_falls_back(self):
161
+ from bot.logic_inference import _select_model
162
+
163
+ ctx = SimpleNamespace(channel_model_routing={})
164
+ assert _select_model(False, False, context=ctx) == "global-default"
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # 3. _build_identity_instruction: Expanded Persona Fields
169
+ # ---------------------------------------------------------------------------
170
+ class TestBuildIdentityInstructionExpanded:
171
+ """Prove system prompt includes sentence_style, banned_topics, and cta_triggers."""
172
+
173
+ def test_includes_sentence_style(self):
174
+ from bot.logic_inference import _build_identity_instruction
175
+
176
+ ctx = SimpleNamespace(
177
+ persona_name="Byte",
178
+ persona_tone="tatico",
179
+ persona_lore="",
180
+ persona_emote_vocab=[],
181
+ persona_sentence_style="short_punchy",
182
+ persona_banned_topics=[],
183
+ persona_cta_triggers=[],
184
+ )
185
+ result = _build_identity_instruction(ctx)
186
+ assert "curto e direto" in result
187
+
188
+ def test_includes_banned_topics(self):
189
+ from bot.logic_inference import _build_identity_instruction
190
+
191
+ ctx = SimpleNamespace(
192
+ persona_name="Byte",
193
+ persona_tone="",
194
+ persona_lore="",
195
+ persona_emote_vocab=[],
196
+ persona_sentence_style="",
197
+ persona_banned_topics=["politics", "religion"],
198
+ persona_cta_triggers=[],
199
+ )
200
+ result = _build_identity_instruction(ctx)
201
+ assert "NUNCA aborde" in result
202
+ assert "politics" in result
203
+ assert "religion" in result
204
+
205
+ def test_includes_cta_triggers(self):
206
+ from bot.logic_inference import _build_identity_instruction
207
+
208
+ ctx = SimpleNamespace(
209
+ persona_name="Byte",
210
+ persona_tone="",
211
+ persona_lore="",
212
+ persona_emote_vocab=[],
213
+ persona_sentence_style="",
214
+ persona_banned_topics=[],
215
+ persona_cta_triggers=["follow", "subscribe"],
216
+ )
217
+ result = _build_identity_instruction(ctx)
218
+ assert "Gatilhos de CTA" in result
219
+ assert "follow" in result
220
+
221
+ def test_empty_fields_produce_no_noise(self):
222
+ from bot.logic_inference import _build_identity_instruction
223
+
224
+ ctx = SimpleNamespace(
225
+ persona_name="Byte",
226
+ persona_tone="",
227
+ persona_lore="",
228
+ persona_emote_vocab=[],
229
+ persona_sentence_style="",
230
+ persona_banned_topics=[],
231
+ persona_cta_triggers=[],
232
+ )
233
+ result = _build_identity_instruction(ctx)
234
+ assert "NUNCA aborde" not in result
235
+ assert "Gatilhos de CTA" not in result
236
+ assert "Estilo de frase" not in result
237
+
238
+ def test_backward_compat_without_new_attrs(self):
239
+ """Context objects without new fields should NOT break."""
240
+ from bot.logic_inference import _build_identity_instruction
241
+
242
+ ctx = SimpleNamespace(
243
+ persona_name="Byte",
244
+ persona_tone="energetico",
245
+ persona_lore="",
246
+ persona_emote_vocab=["PogChamp"],
247
+ )
248
+ result = _build_identity_instruction(ctx)
249
+ assert "Byte" in result
250
+ assert "energetico" in result
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # 4. PersistenceLayer Facade: Cache Sharing
255
+ # ---------------------------------------------------------------------------
256
+ class TestPersistenceLayerFacade:
257
+ """Prove the facade shares cache between save and load."""
258
+
259
+ @patch.dict("os.environ", {"SUPABASE_URL": "", "SUPABASE_KEY": ""})
260
+ def test_facade_save_load_shares_cache(self):
261
+ from bot.persistence_layer import PersistenceLayer
262
+
263
+ pl = PersistenceLayer()
264
+ pl.save_persona_profile_sync(
265
+ "canal_a",
266
+ base_identity={"name": "Coach"},
267
+ tonality_engine={"tone": "motivador"},
268
+ )
269
+ loaded = pl.load_persona_profile_sync("canal_a")
270
+ assert loaded["has_profile"] is True
271
+ assert loaded["base_identity"]["name"] == "Coach"
272
+ assert loaded["tonality_engine"]["tone"] == "motivador"
273
+ assert loaded["source"] == "memory"
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # 5. ContextManager.apply_persona_profile
278
+ # ---------------------------------------------------------------------------
279
+ class TestContextManagerApplyPersonaProfile:
280
+ """Prove apply_persona_profile propagates to StreamContext."""
281
+
282
+ def test_apply_persona_profile_propagates(self):
283
+ from bot.logic_context import ContextManager
284
+
285
+ cm = ContextManager()
286
+ cm.get("test_ch")
287
+
288
+ cm.apply_persona_profile(
289
+ "test_ch",
290
+ base_identity={"name": "Test Persona", "lore": "Test lore"},
291
+ tonality_engine={"tone": "fun", "emote_vocab": ["Kappa"], "sentence_style": "balanced"},
292
+ behavioral_constraints={"banned_topics": ["spoilers"], "cta_triggers": ["sub"]},
293
+ model_routing={"chat": "test-model", "search": None},
294
+ )
295
+
296
+ ctx = cm.get("test_ch")
297
+ assert ctx.persona_name == "Test Persona"
298
+ assert ctx.persona_lore == "Test lore"
299
+ assert ctx.persona_tone == "fun"
300
+ assert ctx.persona_emote_vocab == ["Kappa"]
301
+ assert ctx.persona_sentence_style == "balanced"
302
+ assert ctx.persona_banned_topics == ["spoilers"]
303
+ assert ctx.persona_cta_triggers == ["sub"]
304
+ assert ctx.channel_model_routing["chat"] == "test-model"
305
+ assert ctx.channel_model_routing.get("search") is None
306
+
307
+ def test_apply_to_nonexistent_channel_is_noop(self):
308
+ from bot.logic_context import ContextManager
309
+
310
+ cm = ContextManager()
311
+ cm.apply_persona_profile(
312
+ "ghost_channel",
313
+ base_identity={"name": "Ghost"},
314
+ )
315
+ # Should not crash
bot/tests/scientific_shared.py CHANGED
@@ -1,5 +1,3 @@
1
- # ruff: noqa: F401
2
-
3
  import asyncio
4
  import json
5
  import time
 
 
 
1
  import asyncio
2
  import json
3
  import time
bot/tests/test_control_plane_config.py CHANGED
@@ -102,6 +102,64 @@ class TestControlPlaneConfig(unittest.TestCase):
102
  goals = self.cpc.consume_due_goals(timestamp=now + 61)
103
  self.assertEqual(len(goals), 1)
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def test_consume_due_goals_skips_when_suspended_without_force(self):
106
  self.cpc.update_config(
107
  {"autonomy_enabled": True, "goals": [{"id": "g1", "interval_seconds": 60}]}
 
102
  goals = self.cpc.consume_due_goals(timestamp=now + 61)
103
  self.assertEqual(len(goals), 1)
104
 
105
+ def test_consume_due_goals_fixed_time(self):
106
+ now = time.time()
107
+ # Create a goal with fixed_time
108
+ goals = [
109
+ {
110
+ "id": "fixed_1",
111
+ "enabled": True,
112
+ "schedule_type": "fixed_time",
113
+ "scheduled_at": "2026-03-01T20:00:00Z",
114
+ }
115
+ ]
116
+ self.cpc.update_config({"autonomy_enabled": True, "goals": goals})
117
+
118
+ # Timestamp for 2026-03-01T19:59:59Z
119
+ due = self.cpc.consume_due_goals(timestamp=1772395199.0)
120
+ self.assertEqual(len(due), 0)
121
+
122
+ # Timestamp for 2026-03-01T20:00:01Z
123
+ due = self.cpc.consume_due_goals(timestamp=1772395201.0)
124
+ self.assertEqual(len(due), 1)
125
+ self.assertEqual(due[0]["id"], "fixed_1")
126
+
127
+ # Fixed time goal should be disabled after consumption
128
+ cfg = self.cpc.get_config()
129
+ self.assertFalse(cfg["goals"][0]["enabled"])
130
+
131
+ def test_consume_due_goals_cron(self):
132
+ # We need a predictable time. Let's use 2026-01-01 12:00:00 UTC (timestamp: 1767268800)
133
+ start_time = 1767268800.0
134
+
135
+ goals = [
136
+ {
137
+ "id": "cron_1",
138
+ "enabled": True,
139
+ "schedule_type": "cron",
140
+ "cron_expression": "0 * * * *", # Every hour
141
+ }
142
+ ]
143
+
144
+ self.cpc.update_config({"autonomy_enabled": True, "goals": goals})
145
+
146
+ # Initial run - shouldn't trigger immediately, schedules for 13:00
147
+ due = self.cpc.consume_due_goals(timestamp=start_time)
148
+ self.assertEqual(len(due), 0)
149
+
150
+ # 59 minutes later - still not due
151
+ due = self.cpc.consume_due_goals(timestamp=start_time + 3540)
152
+ self.assertEqual(len(due), 0)
153
+
154
+ # 1 hour later - should be due
155
+ due = self.cpc.consume_due_goals(timestamp=start_time + 3600)
156
+ self.assertEqual(len(due), 1)
157
+ self.assertEqual(due[0]["id"], "cron_1")
158
+
159
+ # Should stay enabled for next run
160
+ cfg = self.cpc.get_config()
161
+ self.assertTrue(cfg["goals"][0]["enabled"])
162
+
163
  def test_consume_due_goals_skips_when_suspended_without_force(self):
164
  self.cpc.update_config(
165
  {"autonomy_enabled": True, "goals": [{"id": "g1", "interval_seconds": 60}]}
bot/tests/test_dashboard_routes_v3.py CHANGED
@@ -191,6 +191,15 @@ class TestDashboardRoutesV3:
191
  "source": "supabase",
192
  }
193
  mock_persistence.load_recent_history_sync.return_value = ["viewer: hi", "byte: bora"]
 
 
 
 
 
 
 
 
 
194
 
195
  payload = build_channel_context_payload("canal_a")
196
 
@@ -206,6 +215,10 @@ class TestDashboardRoutesV3:
206
  assert payload["channel"]["persisted_channel_identity"]["has_identity"] is True
207
  assert payload["channel"]["has_persisted_identity"] is True
208
  assert payload["channel"]["persisted_recent_history"] == ["viewer: hi", "byte: bora"]
 
 
 
 
209
 
210
  @patch("bot.dashboard_server_routes.persistence")
211
  @patch("bot.dashboard_server_routes.context_manager")
@@ -228,6 +241,7 @@ class TestDashboardRoutesV3:
228
  mock_persistence.load_agent_notes_sync.return_value = None
229
  mock_persistence.load_channel_identity_sync.return_value = None
230
  mock_persistence.load_recent_history_sync.return_value = []
 
231
 
232
  payload = build_channel_context_payload("canal_a")
233
 
@@ -239,6 +253,8 @@ class TestDashboardRoutesV3:
239
  assert payload["channel"]["persisted_agent_notes"] is None
240
  assert payload["channel"]["persisted_channel_identity"] is None
241
  assert payload["channel"]["persisted_recent_history"] == []
 
 
242
 
243
  @patch("bot.dashboard_server_routes.persistence")
244
  def test_build_observability_history_payload(self, mock_persistence):
@@ -919,3 +935,82 @@ class TestDashboardRoutesV3:
919
  handler._dashboard_authorized.return_value = False
920
  _handle_autonomy_tick(handler)
921
  handler._send_forbidden.assert_called_once()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  "source": "supabase",
192
  }
193
  mock_persistence.load_recent_history_sync.return_value = ["viewer: hi", "byte: bora"]
194
+ mock_persistence.load_persona_profile_sync.return_value = {
195
+ "channel_id": "canal_a",
196
+ "base_identity": {"name": "Byte Coach", "pronouns": "ele/dele", "lore": ""},
197
+ "tonality_engine": {"tone": "analitico", "emote_vocab": [], "sentence_style": ""},
198
+ "behavioral_constraints": {"banned_topics": [], "cta_triggers": []},
199
+ "model_routing": {"chat": None, "coaching": None, "search": None, "reasoning": None},
200
+ "has_profile": True,
201
+ "source": "memory",
202
+ }
203
 
204
  payload = build_channel_context_payload("canal_a")
205
 
 
215
  assert payload["channel"]["persisted_channel_identity"]["has_identity"] is True
216
  assert payload["channel"]["has_persisted_identity"] is True
217
  assert payload["channel"]["persisted_recent_history"] == ["viewer: hi", "byte: bora"]
218
+ assert payload["channel"]["has_persisted_persona_profile"] is True
219
+ assert (
220
+ payload["channel"]["persisted_persona_profile"]["base_identity"]["name"] == "Byte Coach"
221
+ )
222
 
223
  @patch("bot.dashboard_server_routes.persistence")
224
  @patch("bot.dashboard_server_routes.context_manager")
 
241
  mock_persistence.load_agent_notes_sync.return_value = None
242
  mock_persistence.load_channel_identity_sync.return_value = None
243
  mock_persistence.load_recent_history_sync.return_value = []
244
+ mock_persistence.load_persona_profile_sync.return_value = None
245
 
246
  payload = build_channel_context_payload("canal_a")
247
 
 
253
  assert payload["channel"]["persisted_agent_notes"] is None
254
  assert payload["channel"]["persisted_channel_identity"] is None
255
  assert payload["channel"]["persisted_recent_history"] == []
256
+ assert payload["channel"]["has_persisted_persona_profile"] is False
257
+ assert payload["channel"]["persisted_persona_profile"] == {}
258
 
259
  @patch("bot.dashboard_server_routes.persistence")
260
  def test_build_observability_history_payload(self, mock_persistence):
 
935
  handler._dashboard_authorized.return_value = False
936
  _handle_autonomy_tick(handler)
937
  handler._send_forbidden.assert_called_once()
938
+
939
+ @patch("bot.dashboard_server_routes.persistence")
940
+ def test_handle_get_persona_profile(self, mock_persistence):
941
+ from bot.dashboard_server_routes import _handle_get_persona_profile
942
+
943
+ mock_persistence.load_persona_profile_sync.return_value = {
944
+ "channel_id": "canal_a",
945
+ "base_identity": {"name": "Test Persona", "pronouns": "ele/dele", "lore": ""},
946
+ "tonality_engine": {"tone": "tatico", "emote_vocab": [], "sentence_style": ""},
947
+ "behavioral_constraints": {"banned_topics": [], "cta_triggers": []},
948
+ "model_routing": {"chat": None, "coaching": None, "search": None, "reasoning": None},
949
+ "has_profile": True,
950
+ "source": "memory",
951
+ }
952
+ handler = MagicMock(path="/api/persona-profile?channel=Canal_A")
953
+ handler._dashboard_authorized.return_value = True
954
+ _handle_get_persona_profile(handler, {"channel": ["Canal_A"]})
955
+
956
+ mock_persistence.load_persona_profile_sync.assert_called_with("canal_a")
957
+ handler._send_json.assert_called_once()
958
+ response = handler._send_json.call_args[0][0]
959
+ assert response["ok"] is True
960
+ assert response["profile"]["has_profile"] is True
961
+ assert response["profile"]["base_identity"]["name"] == "Test Persona"
962
+
963
+ @patch("bot.dashboard_server_routes.context_manager")
964
+ @patch("bot.dashboard_server_routes.persistence")
965
+ def test_handle_put_persona_profile(self, mock_persistence, mock_context_manager):
966
+ from bot.dashboard_server_routes import _handle_put_persona_profile
967
+
968
+ mock_persistence.load_persona_profile_sync.return_value = {
969
+ "channel_id": "canal_a",
970
+ "base_identity": {"name": "", "pronouns": "", "lore": ""},
971
+ "tonality_engine": {"tone": "", "emote_vocab": [], "sentence_style": ""},
972
+ "behavioral_constraints": {"banned_topics": [], "cta_triggers": []},
973
+ "model_routing": {"chat": None, "coaching": None, "search": None, "reasoning": None},
974
+ "has_profile": False,
975
+ "source": "memory",
976
+ }
977
+ mock_persistence.save_persona_profile_sync.return_value = {
978
+ "channel_id": "canal_a",
979
+ "base_identity": {"name": "New Persona", "pronouns": "", "lore": ""},
980
+ "tonality_engine": {
981
+ "tone": "energetico",
982
+ "emote_vocab": ["LUL"],
983
+ "sentence_style": "short_punchy",
984
+ },
985
+ "behavioral_constraints": {"banned_topics": ["politics"], "cta_triggers": ["follow"]},
986
+ "model_routing": {
987
+ "chat": "custom-model",
988
+ "coaching": None,
989
+ "search": None,
990
+ "reasoning": None,
991
+ },
992
+ "has_profile": True,
993
+ "source": "memory",
994
+ }
995
+ handler = MagicMock(path="/api/persona-profile?channel=Canal_A")
996
+ handler._dashboard_authorized.return_value = True
997
+ payload = {
998
+ "channel_id": "Canal_A",
999
+ "base_identity": {"name": "New Persona"},
1000
+ "tonality_engine": {
1001
+ "tone": "energetico",
1002
+ "emote_vocab": ["LUL"],
1003
+ "sentence_style": "short_punchy",
1004
+ },
1005
+ "behavioral_constraints": {"banned_topics": ["politics"], "cta_triggers": ["follow"]},
1006
+ "model_routing": {"chat": "custom-model"},
1007
+ }
1008
+ _handle_put_persona_profile(handler, {"channel": ["Canal_A"]}, payload)
1009
+
1010
+ mock_persistence.save_persona_profile_sync.assert_called_once()
1011
+ mock_context_manager.apply_persona_profile.assert_called_once()
1012
+ handler._send_json.assert_called_once()
1013
+ response = handler._send_json.call_args[0][0]
1014
+ assert response["ok"] is True
1015
+ assert response["profile"]["has_profile"] is True
1016
+ assert response["profile"]["model_routing"]["chat"] == "custom-model"
bot/tests/test_irc_state_massive.py CHANGED
@@ -72,6 +72,6 @@ class TestIrcStateMassive(unittest.IsolatedAsyncioTestCase):
72
  async def test_admin_part_channel_not_connected(self):
73
  bot = MockBot()
74
  bot.joined_channels = set()
75
- ok, msg, chans = await bot.admin_part_channel("missing")
76
  self.assertFalse(ok)
77
  self.assertIn("not connected", msg)
 
72
  async def test_admin_part_channel_not_connected(self):
73
  bot = MockBot()
74
  bot.joined_channels = set()
75
+ ok, msg, _chans = await bot.admin_part_channel("missing")
76
  self.assertFalse(ok)
77
  self.assertIn("not connected", msg)
bot/tests/test_irc_state_v3.py CHANGED
@@ -87,7 +87,7 @@ class TestIrcStateV3:
87
  return MagicMock()
88
 
89
  with patch("asyncio.create_task", side_effect=create_task_side_effect):
90
- res, msg, channels = await state.admin_join_channel("channel2")
91
  assert res is True
92
  assert "channel2" in state.joined_channels
93
 
@@ -102,6 +102,6 @@ class TestIrcStateV3:
102
  return MagicMock()
103
 
104
  with patch("asyncio.create_task", side_effect=create_task_side_effect):
105
- res, msg, channels = await state.admin_part_channel("channel2")
106
  assert res is True
107
  assert "channel2" not in state.joined_channels
 
87
  return MagicMock()
88
 
89
  with patch("asyncio.create_task", side_effect=create_task_side_effect):
90
+ res, _msg, _channels = await state.admin_join_channel("channel2")
91
  assert res is True
92
  assert "channel2" in state.joined_channels
93
 
 
102
  return MagicMock()
103
 
104
  with patch("asyncio.create_task", side_effect=create_task_side_effect):
105
+ res, _msg, _channels = await state.admin_part_channel("channel2")
106
  assert res is True
107
  assert "channel2" not in state.joined_channels
bot/tests/test_prompt_flow_v2.py CHANGED
@@ -31,9 +31,11 @@ class TestPromptFlowV2:
31
  rt.logger = MagicMock()
32
  rt.byte_help_message = "Help message"
33
  rt.max_reply_lines = 5
34
- rt.max_chat_message_length = 500
 
35
  rt.serious_reply_max_lines = 10
36
  rt.serious_reply_max_length = 1000
 
37
  rt.quality_safe_fallback = "Safe Fallback"
38
 
39
  rt.format_chat_reply = lambda x: f"Formatted: {x}" if x else ""
@@ -102,6 +104,7 @@ class TestPromptFlowV2:
102
 
103
  @pytest.mark.asyncio
104
  async def test_handle_byte_prompt_status(self, runtime_mock):
 
105
  reply_fn = AsyncMock()
106
  status_line_factory = MagicMock(return_value="My Status")
107
  await handle_byte_prompt_text(
 
31
  rt.logger = MagicMock()
32
  rt.byte_help_message = "Help message"
33
  rt.max_reply_lines = 5
34
+ rt.is_ascii_art_prompt = MagicMock(return_value=False)
35
+ rt.is_help_prompt = MagicMock(return_value=False)
36
  rt.serious_reply_max_lines = 10
37
  rt.serious_reply_max_length = 1000
38
+ rt.max_chat_message_length = 500
39
  rt.quality_safe_fallback = "Safe Fallback"
40
 
41
  rt.format_chat_reply = lambda x: f"Formatted: {x}" if x else ""
 
104
 
105
  @pytest.mark.asyncio
106
  async def test_handle_byte_prompt_status(self, runtime_mock):
107
+ runtime_mock.is_ascii_art_prompt.return_value = False
108
  reply_fn = AsyncMock()
109
  status_line_factory = MagicMock(return_value="My Status")
110
  await handle_byte_prompt_text(
dashboard/features/clips/view.js CHANGED
@@ -1,95 +1,111 @@
1
  // Safe template string for rendering clip cards.
2
  // Uses escapeHtml to avoid XSS.
3
  function escapeHtml(str) {
4
- if (!str) return "";
5
- const d = document.createElement('div');
6
- d.textContent = str;
7
- return d.innerHTML;
8
  }
9
 
10
  export function renderClipCard(job) {
11
- const createdTime = new Date(job.created_at).toLocaleTimeString();
12
- const status = job.status || "queued";
13
 
14
- // Conditional links based on current status
15
- let actionsHtml = '';
16
- if (status === 'ready' && job.clip_url) {
17
- actionsHtml += `<a href="${escapeHtml(job.clip_url)}" target="_blank" class="btn btn-primary btn-sm">Open Clip</a>`;
18
- if (job.download_url) {
19
- actionsHtml += `<a href="${escapeHtml(job.download_url)}" target="_blank" class="btn btn-success btn-sm">Download</a>`;
20
- }
21
- actionsHtml += `<a href="${escapeHtml(job.edit_url)}" target="_blank" class="btn btn-secondary btn-sm">Edit</a>`;
22
- } else if (status === 'creating' || status === 'polling') {
23
- if (job.edit_url) {
24
- actionsHtml += `<a href="${escapeHtml(job.edit_url)}" target="_blank" class="btn btn-secondary btn-sm">Edit (WIP)</a>`;
25
- }
26
- } else if (status === 'failed') {
27
- // Manual retry can be implemented later if endpoint is available.
28
- // For now, error state is displayed only.
29
  }
 
30
 
31
- return `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  <article class="clip-card" data-status="${escapeHtml(status)}" data-id="${escapeHtml(job.job_id)}">
33
- <div class="clip-header">
34
- <span class="clip-status-indicator"></span>
35
- <span class="clip-id-label">#${escapeHtml(job.action_id.split('_').pop())}</span>
36
- <time class="clip-time">${createdTime}</time>
37
- </div>
38
- <div class="clip-body">
39
- <h4 class="clip-title">${escapeHtml(job.title || "Untitled")}</h4>
40
- <div class="clip-meta">
41
- <span class="meta-item mode">${escapeHtml(job.mode)}</span>
42
- <span class="meta-item broadcaster">${escapeHtml(job.broadcaster_id)}</span>
 
 
 
 
 
 
 
43
  </div>
44
- ${job.error ? `<div class="clip-error">${escapeHtml(job.error)}</div>` : ''}
45
- </div>
46
- <div class="clip-actions">
47
- ${actionsHtml}
48
  </div>
49
  </article>
50
  `;
51
  }
52
 
53
  export function getClipsElements() {
54
- return {
55
- container: document.getElementById("clipsList"),
56
- loadingSkeleton: document.getElementById("clipsSkeleton"),
57
- section: document.getElementById("clipsSection"),
58
- visionStatusChip: document.getElementById("visionStatusChip"),
59
- visionFramesCount: document.getElementById("visionFramesCount"),
60
- visionLastIngest: document.getElementById("visionLastIngest"),
61
- visionLastAnalysis: document.getElementById("visionLastAnalysis"),
62
- visionIngestInput: document.getElementById("visionIngestInput"),
63
- visionIngestBtn: document.getElementById("visionIngestBtn"),
64
- visionFeedback: document.getElementById("visionFeedback"),
65
- };
66
  }
67
 
68
  export function renderVisionStatus(payload, els) {
69
- if (!els || !payload) return;
70
 
71
- if (els.visionFramesCount) els.visionFramesCount.textContent = payload.frame_count || 0;
 
72
 
73
- if (els.visionLastIngest) {
74
- if (payload.last_ingest_at && payload.last_ingest_at > 0) {
75
- els.visionLastIngest.textContent = new Date(payload.last_ingest_at * 1000).toLocaleTimeString();
76
- } else {
77
- els.visionLastIngest.textContent = "-";
78
- }
 
79
  }
 
80
 
81
- if (els.visionLastAnalysis) {
82
- els.visionLastAnalysis.textContent = payload.last_analysis || "No analysis available.";
83
- }
 
84
 
85
- if (els.visionStatusChip) {
86
- els.visionStatusChip.classList.remove("ok", "warn", "error", "pending");
87
- if (payload.frame_count > 0) {
88
- els.visionStatusChip.textContent = "Active";
89
- els.visionStatusChip.classList.add("ok");
90
- } else {
91
- els.visionStatusChip.textContent = "Idle";
92
- els.visionStatusChip.classList.add("pending");
93
- }
94
  }
 
95
  }
 
1
  // Safe template string for rendering clip cards.
2
  // Uses escapeHtml to avoid XSS.
3
  function escapeHtml(str) {
4
+ if (!str) return "";
5
+ const d = document.createElement("div");
6
+ d.textContent = str;
7
+ return d.innerHTML;
8
  }
9
 
10
  export function renderClipCard(job) {
11
+ const createdTime = new Date(job.created_at).toLocaleTimeString();
12
+ const status = job.status || "queued";
13
 
14
+ // Conditional links based on current status
15
+ let actionsHtml = "";
16
+ let thumbnailHtml = "";
17
+
18
+ if (status === "ready" && job.clip_url) {
19
+ actionsHtml += `<a href="${escapeHtml(job.clip_url)}" target="_blank" class="btn btn-primary btn-sm">Open Clip</a>`;
20
+ if (job.download_url) {
21
+ actionsHtml += `<a href="${escapeHtml(job.download_url)}" target="_blank" class="btn btn-success btn-sm">Download</a>`;
 
 
 
 
 
 
 
22
  }
23
+ actionsHtml += `<a href="${escapeHtml(job.edit_url)}" target="_blank" class="btn btn-secondary btn-sm">Edit</a>`;
24
 
25
+ // Thumbnail handling
26
+ if (job.thumbnail_url) {
27
+ thumbnailHtml = `<div class="clip-thumbnail-wrapper"><img src="${escapeHtml(job.thumbnail_url)}" alt="Clip Thumbnail" class="clip-thumbnail" loading="lazy"/></div>`;
28
+ }
29
+ } else if (status === "creating" || status === "polling") {
30
+ if (job.edit_url) {
31
+ actionsHtml += `<a href="${escapeHtml(job.edit_url)}" target="_blank" class="btn btn-secondary btn-sm">Edit (WIP)</a>`;
32
+ }
33
+ thumbnailHtml = `<div class="clip-thumbnail-wrapper clip-thumbnail-loading"><div class="spinner-small"></div><span>Processing...</span></div>`;
34
+ } else if (status === "failed") {
35
+ // Manual retry can be implemented later if endpoint is available.
36
+ // For now, error state is displayed only.
37
+ thumbnailHtml = `<div class="clip-thumbnail-wrapper clip-thumbnail-error"><span>Failed</span></div>`;
38
+ }
39
+
40
+ return `
41
  <article class="clip-card" data-status="${escapeHtml(status)}" data-id="${escapeHtml(job.job_id)}">
42
+ ${thumbnailHtml}
43
+ <div class="clip-content-wrapper">
44
+ <div class="clip-header">
45
+ <span class="clip-status-indicator"></span>
46
+ <span class="clip-id-label">#${escapeHtml(job.action_id.split("_").pop())}</span>
47
+ <time class="clip-time">${createdTime}</time>
48
+ </div>
49
+ <div class="clip-body">
50
+ <h4 class="clip-title">${escapeHtml(job.title || "Untitled")}</h4>
51
+ <div class="clip-meta">
52
+ <span class="meta-item mode">${escapeHtml(job.mode)}</span>
53
+ <span class="meta-item broadcaster">${escapeHtml(job.broadcaster_id)}</span>
54
+ </div>
55
+ ${job.error ? `<div class="clip-error">${escapeHtml(job.error)}</div>` : ""}
56
+ </div>
57
+ <div class="clip-actions">
58
+ ${actionsHtml}
59
  </div>
 
 
 
 
60
  </div>
61
  </article>
62
  `;
63
  }
64
 
65
  export function getClipsElements() {
66
+ return {
67
+ container: document.getElementById("clipsList"),
68
+ loadingSkeleton: document.getElementById("clipsSkeleton"),
69
+ section: document.getElementById("clipsSection"),
70
+ visionStatusChip: document.getElementById("visionStatusChip"),
71
+ visionFramesCount: document.getElementById("visionFramesCount"),
72
+ visionLastIngest: document.getElementById("visionLastIngest"),
73
+ visionLastAnalysis: document.getElementById("visionLastAnalysis"),
74
+ visionIngestInput: document.getElementById("visionIngestInput"),
75
+ visionIngestBtn: document.getElementById("visionIngestBtn"),
76
+ visionFeedback: document.getElementById("visionFeedback"),
77
+ };
78
  }
79
 
80
  export function renderVisionStatus(payload, els) {
81
+ if (!els || !payload) return;
82
 
83
+ if (els.visionFramesCount)
84
+ els.visionFramesCount.textContent = payload.frame_count || 0;
85
 
86
+ if (els.visionLastIngest) {
87
+ if (payload.last_ingest_at && payload.last_ingest_at > 0) {
88
+ els.visionLastIngest.textContent = new Date(
89
+ payload.last_ingest_at * 1000,
90
+ ).toLocaleTimeString();
91
+ } else {
92
+ els.visionLastIngest.textContent = "-";
93
  }
94
+ }
95
 
96
+ if (els.visionLastAnalysis) {
97
+ els.visionLastAnalysis.textContent =
98
+ payload.last_analysis || "No analysis available.";
99
+ }
100
 
101
+ if (els.visionStatusChip) {
102
+ els.visionStatusChip.classList.remove("ok", "warn", "error", "pending");
103
+ if (payload.frame_count > 0) {
104
+ els.visionStatusChip.textContent = "Active";
105
+ els.visionStatusChip.classList.add("ok");
106
+ } else {
107
+ els.visionStatusChip.textContent = "Idle";
108
+ els.visionStatusChip.classList.add("pending");
 
109
  }
110
+ }
111
  }
dashboard/features/control-plane/api.js CHANGED
@@ -9,6 +9,7 @@ const AGENT_SUSPEND_ENDPOINT = "./api/agent/suspend";
9
  const AGENT_RESUME_ENDPOINT = "./api/agent/resume";
10
  const WEBHOOKS_ENDPOINT = "./api/webhooks";
11
  const WEBHOOKS_TEST_ENDPOINT = "./api/webhooks/test";
 
12
  const REQUEST_TIMEOUT_MS = 12000;
13
 
14
  function authHeaders() {
@@ -168,3 +169,34 @@ export async function testWebhook(payload, timeoutMs = REQUEST_TIMEOUT_MS) {
168
  timeoutMs,
169
  );
170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  const AGENT_RESUME_ENDPOINT = "./api/agent/resume";
10
  const WEBHOOKS_ENDPOINT = "./api/webhooks";
11
  const WEBHOOKS_TEST_ENDPOINT = "./api/webhooks/test";
12
+ const PERSONA_PROFILE_ENDPOINT = "./api/persona-profile";
13
  const REQUEST_TIMEOUT_MS = 12000;
14
 
15
  function authHeaders() {
 
169
  timeoutMs,
170
  );
171
  }
172
+
173
+ export async function getPersonaProfile(
174
+ channelId,
175
+ timeoutMs = REQUEST_TIMEOUT_MS,
176
+ ) {
177
+ const safeChannelId = encodeURIComponent(
178
+ String(channelId || "")
179
+ .trim()
180
+ .toLowerCase(),
181
+ );
182
+ return await fetchWithTimeout(
183
+ `${PERSONA_PROFILE_ENDPOINT}?channel=${safeChannelId}`,
184
+ { method: "GET", headers: authHeaders() },
185
+ timeoutMs,
186
+ );
187
+ }
188
+
189
+ export async function updatePersonaProfile(
190
+ payload,
191
+ timeoutMs = REQUEST_TIMEOUT_MS,
192
+ ) {
193
+ return await fetchWithTimeout(
194
+ PERSONA_PROFILE_ENDPOINT,
195
+ {
196
+ method: "PUT",
197
+ headers: authHeaders(),
198
+ body: JSON.stringify(payload || {}),
199
+ },
200
+ timeoutMs,
201
+ );
202
+ }
dashboard/features/control-plane/controller.js CHANGED
@@ -2,11 +2,13 @@ import {
2
  getAgentNotes,
3
  getChannelConfig,
4
  getControlPlaneState,
 
5
  resumeAgent,
6
  suspendAgent,
7
  updateAgentNotes,
8
  updateChannelConfig,
9
  updateControlPlaneConfig,
 
10
  getWebhooks,
11
  updateWebhook,
12
  testWebhook,
@@ -16,9 +18,11 @@ import {
16
  collectAgentNotesPayload,
17
  collectChannelConfigPayload,
18
  collectControlPlanePayload,
 
19
  renderAgentNotes,
20
  renderChannelConfig,
21
  renderControlPlaneState,
 
22
  setControlPlaneBusy,
23
  showControlPlaneFeedback,
24
  } from "./view.js";
@@ -90,6 +94,11 @@ export function createControlPlaneController({
90
  renderChannelConfig(response?.channel || {}, cpEls);
91
  renderAgentNotes(notesResponse?.note || {}, cpEls);
92
 
 
 
 
 
 
93
  const whList = webhooksResponse?.webhooks || [];
94
  if (whList.length > 0) {
95
  const wh = whList[0];
@@ -182,6 +191,15 @@ export function createControlPlaneController({
182
  const [updated, updatedNotes] = await Promise.all(promises);
183
  renderChannelConfig(updated?.channel || {}, cpEls);
184
  renderAgentNotes(updatedNotes?.note || {}, cpEls);
 
 
 
 
 
 
 
 
 
185
  showControlPlaneFeedback(
186
  cpEls,
187
  "Operational directives saved successfully.",
@@ -223,6 +241,62 @@ export function createControlPlaneController({
223
  }
224
  }
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  async function updateAgentSuspension(nextSuspended) {
227
  if (!cpEls) return;
228
  const actionLabel = nextSuspended ? "Suspending" : "Resuming";
@@ -301,6 +375,16 @@ export function createControlPlaneController({
301
  triggerTestWebhook();
302
  });
303
  }
 
 
 
 
 
 
 
 
 
 
304
  }
305
 
306
  return {
 
2
  getAgentNotes,
3
  getChannelConfig,
4
  getControlPlaneState,
5
+ getPersonaProfile,
6
  resumeAgent,
7
  suspendAgent,
8
  updateAgentNotes,
9
  updateChannelConfig,
10
  updateControlPlaneConfig,
11
+ updatePersonaProfile,
12
  getWebhooks,
13
  updateWebhook,
14
  testWebhook,
 
18
  collectAgentNotesPayload,
19
  collectChannelConfigPayload,
20
  collectControlPlanePayload,
21
+ collectPersonaProfilePayload,
22
  renderAgentNotes,
23
  renderChannelConfig,
24
  renderControlPlaneState,
25
+ renderPersonaProfile,
26
  setControlPlaneBusy,
27
  showControlPlaneFeedback,
28
  } from "./view.js";
 
94
  renderChannelConfig(response?.channel || {}, cpEls);
95
  renderAgentNotes(notesResponse?.note || {}, cpEls);
96
 
97
+ const profileResponse = await getPersonaProfile(payload.channel_id).catch(
98
+ () => null,
99
+ );
100
+ renderPersonaProfile(profileResponse?.profile || {}, cpEls);
101
+
102
  const whList = webhooksResponse?.webhooks || [];
103
  if (whList.length > 0) {
104
  const wh = whList[0];
 
191
  const [updated, updatedNotes] = await Promise.all(promises);
192
  renderChannelConfig(updated?.channel || {}, cpEls);
193
  renderAgentNotes(updatedNotes?.note || {}, cpEls);
194
+
195
+ const ppPayload = collectPersonaProfilePayload(cpEls);
196
+ try {
197
+ const ppResponse = await updatePersonaProfile(ppPayload);
198
+ renderPersonaProfile(ppResponse?.profile || {}, cpEls);
199
+ } catch {
200
+ // non-blocking: profile save failure should not block channel config save
201
+ }
202
+
203
  showControlPlaneFeedback(
204
  cpEls,
205
  "Operational directives saved successfully.",
 
241
  }
242
  }
243
 
244
+ async function loadPersonaProfile() {
245
+ if (!cpEls) return;
246
+ const rawChannelId = String(cpEls?.channelIdInput?.value || "")
247
+ .trim()
248
+ .toLowerCase();
249
+ if (!rawChannelId) {
250
+ showControlPlaneFeedback(
251
+ cpEls,
252
+ "Provide a channel to load persona profile.",
253
+ "warn",
254
+ );
255
+ return;
256
+ }
257
+ setControlPlaneBusy(cpEls, true);
258
+ showControlPlaneFeedback(cpEls, "Loading persona profile...", "warn");
259
+ try {
260
+ const response = await getPersonaProfile(rawChannelId);
261
+ renderPersonaProfile(response?.profile || {}, cpEls);
262
+ showControlPlaneFeedback(cpEls, "Persona profile synced.", "ok");
263
+ } catch (error) {
264
+ console.error("Persona profile load error", error);
265
+ showControlPlaneFeedback(
266
+ cpEls,
267
+ `Error: ${getErrorMessage(error, "Failed to load persona profile.")}`,
268
+ "error",
269
+ );
270
+ } finally {
271
+ setControlPlaneBusy(cpEls, false);
272
+ }
273
+ }
274
+
275
+ async function savePersonaProfile() {
276
+ if (!cpEls) return;
277
+ setControlPlaneBusy(cpEls, true);
278
+ showControlPlaneFeedback(cpEls, "Applying persona profile...", "warn");
279
+ try {
280
+ const payload = collectPersonaProfilePayload(cpEls);
281
+ const response = await updatePersonaProfile(payload);
282
+ renderPersonaProfile(response?.profile || {}, cpEls);
283
+ showControlPlaneFeedback(
284
+ cpEls,
285
+ "Persona profile saved successfully.",
286
+ "ok",
287
+ );
288
+ } catch (error) {
289
+ console.error("Persona profile save error", error);
290
+ showControlPlaneFeedback(
291
+ cpEls,
292
+ `Error: ${getErrorMessage(error, "Failed to save persona profile.")}`,
293
+ "error",
294
+ );
295
+ } finally {
296
+ setControlPlaneBusy(cpEls, false);
297
+ }
298
+ }
299
+
300
  async function updateAgentSuspension(nextSuspended) {
301
  if (!cpEls) return;
302
  const actionLabel = nextSuspended ? "Suspending" : "Resuming";
 
375
  triggerTestWebhook();
376
  });
377
  }
378
+ if (cpEls.ppLoadBtn) {
379
+ cpEls.ppLoadBtn.addEventListener("click", () => {
380
+ loadPersonaProfile();
381
+ });
382
+ }
383
+ if (cpEls.ppSaveBtn) {
384
+ cpEls.ppSaveBtn.addEventListener("click", () => {
385
+ savePersonaProfile();
386
+ });
387
+ }
388
  }
389
 
390
  return {
dashboard/features/control-plane/view.js CHANGED
@@ -233,6 +233,66 @@ function createGoalCard(goal = {}, index = 0) {
233
  );
234
  card.appendChild(firstRow);
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  const secondRow = document.createElement("div");
237
  secondRow.className = "form-row";
238
  secondRow.style.flexWrap = "wrap";
@@ -324,11 +384,19 @@ function parseGoalFromCard(card, index) {
324
  const safePrompt =
325
  String(getField("prompt")?.value || "").trim() || `Objetivo ${index + 1}.`;
326
 
 
 
 
 
 
327
  return {
328
  id: normalizeGoalId(getField("id")?.value, index),
329
  name: safeName,
330
  prompt: safePrompt,
331
  risk: safeRisk,
 
 
 
332
  interval_seconds: readInt(getField("interval_seconds"), 600, 60, 86400),
333
  enabled: Boolean(getField("enabled")?.checked),
334
  kpi_name: safeKpi,
@@ -576,6 +644,7 @@ export function getControlPlaneElements() {
576
  budgetDaily: document.getElementById("cpBudgetDaily"),
577
  actionIgnoreAfter: document.getElementById("cpActionIgnoreAfter"),
578
  goalsList: document.getElementById("cpGoalsList"),
 
579
  addGoalBtn: document.getElementById("cpAddGoalBtn"),
580
  saveBtn: document.getElementById("cpSaveBtn"),
581
  reloadBtn: document.getElementById("cpReloadBtn"),
@@ -585,6 +654,21 @@ export function getControlPlaneElements() {
585
  cpWebhookSecret: document.getElementById("cpWebhookSecret"),
586
  cpWebhookActive: document.getElementById("cpWebhookActive"),
587
  cpWebhookTestBtn: document.getElementById("cpWebhookTestBtn"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  currentSuspendedState: false,
589
  };
590
  }
@@ -619,6 +703,20 @@ export function setControlPlaneBusy(els, busy) {
619
  els?.agentNotesInput,
620
  els?.loadChannelConfigBtn,
621
  els?.saveChannelConfigBtn,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  ];
623
  inputElements.forEach((element) => {
624
  if (element) element.disabled = disabled;
@@ -675,6 +773,68 @@ export function renderControlPlaneCapabilities(
675
  );
676
  }
677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  export function renderControlPlaneState(payload, els) {
679
  const safePayload = payload && typeof payload === "object" ? payload : {};
680
  const config = safePayload.config || {};
@@ -707,6 +867,8 @@ export function renderControlPlaneState(payload, els) {
707
  appendGoalCard(els, {}, 0);
708
  }
709
 
 
 
710
  renderControlPlaneCapabilities(
711
  els,
712
  safePayload.capabilities || {},
@@ -790,3 +952,89 @@ export function collectAgentNotesPayload(els) {
790
  notes: String(els?.agentNotesInput?.value || ""),
791
  };
792
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  );
234
  card.appendChild(firstRow);
235
 
236
+ const scheduleRow = document.createElement("div");
237
+ scheduleRow.className = "form-row";
238
+ scheduleRow.style.flexWrap = "wrap";
239
+ scheduleRow.style.marginTop = "var(--spacing-2)";
240
+
241
+ const scheduleTypeSelect = document.createElement("select");
242
+ scheduleTypeSelect.className = "form-control";
243
+ scheduleTypeSelect.dataset.goalField = "schedule_type";
244
+ ["interval", "fixed_time", "cron"].forEach((type) => {
245
+ const option = document.createElement("option");
246
+ option.value = type;
247
+ option.textContent = type;
248
+ if ((goal.schedule_type || "interval") === type) option.selected = true;
249
+ scheduleTypeSelect.appendChild(option);
250
+ });
251
+ scheduleRow.appendChild(createGoalField("Schedule Type", scheduleTypeSelect, { minWidth: "150px" }));
252
+
253
+ const scheduledAtInput = document.createElement("input");
254
+ scheduledAtInput.type = "datetime-local";
255
+ scheduledAtInput.className = "form-control";
256
+ let formattedScheduledAt = String(goal.scheduled_at || "");
257
+ if (formattedScheduledAt.endsWith("Z")) {
258
+ formattedScheduledAt = formattedScheduledAt.replace("Z", "").slice(0, 16);
259
+ }
260
+ scheduledAtInput.value = formattedScheduledAt;
261
+ scheduledAtInput.dataset.goalField = "scheduled_at";
262
+ const scheduledAtWrapper = createGoalField("Fixed Time (UTC)", scheduledAtInput, { minWidth: "200px" });
263
+ scheduleRow.appendChild(scheduledAtWrapper);
264
+
265
+ const cronInput = document.createElement("input");
266
+ cronInput.type = "text";
267
+ cronInput.className = "form-control";
268
+ cronInput.placeholder = "e.g. 0 20 * * 5";
269
+ cronInput.value = String(goal.cron_expression || "");
270
+ cronInput.dataset.goalField = "cron_expression";
271
+ const cronWrapper = createGoalField("Cron Expression", cronInput, { minWidth: "180px" });
272
+ scheduleRow.appendChild(cronWrapper);
273
+
274
+ card.appendChild(scheduleRow);
275
+
276
+ const updateScheduleFieldsVisibility = () => {
277
+ const val = scheduleTypeSelect.value;
278
+ const intervalWrapper = intervalInput.parentElement;
279
+ if (val === "interval") {
280
+ intervalWrapper.style.display = "block";
281
+ scheduledAtWrapper.style.display = "none";
282
+ cronWrapper.style.display = "none";
283
+ } else if (val === "fixed_time") {
284
+ intervalWrapper.style.display = "none";
285
+ scheduledAtWrapper.style.display = "block";
286
+ cronWrapper.style.display = "none";
287
+ } else if (val === "cron") {
288
+ intervalWrapper.style.display = "none";
289
+ scheduledAtWrapper.style.display = "none";
290
+ cronWrapper.style.display = "block";
291
+ }
292
+ };
293
+ scheduleTypeSelect.addEventListener("change", updateScheduleFieldsVisibility);
294
+ updateScheduleFieldsVisibility();
295
+
296
  const secondRow = document.createElement("div");
297
  secondRow.className = "form-row";
298
  secondRow.style.flexWrap = "wrap";
 
384
  const safePrompt =
385
  String(getField("prompt")?.value || "").trim() || `Objetivo ${index + 1}.`;
386
 
387
+ let safeScheduledAt = String(getField("scheduled_at")?.value || "").trim();
388
+ if (safeScheduledAt && !safeScheduledAt.endsWith("Z")) {
389
+ safeScheduledAt += "Z";
390
+ }
391
+
392
  return {
393
  id: normalizeGoalId(getField("id")?.value, index),
394
  name: safeName,
395
  prompt: safePrompt,
396
  risk: safeRisk,
397
+ schedule_type: String(getField("schedule_type")?.value || "interval").trim().toLowerCase(),
398
+ scheduled_at: safeScheduledAt,
399
+ cron_expression: String(getField("cron_expression")?.value || "").trim(),
400
  interval_seconds: readInt(getField("interval_seconds"), 600, 60, 86400),
401
  enabled: Boolean(getField("enabled")?.checked),
402
  kpi_name: safeKpi,
 
644
  budgetDaily: document.getElementById("cpBudgetDaily"),
645
  actionIgnoreAfter: document.getElementById("cpActionIgnoreAfter"),
646
  goalsList: document.getElementById("cpGoalsList"),
647
+ calendarTimelineList: document.getElementById("cpCalendarTimelineList"),
648
  addGoalBtn: document.getElementById("cpAddGoalBtn"),
649
  saveBtn: document.getElementById("cpSaveBtn"),
650
  reloadBtn: document.getElementById("cpReloadBtn"),
 
654
  cpWebhookSecret: document.getElementById("cpWebhookSecret"),
655
  cpWebhookActive: document.getElementById("cpWebhookActive"),
656
  cpWebhookTestBtn: document.getElementById("cpWebhookTestBtn"),
657
+ ppPersonaName: document.getElementById("ppPersonaName"),
658
+ ppPronouns: document.getElementById("ppPronouns"),
659
+ ppLore: document.getElementById("ppLore"),
660
+ ppTone: document.getElementById("ppTone"),
661
+ ppSentenceStyle: document.getElementById("ppSentenceStyle"),
662
+ ppEmoteVocab: document.getElementById("ppEmoteVocab"),
663
+ ppBannedTopics: document.getElementById("ppBannedTopics"),
664
+ ppCtaTriggers: document.getElementById("ppCtaTriggers"),
665
+ ppModelChat: document.getElementById("ppModelChat"),
666
+ ppModelCoaching: document.getElementById("ppModelCoaching"),
667
+ ppModelSearch: document.getElementById("ppModelSearch"),
668
+ ppModelReasoning: document.getElementById("ppModelReasoning"),
669
+ ppLoadBtn: document.getElementById("ppLoadBtn"),
670
+ ppSaveBtn: document.getElementById("ppSaveBtn"),
671
+ ppStatusChip: document.getElementById("ppStatusChip"),
672
  currentSuspendedState: false,
673
  };
674
  }
 
703
  els?.agentNotesInput,
704
  els?.loadChannelConfigBtn,
705
  els?.saveChannelConfigBtn,
706
+ els?.ppPersonaName,
707
+ els?.ppPronouns,
708
+ els?.ppLore,
709
+ els?.ppTone,
710
+ els?.ppSentenceStyle,
711
+ els?.ppEmoteVocab,
712
+ els?.ppBannedTopics,
713
+ els?.ppCtaTriggers,
714
+ els?.ppModelChat,
715
+ els?.ppModelCoaching,
716
+ els?.ppModelSearch,
717
+ els?.ppModelReasoning,
718
+ els?.ppLoadBtn,
719
+ els?.ppSaveBtn,
720
  ];
721
  inputElements.forEach((element) => {
722
  if (element) element.disabled = disabled;
 
773
  );
774
  }
775
 
776
+ function renderTacticalCalendar(els, goals = []) {
777
+ if (!els?.calendarTimelineList) return;
778
+ els.calendarTimelineList.innerHTML = "";
779
+
780
+ const scheduledGoals = goals.filter((g) =>
781
+ Boolean(g.enabled) && ["fixed_time", "cron"].includes(String(g.schedule_type).trim().toLowerCase())
782
+ );
783
+
784
+ if (scheduledGoals.length === 0) {
785
+ const emptyLi = document.createElement("li");
786
+ emptyLi.className = "card";
787
+ emptyLi.style.listStyle = "none";
788
+ emptyLi.innerHTML = `<p class="panel-hint" style="text-align:center;margin:0;">No upcoming tactical events.</p>`;
789
+ els.calendarTimelineList.appendChild(emptyLi);
790
+ return;
791
+ }
792
+
793
+ scheduledGoals.forEach((g) => {
794
+ const card = document.createElement("li");
795
+ card.className = "card";
796
+ card.style.listStyle = "none";
797
+ card.style.display = "flex";
798
+ card.style.flexDirection = "column";
799
+ card.style.gap = "0.5rem";
800
+
801
+ const title = document.createElement("strong");
802
+ title.textContent = g.name || g.id || "Unnamed Goal";
803
+
804
+ const badgeWrapper = document.createElement("div");
805
+ badgeWrapper.style.display = "flex";
806
+ badgeWrapper.style.gap = "0.5rem";
807
+
808
+ const typeChip = document.createElement("span");
809
+ typeChip.className = "chip pending";
810
+ const stype = String(g.schedule_type).trim().toLowerCase();
811
+ typeChip.textContent = stype === "cron" ? "RECURRING (CRON)" : "FIXED TIME";
812
+
813
+ const timeChip = document.createElement("span");
814
+ timeChip.className = "chip ok";
815
+ if (stype === "cron") {
816
+ timeChip.textContent = String(g.cron_expression || "invalid cron").toUpperCase();
817
+ } else {
818
+ let t = String(g.scheduled_at || "invalid time").toUpperCase();
819
+ timeChip.textContent = t;
820
+ }
821
+
822
+ badgeWrapper.appendChild(typeChip);
823
+ badgeWrapper.appendChild(timeChip);
824
+
825
+ const desc = document.createElement("p");
826
+ desc.className = "panel-hint";
827
+ desc.style.margin = "0";
828
+ desc.textContent = g.prompt || "No prompt configured.";
829
+
830
+ card.appendChild(title);
831
+ card.appendChild(badgeWrapper);
832
+ card.appendChild(desc);
833
+
834
+ els.calendarTimelineList.appendChild(card);
835
+ });
836
+ }
837
+
838
  export function renderControlPlaneState(payload, els) {
839
  const safePayload = payload && typeof payload === "object" ? payload : {};
840
  const config = safePayload.config || {};
 
867
  appendGoalCard(els, {}, 0);
868
  }
869
 
870
+ renderTacticalCalendar(els, asArray(config.goals));
871
+
872
  renderControlPlaneCapabilities(
873
  els,
874
  safePayload.capabilities || {},
 
952
  notes: String(els?.agentNotesInput?.value || ""),
953
  };
954
  }
955
+
956
+ export function renderPersonaProfile(profile, els) {
957
+ const safeProfile = profile && typeof profile === "object" ? profile : {};
958
+ const base = safeProfile.base_identity || {};
959
+ const tonality = safeProfile.tonality_engine || {};
960
+ const constraints = safeProfile.behavioral_constraints || {};
961
+ const routing = safeProfile.model_routing || {};
962
+
963
+ if (els?.ppPersonaName) els.ppPersonaName.value = String(base.name || "");
964
+ if (els?.ppPronouns) els.ppPronouns.value = String(base.pronouns || "");
965
+ if (els?.ppLore) els.ppLore.value = String(base.lore || "");
966
+ if (els?.ppTone) els.ppTone.value = String(tonality.tone || "");
967
+ if (els?.ppSentenceStyle)
968
+ els.ppSentenceStyle.value = String(tonality.sentence_style || "");
969
+ if (els?.ppEmoteVocab)
970
+ els.ppEmoteVocab.value = Array.isArray(tonality.emote_vocab)
971
+ ? tonality.emote_vocab.join(", ")
972
+ : "";
973
+ if (els?.ppBannedTopics)
974
+ els.ppBannedTopics.value = Array.isArray(constraints.banned_topics)
975
+ ? constraints.banned_topics.join(", ")
976
+ : "";
977
+ if (els?.ppCtaTriggers)
978
+ els.ppCtaTriggers.value = Array.isArray(constraints.cta_triggers)
979
+ ? constraints.cta_triggers.join(", ")
980
+ : "";
981
+ if (els?.ppModelChat)
982
+ els.ppModelChat.value = String(routing.chat || "");
983
+ if (els?.ppModelCoaching)
984
+ els.ppModelCoaching.value = String(routing.coaching || "");
985
+ if (els?.ppModelSearch)
986
+ els.ppModelSearch.value = String(routing.search || "");
987
+ if (els?.ppModelReasoning)
988
+ els.ppModelReasoning.value = String(routing.reasoning || "");
989
+
990
+ if (els?.ppStatusChip) {
991
+ const hasProfile = Boolean(safeProfile.has_profile);
992
+ setText(
993
+ els.ppStatusChip,
994
+ hasProfile
995
+ ? `Profile loaded (${safeProfile.source || "memory"})`
996
+ : "No profile loaded.",
997
+ );
998
+ els.ppStatusChip.className = `panel-hint event-level-${hasProfile ? "info" : "warn"}`;
999
+ }
1000
+ }
1001
+
1002
+ export function collectPersonaProfilePayload(els) {
1003
+ const safeChannelId =
1004
+ String(els?.channelIdInput?.value || "")
1005
+ .trim()
1006
+ .toLowerCase() || "default";
1007
+
1008
+ const parseTokenList = (el) => {
1009
+ const raw = String(el?.value || "").trim();
1010
+ if (!raw) return [];
1011
+ return raw
1012
+ .split(",")
1013
+ .map((t) => t.trim())
1014
+ .filter(Boolean);
1015
+ };
1016
+
1017
+ return {
1018
+ channel_id: safeChannelId,
1019
+ base_identity: {
1020
+ name: String(els?.ppPersonaName?.value || "").trim(),
1021
+ pronouns: String(els?.ppPronouns?.value || "").trim(),
1022
+ lore: String(els?.ppLore?.value || "").trim(),
1023
+ },
1024
+ tonality_engine: {
1025
+ tone: String(els?.ppTone?.value || "").trim(),
1026
+ sentence_style: String(els?.ppSentenceStyle?.value || "").trim(),
1027
+ emote_vocab: parseTokenList(els?.ppEmoteVocab),
1028
+ },
1029
+ behavioral_constraints: {
1030
+ banned_topics: parseTokenList(els?.ppBannedTopics),
1031
+ cta_triggers: parseTokenList(els?.ppCtaTriggers),
1032
+ },
1033
+ model_routing: {
1034
+ chat: String(els?.ppModelChat?.value || "").trim() || null,
1035
+ coaching: String(els?.ppModelCoaching?.value || "").trim() || null,
1036
+ search: String(els?.ppModelSearch?.value || "").trim() || null,
1037
+ reasoning: String(els?.ppModelReasoning?.value || "").trim() || null,
1038
+ },
1039
+ };
1040
+ }
dashboard/partials/control_plane.html CHANGED
@@ -8,22 +8,14 @@
8
 
9
  <div class="form-row form-row-center form-row-mb-3">
10
  <span id="cpModeChip" class="chip pending">MODE</span>
11
- <span id="cpResponseContract" class="chip"
12
- >Active contract: 1 message / 4 lines.</span
13
- >
14
  </div>
15
  <p id="cpCapabilitiesLine" class="panel-hint panel-hint-mb-4">
16
  Loading capabilities...
17
  </p>
18
 
19
- <section
20
- id="cpOperationalControlSection"
21
- aria-labelledby="cpOperationalControlHeading"
22
- class="section-surface"
23
- >
24
- <div
25
- class="form-row form-row-center form-row-between form-row-gap-3 form-row-mb-2"
26
- >
27
  <h3 id="cpOperationalControlHeading" class="heading-reset">
28
  Operational Control
29
  </h3>
@@ -42,35 +34,19 @@
42
  </div>
43
  </section>
44
 
45
- <section
46
- id="cpChannelDirectivesSection"
47
- aria-labelledby="cpChannelDirectivesHeading"
48
- class="section-surface"
49
- >
50
- <div
51
- class="form-row form-row-center form-row-between form-row-gap-3 form-row-mb-2"
52
- >
53
  <div class="form-row form-row-center form-row-gap-2 form-row-m0">
54
  <h3 id="cpChannelDirectivesHeading" class="heading-reset">
55
  Channel Directives
56
  </h3>
57
- <span id="cpChannelConfigStatusChip" class="chip pending"
58
- >MODEL DEFAULT</span
59
- >
60
  </div>
61
  <div class="form-row form-row-gap-2 form-row-m0">
62
- <button
63
- id="cpLoadChannelConfigBtn"
64
- class="btn btn-secondary"
65
- type="button"
66
- >
67
  Load Channel
68
  </button>
69
- <button
70
- id="cpSaveChannelConfigBtn"
71
- class="btn btn-primary"
72
- type="button"
73
- >
74
  Apply Directives
75
  </button>
76
  </div>
@@ -83,36 +59,16 @@
83
  <div class="form-row form-row-wrap form-row-mb-2">
84
  <div class="form-group form-group-min-170">
85
  <label for="cpChannelConfigId">Channel</label>
86
- <input
87
- id="cpChannelConfigId"
88
- class="form-control"
89
- type="text"
90
- value="default"
91
- />
92
  </div>
93
  <div class="form-group form-group-min-150">
94
  <label for="cpChannelTemperature">Temperature</label>
95
- <input
96
- id="cpChannelTemperature"
97
- class="form-control"
98
- type="number"
99
- min="0"
100
- max="2"
101
- step="0.01"
102
- placeholder="0.15"
103
- />
104
  </div>
105
  <div class="form-group form-group-min-150">
106
  <label for="cpChannelTopP">Top P</label>
107
- <input
108
- id="cpChannelTopP"
109
- class="form-control"
110
- type="number"
111
- min="0"
112
- max="1"
113
- step="0.01"
114
- placeholder="auto"
115
- />
116
  </div>
117
  <div class="form-group form-group-min-220">
118
  <label class="toggle-switch toggle-switch-mt-14">
@@ -128,12 +84,8 @@
128
  <summary>Identity + Agent Notes</summary>
129
  <div class="advanced-settings-content">
130
  <div class="form-row form-row-center form-row-gap-2 form-row-mb-2">
131
- <span id="cpChannelIdentityStatusChip" class="chip pending"
132
- >IDENTITY CLEAR</span
133
- >
134
- <span id="cpAgentNotesStatusChip" class="chip pending"
135
- >NOTES CLEAR</span
136
- >
137
  </div>
138
 
139
  <p id="cpChannelIdentityHint" class="panel-hint panel-hint-m0-mb-1">
@@ -148,82 +100,45 @@
148
  <div class="form-row form-row-wrap form-row-mb-2">
149
  <div class="form-group form-group-min-220">
150
  <label for="cpChannelPersonaName">Persona Name</label>
151
- <input
152
- id="cpChannelPersonaName"
153
- class="form-control"
154
- type="text"
155
- maxlength="80"
156
- placeholder="e.g. Byte Coach"
157
- />
158
  </div>
159
  <div class="form-group form-group-min-280">
160
  <label for="cpChannelTone">Tone</label>
161
- <input
162
- id="cpChannelTone"
163
- class="form-control"
164
- type="text"
165
- maxlength="160"
166
- placeholder="e.g. tactical, objective, spoiler-free"
167
- />
168
  </div>
169
  </div>
170
 
171
  <div class="form-row form-row-wrap form-row-mb-2">
172
  <div class="form-group form-group-min-260 u-flex-1">
173
  <label for="cpChannelEmoteVocab">Emote Vocab (csv)</label>
174
- <input
175
- id="cpChannelEmoteVocab"
176
- class="form-control"
177
- type="text"
178
- maxlength="720"
179
- placeholder="e.g. PogChamp, Kappa, LUL"
180
- />
181
  </div>
182
  </div>
183
 
184
  <div class="form-group form-group-mb-2">
185
  <label for="cpChannelLore">Lore / Continuity</label>
186
- <textarea
187
- id="cpChannelLore"
188
- class="form-control"
189
- rows="3"
190
- maxlength="1200"
191
- placeholder="Recurring channel context to keep reply continuity."
192
- ></textarea>
193
  </div>
194
  <div class="form-group form-group-mb-0">
195
  <label for="cpAgentNotes">Operational Notes</label>
196
- <textarea
197
- id="cpAgentNotes"
198
- class="form-control"
199
- rows="4"
200
- placeholder="e.g. Prioritize streamer lore/context. Avoid aggressive backseating."
201
- ></textarea>
202
  </div>
203
  </div>
204
  </details>
205
 
206
- <section
207
- id="cpGoalsSchedulerSection"
208
- aria-labelledby="cpGoalsSchedulerHeading"
209
- class="form-row-mt-4"
210
- >
211
  <div class="form-row form-row-between form-row-center form-row-mb-2">
212
  <h3 id="cpGoalsSchedulerHeading" class="heading-reset">
213
  Goals Scheduler
214
  </h3>
215
  <button id="cpAddGoalBtn" class="btn btn-secondary" type="button">
216
- <svg
217
- xmlns="http://www.w3.org/2000/svg"
218
- width="16"
219
- height="16"
220
- viewBox="0 0 24 24"
221
- fill="none"
222
- stroke="currentColor"
223
- stroke-width="2"
224
- stroke-linecap="round"
225
- stroke-linejoin="round"
226
- >
227
  <line x1="12" y1="5" x2="12" y2="19"></line>
228
  <line x1="5" y1="12" x2="19" y2="12"></line>
229
  </svg>
@@ -236,6 +151,18 @@
236
  <ul id="cpGoalsList" class="events-list"></ul>
237
  </section>
238
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  <details class="advanced-settings">
240
  <summary>Advanced Budget, Cooldowns and Webhooks</summary>
241
  <div class="advanced-settings-content">
@@ -252,72 +179,30 @@
252
  <div class="form-row form-row-wrap form-row-mb-4">
253
  <div class="form-group form-group-min-140">
254
  <label for="cpHeartbeatInterval">Heartbeat (s)</label>
255
- <input
256
- id="cpHeartbeatInterval"
257
- class="form-control"
258
- type="number"
259
- min="15"
260
- max="3600"
261
- step="1"
262
- />
263
  </div>
264
  <div class="form-group form-group-min-140">
265
  <label for="cpMinCooldown">Cooldown (s)</label>
266
- <input
267
- id="cpMinCooldown"
268
- class="form-control"
269
- type="number"
270
- min="15"
271
- max="3600"
272
- step="1"
273
- />
274
  </div>
275
  <div class="form-group form-group-min-140">
276
  <label for="cpActionIgnoreAfter">Auto-ignore (s)</label>
277
- <input
278
- id="cpActionIgnoreAfter"
279
- class="form-control"
280
- type="number"
281
- min="60"
282
- max="86400"
283
- step="1"
284
- />
285
  </div>
286
  </div>
287
 
288
  <div class="form-row form-row-wrap">
289
  <div class="form-group form-group-min-140">
290
  <label for="cpBudget10m">Budget 10m</label>
291
- <input
292
- id="cpBudget10m"
293
- class="form-control"
294
- type="number"
295
- min="0"
296
- max="100"
297
- step="1"
298
- />
299
  </div>
300
  <div class="form-group form-group-min-140">
301
  <label for="cpBudget60m">Budget 60m</label>
302
- <input
303
- id="cpBudget60m"
304
- class="form-control"
305
- type="number"
306
- min="0"
307
- max="600"
308
- step="1"
309
- />
310
  </div>
311
  <div class="form-group form-group-min-140">
312
  <label for="cpBudgetDaily">Daily Budget</label>
313
- <input
314
- id="cpBudgetDaily"
315
- class="form-control"
316
- type="number"
317
- min="0"
318
- max="5000"
319
- step="1"
320
- />
321
  </div>
322
  </div>
323
 
@@ -329,21 +214,13 @@
329
  <div class="form-row form-row-wrap form-row-mb-2">
330
  <div class="form-group form-group-min-220 u-flex-2">
331
  <label for="cpWebhookUrl">Webhook URL</label>
332
- <input
333
- id="cpWebhookUrl"
334
- class="form-control"
335
- type="url"
336
- placeholder="https://api.example.com/webhooks/byte"
337
- />
338
  </div>
339
  <div class="form-group form-group-min-180 u-flex-1">
340
  <label for="cpWebhookSecret">HMAC Secret (optional)</label>
341
- <input
342
- id="cpWebhookSecret"
343
- class="form-control"
344
- type="password"
345
- placeholder="Secret for sha256 signing"
346
- />
347
  </div>
348
  </div>
349
  <div class="form-row form-row-center form-row-between form-row-gap-3">
@@ -352,11 +229,7 @@
352
  <span class="toggle-slider"></span>
353
  Enable Dispatch
354
  </label>
355
- <button
356
- id="cpWebhookTestBtn"
357
- class="btn btn-secondary"
358
- type="button"
359
- >
360
  Test Webhook
361
  </button>
362
  </div>
@@ -364,22 +237,115 @@
364
  </div>
365
  </details>
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  <div class="form-row form-row-mt-5">
368
  <button id="cpSaveBtn" class="btn btn-primary" type="button">
369
- <svg
370
- xmlns="http://www.w3.org/2000/svg"
371
- width="16"
372
- height="16"
373
- viewBox="0 0 24 24"
374
- fill="none"
375
- stroke="currentColor"
376
- stroke-width="2"
377
- stroke-linecap="round"
378
- stroke-linejoin="round"
379
- >
380
- <path
381
- d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
382
- ></path>
383
  <polyline points="17 21 17 13 7 13 7 21"></polyline>
384
  <polyline points="7 3 7 8 15 8"></polyline>
385
  </svg>
 
8
 
9
  <div class="form-row form-row-center form-row-mb-3">
10
  <span id="cpModeChip" class="chip pending">MODE</span>
11
+ <span id="cpResponseContract" class="chip">Active contract: 1 message / 4 lines.</span>
 
 
12
  </div>
13
  <p id="cpCapabilitiesLine" class="panel-hint panel-hint-mb-4">
14
  Loading capabilities...
15
  </p>
16
 
17
+ <section id="cpOperationalControlSection" aria-labelledby="cpOperationalControlHeading" class="section-surface">
18
+ <div class="form-row form-row-center form-row-between form-row-gap-3 form-row-mb-2">
 
 
 
 
 
 
19
  <h3 id="cpOperationalControlHeading" class="heading-reset">
20
  Operational Control
21
  </h3>
 
34
  </div>
35
  </section>
36
 
37
+ <section id="cpChannelDirectivesSection" aria-labelledby="cpChannelDirectivesHeading" class="section-surface">
38
+ <div class="form-row form-row-center form-row-between form-row-gap-3 form-row-mb-2">
 
 
 
 
 
 
39
  <div class="form-row form-row-center form-row-gap-2 form-row-m0">
40
  <h3 id="cpChannelDirectivesHeading" class="heading-reset">
41
  Channel Directives
42
  </h3>
43
+ <span id="cpChannelConfigStatusChip" class="chip pending">MODEL DEFAULT</span>
 
 
44
  </div>
45
  <div class="form-row form-row-gap-2 form-row-m0">
46
+ <button id="cpLoadChannelConfigBtn" class="btn btn-secondary" type="button">
 
 
 
 
47
  Load Channel
48
  </button>
49
+ <button id="cpSaveChannelConfigBtn" class="btn btn-primary" type="button">
 
 
 
 
50
  Apply Directives
51
  </button>
52
  </div>
 
59
  <div class="form-row form-row-wrap form-row-mb-2">
60
  <div class="form-group form-group-min-170">
61
  <label for="cpChannelConfigId">Channel</label>
62
+ <input id="cpChannelConfigId" class="form-control" type="text" value="default" />
 
 
 
 
 
63
  </div>
64
  <div class="form-group form-group-min-150">
65
  <label for="cpChannelTemperature">Temperature</label>
66
+ <input id="cpChannelTemperature" class="form-control" type="number" min="0" max="2" step="0.01"
67
+ placeholder="0.15" />
 
 
 
 
 
 
 
68
  </div>
69
  <div class="form-group form-group-min-150">
70
  <label for="cpChannelTopP">Top P</label>
71
+ <input id="cpChannelTopP" class="form-control" type="number" min="0" max="1" step="0.01" placeholder="auto" />
 
 
 
 
 
 
 
 
72
  </div>
73
  <div class="form-group form-group-min-220">
74
  <label class="toggle-switch toggle-switch-mt-14">
 
84
  <summary>Identity + Agent Notes</summary>
85
  <div class="advanced-settings-content">
86
  <div class="form-row form-row-center form-row-gap-2 form-row-mb-2">
87
+ <span id="cpChannelIdentityStatusChip" class="chip pending">IDENTITY CLEAR</span>
88
+ <span id="cpAgentNotesStatusChip" class="chip pending">NOTES CLEAR</span>
 
 
 
 
89
  </div>
90
 
91
  <p id="cpChannelIdentityHint" class="panel-hint panel-hint-m0-mb-1">
 
100
  <div class="form-row form-row-wrap form-row-mb-2">
101
  <div class="form-group form-group-min-220">
102
  <label for="cpChannelPersonaName">Persona Name</label>
103
+ <input id="cpChannelPersonaName" class="form-control" type="text" maxlength="80"
104
+ placeholder="e.g. Byte Coach" />
 
 
 
 
 
105
  </div>
106
  <div class="form-group form-group-min-280">
107
  <label for="cpChannelTone">Tone</label>
108
+ <input id="cpChannelTone" class="form-control" type="text" maxlength="160"
109
+ placeholder="e.g. tactical, objective, spoiler-free" />
 
 
 
 
 
110
  </div>
111
  </div>
112
 
113
  <div class="form-row form-row-wrap form-row-mb-2">
114
  <div class="form-group form-group-min-260 u-flex-1">
115
  <label for="cpChannelEmoteVocab">Emote Vocab (csv)</label>
116
+ <input id="cpChannelEmoteVocab" class="form-control" type="text" maxlength="720"
117
+ placeholder="e.g. PogChamp, Kappa, LUL" />
 
 
 
 
 
118
  </div>
119
  </div>
120
 
121
  <div class="form-group form-group-mb-2">
122
  <label for="cpChannelLore">Lore / Continuity</label>
123
+ <textarea id="cpChannelLore" class="form-control" rows="3" maxlength="1200"
124
+ placeholder="Recurring channel context to keep reply continuity."></textarea>
 
 
 
 
 
125
  </div>
126
  <div class="form-group form-group-mb-0">
127
  <label for="cpAgentNotes">Operational Notes</label>
128
+ <textarea id="cpAgentNotes" class="form-control" rows="4"
129
+ placeholder="e.g. Prioritize streamer lore/context. Avoid aggressive backseating."></textarea>
 
 
 
 
130
  </div>
131
  </div>
132
  </details>
133
 
134
+ <section id="cpGoalsSchedulerSection" aria-labelledby="cpGoalsSchedulerHeading" class="form-row-mt-4">
 
 
 
 
135
  <div class="form-row form-row-between form-row-center form-row-mb-2">
136
  <h3 id="cpGoalsSchedulerHeading" class="heading-reset">
137
  Goals Scheduler
138
  </h3>
139
  <button id="cpAddGoalBtn" class="btn btn-secondary" type="button">
140
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
141
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 
 
 
 
 
 
 
 
 
142
  <line x1="12" y1="5" x2="12" y2="19"></line>
143
  <line x1="5" y1="12" x2="19" y2="12"></line>
144
  </svg>
 
151
  <ul id="cpGoalsList" class="events-list"></ul>
152
  </section>
153
 
154
+ <section id="cpTacticalCalendarSection" aria-labelledby="cpTacticalCalendarHeading" class="form-row-mt-4">
155
+ <div class="form-row form-row-between form-row-center form-row-mb-2">
156
+ <h3 id="cpTacticalCalendarHeading" class="heading-reset">
157
+ Tactical Calendar
158
+ </h3>
159
+ </div>
160
+ <p class="panel-hint panel-hint-mb-2">
161
+ Timeline of upcoming scheduled goals and cron jobs. Edit their settings in the Goals Scheduler above.
162
+ </p>
163
+ <ul id="cpCalendarTimelineList" class="events-list"></ul>
164
+ </section>
165
+
166
  <details class="advanced-settings">
167
  <summary>Advanced Budget, Cooldowns and Webhooks</summary>
168
  <div class="advanced-settings-content">
 
179
  <div class="form-row form-row-wrap form-row-mb-4">
180
  <div class="form-group form-group-min-140">
181
  <label for="cpHeartbeatInterval">Heartbeat (s)</label>
182
+ <input id="cpHeartbeatInterval" class="form-control" type="number" min="15" max="3600" step="1" />
 
 
 
 
 
 
 
183
  </div>
184
  <div class="form-group form-group-min-140">
185
  <label for="cpMinCooldown">Cooldown (s)</label>
186
+ <input id="cpMinCooldown" class="form-control" type="number" min="15" max="3600" step="1" />
 
 
 
 
 
 
 
187
  </div>
188
  <div class="form-group form-group-min-140">
189
  <label for="cpActionIgnoreAfter">Auto-ignore (s)</label>
190
+ <input id="cpActionIgnoreAfter" class="form-control" type="number" min="60" max="86400" step="1" />
 
 
 
 
 
 
 
191
  </div>
192
  </div>
193
 
194
  <div class="form-row form-row-wrap">
195
  <div class="form-group form-group-min-140">
196
  <label for="cpBudget10m">Budget 10m</label>
197
+ <input id="cpBudget10m" class="form-control" type="number" min="0" max="100" step="1" />
 
 
 
 
 
 
 
198
  </div>
199
  <div class="form-group form-group-min-140">
200
  <label for="cpBudget60m">Budget 60m</label>
201
+ <input id="cpBudget60m" class="form-control" type="number" min="0" max="600" step="1" />
 
 
 
 
 
 
 
202
  </div>
203
  <div class="form-group form-group-min-140">
204
  <label for="cpBudgetDaily">Daily Budget</label>
205
+ <input id="cpBudgetDaily" class="form-control" type="number" min="0" max="5000" step="1" />
 
 
 
 
 
 
 
206
  </div>
207
  </div>
208
 
 
214
  <div class="form-row form-row-wrap form-row-mb-2">
215
  <div class="form-group form-group-min-220 u-flex-2">
216
  <label for="cpWebhookUrl">Webhook URL</label>
217
+ <input id="cpWebhookUrl" class="form-control" type="url"
218
+ placeholder="https://api.example.com/webhooks/byte" />
 
 
 
 
219
  </div>
220
  <div class="form-group form-group-min-180 u-flex-1">
221
  <label for="cpWebhookSecret">HMAC Secret (optional)</label>
222
+ <input id="cpWebhookSecret" class="form-control" type="password"
223
+ placeholder="Secret for sha256 signing" />
 
 
 
 
224
  </div>
225
  </div>
226
  <div class="form-row form-row-center form-row-between form-row-gap-3">
 
229
  <span class="toggle-slider"></span>
230
  Enable Dispatch
231
  </label>
232
+ <button id="cpWebhookTestBtn" class="btn btn-secondary" type="button">
 
 
 
 
233
  Test Webhook
234
  </button>
235
  </div>
 
237
  </div>
238
  </details>
239
 
240
+ <details class="advanced-settings">
241
+ <summary>Persona Studio & Model Routing (Phase 22)</summary>
242
+ <div class="advanced-settings-content">
243
+
244
+ <div class="section-divider-top">
245
+ <h4 class="heading-xs-strong">Base Identity</h4>
246
+ <div class="form-row form-row-wrap form-row-mb-2">
247
+ <div class="form-group form-group-min-180 u-flex-1">
248
+ <label for="ppPersonaName">Persona Name</label>
249
+ <input id="ppPersonaName" class="form-control" type="text" maxlength="80" placeholder="Byte Coach" />
250
+ </div>
251
+ <div class="form-group form-group-min-120 u-flex-1">
252
+ <label for="ppPronouns">Pronouns</label>
253
+ <input id="ppPronouns" class="form-control" type="text" maxlength="40" placeholder="ele/dele" />
254
+ </div>
255
+ </div>
256
+ <div class="form-group form-group-mb-3">
257
+ <label for="ppLore">Lore / Backstory</label>
258
+ <textarea id="ppLore" class="form-control" rows="3" maxlength="1200"
259
+ placeholder="Backstory and continuity..."></textarea>
260
+ </div>
261
+ </div>
262
+
263
+ <div class="section-divider-top">
264
+ <h4 class="heading-xs-strong">Tonality Engine</h4>
265
+ <div class="form-row form-row-wrap form-row-mb-2">
266
+ <div class="form-group form-group-min-220 u-flex-2">
267
+ <label for="ppTone">Tone of Voice</label>
268
+ <input id="ppTone" class="form-control" type="text" maxlength="160"
269
+ placeholder="tatico, objetivo, motivador" />
270
+ </div>
271
+ <div class="form-group form-group-min-140 u-flex-1">
272
+ <label for="ppSentenceStyle">Sentence Style</label>
273
+ <select id="ppSentenceStyle" class="form-control">
274
+ <option value="">Default</option>
275
+ <option value="short_punchy">Short & Punchy</option>
276
+ <option value="long_analytical">Analytical & Detailed</option>
277
+ <option value="balanced">Balanced</option>
278
+ </select>
279
+ </div>
280
+ </div>
281
+ <div class="form-group form-group-mb-3">
282
+ <label for="ppEmoteVocab">Emote Vocabulary</label>
283
+ <input id="ppEmoteVocab" class="form-control" type="text"
284
+ placeholder="PogChamp, LUL, Kappa (comma-separated)" />
285
+ </div>
286
+ </div>
287
+
288
+ <div class="section-divider-top">
289
+ <h4 class="heading-xs-strong">Behavioral Constraints</h4>
290
+ <div class="form-row form-row-wrap form-row-mb-2">
291
+ <div class="form-group form-group-min-220 u-flex-1">
292
+ <label for="ppBannedTopics">Banned Topics</label>
293
+ <input id="ppBannedTopics" class="form-control" type="text"
294
+ placeholder="politics, religion (comma-separated)" />
295
+ </div>
296
+ <div class="form-group form-group-min-220 u-flex-1">
297
+ <label for="ppCtaTriggers">CTA Triggers</label>
298
+ <input id="ppCtaTriggers" class="form-control" type="text"
299
+ placeholder="follow, subscribe (comma-separated)" />
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <div class="section-divider-top">
305
+ <h4 class="heading-xs-strong">Model Routing Overrides</h4>
306
+ <p class="panel-hint panel-hint-mb-3">
307
+ Per-channel model overrides. Leave empty to use global defaults.
308
+ </p>
309
+ <div class="form-row form-row-wrap form-row-mb-2">
310
+ <div class="form-group form-group-min-180 u-flex-1">
311
+ <label for="ppModelChat">Chat Model</label>
312
+ <input id="ppModelChat" class="form-control" type="text" maxlength="120" placeholder="global default" />
313
+ </div>
314
+ <div class="form-group form-group-min-180 u-flex-1">
315
+ <label for="ppModelCoaching">Coaching Model</label>
316
+ <input id="ppModelCoaching" class="form-control" type="text" maxlength="120"
317
+ placeholder="global default" />
318
+ </div>
319
+ </div>
320
+ <div class="form-row form-row-wrap form-row-mb-2">
321
+ <div class="form-group form-group-min-180 u-flex-1">
322
+ <label for="ppModelSearch">Search Model</label>
323
+ <input id="ppModelSearch" class="form-control" type="text" maxlength="120" placeholder="global default" />
324
+ </div>
325
+ <div class="form-group form-group-min-180 u-flex-1">
326
+ <label for="ppModelReasoning">Reasoning Model</label>
327
+ <input id="ppModelReasoning" class="form-control" type="text" maxlength="120"
328
+ placeholder="global default" />
329
+ </div>
330
+ </div>
331
+ </div>
332
+
333
+ <div class="form-row form-row-mt-3 form-row-center form-row-between form-row-gap-3">
334
+ <button id="ppLoadBtn" class="btn btn-secondary" type="button">Load Profile</button>
335
+ <button id="ppSaveBtn" class="btn btn-primary" type="button">Apply Profile</button>
336
+ </div>
337
+ <p id="ppStatusChip" class="panel-hint event-level-info panel-hint-mt-2">
338
+ No profile loaded.
339
+ </p>
340
+
341
+ </div>
342
+ </details>
343
+
344
  <div class="form-row form-row-mt-5">
345
  <button id="cpSaveBtn" class="btn btn-primary" type="button">
346
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
347
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
348
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
 
 
 
 
 
 
 
 
 
 
 
349
  <polyline points="17 21 17 13 7 13 7 21"></polyline>
350
  <polyline points="7 3 7 8 15 8"></polyline>
351
  </svg>
dashboard/styles/clips.css CHANGED
@@ -1,107 +1,178 @@
1
  /* Scoped styles for Clip Cards */
2
  .clip-card {
3
- display: flex;
4
- flex-direction: column;
5
- background: var(--surface-2);
6
- border: 1px solid var(--border-color);
7
- border-radius: var(--radius-md);
8
- padding: var(--spacing-3);
9
- margin-bottom: var(--spacing-2);
10
- position: relative;
11
- overflow: hidden;
12
- contain: content; /* Performance optimization */
13
- transition: transform 0.2s ease, border-color 0.2s ease;
 
 
14
  }
15
 
16
  .clip-card:hover {
17
- border-color: var(--primary-color);
18
- transform: translateY(-1px);
19
  }
20
 
21
  /* Status Indicator Bar via CSS pseudo-element */
22
  .clip-card::before {
23
- content: '';
24
- position: absolute;
25
- left: 0;
26
- top: 0;
27
- bottom: 0;
28
- width: 4px;
29
- background: var(--status-color);
30
  }
31
 
32
  /* Status Color Mapping */
33
- .clip-card[data-status="queued"] { --status-color: var(--color-gray-500); }
34
- .clip-card[data-status="creating"] { --status-color: var(--color-blue-500); }
35
- .clip-card[data-status="polling"] { --status-color: var(--color-purple-500); }
36
- .clip-card[data-status="ready"] { --status-color: var(--color-green-500); }
37
- .clip-card[data-status="failed"] { --status-color: var(--color-red-500); }
 
 
 
 
 
 
 
 
 
 
38
 
39
  .clip-header {
40
- display: flex;
41
- justify-content: space-between;
42
- font-size: 0.75rem;
43
- color: var(--text-muted);
44
- margin-bottom: var(--spacing-2);
45
  }
46
 
47
  .clip-title {
48
- margin: 0 0 var(--spacing-2) 0;
49
- font-size: 1rem;
50
- line-height: 1.4;
51
- color: var(--text-main);
52
  }
53
 
54
  .clip-meta {
55
- display: flex;
56
- gap: var(--spacing-2);
57
- font-size: 0.75rem;
58
  }
59
 
60
  .meta-item {
61
- background: var(--surface-3);
62
- padding: 2px 6px;
63
- border-radius: var(--radius-sm);
64
- text-transform: uppercase;
65
- font-weight: 600;
66
- letter-spacing: 0.05em;
67
  }
68
 
69
  .clip-error {
70
- color: var(--color-red-400);
71
- font-size: 0.8rem;
72
- margin-top: var(--spacing-2);
73
- background: rgba(239, 68, 68, 0.1);
74
- padding: var(--spacing-2);
75
- border-radius: var(--radius-sm);
76
  }
77
 
78
  .clip-actions {
79
- margin-top: var(--spacing-3);
80
- display: flex;
81
- gap: var(--spacing-2);
82
  }
83
 
84
  /* Skeleton Loading */
85
  .clips-skeleton .skeleton-card {
86
- height: 120px;
87
- background: var(--surface-2);
88
- border-radius: var(--radius-md);
89
- margin-bottom: var(--spacing-2);
90
- animation: pulse 1.5s infinite;
91
  }
92
 
93
  @keyframes pulse {
94
- 0% { opacity: 0.6; }
95
- 50% { opacity: 0.8; }
96
- 100% { opacity: 0.6; }
 
 
 
 
 
 
97
  }
98
 
99
  .empty-state {
100
- padding: var(--spacing-4);
101
- text-align: center;
102
- color: var(--text-muted);
103
- font-style: italic;
104
- background: var(--surface-1);
105
- border-radius: var(--radius-md);
106
- border: 1px dashed var(--border-color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  }
 
1
  /* Scoped styles for Clip Cards */
2
  .clip-card {
3
+ display: flex;
4
+ flex-direction: column;
5
+ background: var(--surface-2);
6
+ border: 1px solid var(--border-color);
7
+ border-radius: var(--radius-md);
8
+ padding: var(--spacing-3);
9
+ margin-bottom: var(--spacing-2);
10
+ position: relative;
11
+ overflow: hidden;
12
+ contain: content; /* Performance optimization */
13
+ transition:
14
+ transform 0.2s ease,
15
+ border-color 0.2s ease;
16
  }
17
 
18
  .clip-card:hover {
19
+ border-color: var(--primary-color);
20
+ transform: translateY(-1px);
21
  }
22
 
23
  /* Status Indicator Bar via CSS pseudo-element */
24
  .clip-card::before {
25
+ content: "";
26
+ position: absolute;
27
+ left: 0;
28
+ top: 0;
29
+ bottom: 0;
30
+ width: 4px;
31
+ background: var(--status-color);
32
  }
33
 
34
  /* Status Color Mapping */
35
+ .clip-card[data-status="queued"] {
36
+ --status-color: var(--color-gray-500);
37
+ }
38
+ .clip-card[data-status="creating"] {
39
+ --status-color: var(--color-blue-500);
40
+ }
41
+ .clip-card[data-status="polling"] {
42
+ --status-color: var(--color-purple-500);
43
+ }
44
+ .clip-card[data-status="ready"] {
45
+ --status-color: var(--color-green-500);
46
+ }
47
+ .clip-card[data-status="failed"] {
48
+ --status-color: var(--color-red-500);
49
+ }
50
 
51
  .clip-header {
52
+ display: flex;
53
+ justify-content: space-between;
54
+ font-size: 0.75rem;
55
+ color: var(--text-muted);
56
+ margin-bottom: var(--spacing-2);
57
  }
58
 
59
  .clip-title {
60
+ margin: 0 0 var(--spacing-2) 0;
61
+ font-size: 1rem;
62
+ line-height: 1.4;
63
+ color: var(--text-main);
64
  }
65
 
66
  .clip-meta {
67
+ display: flex;
68
+ gap: var(--spacing-2);
69
+ font-size: 0.75rem;
70
  }
71
 
72
  .meta-item {
73
+ background: var(--surface-3);
74
+ padding: 2px 6px;
75
+ border-radius: var(--radius-sm);
76
+ text-transform: uppercase;
77
+ font-weight: 600;
78
+ letter-spacing: 0.05em;
79
  }
80
 
81
  .clip-error {
82
+ color: var(--color-red-400);
83
+ font-size: 0.8rem;
84
+ margin-top: var(--spacing-2);
85
+ background: rgba(239, 68, 68, 0.1);
86
+ padding: var(--spacing-2);
87
+ border-radius: var(--radius-sm);
88
  }
89
 
90
  .clip-actions {
91
+ margin-top: var(--spacing-3);
92
+ display: flex;
93
+ gap: var(--spacing-2);
94
  }
95
 
96
  /* Skeleton Loading */
97
  .clips-skeleton .skeleton-card {
98
+ height: 120px;
99
+ background: var(--surface-2);
100
+ border-radius: var(--radius-md);
101
+ margin-bottom: var(--spacing-2);
102
+ animation: pulse 1.5s infinite;
103
  }
104
 
105
  @keyframes pulse {
106
+ 0% {
107
+ opacity: 0.6;
108
+ }
109
+ 50% {
110
+ opacity: 0.8;
111
+ }
112
+ 100% {
113
+ opacity: 0.6;
114
+ }
115
  }
116
 
117
  .empty-state {
118
+ padding: var(--spacing-4);
119
+ text-align: center;
120
+ color: var(--text-muted);
121
+ font-style: italic;
122
+ background: var(--surface-1);
123
+ border-radius: var(--radius-md);
124
+ border: 1px dashed var(--border-color);
125
+ }
126
+
127
+ /* Clip Card Thumbnails */
128
+ .clip-thumbnail-wrapper {
129
+ width: 100%;
130
+ aspect-ratio: 16 / 9;
131
+ background-color: var(--surface-3);
132
+ border-radius: var(--radius-sm);
133
+ overflow: hidden;
134
+ margin-bottom: var(--spacing-3);
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ position: relative;
139
+ }
140
+
141
+ .clip-thumbnail {
142
+ width: 100%;
143
+ height: 100%;
144
+ object-fit: cover;
145
+ transition: transform 0.2s ease;
146
+ }
147
+
148
+ .clip-card:hover .clip-thumbnail {
149
+ transform: scale(1.02);
150
+ }
151
+
152
+ .clip-thumbnail-loading,
153
+ .clip-thumbnail-error {
154
+ flex-direction: column;
155
+ gap: var(--spacing-2);
156
+ color: var(--text-muted);
157
+ font-size: 0.875rem;
158
+ }
159
+
160
+ .clip-thumbnail-error {
161
+ color: var(--color-red-400);
162
+ background-color: rgba(239, 68, 68, 0.1);
163
+ }
164
+
165
+ .spinner-small {
166
+ width: 1.5rem;
167
+ height: 1.5rem;
168
+ border: 2px solid var(--text-muted);
169
+ border-top-color: var(--primary-color);
170
+ border-radius: 50%;
171
+ animation: spin 1s linear infinite;
172
+ }
173
+
174
+ @keyframes spin {
175
+ to {
176
+ transform: rotate(360deg);
177
+ }
178
  }
dashboard/tests/api_contract_parity.test.js CHANGED
@@ -6,11 +6,13 @@ import {
6
  getControlPlaneState,
7
  getChannelConfig,
8
  getAgentNotes,
 
9
  resumeAgent,
10
  suspendAgent,
11
  updateAgentNotes,
12
  updateChannelConfig,
13
  updateControlPlaneConfig,
 
14
  getWebhooks,
15
  updateWebhook,
16
  testWebhook,
@@ -124,6 +126,12 @@ test("control-plane and channel directives APIs keep backend contract", async ()
124
  is_active: true,
125
  });
126
  await testWebhook({ channel_id: "canal_a" });
 
 
 
 
 
 
127
 
128
  assert.ok(findCall(calls, "/api/control-plane", "GET"));
129
  const updateControlPlaneCall = findCall(calls, "/api/control-plane", "PUT");
@@ -164,6 +172,14 @@ test("control-plane and channel directives APIs keep backend contract", async ()
164
  );
165
 
166
  assert.ok(findCall(calls, "/api/webhooks/test", "POST"));
 
 
 
 
 
 
 
 
167
  });
168
 
169
  test("operational runtime APIs keep backend contract", async () => {
 
6
  getControlPlaneState,
7
  getChannelConfig,
8
  getAgentNotes,
9
+ getPersonaProfile,
10
  resumeAgent,
11
  suspendAgent,
12
  updateAgentNotes,
13
  updateChannelConfig,
14
  updateControlPlaneConfig,
15
+ updatePersonaProfile,
16
  getWebhooks,
17
  updateWebhook,
18
  testWebhook,
 
126
  is_active: true,
127
  });
128
  await testWebhook({ channel_id: "canal_a" });
129
+ await getPersonaProfile("Canal_A");
130
+ await updatePersonaProfile({
131
+ channel_id: "canal_a",
132
+ base_identity: { name: "Byte Coach" },
133
+ tonality_engine: { tone: "tatico" },
134
+ });
135
 
136
  assert.ok(findCall(calls, "/api/control-plane", "GET"));
137
  const updateControlPlaneCall = findCall(calls, "/api/control-plane", "PUT");
 
172
  );
173
 
174
  assert.ok(findCall(calls, "/api/webhooks/test", "POST"));
175
+
176
+ assert.ok(findCall(calls, "/api/persona-profile?channel=canal_a", "GET"));
177
+ const updateProfileCall = findCall(calls, "/api/persona-profile", "PUT");
178
+ assert.ok(updateProfileCall);
179
+ assert.equal(
180
+ JSON.parse(String(updateProfileCall.options.body)).base_identity.name,
181
+ "Byte Coach",
182
+ );
183
  });
184
 
185
  test("operational runtime APIs keep backend contract", async () => {
dashboard/tests/multi_channel_focus.test.js CHANGED
@@ -100,7 +100,7 @@ class MockElement {
100
  const safeEvent = event || {};
101
  safeEvent.target = safeEvent.target || this;
102
  safeEvent.currentTarget = this;
103
- safeEvent.preventDefault = safeEvent.preventDefault || (() => {});
104
  const handlers = this.listeners.get(safeEvent.type) || [];
105
  handlers.forEach((handler) => handler(safeEvent));
106
  return true;
@@ -646,7 +646,7 @@ test("channel control controller propagates focused channel sync failures to fee
646
  const els = createChannelControlElements(document);
647
  const controller = createChannelControlController({
648
  ctrlEls: els,
649
- applyRuntimeCapabilities() {},
650
  getErrorMessage(error, fallback) {
651
  return error?.message || fallback;
652
  },
@@ -1424,7 +1424,7 @@ test("control plane controller mirrors the focused channel into channel tuning",
1424
  const controller = createControlPlaneController({
1425
  cpEls,
1426
  autEls: null,
1427
- applyRuntimeCapabilities() {},
1428
  getErrorMessage(_error, fallback) {
1429
  return fallback;
1430
  },
@@ -1447,7 +1447,7 @@ test("control plane controller warns when loading directives without a channel",
1447
  const controller = createControlPlaneController({
1448
  cpEls,
1449
  autEls: null,
1450
- applyRuntimeCapabilities() {},
1451
  getErrorMessage(error, fallback) {
1452
  return error?.message || fallback;
1453
  },
@@ -1507,7 +1507,7 @@ test("control plane controller loads channel directives including agent notes",
1507
  const controller = createControlPlaneController({
1508
  cpEls,
1509
  autEls: null,
1510
- applyRuntimeCapabilities() {},
1511
  getErrorMessage(error, fallback) {
1512
  return error?.message || fallback;
1513
  },
@@ -1540,7 +1540,7 @@ test("control plane controller surfaces directive load errors", async () => {
1540
  const controller = createControlPlaneController({
1541
  cpEls,
1542
  autEls: null,
1543
- applyRuntimeCapabilities() {},
1544
  getErrorMessage(error, fallback) {
1545
  return error?.message || fallback;
1546
  },
@@ -1607,7 +1607,7 @@ test("control plane controller saves channel directives including agent notes",
1607
  const controller = createControlPlaneController({
1608
  cpEls,
1609
  autEls: null,
1610
- applyRuntimeCapabilities() {},
1611
  getErrorMessage(error, fallback) {
1612
  return error?.message || fallback;
1613
  },
@@ -1617,9 +1617,10 @@ test("control plane controller saves channel directives including agent notes",
1617
  cpEls.saveChannelConfigBtn.click();
1618
  await new Promise((resolve) => setTimeout(resolve, 0));
1619
 
1620
- assert.equal(calls.length, 2);
1621
  assert.ok(calls.some((call) => call.url.includes("/api/channel-config")));
1622
  assert.ok(calls.some((call) => call.url.includes("/api/agent-notes")));
 
1623
  const channelConfigCall = calls.find((call) =>
1624
  call.url.includes("/api/channel-config"),
1625
  );
@@ -1667,7 +1668,7 @@ test("control plane controller surfaces directive save errors", async () => {
1667
  const controller = createControlPlaneController({
1668
  cpEls,
1669
  autEls: null,
1670
- applyRuntimeCapabilities() {},
1671
  getErrorMessage(error, fallback) {
1672
  return error?.message || fallback;
1673
  },
 
100
  const safeEvent = event || {};
101
  safeEvent.target = safeEvent.target || this;
102
  safeEvent.currentTarget = this;
103
+ safeEvent.preventDefault = safeEvent.preventDefault || (() => { });
104
  const handlers = this.listeners.get(safeEvent.type) || [];
105
  handlers.forEach((handler) => handler(safeEvent));
106
  return true;
 
646
  const els = createChannelControlElements(document);
647
  const controller = createChannelControlController({
648
  ctrlEls: els,
649
+ applyRuntimeCapabilities() { },
650
  getErrorMessage(error, fallback) {
651
  return error?.message || fallback;
652
  },
 
1424
  const controller = createControlPlaneController({
1425
  cpEls,
1426
  autEls: null,
1427
+ applyRuntimeCapabilities() { },
1428
  getErrorMessage(_error, fallback) {
1429
  return fallback;
1430
  },
 
1447
  const controller = createControlPlaneController({
1448
  cpEls,
1449
  autEls: null,
1450
+ applyRuntimeCapabilities() { },
1451
  getErrorMessage(error, fallback) {
1452
  return error?.message || fallback;
1453
  },
 
1507
  const controller = createControlPlaneController({
1508
  cpEls,
1509
  autEls: null,
1510
+ applyRuntimeCapabilities() { },
1511
  getErrorMessage(error, fallback) {
1512
  return error?.message || fallback;
1513
  },
 
1540
  const controller = createControlPlaneController({
1541
  cpEls,
1542
  autEls: null,
1543
+ applyRuntimeCapabilities() { },
1544
  getErrorMessage(error, fallback) {
1545
  return error?.message || fallback;
1546
  },
 
1607
  const controller = createControlPlaneController({
1608
  cpEls,
1609
  autEls: null,
1610
+ applyRuntimeCapabilities() { },
1611
  getErrorMessage(error, fallback) {
1612
  return error?.message || fallback;
1613
  },
 
1617
  cpEls.saveChannelConfigBtn.click();
1618
  await new Promise((resolve) => setTimeout(resolve, 0));
1619
 
1620
+ assert.equal(calls.length, 3);
1621
  assert.ok(calls.some((call) => call.url.includes("/api/channel-config")));
1622
  assert.ok(calls.some((call) => call.url.includes("/api/agent-notes")));
1623
+ assert.ok(calls.some((call) => call.url.includes("/api/persona-profile")));
1624
  const channelConfigCall = calls.find((call) =>
1625
  call.url.includes("/api/channel-config"),
1626
  );
 
1668
  const controller = createControlPlaneController({
1669
  cpEls,
1670
  autEls: null,
1671
+ applyRuntimeCapabilities() { },
1672
  getErrorMessage(error, fallback) {
1673
  return error?.message || fallback;
1674
  },
docs/IMPLEMENTATION_PLAN_PERSISTENCIA_STATEFUL_SUPABASE.md CHANGED
@@ -1,8 +1,8 @@
1
  # Plano de Implementação: Camada de Persistência Stateful (Supabase)
2
 
3
- **Versão:** 1.31
4
- **Data:** 28 de Fevereiro de 2026
5
- **Status:** Fases antigas (1-13) + Fases 14-21 concluídas ✅ | Backlog ativo: índices ANN avançados por escala (futuro)
6
 
7
  ---
8
 
@@ -42,7 +42,12 @@
42
  | **18** | Outbound Webhook API | `bot/outbound_webhooks.py` (engine hmac/retry), `bot/persistence_webhook_repository.py`, endpoints `/api/webhooks` e `/api/webhooks/test`, UI agregada no Control Plane. | `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/api_contract_parity.test.js`, `dashboard/tests/multi_channel_focus.test.js`, `python -m bot.dashboard_parity_gate` | ✅ |
43
  | **19** | Autonomous Clip Suggestion Intelligence | Exposição de métricas e ingest manual em `dashboard/partials/clips_section.html`, engine visual acoplada ao Autonomy (`bot/vision_runtime.py`), endpoints `/api/vision/status` e `/api/vision/ingest` consolidados como `integrated`. | `bot/tests/test_dashboard_routes_v3.py`, `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` | ✅ |
44
  | **20** | Otimização ANN de Memória Semântica com `pgvector` | `bot/persistence_semantic_memory_repository.py` com busca via RPC (`semantic_memory_search_pgvector`/`semantic_memory_search`) e fallback automático para ranking determinístico atual; flags `SEMANTIC_MEMORY_PGVECTOR_ENABLED` e `SEMANTIC_MEMORY_PGVECTOR_RPC`. | `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_persistence_repositories.py`, `bot/tests/test_persistence_layer.py`, `bot/tests/test_semantic_memory.py`, `bot/tests/test_dashboard_routes_v3.py -k semantic_memory`, `python -m bot.dashboard_parity_gate`, `python -m bot.structural_health_gate`, `node --test dashboard/tests/api_contract_parity.test.js` | ✅ |
45
- | **21** | Tuning operacional ANN + diagnóstico de engine semântica | `bot/persistence_semantic_memory_repository.py` com `SEMANTIC_MEMORY_MIN_SIMILARITY`, busca com `min_similarity`/`force_fallback`, `search_settings_sync()` e `search_entries_with_diagnostics_sync()`; facade expandida em `bot/persistence_layer.py`; rota `/api/semantic-memory` com query params de tuning e payload com `search_settings`/`search_diagnostics`; UI integrada no painel atual em `dashboard/partials/intelligence_panel.html`, `dashboard/features/observability/api.js`, `dashboard/features/observability/controller.js`, `dashboard/features/observability/view.js`. | `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_persistence_repositories.py`, `bot/tests/test_persistence_layer.py`, `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/api_contract_parity.test.js`, `dashboard/tests/multi_channel_focus.test.js`, `python -m bot.dashboard_parity_gate`, `python -m bot.structural_health_gate` | ✅ |
 
 
 
 
 
46
 
47
  ---
48
 
@@ -52,64 +57,69 @@
52
  | :------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
  | Contexto por canal focado | `/api/observability?channel=`, `/api/channel-context`, `/api/observability/history` | `Agent Context & Internals` | `dashboard/tests/multi_channel_focus.test.js`, `bot/tests/test_dashboard_routes_v3.py` |
54
  | Memória semântica | `/api/semantic-memory` (`GET/PUT`) + busca ANN opcional via `pgvector` no `SemanticMemoryRepository` com fallback determinístico | `Intelligence Overview` | `bot/tests/test_semantic_memory.py`, `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_dashboard_routes.py`, `dashboard/tests/multi_channel_focus.test.js` |
55
- | Memória semântica (tuning operacional) | `/api/semantic-memory?min_similarity=&force_fallback=` + `search_settings/search_diagnostics` para leitura de engine/threshold/resultados | `Intelligence Overview` (controles `Min Similarity`, `Deterministic Fallback`, chip de engine e hint diagnóstico) | `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `bot/tests/test_persistence_layer.py`, `dashboard/tests/api_contract_parity.test.js`, `dashboard/tests/multi_channel_focus.test.js` |
 
56
  | Stream health score | `/api/sentiment/scores` | `metrics_health`, `intelligence_panel`, `agent_context_internals` | `bot/tests/test_stream_health_score.py`, `dashboard/tests/multi_channel_focus.test.js` |
57
- | Post-stream report | `/api/observability/post-stream-report` (`generate=1`) + auto em `part` | `Intelligence Overview` (mesmo painel) | `bot/tests/test_post_stream_report.py`, `bot/tests/test_dashboard_server_extra.py`, `bot/tests/test_dashboard_routes_v3.py` |
58
- | Coaching churn risk em tempo real | `/api/observability` com bloco `coaching` + emissão HUD via `coaching_runtime` (`source=coaching`) | `Intelligence Overview` + `Streamer HUD` (mesmo layout/painéis atuais) | `bot/tests/test_coaching_churn_risk.py`, `bot/tests/test_coaching_runtime.py`, `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/multi_channel_focus.test.js` |
59
- | Goal KPI por sessão | runtime `register_goal_session_result` + telemetria `kpi_met/kpi_missed` | `Control Plane` (editor de goals existente) | `bot/tests/test_control_plane_config.py`, `bot/tests/test_autonomy_runtime.py`, `dashboard/tests/multi_channel_focus.test.js` |
60
- | Identidade estruturada por canal | `channel_identity` via `PersistenceLayer`, `/api/channel-config` (`GET/PUT`) e `/api/channel-context` | `Control Plane` (card `Channel Directives`, sem dashboard paralela) | `bot/tests/test_persistence_repositories.py`, `bot/tests/test_persistence_layer.py`, `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/multi_channel_focus.test.js`, `dashboard/tests/api_contract_parity.test.js` |
61
- | Soberania operacional | `/api/autonomy/tick`, `/api/agent/suspend`, `/api/agent/resume` | `Control Plane` + HUD | `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/multi_channel_focus.test.js` |
62
- | Ops playbooks determinísticos | `/api/ops-playbooks` (`GET`) + `/api/ops-playbooks/trigger` (`POST`) + execução em `autonomy_runtime` | `Risk Queue` (mesmo painel/layout atual) | `bot/tests/test_ops_playbooks.py`, `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/api_contract_parity.test.js` |
63
- | Atribuição de Conversões (Trace) | `/api/observability/conversions` (`GET`) + `/api/observability/conversion` (`POST`) | `Intelligence Overview` (Fim do painel, trace list) | `bot/tests/test_revenue_attribution_engine.py`, `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` |
64
- | Outbound Webhooks | `/api/webhooks` (`GET`/`PUT`) + `/api/webhooks/test` (`POST`) | `Control Plane` (Embaixo do Budget diário) | `bot/tests/test_dashboard_routes.py`, `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` |
65
- | Autonomous Clip Suggestion | `/api/vision/status` (`GET`) + `/api/vision/ingest` (`POST`) | `Clips Pipeline` (Painel lateral visual_clip_widget) | `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` |
66
 
67
  ---
68
 
69
- ## 4. Backlog Prioritário (Fases Futuras)
70
 
71
- 1. **Evolução futura:** índices ANN avançados (`ivfflat`/HNSW) e política dinâmica de probes/latência por volume real.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  ---
74
 
75
  ## 5. Evidências de Validação (ciclo de auditoria atual)
76
 
77
- - `pytest -q --no-cov bot/tests/test_persistence_semantic_memory_pgvector.py bot/tests/test_persistence_repositories.py bot/tests/test_persistence_layer.py bot/tests/test_semantic_memory.py`
78
- **Resultado:** `58 passed`.
79
- - `pytest -q --no-cov bot/tests/test_dashboard_routes_v3.py -k semantic_memory`
80
- **Resultado:** `2 passed, 44 deselected`.
81
- - `node --test dashboard/tests/api_contract_parity.test.js`
82
- **Resultado:** `2 passed`.
83
- - `pytest -q --no-cov bot/tests/`
84
- **Resultado:** `927 passed, 4 skipped, 0 warnings`.
85
- - `node --test dashboard/tests/api_contract_parity.test.js dashboard/tests/multi_channel_focus.test.js`
86
- **Resultado:** `23 passed`.
87
- - `python -m bot.dashboard_parity_gate`
88
- **Resultado:** `ok integrated=30 headless_approved=0`.
89
- - `python -m bot.structural_health_gate`
90
- **Resultado:** `ok`.
91
- - `bash validate_plan.sh`
92
- **Resultado:** `Auditoria concluída sem inconsistências`.
93
- - `pre-commit run --all-files`
94
- **Resultado:** todos os hooks passaram (`black`, `ruff`, `ruff-format`, checks auxiliares).
95
- - `pytest -q --no-cov bot/tests/test_persistence_semantic_memory_pgvector.py bot/tests/test_persistence_repositories.py bot/tests/test_persistence_layer.py bot/tests/test_dashboard_routes.py bot/tests/test_dashboard_routes_v3.py`
96
- **Resultado:** `136 passed`.
97
- - `node --test dashboard/tests/api_contract_parity.test.js dashboard/tests/multi_channel_focus.test.js dashboard/tests/dashboard_asset_import_integrity.test.js dashboard/tests/dashboard_semantic_consistency.test.js dashboard/tests/tabs_responsiveness_contract.test.js`
98
- **Resultado:** `30 passed`.
99
 
100
  ---
101
 
102
  ## 6. Regras de Execução por Etapa (Contrato Operacional)
103
 
104
- Toda etapa segue o fluxo obrigatório:
105
-
106
- 1. Implementação.
107
- 2. Validação funcional.
108
- 3. Novos testes para linhas novas (fluxo real).
109
- 4. Ajuste de testes antigos impactados.
110
- 5. Execução dos testes/gates.
111
- 6. Atualização deste plano com rastreabilidade da etapa.
112
- 7. Commit com hook verde (corrigir falhas até passar).
113
 
114
  ---
115
 
 
1
  # Plano de Implementação: Camada de Persistência Stateful (Supabase)
2
 
3
+ **Versão:** 1.35
4
+ **Data:** 01 de Março de 2026
5
+ **Status:** Fases 1-25 concluídas ✅ | Backlog ativo: Fase 26 (Agentic Utilities)
6
 
7
  ---
8
 
 
42
  | **18** | Outbound Webhook API | `bot/outbound_webhooks.py` (engine hmac/retry), `bot/persistence_webhook_repository.py`, endpoints `/api/webhooks` e `/api/webhooks/test`, UI agregada no Control Plane. | `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/api_contract_parity.test.js`, `dashboard/tests/multi_channel_focus.test.js`, `python -m bot.dashboard_parity_gate` | ✅ |
43
  | **19** | Autonomous Clip Suggestion Intelligence | Exposição de métricas e ingest manual em `dashboard/partials/clips_section.html`, engine visual acoplada ao Autonomy (`bot/vision_runtime.py`), endpoints `/api/vision/status` e `/api/vision/ingest` consolidados como `integrated`. | `bot/tests/test_dashboard_routes_v3.py`, `bot/tests/test_dashboard_routes_post.py`, `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` | ✅ |
44
  | **20** | Otimização ANN de Memória Semântica com `pgvector` | `bot/persistence_semantic_memory_repository.py` com busca via RPC (`semantic_memory_search_pgvector`/`semantic_memory_search`) e fallback automático para ranking determinístico atual; flags `SEMANTIC_MEMORY_PGVECTOR_ENABLED` e `SEMANTIC_MEMORY_PGVECTOR_RPC`. | `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_persistence_repositories.py`, `bot/tests/test_persistence_layer.py`, `bot/tests/test_semantic_memory.py`, `bot/tests/test_dashboard_routes_v3.py -k semantic_memory`, `python -m bot.dashboard_parity_gate`, `python -m bot.structural_health_gate`, `node --test dashboard/tests/api_contract_parity.test.js` | ✅ |
45
+ | **21** | Tuning operacional ANN + diagnóstico de engine semântica | `bot/persistence_semantic_memory_repository.py` with `SEMANTIC_MEMORY_MIN_SIMILARITY`, busca com `min_similarity`/`force_fallback`, `search_settings_sync()` e `search_entries_with_diagnostics_sync()`; facade expandida em `bot/persistence_layer.py`; rota `/api/semantic-memory` com query params de tuning e payload com `search_settings`/`search_diagnostics`; UI integrada no painel atual em `dashboard/partials/intelligence_panel.html`, `dashboard/features/observability/api.js`, `dashboard/features/observability/controller.js`, `dashboard/features/observability/view.js`. | `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_persistence_repositories.py`, `bot/tests/test_persistence_layer.py`, `bot/tests/test_dashboard_routes.py`, `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/api_contract_parity.test.js`, `dashboard/tests/multi_channel_focus.test.js`, `python -m bot.dashboard_parity_gate`, `python -m bot.structural_health_gate` | ✅ |
46
+ | **22** | Prompt & Persona Studio + model routing por atividade | **Concluída**: `bot/persistence_persona_profile_repository.py` (persona_profiles com `base_identity`, `tonality_engine`, `behavioral_constraints`, `model_routing`), routing per-channel em `bot/logic_inference.py`, Studio UI no Control Plane. | Testes em `bot/tests/scientific/test_phase22_persona_routing.py`, rotas validadas e 100% de paridade dashboard. | ✅ |
47
+ | **23** | Geração de arte ASCII sob demanda no chat Twitch | **Concluída**: Upgrade para motor **Braille 2x4 nativo** em `bot/ascii_art_runtime.py` (densidade superior, aspect-ratio corrigido), busca via DuckDuckGo e cooldown tático por canal. | `tests/test_ascii_art_runtime.py` (20 testes passando), validação visual de Pikachu/Batman Logos no chat. | ✅ |
48
+ | **24** | Calendário Tático (Tactical Calendar) nativo no Dashboard| **Concluída**: Evolução do sistema de Goals em `bot/control_plane_config.py` suportando cron-jobs e horários fixos via `croniter`. Timeline visual Vanilla JS no Control Plane. | `bot/tests/test_control_plane_config.py` (casos de cron/fixed_time), paridade de contrato mantida. | ✅ |
49
+ | **25** | Dashboard UX Upgrade: Thumbs de Clips | **Concluída**: Injeção de `thumbnail_url` real da Twitch, spinners de loading CSS e layout fixo 16:9 no painel de clips. | Testes de paridade de UI e gates de integridade estrutural ok. | ✅ |
50
+ | **26** | Agentic Utilities & Engagement Engine (Evolução Nightbot) | **Planejada**: Implementação de utilitários clássicos (Giveaways, Polls, Timers) evoluídos para o modelo de agência autônoma. Uso do `SentimentEngine` para sorteios por mérito e enquetes preditivas. | **Planejada**: Novos módulos `bot/giveaway_runtime.py`, integração no `autonomy_runtime.py` e novas tabelas Supabase. | ⏳ |
51
 
52
  ---
53
 
 
57
  | :------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
  | Contexto por canal focado | `/api/observability?channel=`, `/api/channel-context`, `/api/observability/history` | `Agent Context & Internals` | `dashboard/tests/multi_channel_focus.test.js`, `bot/tests/test_dashboard_routes_v3.py` |
59
  | Memória semântica | `/api/semantic-memory` (`GET/PUT`) + busca ANN opcional via `pgvector` no `SemanticMemoryRepository` com fallback determinístico | `Intelligence Overview` | `bot/tests/test_semantic_memory.py`, `bot/tests/test_persistence_semantic_memory_pgvector.py`, `bot/tests/test_dashboard_routes.py`, `dashboard/tests/multi_channel_focus.test.js` |
60
+ | Persona Studio & Model Routing | `/api/persona-profile` (`GET/PUT`) + routing dinâmico em `bot/logic_inference.py` | `Control Plane` (Seção Persona Studio & Model Routing) | `bot/tests/scientific/test_phase22_persona_routing.py`, `bot/tests/test_dashboard_routes_v3.py`, `dashboard/tests/api_contract_parity.test.js` |
61
+ | Tactical Calendar | `consume_due_goals` em `ControlPlaneConfigRuntime` com suporte a `cron` e `fixed_time` | `Control Plane` (Seção Tactical Calendar Timeline) | `bot/tests/test_control_plane_config.py`, `dashboard/tests/api_contract_parity.test.js` |
62
  | Stream health score | `/api/sentiment/scores` | `metrics_health`, `intelligence_panel`, `agent_context_internals` | `bot/tests/test_stream_health_score.py`, `dashboard/tests/multi_channel_focus.test.js` |
63
+ | Clips Pipeline (Visual) | `ClipJobsRuntime` + `thumbnail_url` in `/api/clip-jobs` | `Clips Pipeline` (Cards with real-time thumbnails) | `dashboard/tests/api_contract_parity.test.js`, `bot/dashboard_parity_gate.py` |
 
 
 
 
 
 
 
 
64
 
65
  ---
66
 
67
+ ## 4. Backlog Prioritário (Fases Futuras e Detalhamento)
68
 
69
+ ### 4.1 Detalhamento da Fase 22: Persona Studio & Model Routing (Concluída)
70
+ - Migração de identidade para `persona_profiles` no Supabase.
71
+ - Implementação do Intelligent Router: `DeepSeek-V3-Fast` (Chat), `DeepSeek-R1` (Reasoning), `Qwen-Coder` (Tools/JSON).
72
+ - Injeção dinâmica de `banned_topics`, `cta_triggers` e `sentence_style` no System Prompt.
73
+
74
+ ### 4.2 Detalhamento da Fase 23: Braille Pro ASCII Engine (Concluída)
75
+ - Pivot de `ascii-magic` para motor nativo **Braille 2x4 Matrix**.
76
+ - Resolução visual 4x superior via Unicode `U+2800`.
77
+ - Algoritmo de **Auto-Crop** e **Dynamic Thresholding** (Pillow).
78
+ - Correção de aspect-ratio (`0.75`) para compensar altura de linha do chat Twitch.
79
+
80
+ ### 4.3 Detalhamento da Fase 24: Tactical Calendar (Concluída)
81
+ - Integração de `croniter` para processamento de expressões cron.
82
+ - Suporte a agendamentos `fixed_time` (ISO UTC) com desativação automática pós-uso.
83
+ - UI: Timeline tática *read-only* listando eventos futuros de forma limpa.
84
+
85
+ ### 4.4 Detalhamento da Fase 25: Dashboard UX (Concluída)
86
+ - Thumbnails reais nos cards de clips via API Helix.
87
+ - Spinners de estado `polling` e `creating`.
88
+ - Fixação de `aspect-ratio: 16/9` para estabilidade visual (Core Web Vitals).
89
+
90
+ ### 4.5 Escopo planejado da Fase 26: Agentic Utilities (Evolução Nightbot)
91
+
92
+ **Objetivo**: Implementar utilitários de engajamento (Sorteios, Enquetes, Timers) evoluídos para o modelo de agência autônoma do Byte. Diferente de bots estáticos, o Byte usará o `SentimentEngine`, a `Persona` e o histórico de chat para tomadas de decisão inteligentes e proativas.
93
+
94
+ #### 4.5.1 Giveaway System (Merit-Aware)
95
+ - **Mecânica Agentica**: Criação do módulo `bot/giveaway_runtime.py`.
96
+ - **Diferencial**: O bot rastreia a participação positiva. No momento do sorteio, o peso do usuário no pool é calculado com base no `sentiment_score` acumulado e tempo de atividade real, garantindo que a recompensa vá para membros ativos da comunidade.
97
+ - **Persistência**: Tabelas `channel_giveaways` e `giveaway_participants` no Supabase para gerenciar estados e histórico de ganhadores por canal.
98
+
99
+ #### 4.5.2 Autonomous Predictive Polls
100
+ - **Mecânica Agentica**: Extensão do `autonomy_runtime.py` para detectar padrões de dúvida ou debate no chat (via LLM ou parser de intenção leve).
101
+ - **Ação**: O bot lança uma enquete oficial (via Twitch API) sem intervenção do streamer quando percebe que a comunidade está dividida sobre um tema da live (ex: "Qual arma usar?", "Próximo jogo?").
102
+ - **Dashboard**: Painel de controle de enquetes ativas e histórico de resultados na aba Intelligence.
103
+
104
+ #### 4.5.3 Strategic Timers (Contextual Interjections)
105
+ - **Mecânica Agentica**: Substituir o timer fixo por um `StrategicInterjectionHandler`.
106
+ - **Lógica**: Em vez de postar a cada X minutos, o bot monitora a densidade e o tópico do chat. Ele injeta mensagens de CTA (Social, Subs, Parcerias) em momentos de "baixa energia" ou quando o assunto for correlato, tornando a inserção orgânica.
107
+ - **Configuração**: Nova seção no Persona Studio para gerenciar mensagens de CTA e pesos de prioridade.
108
 
109
  ---
110
 
111
  ## 5. Evidências de Validação (ciclo de auditoria atual)
112
 
113
+ - `pytest -q --no-cov bot/tests/` -> **1000 passed**.
114
+ - `python -m bot.structural_health_gate` -> **ok (10.00/10)**.
115
+ - `node --test dashboard/tests/api_contract_parity.test.js` -> **23 passed**.
116
+ - `python -m bot.dashboard_parity_gate` -> **ok (integrated=32)**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  ---
119
 
120
  ## 6. Regras de Execução por Etapa (Contrato Operacional)
121
 
122
+ Toda etapa segue o fluxo obrigatório: Implementação -> Validação Funcional -> Testes Científicos -> Health Gates -> Documentação -> Commit.
 
 
 
 
 
 
 
 
123
 
124
  ---
125
 
docs/NIGHTBOT_COMPETITIVE_REPORT.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Report: Nightbot Legacy Utilities vs. Byte AI Evolution
2
+ **Status:** Competitive Analysis for Strategic Implementation
3
+
4
+ Nightbot is the industry standard for "Utility Bots" (2014-2024). Its core value lies in simple, reliable tools for streamers. To achieve total dominance, **Byte** should not just copy these features, but evolve them using AI context-awareness.
5
+
6
+ ## 1. Core Utilities Mapping
7
+
8
+ | Feature | Nightbot Mechanics (Static) | Byte AI Evolution (Contextual) |
9
+ | :--- | :--- | :--- |
10
+ | **Giveaways** | Random draw from users who typed a keyword in a fixed window. | **Merit-Aware Rewards:** Weight entries based on chat sentiment, hype contribution, or "community loyalty" detected via history. |
11
+ | **Polls** | Manual creation of Q&A. Users vote via static keywords (1, 2, 3). | **Predictive Polling:** Byte detects a debate in chat (e.g., "Which gun is better?") and launches a poll automatically to resolve it. |
12
+ | **Song Requests** | Linear queue of links. Blacklist based on keywords. | **Vibe-Matched Music:** Reorders queue based on stream energy (Hype vs. Chill) and auto-filters "troll" audio via metadata analysis. |
13
+ | **Timers** | Post message X every Y minutes strictly. | **Strategic Interjections:** Byte waits for a "natural pause" in chat to promote a link, or injects it when the topic becomes relevant. |
14
+ | **Auto-Mod** | Thresholds for CAPS, Emotes, Links, and banned words. | **Intent-Based Moderation:** Allows "HYPE CAPS" during wins but flags "Aggressive CAPS" during toxic arguments. |
15
+
16
+ ## 2. Implementation Roadmap for Byte Utilities
17
+
18
+ ### A. The "Engagement" Module (Priority: High)
19
+ * **Giveaway System:** Add a specialized `GiveawayManager` that tracks active participants. Integration with Supabase to persist "Community Score" for weighting draws.
20
+ * **Poll System:** Use the existing `SentimentEngine` to trigger "Vibe Polls" or "Decision Polls" through autonomous goals.
21
+
22
+ ### B. The "Vibe Guard" (Priority: Medium)
23
+ * **Dynamic Spam Control:** Upgrade `byte_semantics_quality.py` to allow different emote/caps density based on the `stream_vibe` (e.g., allow more spam during "Hype" vibe).
24
+
25
+ ### C. The "Music Director" (Priority: Low)
26
+ * **Smart SR:** A dedicated `SongRequestRuntime` that interfaces with YouTube/Spotify API, using AI to categorize the mood of the song and matching it to the current `current_game`.
27
+
28
+ ## 3. Why this wins the Market
29
+ While Nightbot requires the streamer to be a "Bot Operator" (typing commands, managing lists), Byte acts as a **"Co-Producer"**. It handles the "boring" utility tasks autonomously by reading the room.
30
+
31
+ ---
32
+ *Created for VÉRTICE Core Analytics x BYTE AI - March 2026*
docs/OPERATIONS_GUIDE.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 📖 Guia de Operação: BYTE AI (Manual Antiburro)
2
+ **Versão:** 1.0 (Março 2026)
3
+ **Objetivo:** Operar o Byte com 100% de eficiência, sem quebras e com o máximo de performance.
4
+
5
+ ---
6
+
7
+ ## 1. Configuração Inicial (The "Must-Have")
8
+
9
+ ### 1.1 Variáveis de Ambiente (.env)
10
+ O Byte não perdoa erros no `.env`. Certifique-se de preencher:
11
+ - `BYTE_DASHBOARD_ADMIN_TOKEN`: Crie uma senha forte (32+ chars). Sem ela, a dashboard não salva nada.
12
+ - `NEBIUS_API_KEY`: Sua chave da Nebius AI.
13
+ - `SUPABASE_URL` / `SUPABASE_KEY`: Credenciais do seu banco de dados.
14
+ - `TWITCH_USER_TOKEN`: Deve começar com `oauth:`.
15
+
16
+ ### 1.2 O Banco de Dados (Supabase)
17
+ Certifique-se de que a extensão `pgvector` está ativa no seu banco Supabase. O Byte usa isso para a **Memória Semântica**.
18
+
19
+ ---
20
+
21
+ ## 2. Dashboard: O Centro de Comando
22
+
23
+ Acesse via `http://localhost:8080` (ou sua URL de deploy).
24
+
25
+ ### 2.1 Aba: Control Plane (Configuração)
26
+ Aqui você define **quem o bot é**.
27
+ - **Persona Studio:** No final da página, abra o "Persona Studio".
28
+ - **Lore:** Escreva a história do bot (ex: "Você é um assistente tático de Valorant").
29
+ - **Model Routing:** Escolha `Kimi-K2-Thinking` para coaching e `Kimi-K2.5` para chat.
30
+ - **Goals Scheduler:** Adicione metas.
31
+ - **Interval:** "Fale algo a cada 600 segundos".
32
+ - **Fixed Time:** Use para eventos únicos. **IMPORTANTE:** O horário deve ser em **UTC**. Se você está no Brasil (BRT), subtraia 3 horas do horário desejado.
33
+ - **Cron:** Use `0 20 * * 5` para "Toda sexta às 20h UTC".
34
+
35
+ ### 2.2 Aba: Operation (Observabilidade)
36
+ - **Stream Health:** Se a barra estiver vermelha, o chat está "morno" ou negativo. O Byte tentará intervir sozinho se a Autonomia estiver ligada.
37
+ - **Sentiment Scores:** Veja em tempo real se o público está empolgado (`Hype`) ou confuso.
38
+
39
+ ### 2.3 Aba: Clips Pipeline
40
+ - Quando o Byte detectar um momento épico, o card aparecerá aqui.
41
+ - O card terá um **spinner** (processando) e depois uma **Thumbnail**.
42
+ - Você pode clicar em **Edit** para mudar o título na Twitch antes de postar.
43
+
44
+ ---
45
+
46
+ ## 3. Comandos de Elite (Chat Twitch)
47
+
48
+ O Byte entende linguagem natural, mas alguns comandos são otimizados:
49
+
50
+ ### 3.1 Arte ASCII (Braille 2x4)
51
+ - `byte arte ascii do goku`
52
+ - `byte ascii batman`
53
+ - **Regra:** Existe um cooldown de 30 segundos por canal. Se pedir rápido demais, ele ignora para evitar ban da Twitch.
54
+
55
+ ### 3.2 Status e Ajuda
56
+ - `byte status`: O bot reporta a saúde do sistema e o modelo que está usando.
57
+ - `byte help`: Lista as capacidades básicas.
58
+
59
+ ---
60
+
61
+ ## 4. Resolução de Problemas (Troubleshooting)
62
+
63
+ | Problema | Causa Provável | Solução |
64
+ | :--- | :--- | :--- |
65
+ | **Dashboard não salva** | Token Admin errado ou ausente. | Verifique o `BYTE_DASHBOARD_ADMIN_TOKEN` no seu `.env` e no campo de login da Dash. |
66
+ | **Bot não responde** | Token Twitch expirado. | O bot tenta renovar sozinho, mas se falhar, gere um novo token no Twitch Token Generator. |
67
+ | **Erro de Modelo (Inference)** | Saldo na Nebius ou Model Name errado. | Verifique seu dashboard na Nebius AI Studio. |
68
+ | **ASCII Art borrada** | Imagem original com pouco contraste. | Tente temas com silhuetas claras (logos, personagens de anime). |
69
+
70
+ ---
71
+
72
+ ## 5. Regras de Ouro (Constituição Vértice)
73
+ 1. **Nunca force um restart** enquanto um clipe está em `polling`. Você perderá o rastro do job.
74
+ 2. **Respeite o Orçamento (Budget):** Se você configurou 10 mensagens por hora, o Byte vai parar de falar na 11ª. Ajuste isso no Control Plane se necessário.
75
+ 3. **UTC é Vida:** Lembre-se sempre que o servidor e o calendário falam em UTC.
76
+
77
+ ---
78
+ *Guia mantido por VÉRTICE Core Analytics — 2026*
docs/nebius/ASSET_CHECKLIST.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nebius Partnership — Asset Checklist
2
+ ## Materials Juan needs to prepare
3
+
4
+ ---
5
+
6
+ ## Required (Before First Contact)
7
+
8
+ ### 1. Screenshots / Screen Recordings
9
+
10
+ - [ ] **Dashboard Overview**: Full screenshot of the main dashboard with all panels visible (Observability, Intelligence, Control Plane)
11
+ - [ ] **Tactical Calendar**: Screenshot of the new timeline showing upcoming cron/fixed events
12
+ - [ ] **Clips Pipeline (Visual)**: Screenshot showing the clips list with real thumbnails and loading spinners
13
+ - [ ] **Twitch Chat — ASCII Art**: Screenshot of the agent delivering high-density Braille ASCII art in chat
14
+ - [ ] **Control Plane — Persona Studio**: Screenshot showing the persona studio section with filled-in fields (name, tone, emote vocab, model routing)
15
+ - [ ] **Observability Panel**: Screenshot showing real-time stream health, sentiment scores, event timeline
16
+ - [ ] **Action Queue**: Screenshot showing clip candidates in the approval queue
17
+ - [ ] **Clip Jobs Panel**: Screenshot showing clips in various states (queued, polling, ready)
18
+ - [ ] **HUD Overlay**: Screenshot of the streamer overlay view
19
+ - [ ] **Terminal running**: Screenshot of the agent running in terminal with Nebius inference logs visible
20
+
21
+ ### 2. Demo Video (1-3 min)
22
+
23
+ - [ ] **Live demo clip** showing: Agent responding in Twitch chat → Dashboard updating in real-time → Approval flow for an autonomous action
24
+ - [ ] Can be recorded with OBS or screen capture. Show the chat + dashboard side by side.
25
+
26
+ ### 3. Architecture Diagram (High-Res)
27
+
28
+ - [ ] The `assets/architecture-byte-flow-novo.png` from the README — verify it's current and mentions Nebius prominently
29
+ - [ ] If outdated, update to show: Nebius as the "brain", Supabase as "memory", Twitch as "arms", Dashboard as "eyes"
30
+
31
+ ### 4. Personal / Company Info
32
+
33
+ - [ ] Your professional email (for the contact section)
34
+ - [ ] LinkedIn profile URL
35
+ - [ ] Brief 2-line bio for the proposal
36
+
37
+ ---
38
+
39
+ ## Recommended (Nice to Have)
40
+
41
+ ### 5. Metrics / Logs
42
+
43
+ - [ ] **Inference log sample**: Anonymized log showing a chat session with Nebius API calls (model, tokens, latency)
44
+ - [ ] **Token usage estimate**: Monthly tokens consumed during the current single-streamer testing phase
45
+ - [ ] **Uptime data**: How long has the HF Space been running? Any uptime screenshots from HF dashboard?
46
+
47
+ ### 6. User Testimonials / Community
48
+
49
+ - [ ] Any feedback from streamers who tested Byte?
50
+ - [ ] Community Discord or Twitch clips showing Byte in action?
51
+
52
+ ### 7. Competitive Landscape Slide
53
+
54
+ - [ ] Simple comparison table: Byte vs. manual moderation vs. Nightbot vs. StreamElements
55
+ - [ ] Key differentiator: Byte is an **autonomous agent**, not a command-response bot
56
+
57
+ ---
58
+
59
+ ## File Naming Convention
60
+
61
+ Save all assets to: `/docs/nebius/assets/`
62
+
63
+ ```
64
+ docs/nebius/
65
+ ├── PARTNERSHIP_PROPOSAL.md ← Main document (DONE)
66
+ ├── TECHNICAL_BRIEF.md ← Technical architecture (DONE)
67
+ ├── ASSET_CHECKLIST.md ← This file (DONE)
68
+ └── assets/
69
+ ├── dashboard-overview.png
70
+ ├── persona-studio.png
71
+ ├── observability-panel.png
72
+ ├── action-queue.png
73
+ ├── clip-jobs.png
74
+ ├── hud-overlay.png
75
+ ├── terminal-running.png
76
+ ├── architecture-diagram.png
77
+ └── demo-video.mp4
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Nebius Contact Channels
83
+
84
+ | Channel | URL | Notes |
85
+ |---|---|---|
86
+ | Partners page | https://nebius.ai/partners | Check for application form |
87
+ | Developer Relations | Look for devrel@ or partnerships@ | Usually listed on careers/about |
88
+ | LinkedIn | Search "Nebius AI" company page | DM to head of partnerships |
89
+ | Twitter/X | @nebaborai or similar | Public visibility, DM for intro |
90
+ | Discord / Community | Check nebius.ai for community links | Developer community channel |
91
+
92
+ > **Tip:** Start with LinkedIn. Find the **Head of Developer Relations** or **Partnerships Lead** at Nebius. Send a brief message referencing Byte's open source nature and production Nebius usage, then offer to share the full proposal.
93
+
94
+ ---
95
+
96
+ ## Email Template (First Contact)
97
+
98
+ ```
99
+ Subject: Partnership Proposal — Production AI Agent powered by Nebius Inference
100
+
101
+ Hi [Name],
102
+
103
+ I'm Juan Carlos, developer of Byte — an open-source AI Agent Runtime
104
+ for Twitch streaming operations. Byte is 100% powered by Nebius AI
105
+ (Kimi K2.5 / K2-Thinking) and is currently deployed on HuggingFace
106
+ Spaces.
107
+
108
+ The system has 89 Python modules, 1,000 passing tests, and a full
109
+ operational dashboard. Every intelligent decision the agent makes
110
+ uses Nebius inference.
111
+
112
+ I'm looking to scale from single-streamer to multi-tenant (agencies
113
+ managing 20+ streamers). I'd love to explore a partnership:
114
+ inference credits, co-marketing, or infrastructure support.
115
+
116
+ Happy to share the full technical brief and a live demo.
117
+
118
+ Best,
119
+ Juan Carlos
120
+ GitHub: JuanCS-Dev
121
+ ```
docs/nebius/PARTNERSHIP_PROPOSAL.md ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte — AI Agent Runtime for Streaming Operations
2
+ ## Nebius AI Partnership Proposal
3
+
4
+ **From:** Juan Carlos — VÉRTICE Core Analytics × BYTE AI
5
+ **To:** Nebius AI — Partnerships / Developer Relations
6
+ **Date:** March 2026
7
+ **Version:** 1.0
8
+
9
+ ---
10
+
11
+ ## 1. Executive Summary
12
+
13
+ **Byte** is a production-grade AI Agent Runtime purpose-built for live streaming operations on Twitch. It is the first product in a new category: **autonomous stream management infrastructure** — replacing the need for manual moderation, dedicated stream managers, or fragmented multi-tool setups.
14
+
15
+ **Nebius AI is the core inference engine.** Every intelligent decision Byte makes — from chat responses to clip detection to coaching insights — is powered by Nebius-hosted models via the OpenAI-compatible API.
16
+
17
+ We are seeking a **technology partnership** with Nebius to:
18
+ 1. Scale from a single-streamer prototype to a **multi-tenant SaaS** serving agencies that manage dozens of streamers
19
+ 2. Showcase Byte as a **reference application** for Nebius inference in production
20
+ 3. Explore **inference credits or volume pricing** to support the scaling phase
21
+
22
+ ---
23
+
24
+ ## 2. What Byte Does (Product Overview)
25
+
26
+ Byte operates as an always-on AI co-pilot inside Twitch chat. It connects via IRC and EventSub, runs a real-time decision loop, and exposes a full operational dashboard for stream managers.
27
+
28
+ ### Core Capabilities (All Shipping in Production)
29
+
30
+ | Capability | Description | Nebius Models Used |
31
+ |---|---|---|
32
+ | **Intelligent Chat** | Context-aware responses with personality, tone control, and emote vocabulary | Kimi K2.5 (default) |
33
+ | **Real-time Search Grounding** | Web search integration for current events and game updates | Kimi K2.5 (search) |
34
+ | **Deep Reasoning** | Complex analysis for coaching, strategy, and technical questions | Kimi K2-Thinking |
35
+ | **Visual Intelligence** | Screen capture analysis for game state detection and clip triggers | Kimi K2.5 (vision) |
36
+ | **Autonomous Actions** | Goal-driven behavior loop with risk-assessed action queue | Kimi K2.5 |
37
+ | **Clip Pipeline** | AI-detected highlight moments → Twitch clip creation → lifecycle management | Kimi K2.5 |
38
+ | **Chat Sentiment Engine** | Real-time audience mood tracking (hype, confusion, boredom) | Local NLP |
39
+ | **Post-Stream Reports** | Automated narrative summaries with coaching recommendations | Kimi K2.5 |
40
+ | **Tactical Scheduling** | Proactive goal execution via Cron and fixed UTC schedules for long-term strategy | Kimi K2.5 |
41
+ | **Persona Studio** | Per-channel personality, tone, banned topics, CTA triggers | — (config) |
42
+ | **Per-Channel Model Routing** | Override which Nebius model handles each activity per streamer | — (routing) |
43
+
44
+ ### Technical Scale
45
+
46
+ | Metric | Value |
47
+ |---|---|
48
+ | Python modules | 89 |
49
+ | Test suite | 1,000 tests, 0 failures |
50
+ | Quality gates | Parity (32 routes), Structural (10.00/10), Ruff (0 errors) |
51
+ | Dashboard | Full operational UI, vanilla JS, 7 feature panels |
52
+ | API routes | 32 backend endpoints, 25 frontend integrations |
53
+ | Implementation phases | 25 completed |
54
+ | License | MIT (open source) |
55
+
56
+ ---
57
+
58
+ ## 3. Nebius Integration Depth
59
+
60
+ Byte is **not a thin wrapper** around a chat API. Nebius inference is deeply embedded across the entire system.
61
+
62
+ ### 3.1 Model Configuration
63
+
64
+ ```
65
+ NEBIUS_BASE_URL = https://api.studio.nebius.ai/v1
66
+ NEBIUS_MODEL_DEFAULT = moonshotai/Kimi-K2.5 (chat, autonomy, reports)
67
+ NEBIUS_MODEL_SEARCH = moonshotai/Kimi-K2.5 (grounded responses)
68
+ NEBIUS_MODEL_REASONING = moonshotai/Kimi-K2-Thinking (coaching, deep analysis)
69
+ NEBIUS_MODEL_VISION = moonshotai/Kimi-K2.5 (screen analysis)
70
+ ```
71
+
72
+ ### 3.2 Inference Pipeline
73
+
74
+ ```
75
+ User Message → Semantic Parsing → Context Assembly → Nebius Inference → Quality Gate → IRC Delivery
76
+ ↑ ↑
77
+ Persona Profile Model Selection
78
+ (tone, style, (per-channel routing
79
+ constraints) via persona config)
80
+ ```
81
+
82
+ ### 3.3 Continuous Inference Load per Streamer
83
+
84
+ During a single live stream, Byte generates inference requests from:
85
+
86
+ | Source | Frequency | Model |
87
+ |---|---|---|
88
+ | Chat responses | On demand (1-30/hour depending on chat activity) | Default / Search |
89
+ | Autonomy heartbeat | Every 60-300s (configurable) | Default |
90
+ | Clip candidate detection | Every heartbeat cycle | Default |
91
+ | Vision analysis | Every 10-30s (when enabled) | Vision |
92
+ | Coaching insights | On churn risk trigger | Reasoning |
93
+ | Post-stream report | Once per stream | Default |
94
+ | Semantic memory search | On demand | — (local + pgvector) |
95
+
96
+ **A single active streamer generates 50-200+ inference requests per hour.** An agency managing 20 streamers would generate **1,000-4,000+ requests/hour during peak streaming hours.**
97
+
98
+ ---
99
+
100
+ ## 4. The Commercial Opportunity
101
+
102
+ ### 4.1 Current State
103
+
104
+ | Item | Current | Target |
105
+ |---|---|---|
106
+ | Deployment | Hugging Face Spaces (free Docker) | Dedicated cloud (Nebius / hybrid) |
107
+ | Tenancy | Single-streamer | Multi-tenant (agencies) |
108
+ | Pricing | Free (proof of concept) | SaaS subscription |
109
+ | Inference cost | Personal Nebius API key | Volume/partner pricing |
110
+
111
+ ### 4.2 Target Market
112
+
113
+ **Primary:** Stream management agencies (Brazil + LATAM initially)
114
+ - Agencies managing 5-50 streamers each
115
+ - Each streamer = 1 persistent Byte agent instance
116
+ - Monthly streaming volume: 60-200 hours/streamer
117
+
118
+ **Secondary:** Individual professional streamers
119
+ - Streamers with 500+ concurrent viewers
120
+ - Revenue-focused (sponsorships, subscriptions, donations)
121
+
122
+ ### 4.3 Revenue Model
123
+
124
+ | Tier | Streamers | Monthly (est.) | Nebius Inference Volume |
125
+ |---|---|---|---|
126
+ | Solo | 1 | $19-29 | ~10K requests |
127
+ | Team | 5 | $79-129 | ~50K requests |
128
+ | Agency | 20 | $249-499 | ~200K requests |
129
+ | Enterprise | 50+ | Custom | ~500K+ requests |
130
+
131
+ ### 4.4 Why Nebius Wins
132
+
133
+ 1. **Lock-in through production usage**: Byte's entire inference is Nebius-native. Every agency onboarded = guaranteed inference volume.
134
+ 2. **Reference application**: A production AI agent with 1,000 tests, real dashboard, and open source visibility.
135
+ 3. **LATAM market entry**: Streaming is massive in Brazil. Byte is built by a Brazilian developer, in Portuguese, for the Portuguese-speaking market first.
136
+ 4. **Model showcase**: Per-channel model routing means agencies can A/B test Nebius models in production. New model launches get immediate real-world validation.
137
+
138
+ ---
139
+
140
+ ## 5. What We're Asking For
141
+
142
+ ### Tier 1: Inference Partnership
143
+ - **Volume pricing** or inference credits for the scaling phase (6-12 months)
144
+ - **Priority access** to new models for integration testing
145
+ - **Technical support** channel for API edge cases at scale
146
+
147
+ ### Tier 2: Infrastructure Partnership (Ideal)
148
+ - **Compute credits** for dedicated deployment (GPU instances for low-latency inference)
149
+ - **Co-marketing**: Byte as a Nebius reference application / case study
150
+ - **Partner badge**: "Powered by Nebius AI" in all Byte materials
151
+ - **Developer spotlight**: Blog post, social media, conference demos
152
+
153
+ ### Tier 3: Strategic Partnership (Aspirational)
154
+ - **Joint go-to-market** for the LATAM streaming market
155
+ - **Revenue share** model on inference consumed by Byte customers
156
+ - **Early access** to Nebius enterprise features (dedicated endpoints, SLA)
157
+
158
+ ---
159
+
160
+ ## 6. What We Bring to the Table
161
+
162
+ | Asset | Value for Nebius |
163
+ |---|---|
164
+ | **Open source visibility** | MIT license, public GitHub, HuggingFace Space |
165
+ | **Production-grade quality** | 1,000 tests, automated quality gates, zero tech debt |
166
+ | **Real inference workload** | Not synthetic benchmarks — real user conversations, real streaming |
167
+ | **Multi-model showcase** | 4 model slots demonstrating model routing in production |
168
+ | **Content creation** | Willing to produce demos, blog posts, and conference talks |
169
+ | **LATAM market access** | Built for Brazilian Portuguese, targeting the 3rd largest streaming market |
170
+
171
+ ---
172
+
173
+ ## 7. Demo & Proof Points
174
+
175
+ | Item | Link / Location |
176
+ |---|---|
177
+ | Live deployment | [HuggingFace Space](https://huggingface.co/spaces/JuanCS-Dev/twitch-byte-bot) |
178
+ | Source code | GitHub (available on request) |
179
+ | Dashboard demo | Screenshots attached (see Asset Pack) |
180
+ | Architecture diagram | Included in technical brief |
181
+ | Test results | 1,000/1,000 passing, CI audit logs available |
182
+
183
+ ---
184
+
185
+ ## 8. Next Steps
186
+
187
+ 1. **Introductory call** to discuss partnership fit and commercial terms
188
+ 2. **Technical deep-dive** with Nebius engineering (API usage patterns, model selection)
189
+ 3. **Pilot program** with 3 initial agencies (controlled inference volume)
190
+ 4. **Public launch** with co-marketing announcement
191
+
192
+ ---
193
+
194
+ ## 9. Contact
195
+
196
+ **Juan Carlos**
197
+ Developer & Founder, VÉRTICE Core Analytics
198
+ GitHub: JuanCS-Dev
199
+ Email: [YOUR EMAIL]
200
+ Location: Brazil
201
+
202
+ ---
203
+
204
+ *This document is confidential and intended for Nebius AI partnership evaluation.*
docs/nebius/TECHNICAL_BRIEF.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte — Technical Architecture Brief
2
+ ## For Nebius AI Partnership Evaluation
3
+
4
+ ---
5
+
6
+ ## System Overview
7
+
8
+ ```
9
+ ┌─────────────────────────────────────────────────────────────────┐
10
+ │ BYTE AI AGENT RUNTIME │
11
+ │ │
12
+ │ ┌──────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
13
+ │ │ Twitch │ │ Prompt │ │ Inference Engine │ │
14
+ │ │ IRC / │→ │ Flow │→ │ (Nebius AI Studio) │ │
15
+ │ │ EventSub │ │ + Context │ │ │ │
16
+ │ └──────────┘ │ Assembly │ │ ┌─────────────────────┐ │ │
17
+ │ └──────────────┘ │ │ Kimi K2.5 (default) │ │ │
18
+ │ ┌──────────┐ │ │ Kimi K2.5 (search) │ │ │
19
+ │ │ Control │ ┌──────────────┐ │ │ Kimi K2-Think (rsn) │ │ │
20
+ │ │ Plane │→ │ Autonomy │→│ │ Kimi K2.5 (vision) │ │ │
21
+ │ │ Config │ │ Runtime │ │ └─────────────────────┘ │ │
22
+ │ └──────────┘ └──────────────┘ └───────────────────────────┘ │
23
+ │ │
24
+ │ ┌──────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
25
+ │ │ Persona │ │ Sentiment │ │ Dashboard Server │ │
26
+ │ │ Studio │ │ Engine │ │ (32 API routes) │ │
27
+ │ │ + Model │ │ + Stream │ │ (7 feature panels) │ │
28
+ │ │ Routing │ │ Health │ │ (real-time polling) │ │
29
+ │ └──────────┘ └──────────────┘ └───────────────────────────┘ │
30
+ │ │
31
+ │ ┌──────────────────────────────────────────────────────────┐ │
32
+ │ │ Persistence Layer (Supabase) │ │
33
+ │ │ Channel State │ Persona Profiles │ Semantic Memory │ │
34
+ │ │ Agent Notes │ Obs History │ Revenue Attribution │ │
35
+ │ │ Webhooks │ Post-Stream Rpts │ Clip Jobs (Firestore) │ │
36
+ │ └──────────────────────────────────────────────────────────┘ │
37
+ └─────────────────────────────────────────────────────────────────┘
38
+ ```
39
+
40
+ ## Nebius API Usage Pattern
41
+
42
+ ### Client Initialization
43
+ ```python
44
+ from openai import OpenAI
45
+
46
+ client = OpenAI(
47
+ api_key=NEBIUS_API_KEY,
48
+ base_url="https://api.studio.nebius.ai/v1"
49
+ )
50
+ ```
51
+
52
+ ### Model Routing Logic
53
+ ```python
54
+ def _select_model(enable_grounding, is_serious, *, context=None):
55
+ routing = getattr(context, "channel_model_routing", None) or {}
56
+
57
+ if enable_grounding:
58
+ override = routing.get("search")
59
+ return override if override else NEBIUS_MODEL_SEARCH
60
+
61
+ if is_serious:
62
+ override = routing.get("reasoning")
63
+ return override if override else NEBIUS_MODEL_REASONING
64
+
65
+ override = routing.get("chat")
66
+ return override if override else NEBIUS_MODEL_DEFAULT
67
+ ```
68
+
69
+ ### Inference Call (Streaming)
70
+ ```python
71
+ response = client.chat.completions.create(
72
+ model=selected_model,
73
+ messages=assembled_messages,
74
+ temperature=context.inference_temperature or 0.72,
75
+ top_p=context.inference_top_p or 0.85,
76
+ max_tokens=max_tokens,
77
+ stream=True,
78
+ )
79
+ ```
80
+
81
+ ## Module Inventory (89 files)
82
+
83
+ | Category | Files | Description |
84
+ |---|---|---|
85
+ | **Inference** | logic_inference, logic_grounding, logic_context | Core LLM interaction via Nebius |
86
+ | **Autonomy** | autonomy_runtime, autonomy_logic, control_plane* | Goal-driven autonomous behavior |
87
+ | **Chat** | irc_*, eventsub_*, prompt_flow, prompt_runtime | Twitch integration layer |
88
+ | **Observability** | observability_*, stream_health_score, sentiment_* | Real-time monitoring |
89
+ | **Persistence** | persistence_*, supabase_client | 9 repository modules (Supabase) |
90
+ | **Dashboard** | dashboard_server*, dashboard_http_helpers | HTTP server + 32 API routes |
91
+ | **Features** | clips, coaching, vision, semantic_memory, webhooks | Specialized capabilities |
92
+ | **Engagement** | ascii_art_runtime | Proprietary 2x4 Braille ASCII rendering engine |
93
+
94
+ ## Quality Metrics
95
+
96
+ | Metric | Value |
97
+ |---|---|
98
+ | Test count | 1,000 |
99
+ | Test pass rate | 100% |
100
+ | Resilience | Native retry/backoff logic for 429 & timeout errors |
101
+ | Scientific test suites | 12 |
102
+ | Parity routes covered | 32/32 |
103
+ | Structural health score | 10.00/10 |
104
+ | Ruff lint errors | 0 |
105
+ | McCabe complexity violations | 0 |
106
+ | Code duplication violations | 0 |
107
+
108
+ ## Scaling Architecture (Proposed)
109
+
110
+ ```
111
+ ┌──────────────────────┐
112
+ │ Load Balancer │
113
+ └──────────┬───────────┘
114
+ │
115
+ ┌────────────────┼────────────────┐
116
+ │ │ │
117
+ ┌─────────▼──────┐ ┌──────▼───────┐ ┌──────▼───────┐
118
+ │ Agent Pool A │ │ Agent Pool B │ │ Agent Pool C │
119
+ │ (Agency 1) │ │ (Agency 2) │ │ (Agency 3) │
120
+ │ 5 streamers │ │ 12 streamers │ │ 20 streamers │
121
+ └─────────┬──────┘ └──────┬───────┘ └──────┬───────┘
122
+ │ │ │
123
+ └────────────────┼────────────────┘
124
+ │
125
+ ┌──────────▼───────────┐
126
+ │ Nebius AI Studio │
127
+ │ (Shared Inference) │
128
+ │ │
129
+ │ Kimi K2.5 Pool │
130
+ │ Kimi K2-Thinking │
131
+ └──────────────────────┘
132
+ ```
133
+
134
+ **Per-agency isolation:** Each agency gets isolated Supabase schema + dedicated agent pool. All share Nebius inference with per-tenant API key tracking.
docs/tactical_calendar_implementation_plan.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Implementação do Calendário Tático (Tactical Calendar)
2
+ **Status:** Análise Estrutural Corrigida e Proposta Arquitetural Definitiva
3
+
4
+ ## 1. Análise do Sistema Atual de Goals
5
+ Atualmente, o sistema de `goals` (objetivos autônomos do bot) funciona de maneira reativa e periódica:
6
+ - O estado e configuração dos goals ficam no `bot/control_plane_config.py` (`ControlPlaneConfigRuntime`).
7
+ - O runtime de autonomia (`bot/autonomy_runtime.py`) roda um loop (`_heartbeat_loop`) que chama o `_run_tick` periodicamente.
8
+ - O `_run_tick` consome os goals que estão vencidos chamando a função `consume_due_goals` do control plane.
9
+ - Atualmente, um goal vence com base no campo `interval_seconds` (ex: rodar a cada 600 segundos). O control plane guarda em memória o `_next_goal_due_at` e verifica se `time.time() >= due_at`.
10
+
11
+ Isso funciona perfeitamente para "Lembretes a cada 10 minutos", mas **não** atende ao caso de uso de um calendário tático onde o gestor/streamer queira programar: "Disparar a meta X exatamente às 20h00" ou "Rodar o evento Y na sexta-feira às 15h00".
12
+
13
+ ## 2. Abordagens para o Calendário Tático
14
+
15
+ ### Abordagem A: Integração com Google Calendar (Terceiros)
16
+ Neste modelo, o streamer cria eventos no seu Google Calendar marcados com tags específicas (ex: `[BOT-GOAL]`). O bot leria esses eventos via polling ou webhooks.
17
+ - **Prós:** Interface nativa e familiar pelo celular/web do Google.
18
+ - **Contras:** Requer configuração complexa de OAuth2 e credenciais GCP; adiciona ponto de falha externo; difícil de mapear propriedades vitais do bot (risk level, prompt payload) apenas através da descrição do evento do calendário. **(Descartada)**
19
+
20
+ ### Abordagem B: Sistema Nativo Integrado (Recomendada e Validada contra o Código)
21
+ Expandir o `ControlPlaneConfigRuntime` e o Dashboard local para aceitarem horários fixos e cron-jobs.
22
+ *Nota de Auditoria: O ambiente atual do dashboard é estritamente Vanilla JS (sem bundlers, React ou frameworks), e o backend Python tem um controle de tick já pronto e performático.*
23
+ - **Prós:** Nenhuma dependência externa de infraestrutura; controle absoluto do UX; aproveitamento de 100% do loop de autonomia existente (`consume_due_goals`); baixo overhead.
24
+ - **Contras:** Interface visual precisará ser construída utilizando Vanilla JS puro.
25
+
26
+ ---
27
+
28
+ ## 3. Plano de Implementação Sugerido (Nativo Integrado)
29
+
30
+ ### Passo 1: Modificar o Modelo de Dados (Backend Python)
31
+ O dicionário de configuração de `goals` (`_config["goals"]`) no `bot/control_plane_config.py` será estendido sem quebrar compatibilidade reversa.
32
+ Adicionar suporte para novos campos num Goal:
33
+ - `schedule_type`: `"interval"` (padrão atual) | `"fixed_time"` | `"cron"`
34
+ - `scheduled_at`: (Opcional) Timestamp ISO 8601 para agendamento exato de disparo único (ex: `2026-03-01T20:00:00Z`).
35
+ - `cron_expression`: (Opcional) Para eventos recorrentes nativos, ex: `0 20 * * 5` (toda sexta às 20h).
36
+
37
+ *Solução Backend:* Na função `consume_due_goals`, a lógica atual de `_next_goal_due_at` será mantida. Ao calcular o próximo `due_at`, usaremos a biblioteca leve `croniter` para calcular o exato timestamp do próximo acionamento baseado no cron, ou definiremos o `due_at` com base no `scheduled_at`. **Evitaremos overengineering (como APScheduler)**; o heartbeat atual via `asyncio.sleep` no `_heartbeat_loop` já é perfeito para disparar os checks e acionar o tick.
38
+
39
+ ### Passo 2: Atualizar Endpoints do Control Plane e Persistência
40
+ - Ajustar `bot/dashboard_server_routes.py` para receber, validar e armazenar os novos campos `schedule_type`, `scheduled_at` e `cron_expression`.
41
+ - Adicionar validação estrita (com `croniter.is_valid`) antes de persistir as configurações para não corromper o estado em runtime.
42
+ - Os dados serão mantidos na persistência nativa do Supabase que já gere os configs dos canais e goals.
43
+
44
+ ### Passo 3: Componente Visual no Dashboard (Frontend Vanilla JS)
45
+ Como o dashboard (em `dashboard/features/...`) é puramente Vanilla JS/HTML sem build steps, **rejeitamos soluções React/Shadcn**.
46
+ - **Estratégia de UI Minimalista (Sem Libs Externas de Calendário):** Para a tela do Control Plane, em vez de um "calendário visual de parede", renderizaremos os Goals em formato de **"Timeline de Agendamentos"**.
47
+ - **Formulário de Entrada:** No modal de Action/Goal, usaremos inputs nativos do HTML5 (`<input type="datetime-local">` para horários fixos) e um campo de texto formatado para o Cron Job (com links de ajuda `crontab.guru`).
48
+ - **Alternativa Visual (Caso Necessário Efeito UAU):** Se a visão de calendário em grade for obrigatória, importar via CDN a biblioteca `Vanilla Calendar Pro` (zero dependências, compatível com a arquitetura atual) para montar o calendário mensal.
49
+
50
+ ### Passo 4: Execução Tática no Autonomy Runtime
51
+ Quando o loop de `autonomy_runtime.py` rodar o tick, ele consumirá os goals pontuais e os removerá do array (ou os marcará como inativos se `schedule_type == "fixed_time"`), enquanto os goals com `cron` apenas atualizarão a variável `_next_goal_due_at` para o próximo período. O runtime repassará a prioridade e fará a chamada LLM/sentiment engine assim como hoje faz com os goals por intervalo.
52
+
53
+ ## Conclusão
54
+ Esta correção arquitetural foca no que já existe. Aproveitando o array `_next_goal_due_at` nativo no `control_plane_config.py` em conjunto com a lib `croniter` (backend), resolvemos o tempo de execução e agendamento contínuo. No frontend, abraçamos a natureza Vanilla JS do projeto, utilizando componentes nativos da plataforma Web para garantir performance máxima, em total aderência à atual base de código do Vértice.
test_ascii_quality.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from io import BytesIO
3
+
4
+ import requests
5
+ from PIL import Image
6
+
7
+ sys.path.append(".")
8
+ from bot.ascii_art_runtime import ASCII_ART_MAX_LINES, _download_image_sync
9
+
10
+
11
+ def _generate_twitch_braille_true(image_bytes: bytes, width: int = 24) -> list[str]:
12
+ try:
13
+ img = Image.open(BytesIO(image_bytes)).convert("RGBA")
14
+
15
+ # Determine background color based on average edge color to guess transparent PNG intention
16
+ # For simple icons, usually white is better.
17
+ bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
18
+ img = Image.alpha_composite(bg, img).convert("L")
19
+
20
+ # Crops empty white space around the image
21
+ from PIL import ImageChops
22
+
23
+ inv = Image.eval(img, lambda p: 255 - p)
24
+ bbox = inv.getbbox()
25
+ if bbox:
26
+ img = img.crop(bbox)
27
+
28
+ pixel_width = width * 2
29
+ # Braille dots are roughly 2x taller than wide in terminal
30
+ # To avoid squashing, we adjust the aspect ratio.
31
+ aspect_ratio = img.height / img.width
32
+ pixel_height = int(pixel_width * aspect_ratio * 0.75)
33
+
34
+ img = img.resize((pixel_width, pixel_height), Image.Resampling.LANCZOS)
35
+
36
+ # Calculate dynamic threshold (average pixel value minus an offset to catch mid-tones)
37
+ from PIL import ImageStat
38
+
39
+ stat = ImageStat.Stat(img)
40
+ mean = stat.mean[0]
41
+ threshold = min(200, max(50, mean - 20)) # Darker than mean is considered "drawn"
42
+
43
+ braille_map = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]
44
+
45
+ lines = []
46
+ char_height = pixel_height // 4 + (1 if pixel_height % 4 != 0 else 0)
47
+
48
+ for y in range(min(char_height, ASCII_ART_MAX_LINES)):
49
+ line = ""
50
+ for x in range(width):
51
+ char_val = 0
52
+ for dy in range(4):
53
+ for dx in range(2):
54
+ px = x * 2 + dx
55
+ py = y * 4 + dy
56
+ if px < pixel_width and py < pixel_height:
57
+ # if pixel is darker than threshold, add dot
58
+ if img.getpixel((px, py)) < threshold:
59
+ char_val += braille_map[dy][dx]
60
+ # Only append non-empty characters to preserve padding
61
+ # If char_val == 0, use standard space instead of braille empty space (helps rendering)
62
+ if char_val == 0:
63
+ line += " "
64
+ else:
65
+ line += chr(0x2800 + char_val)
66
+ lines.append(line.rstrip())
67
+ return lines
68
+ except Exception as e:
69
+ print(e)
70
+ return []
71
+
72
+
73
+ def test_quality(url, name):
74
+ print(f"\n======== TESTANDO: {name} ========")
75
+ image_bytes = _download_image_sync(url)
76
+ if not image_bytes:
77
+ print("Falha no download.")
78
+ return
79
+ print("\n--- TWITCH TRUE BRAILLE (ASPECT + STRIP) ---")
80
+ lines = _generate_twitch_braille_true(image_bytes, width=28)
81
+ print("\n".join(lines))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ test_quality(
86
+ "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/200px-React-icon.svg.png",
87
+ "React Logo",
88
+ )
89
+ test_quality(
90
+ "https://upload.wikimedia.org/wikipedia/en/thumb/8/87/Batman_DC_Comics.png/250px-Batman_DC_Comics.png",
91
+ "Batman Logo",
92
+ )
93
+ test_quality(
94
+ "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", "Pikachu"
95
+ )
tests/test_ascii_art_runtime.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Testes focados para Fase 23: ASCII Art Runtime (Versão Braille Pro)."""
2
+
3
+ import asyncio
4
+ import io
5
+ import time
6
+ from unittest.mock import AsyncMock, MagicMock, patch
7
+
8
+ import pytest
9
+ from PIL import Image
10
+
11
+ # Importa módulos a serem testados
12
+ from bot.ascii_art_runtime import (
13
+ AsciiArtCooldown,
14
+ AsciiArtResult,
15
+ _generate_ascii_with_lib,
16
+ _sanitize_ascii_lines,
17
+ generate_ascii_art,
18
+ get_cooldown_status,
19
+ handle_ascii_art_prompt,
20
+ reset_cooldown,
21
+ )
22
+ from bot.byte_semantics_base import (
23
+ _normalize_ascii_prompt,
24
+ extract_ascii_subject,
25
+ is_ascii_art_prompt,
26
+ )
27
+
28
+
29
+ class TestAsciiArtCooldown:
30
+ def test_cooldown_flow(self):
31
+ cd = AsciiArtCooldown(cooldown_seconds=1.0)
32
+ channel = "test_chan"
33
+
34
+ can, rem = cd.can_use(channel)
35
+ assert can is True
36
+
37
+ cd.mark_used(channel)
38
+ can, rem = cd.can_use(channel)
39
+ assert can is False
40
+ assert rem > 0.0
41
+
42
+ time.sleep(1.1)
43
+ can, rem = cd.can_use(channel)
44
+ assert can is True
45
+
46
+
47
+ class TestAsciiSemantics:
48
+ def test_detection(self):
49
+ assert is_ascii_art_prompt("byte arte ascii de goku") is True
50
+ assert is_ascii_art_prompt("byte ascii batman") is True
51
+ assert is_ascii_art_prompt("oi byte") is False
52
+
53
+ def test_extraction(self):
54
+ assert extract_ascii_subject("byte arte ascii de goku") == "goku"
55
+ assert extract_ascii_subject("byte ascii batman") == "batman"
56
+
57
+
58
+ class TestAsciiGeneration:
59
+ def _get_valid_image_bytes(self):
60
+ img = Image.new("RGB", (10, 10), color="black")
61
+ buf = io.BytesIO()
62
+ img.save(buf, format="PNG")
63
+ return buf.getvalue()
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_generate_ascii_success(self):
67
+ reset_cooldown("test_channel_success")
68
+ mock_image = self._get_valid_image_bytes()
69
+
70
+ with (
71
+ patch("bot.ascii_art_runtime._search_image", return_value="http://img.com/1.png"),
72
+ patch("bot.ascii_art_runtime._download_image", return_value=mock_image),
73
+ ):
74
+ result = await generate_ascii_art("goku", "test_channel_success")
75
+ assert result.success is True
76
+ assert len(result.lines) > 0
77
+ # Check for Braille dots
78
+ has_braille = any(ord(c) >= 0x2800 for line in result.lines for c in line)
79
+ assert has_braille is True
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_generate_ascii_no_image(self):
83
+ reset_cooldown("test_channel_fail")
84
+ with patch("bot.ascii_art_runtime._search_image", return_value=None):
85
+ result = await generate_ascii_art("nao_existe_isso", "test_channel_fail")
86
+ assert result.success is False
87
+ assert "Não encontrei" in result.error_message
88
+
89
+
90
+ class TestHandleAsciiArtPrompt:
91
+ @pytest.mark.asyncio
92
+ async def test_handle_flow(self):
93
+ reset_cooldown("test_chan_handle")
94
+ reply_mock = AsyncMock()
95
+ img = Image.new("RGB", (10, 10), color="black")
96
+ buf = io.BytesIO()
97
+ img.save(buf, format="PNG")
98
+ mock_image = buf.getvalue()
99
+
100
+ with (
101
+ patch("bot.ascii_art_runtime._search_image", return_value="http://img.com/1.png"),
102
+ patch("bot.ascii_art_runtime._download_image", return_value=mock_image),
103
+ ):
104
+ success = await handle_ascii_art_prompt("gato", "user", reply_mock, "test_chan_handle")
105
+ assert success is True
106
+ assert reply_mock.call_count > 0