Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub (Vértice Elite Deploy)
Browse files- README.md +48 -73
- bot/ascii_art_runtime.py +333 -0
- bot/byte_semantics.py +4 -0
- bot/byte_semantics_base.py +46 -0
- bot/byte_semantics_constants.py +1 -1
- bot/control_plane_config.py +18 -4
- bot/control_plane_config_helpers.py +36 -0
- bot/dashboard_parity_gate.py +22 -0
- bot/dashboard_server_routes.py +76 -0
- bot/logic_context.py +64 -0
- bot/logic_inference.py +43 -6
- bot/ops_playbooks.py +1 -1
- bot/persistence_layer.py +49 -0
- bot/persistence_persona_profile_repository.py +252 -0
- bot/prompt_flow.py +20 -0
- bot/prompt_runtime.py +4 -0
- bot/requirements.txt +3 -0
- bot/tests/scientific/suite_scene_irc.py +1 -1
- bot/tests/scientific/test_phase22_debt_free_validation.py +584 -0
- bot/tests/scientific/test_phase22_persona_routing.py +315 -0
- bot/tests/scientific_shared.py +0 -2
- bot/tests/test_control_plane_config.py +58 -0
- bot/tests/test_dashboard_routes_v3.py +95 -0
- bot/tests/test_irc_state_massive.py +1 -1
- bot/tests/test_irc_state_v3.py +2 -2
- bot/tests/test_prompt_flow_v2.py +4 -1
- dashboard/features/clips/view.js +84 -68
- dashboard/features/control-plane/api.js +32 -0
- dashboard/features/control-plane/controller.js +84 -0
- dashboard/features/control-plane/view.js +248 -0
- dashboard/partials/control_plane.html +157 -191
- dashboard/styles/clips.css +138 -67
- dashboard/tests/api_contract_parity.test.js +16 -0
- dashboard/tests/multi_channel_focus.test.js +10 -9
- docs/IMPLEMENTATION_PLAN_PERSISTENCIA_STATEFUL_SUPABASE.md +57 -47
- docs/NIGHTBOT_COMPETITIVE_REPORT.md +32 -0
- docs/OPERATIONS_GUIDE.md +78 -0
- docs/nebius/ASSET_CHECKLIST.md +121 -0
- docs/nebius/PARTNERSHIP_PROPOSAL.md +204 -0
- docs/nebius/TECHNICAL_BRIEF.md +134 -0
- docs/tactical_calendar_implementation_plan.md +54 -0
- test_ascii_quality.py +95 -0
- tests/test_ascii_art_runtime.py +106 -0
README.md
CHANGED
|
@@ -1,94 +1,69 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 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 |
-

|
| 12 |
-
|
| 13 |
-
[](https://huggingface.co/spaces/JuanCS-Dev/twitch-byte-bot)
|
| 14 |
-
[](https://nebius.ai/)
|
| 15 |
-
[](https://supabase.com/)
|
| 16 |
-
[](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 |
-
**
|
| 23 |
|
| 24 |
-
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 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 |
-
##
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
##
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
- `byte movie fact sheet <movie>` - Ficha técnica de filmes.
|
| 52 |
-
- `byte <pergunta livre>` - Inferência direta via Nebius.
|
| 53 |
|
| 54 |
-
|
|
|
|
| 55 |
|
| 56 |
-
##
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 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 |
-
|
| 68 |
|
| 69 |
-
1.
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
-
|
|
|
|
| 79 |
|
| 80 |
---
|
| 81 |
|
| 82 |
-
##
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
-
|
| 86 |
-
-
|
| 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 |
-
*
|
|
|
|
| 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 |
-
|
|
|
|
| 420 |
if force:
|
| 421 |
due_goals.append(copy.deepcopy(goal))
|
| 422 |
-
self._next_goal_due_at[goal_id] =
|
|
|
|
|
|
|
| 423 |
continue
|
| 424 |
|
| 425 |
due_at = self._next_goal_due_at.get(goal_id)
|
| 426 |
if due_at is None:
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
continue
|
| 429 |
|
| 430 |
if now >= due_at:
|
| 431 |
due_goals.append(copy.deepcopy(goal))
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
from bot.runtime_config import NEBIUS_MODEL_DEFAULT, NEBIUS_MODEL_REASONING, NEBIUS_MODEL_SEARCH
|
| 71 |
|
|
|
|
|
|
|
| 72 |
if enable_grounding:
|
| 73 |
-
|
|
|
|
| 74 |
if is_serious:
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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,
|
| 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,
|
| 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,
|
| 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.
|
|
|
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
}
|
| 9 |
|
| 10 |
export function renderClipCard(job) {
|
| 11 |
-
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
<article class="clip-card" data-status="${escapeHtml(status)}" data-id="${escapeHtml(job.job_id)}">
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
<
|
| 40 |
-
<div class="clip-
|
| 41 |
-
<
|
| 42 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
}
|
| 67 |
|
| 68 |
export function renderVisionStatus(payload, els) {
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
| 79 |
}
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
}
|
| 94 |
}
|
|
|
|
| 95 |
}
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 371 |
-
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
.clip-card:hover {
|
| 17 |
-
|
| 18 |
-
|
| 19 |
}
|
| 20 |
|
| 21 |
/* Status Indicator Bar via CSS pseudo-element */
|
| 22 |
.clip-card::before {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
}
|
| 31 |
|
| 32 |
/* Status Color Mapping */
|
| 33 |
-
.clip-card[data-status="queued"] {
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
.clip-card[data-status="
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
.clip-header {
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
}
|
| 46 |
|
| 47 |
.clip-title {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
}
|
| 53 |
|
| 54 |
.clip-meta {
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
}
|
| 59 |
|
| 60 |
.meta-item {
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
}
|
| 68 |
|
| 69 |
.clip-error {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
}
|
| 77 |
|
| 78 |
.clip-actions {
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
}
|
| 83 |
|
| 84 |
/* Skeleton Loading */
|
| 85 |
.clips-skeleton .skeleton-card {
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
}
|
| 92 |
|
| 93 |
@keyframes pulse {
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
.empty-state {
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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.
|
| 4 |
-
**Data:**
|
| 5 |
-
**Status:** Fases
|
| 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`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
| 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 |
-
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
---
|
| 74 |
|
| 75 |
## 5. Evidências de Validação (ciclo de auditoria atual)
|
| 76 |
|
| 77 |
-
- `pytest -q --no-cov bot/tests/
|
| 78 |
-
|
| 79 |
-
- `
|
| 80 |
-
|
| 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
|