Spaces:
Sleeping
Sleeping
Commit ·
89b6a21
0
Parent(s):
fix(deploy): fix 403 Forbidden and AttributeError in dashboard
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +36 -0
- Dockerfile +10 -0
- README.md +32 -0
- bot/Dockerfile +8 -0
- bot/__init__.py +0 -0
- bot/access_control.py +15 -0
- bot/autonomy_logic.py +269 -0
- bot/autonomy_runtime.py +136 -0
- bot/bootstrap_runtime.py +188 -0
- bot/byte_semantics.py +81 -0
- bot/byte_semantics_base.py +132 -0
- bot/byte_semantics_constants.py +98 -0
- bot/byte_semantics_current_events.py +256 -0
- bot/byte_semantics_quality.py +284 -0
- bot/byte_semantics_reply.py +105 -0
- bot/channel_control.py +158 -0
- bot/channel_status.py +64 -0
- bot/clip_jobs_runtime.py +316 -0
- bot/clip_jobs_store.py +137 -0
- bot/control_plane.py +180 -0
- bot/control_plane_actions.py +235 -0
- bot/control_plane_config.py +271 -0
- bot/control_plane_config_helpers.py +119 -0
- bot/control_plane_constants.py +76 -0
- bot/dashboard_server.py +187 -0
- bot/dashboard_server_routes.py +205 -0
- bot/dashboard_server_routes_post.py +162 -0
- bot/eventsub_runtime.py +232 -0
- bot/heartbeat.py +39 -0
- bot/hud_runtime.py +52 -0
- bot/irc_connection.py +135 -0
- bot/irc_handlers.py +181 -0
- bot/irc_management.py +127 -0
- bot/irc_protocol.py +90 -0
- bot/irc_runtime.py +74 -0
- bot/irc_state.py +264 -0
- bot/logic.py +52 -0
- bot/logic_constants.py +41 -0
- bot/logic_context.py +198 -0
- bot/logic_grounding.py +110 -0
- bot/logic_inference.py +225 -0
- bot/main.py +153 -0
- bot/observability.py +5 -0
- bot/observability_analytics.py +144 -0
- bot/observability_helpers.py +36 -0
- bot/observability_snapshot.py +211 -0
- bot/observability_state.py +225 -0
- bot/observability_state_core.py +76 -0
- bot/observability_state_recorders.py +259 -0
- bot/prompt_flow.py +249 -0
.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
| 25 |
+
|
| 26 |
+
# Python cache and virtual env artifacts
|
| 27 |
+
__pycache__/
|
| 28 |
+
*.py[cod]
|
| 29 |
+
*.pyo
|
| 30 |
+
.pytest_cache/
|
| 31 |
+
.ruff_cache/
|
| 32 |
+
|
| 33 |
+
# Local environment files (keep template tracked)
|
| 34 |
+
.env
|
| 35 |
+
.env.*
|
| 36 |
+
!.env.example
|
Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY bot/requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY bot/ /app/bot/
|
| 6 |
+
COPY dashboard/ /app/dashboard/
|
| 7 |
+
# HF Spaces requires port 7860
|
| 8 |
+
ENV PORT=7860
|
| 9 |
+
EXPOSE 7860
|
| 10 |
+
CMD ["python", "-m", "bot.main"]
|
README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Twitch Byte Bot
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# ByteBot Twitch (HF Space)
|
| 12 |
+
|
| 13 |
+
Este é o deploy automático do ByteBot para a Twitch rodando no Hugging Face Spaces via Docker.
|
| 14 |
+
|
| 15 |
+
## Configuração
|
| 16 |
+
|
| 17 |
+
O bot precisa das seguintes variáveis de ambiente (configuradas no painel do Space em **Settings -> Repository Secrets**):
|
| 18 |
+
|
| 19 |
+
- `NEBIUS_API_KEY`
|
| 20 |
+
- `TWITCH_OWNER_ID`
|
| 21 |
+
- `TWITCH_CLIENT_ID`
|
| 22 |
+
- `TWITCH_BOT_ID`
|
| 23 |
+
- `TWITCH_CHANNEL_ID`
|
| 24 |
+
- `TWITCH_BOT_LOGIN`
|
| 25 |
+
- `TWITCH_CHANNEL_LOGIN`
|
| 26 |
+
- `TWITCH_USER_TOKEN`
|
| 27 |
+
- `TWITCH_REFRESH_TOKEN`
|
| 28 |
+
- `BYTE_DASHBOARD_ADMIN_TOKEN`
|
| 29 |
+
- `SUPABASE_URL`
|
| 30 |
+
- `SUPABASE_KEY`
|
| 31 |
+
|
| 32 |
+
O bot usa o `duckduckgo-search` para current events e mantém a sessão ativa via self-heartbeat de 3 em 3 minutos.
|
bot/Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY bot/requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY bot/ /app/bot/
|
| 6 |
+
COPY dashboard/ /app/dashboard/
|
| 7 |
+
EXPOSE 8080
|
| 8 |
+
CMD ["python", "-m", "bot.main"]
|
bot/__init__.py
ADDED
|
File without changes
|
bot/access_control.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def is_owner(user_id: str, owner_id: str) -> bool:
|
| 5 |
+
return str(user_id) == str(owner_id)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def is_moderator(author: Any) -> bool:
|
| 9 |
+
return bool(getattr(author, "is_mod", False)) or bool(
|
| 10 |
+
getattr(author, "is_moderator", False)
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def is_trusted_curator(author: Any, owner_id: str) -> bool:
|
| 15 |
+
return is_owner(getattr(author, "id", ""), owner_id) or is_moderator(author)
|
bot/autonomy_logic.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Any, Callable, Awaitable
|
| 3 |
+
|
| 4 |
+
from bot.byte_semantics import format_chat_reply
|
| 5 |
+
from bot.control_plane import (
|
| 6 |
+
RISK_AUTO_CHAT,
|
| 7 |
+
RISK_CLIP_CANDIDATE,
|
| 8 |
+
RISK_MODERATION_ACTION,
|
| 9 |
+
RISK_SUGGEST_STREAMER,
|
| 10 |
+
control_plane,
|
| 11 |
+
)
|
| 12 |
+
from bot.control_plane_constants import utc_iso
|
| 13 |
+
from bot.hud_runtime import hud_runtime
|
| 14 |
+
from bot.logic import MAX_REPLY_LENGTH, MAX_REPLY_LINES, agent_inference, context
|
| 15 |
+
from bot.observability import observability
|
| 16 |
+
from bot.runtime_config import CHANNEL_ID, ENABLE_LIVE_CONTEXT_LEARNING, client
|
| 17 |
+
|
| 18 |
+
AutoChatDispatcher = Callable[[str], Awaitable[None]]
|
| 19 |
+
|
| 20 |
+
def _clip_line(text: str, max_chars: int = 80) -> str:
|
| 21 |
+
compact = " ".join((text or "").split())
|
| 22 |
+
if len(compact) <= max_chars:
|
| 23 |
+
return compact
|
| 24 |
+
return compact[: max_chars - 3].rstrip() + "..."
|
| 25 |
+
|
| 26 |
+
async def generate_goal_text(prompt: str, risk: str) -> str:
|
| 27 |
+
risk_hint = {
|
| 28 |
+
RISK_AUTO_CHAT: (
|
| 29 |
+
"Crie uma mensagem para o chat com valor imediato para a live. "
|
| 30 |
+
"Objetivo: estimular conversa util sem parecer spam."
|
| 31 |
+
),
|
| 32 |
+
RISK_SUGGEST_STREAMER: (
|
| 33 |
+
"Crie uma sugestao objetiva para o streamer melhorar clareza, ritmo "
|
| 34 |
+
"ou engajamento nos proximos minutos."
|
| 35 |
+
),
|
| 36 |
+
RISK_MODERATION_ACTION: (
|
| 37 |
+
"Identifique risco de moderacao e recomende acao conservadora com justificativa curta."
|
| 38 |
+
),
|
| 39 |
+
RISK_CLIP_CANDIDATE: (
|
| 40 |
+
"Identifique um momento memoravel recente na live. "
|
| 41 |
+
"Se houver algo digno de clipe, descreva o motivo em 1 frase. "
|
| 42 |
+
"Se nao houver nada relevante, responda apenas 'NADA'."
|
| 43 |
+
),
|
| 44 |
+
}.get(risk, "Responda com recomendacao objetiva.")
|
| 45 |
+
|
| 46 |
+
autonomy_prompt = (
|
| 47 |
+
"Modo autonomia Byte para Twitch.\n"
|
| 48 |
+
f"Risco: {risk}\n"
|
| 49 |
+
f"Tarefa: {prompt}\n"
|
| 50 |
+
f"{risk_hint}\n"
|
| 51 |
+
"Contrato de saida: 1 mensagem, no maximo 4 linhas, alta densidade, sem markdown."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
answer = await agent_inference(
|
| 55 |
+
autonomy_prompt,
|
| 56 |
+
"autonomy",
|
| 57 |
+
client,
|
| 58 |
+
context,
|
| 59 |
+
enable_live_context=ENABLE_LIVE_CONTEXT_LEARNING,
|
| 60 |
+
max_lines=MAX_REPLY_LINES,
|
| 61 |
+
max_length=MAX_REPLY_LENGTH,
|
| 62 |
+
)
|
| 63 |
+
safe_text = format_chat_reply(str(answer or ""))
|
| 64 |
+
return safe_text.replace("[BYTE_SPLIT]", "").strip()
|
| 65 |
+
|
| 66 |
+
async def process_autonomy_goal(
|
| 67 |
+
goal: dict[str, Any],
|
| 68 |
+
dispatcher: AutoChatDispatcher | None,
|
| 69 |
+
) -> dict[str, Any]:
|
| 70 |
+
goal_id = str(goal.get("id", "goal") or "goal")
|
| 71 |
+
goal_name = _clip_line(str(goal.get("name", goal_id) or goal_id), max_chars=60)
|
| 72 |
+
risk = str(goal.get("risk", RISK_SUGGEST_STREAMER) or RISK_SUGGEST_STREAMER)
|
| 73 |
+
prompt = str(goal.get("prompt", "") or "").strip()
|
| 74 |
+
safe_prompt = prompt or f"Objetivo autonomo {goal_name}."
|
| 75 |
+
|
| 76 |
+
control_plane.register_goal_run(goal_id=goal_id, risk=risk)
|
| 77 |
+
|
| 78 |
+
generated_text = await generate_goal_text(prompt=safe_prompt, risk=risk)
|
| 79 |
+
if not generated_text:
|
| 80 |
+
control_plane.register_dispatch_failure("goal_generation_empty")
|
| 81 |
+
observability.record_error(
|
| 82 |
+
category="autonomy_goal",
|
| 83 |
+
details=f"{goal_id}: resposta vazia",
|
| 84 |
+
)
|
| 85 |
+
return {
|
| 86 |
+
"goal_id": goal_id,
|
| 87 |
+
"risk": risk,
|
| 88 |
+
"outcome": "generation_empty",
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if risk == RISK_AUTO_CHAT:
|
| 92 |
+
return await _handle_auto_chat(goal_id, risk, goal_name, generated_text, dispatcher)
|
| 93 |
+
|
| 94 |
+
if risk == RISK_CLIP_CANDIDATE:
|
| 95 |
+
return _handle_clip_candidate(goal_id, risk, goal_name, generated_text)
|
| 96 |
+
|
| 97 |
+
return _handle_generic_suggestion(goal_id, risk, goal_name, safe_prompt, generated_text)
|
| 98 |
+
|
| 99 |
+
async def _handle_auto_chat(
|
| 100 |
+
goal_id: str,
|
| 101 |
+
risk: str,
|
| 102 |
+
goal_name: str,
|
| 103 |
+
text: str,
|
| 104 |
+
dispatcher: AutoChatDispatcher | None,
|
| 105 |
+
) -> dict[str, Any]:
|
| 106 |
+
allowed, block_reason, usage = control_plane.can_send_auto_chat()
|
| 107 |
+
if not allowed:
|
| 108 |
+
control_plane.register_budget_block(block_reason)
|
| 109 |
+
observability.record_autonomy_goal(
|
| 110 |
+
risk=risk,
|
| 111 |
+
outcome="budget_blocked",
|
| 112 |
+
details=block_reason,
|
| 113 |
+
)
|
| 114 |
+
return {
|
| 115 |
+
"goal_id": goal_id,
|
| 116 |
+
"risk": risk,
|
| 117 |
+
"outcome": "budget_blocked",
|
| 118 |
+
"block_reason": block_reason,
|
| 119 |
+
"budget_usage": usage,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if dispatcher is None:
|
| 123 |
+
queued = control_plane.enqueue_action(
|
| 124 |
+
kind="autonomy_fallback",
|
| 125 |
+
risk=RISK_SUGGEST_STREAMER,
|
| 126 |
+
title=f"Auto chat sem dispatcher ({goal_name})",
|
| 127 |
+
body=text,
|
| 128 |
+
payload={"goal_id": goal_id, "source_risk": risk},
|
| 129 |
+
created_by="autonomy",
|
| 130 |
+
)
|
| 131 |
+
observability.record_autonomy_goal(
|
| 132 |
+
risk=risk,
|
| 133 |
+
outcome="queued_no_dispatcher",
|
| 134 |
+
details=goal_id,
|
| 135 |
+
)
|
| 136 |
+
return {
|
| 137 |
+
"goal_id": goal_id,
|
| 138 |
+
"risk": risk,
|
| 139 |
+
"outcome": "queued_no_dispatcher",
|
| 140 |
+
"action_id": queued.get("id", ""),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
await dispatcher(text)
|
| 145 |
+
except Exception as error:
|
| 146 |
+
control_plane.register_dispatch_failure(f"auto_chat_send_error:{error}")
|
| 147 |
+
observability.record_error(
|
| 148 |
+
category="autonomy_dispatch",
|
| 149 |
+
details=str(error),
|
| 150 |
+
)
|
| 151 |
+
return {
|
| 152 |
+
"goal_id": goal_id,
|
| 153 |
+
"risk": risk,
|
| 154 |
+
"outcome": "dispatch_error",
|
| 155 |
+
"error": str(error),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
control_plane.register_auto_chat_sent()
|
| 159 |
+
context.remember_bot_reply(text)
|
| 160 |
+
observability.record_reply(text=text)
|
| 161 |
+
observability.record_autonomy_goal(
|
| 162 |
+
risk=risk,
|
| 163 |
+
outcome="auto_chat_sent",
|
| 164 |
+
details=goal_id,
|
| 165 |
+
)
|
| 166 |
+
return {
|
| 167 |
+
"goal_id": goal_id,
|
| 168 |
+
"risk": risk,
|
| 169 |
+
"outcome": "auto_chat_sent",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def _handle_clip_candidate(
|
| 173 |
+
goal_id: str,
|
| 174 |
+
risk: str,
|
| 175 |
+
goal_name: str,
|
| 176 |
+
text: str,
|
| 177 |
+
) -> dict[str, Any]:
|
| 178 |
+
cfg = control_plane.get_config()
|
| 179 |
+
if not cfg.get("clip_pipeline_enabled"):
|
| 180 |
+
observability.record_autonomy_goal(
|
| 181 |
+
risk=risk,
|
| 182 |
+
outcome="disabled",
|
| 183 |
+
details="clip_pipeline_disabled",
|
| 184 |
+
)
|
| 185 |
+
return {
|
| 186 |
+
"goal_id": goal_id,
|
| 187 |
+
"risk": risk,
|
| 188 |
+
"outcome": "disabled",
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if len(text) < 5 or "nada" in text.lower():
|
| 192 |
+
control_plane.register_dispatch_failure("clip_candidate_none")
|
| 193 |
+
observability.record_autonomy_goal(
|
| 194 |
+
risk=risk,
|
| 195 |
+
outcome="no_candidate",
|
| 196 |
+
details="LLM retornou NADA",
|
| 197 |
+
)
|
| 198 |
+
return {
|
| 199 |
+
"goal_id": goal_id,
|
| 200 |
+
"risk": risk,
|
| 201 |
+
"outcome": "no_candidate",
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
now_ts = utc_iso(time.time())
|
| 205 |
+
candidate_payload = {
|
| 206 |
+
"candidate_id": f"clip_{int(time.time())}",
|
| 207 |
+
"broadcaster_id": str(CHANNEL_ID),
|
| 208 |
+
"mode": str(cfg.get("clip_mode_default", "live")),
|
| 209 |
+
"suggested_duration": 30.0,
|
| 210 |
+
"suggested_title": text[:100],
|
| 211 |
+
"source": "autonomy_goal",
|
| 212 |
+
"source_ts": now_ts,
|
| 213 |
+
"context_excerpt": text,
|
| 214 |
+
"dedupe_key": f"clip_{int(time.time() / 60)}",
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
queued_item = control_plane.enqueue_action(
|
| 218 |
+
kind="clip_candidate",
|
| 219 |
+
risk=RISK_CLIP_CANDIDATE,
|
| 220 |
+
title=f"Sugestao de Clip ({goal_name})",
|
| 221 |
+
body=text,
|
| 222 |
+
payload=candidate_payload,
|
| 223 |
+
created_by="autonomy",
|
| 224 |
+
)
|
| 225 |
+
observability.record_autonomy_goal(
|
| 226 |
+
risk=risk,
|
| 227 |
+
outcome="queued",
|
| 228 |
+
details=queued_item.get("id", ""),
|
| 229 |
+
)
|
| 230 |
+
return {
|
| 231 |
+
"goal_id": goal_id,
|
| 232 |
+
"risk": risk,
|
| 233 |
+
"outcome": "queued",
|
| 234 |
+
"action_id": queued_item.get("id", ""),
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
def _handle_generic_suggestion(
|
| 238 |
+
goal_id: str,
|
| 239 |
+
risk: str,
|
| 240 |
+
goal_name: str,
|
| 241 |
+
prompt: str,
|
| 242 |
+
text: str,
|
| 243 |
+
) -> dict[str, Any]:
|
| 244 |
+
action_kind = "suggestion" if risk == RISK_SUGGEST_STREAMER else "moderation_review"
|
| 245 |
+
queued_item = control_plane.enqueue_action(
|
| 246 |
+
kind=action_kind,
|
| 247 |
+
risk=risk,
|
| 248 |
+
title=goal_name,
|
| 249 |
+
body=text,
|
| 250 |
+
payload={
|
| 251 |
+
"goal_id": goal_id,
|
| 252 |
+
"goal_prompt": prompt,
|
| 253 |
+
"requires_confirmation": risk == RISK_MODERATION_ACTION,
|
| 254 |
+
},
|
| 255 |
+
created_by="autonomy",
|
| 256 |
+
)
|
| 257 |
+
observability.record_autonomy_goal(
|
| 258 |
+
risk=risk,
|
| 259 |
+
outcome="queued",
|
| 260 |
+
details=queued_item.get("id", ""),
|
| 261 |
+
)
|
| 262 |
+
if risk == RISK_SUGGEST_STREAMER:
|
| 263 |
+
hud_runtime.push_message(text, source="autonomy")
|
| 264 |
+
return {
|
| 265 |
+
"goal_id": goal_id,
|
| 266 |
+
"risk": risk,
|
| 267 |
+
"outcome": "queued",
|
| 268 |
+
"action_id": queued_item.get("id", ""),
|
| 269 |
+
}
|
bot/autonomy_runtime.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import threading
|
| 3 |
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
| 4 |
+
from typing import Any, Awaitable, Callable
|
| 5 |
+
|
| 6 |
+
from bot.control_plane import control_plane
|
| 7 |
+
from bot.observability import observability
|
| 8 |
+
from bot.runtime_config import logger
|
| 9 |
+
from bot.autonomy_logic import process_autonomy_goal
|
| 10 |
+
|
| 11 |
+
AutoChatDispatcher = Callable[[str], Awaitable[None]]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AutonomyRuntime:
|
| 15 |
+
def __init__(self) -> None:
|
| 16 |
+
self._lock = threading.Lock()
|
| 17 |
+
self._loop: asyncio.AbstractEventLoop | None = None
|
| 18 |
+
self._loop_task: asyncio.Task[Any] | None = None
|
| 19 |
+
self._tick_lock: asyncio.Lock | None = None
|
| 20 |
+
self._auto_chat_dispatcher: AutoChatDispatcher | None = None
|
| 21 |
+
self._mode = "eventsub"
|
| 22 |
+
|
| 23 |
+
def bind(
|
| 24 |
+
self,
|
| 25 |
+
*,
|
| 26 |
+
loop: asyncio.AbstractEventLoop,
|
| 27 |
+
mode: str,
|
| 28 |
+
auto_chat_dispatcher: AutoChatDispatcher | None = None,
|
| 29 |
+
) -> None:
|
| 30 |
+
with self._lock:
|
| 31 |
+
self._loop = loop
|
| 32 |
+
self._mode = (mode or "eventsub").strip().lower() or "eventsub"
|
| 33 |
+
self._auto_chat_dispatcher = auto_chat_dispatcher
|
| 34 |
+
loop.call_soon_threadsafe(self._ensure_loop_task)
|
| 35 |
+
|
| 36 |
+
def unbind(self) -> None:
|
| 37 |
+
with self._lock:
|
| 38 |
+
task = self._loop_task
|
| 39 |
+
self._loop = None
|
| 40 |
+
self._loop_task = None
|
| 41 |
+
self._tick_lock = None
|
| 42 |
+
self._auto_chat_dispatcher = None
|
| 43 |
+
self._mode = "eventsub"
|
| 44 |
+
if task is not None:
|
| 45 |
+
task.cancel()
|
| 46 |
+
control_plane.set_loop_running(False)
|
| 47 |
+
|
| 48 |
+
def _ensure_loop_task(self) -> None:
|
| 49 |
+
with self._lock:
|
| 50 |
+
loop = self._loop
|
| 51 |
+
task = self._loop_task
|
| 52 |
+
if loop is None or loop.is_closed():
|
| 53 |
+
return
|
| 54 |
+
if task is not None and not task.done():
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
created_task = loop.create_task(self._heartbeat_loop())
|
| 58 |
+
with self._lock:
|
| 59 |
+
if self._loop is loop:
|
| 60 |
+
self._loop_task = created_task
|
| 61 |
+
else:
|
| 62 |
+
created_task.cancel()
|
| 63 |
+
|
| 64 |
+
async def _heartbeat_loop(self) -> None:
|
| 65 |
+
control_plane.set_loop_running(True)
|
| 66 |
+
try:
|
| 67 |
+
while True:
|
| 68 |
+
with self._lock:
|
| 69 |
+
active_loop = self._loop
|
| 70 |
+
if active_loop is None or active_loop is not asyncio.get_running_loop():
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
await self._run_tick(force=False, reason="heartbeat")
|
| 75 |
+
except Exception as error:
|
| 76 |
+
logger.error("Autonomy heartbeat falhou: %s", error)
|
| 77 |
+
control_plane.register_dispatch_failure(f"heartbeat_error:{error}")
|
| 78 |
+
observability.record_error(
|
| 79 |
+
category="autonomy_heartbeat",
|
| 80 |
+
details=str(error),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
heartbeat_seconds = int(
|
| 84 |
+
control_plane.get_config().get("heartbeat_interval_seconds", 60)
|
| 85 |
+
)
|
| 86 |
+
await asyncio.sleep(max(15, heartbeat_seconds))
|
| 87 |
+
except asyncio.CancelledError:
|
| 88 |
+
raise
|
| 89 |
+
finally:
|
| 90 |
+
control_plane.set_loop_running(False)
|
| 91 |
+
|
| 92 |
+
async def _run_tick(self, *, force: bool, reason: str) -> dict[str, Any]:
|
| 93 |
+
if self._tick_lock is None:
|
| 94 |
+
self._tick_lock = asyncio.Lock()
|
| 95 |
+
async with self._tick_lock:
|
| 96 |
+
control_plane.touch_heartbeat()
|
| 97 |
+
control_plane.register_tick(reason=reason)
|
| 98 |
+
|
| 99 |
+
due_goals = control_plane.consume_due_goals(force=force)
|
| 100 |
+
processed: list[dict[str, Any]] = []
|
| 101 |
+
|
| 102 |
+
dispatcher = self._get_auto_chat_dispatcher()
|
| 103 |
+
for goal in due_goals:
|
| 104 |
+
processed.append(await process_autonomy_goal(goal, dispatcher))
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"ok": True,
|
| 108 |
+
"reason": reason,
|
| 109 |
+
"force": bool(force),
|
| 110 |
+
"due_goals": len(due_goals),
|
| 111 |
+
"processed": processed,
|
| 112 |
+
"runtime": control_plane.runtime_snapshot(),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
def _get_auto_chat_dispatcher(self) -> AutoChatDispatcher | None:
|
| 116 |
+
with self._lock:
|
| 117 |
+
return self._auto_chat_dispatcher
|
| 118 |
+
|
| 119 |
+
def run_manual_tick(self, *, force: bool = True, reason: str = "manual") -> dict[str, Any]:
|
| 120 |
+
coroutine = self._run_tick(force=force, reason=reason)
|
| 121 |
+
with self._lock:
|
| 122 |
+
loop = self._loop
|
| 123 |
+
if loop is None or loop.is_closed() or not loop.is_running():
|
| 124 |
+
return asyncio.run(coroutine)
|
| 125 |
+
|
| 126 |
+
future = asyncio.run_coroutine_threadsafe(coroutine, loop)
|
| 127 |
+
try:
|
| 128 |
+
return future.result(timeout=20.0)
|
| 129 |
+
except FutureTimeoutError as error:
|
| 130 |
+
future.cancel()
|
| 131 |
+
raise TimeoutError("Autonomy tick timeout.") from error
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
autonomy_runtime = AutonomyRuntime()
|
| 135 |
+
|
| 136 |
+
__all__ = ["AutonomyRuntime", "autonomy_runtime"]
|
bot/bootstrap_runtime.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
from bot.autonomy_runtime import autonomy_runtime
|
| 5 |
+
from bot.clip_jobs_runtime import clip_jobs
|
| 6 |
+
from bot.eventsub_runtime import ByteBot
|
| 7 |
+
from bot.irc_runtime import IrcByteBot
|
| 8 |
+
from bot.observability import observability
|
| 9 |
+
from bot.runtime_config import (
|
| 10 |
+
CLIENT_ID,
|
| 11 |
+
TWITCH_BOT_LOGIN,
|
| 12 |
+
TWITCH_CHANNEL_LOGIN,
|
| 13 |
+
TWITCH_CHANNEL_LOGINS_RAW,
|
| 14 |
+
TWITCH_CLIENT_SECRET_INLINE,
|
| 15 |
+
TWITCH_CLIENT_SECRET_NAME,
|
| 16 |
+
TWITCH_IRC_HOST,
|
| 17 |
+
TWITCH_IRC_PORT,
|
| 18 |
+
TWITCH_IRC_TLS,
|
| 19 |
+
TWITCH_REFRESH_TOKEN,
|
| 20 |
+
TWITCH_TOKEN_REFRESH_MARGIN_SECONDS,
|
| 21 |
+
TWITCH_TOKEN_REFRESH_TIMEOUT_SECONDS,
|
| 22 |
+
TWITCH_TOKEN_VALIDATE_TIMEOUT_SECONDS,
|
| 23 |
+
TWITCH_USER_TOKEN,
|
| 24 |
+
irc_channel_control,
|
| 25 |
+
logger,
|
| 26 |
+
)
|
| 27 |
+
from bot.status_runtime import parse_channel_logins
|
| 28 |
+
from bot.twitch_tokens import TwitchTokenManager, TwitchTokenManagerSettings
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
def get_secret(secret_name: str = "twitch-client-secret") -> str:
|
| 34 |
+
# Ler do ENV usando o nome do secret
|
| 35 |
+
env_key = secret_name.upper().replace("-", "_")
|
| 36 |
+
val = os.environ.get(env_key)
|
| 37 |
+
if val:
|
| 38 |
+
return val
|
| 39 |
+
raise RuntimeError(f"Secret {env_key} not in env.")
|
| 40 |
+
|
| 41 |
+
def require_env(name: str) -> str:
|
| 42 |
+
value = (os.environ.get(name) or "").strip()
|
| 43 |
+
if not value:
|
| 44 |
+
raise RuntimeError(f"Variavel obrigatoria ausente: {name}")
|
| 45 |
+
return value
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def resolve_irc_channel_logins() -> list[str]:
|
| 49 |
+
explicit_channels = parse_channel_logins(TWITCH_CHANNEL_LOGINS_RAW)
|
| 50 |
+
if explicit_channels:
|
| 51 |
+
return explicit_channels
|
| 52 |
+
fallback_channels = parse_channel_logins(TWITCH_CHANNEL_LOGIN)
|
| 53 |
+
if fallback_channels:
|
| 54 |
+
return fallback_channels
|
| 55 |
+
|
| 56 |
+
required_single_channel = require_env("TWITCH_CHANNEL_LOGIN")
|
| 57 |
+
required_channels = parse_channel_logins(required_single_channel)
|
| 58 |
+
if not required_channels:
|
| 59 |
+
raise RuntimeError(
|
| 60 |
+
"TWITCH_CHANNEL_LOGIN invalido. Use login Twitch sem # e com 3-25 caracteres."
|
| 61 |
+
)
|
| 62 |
+
return required_channels
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def resolve_client_secret_for_irc_refresh() -> str:
|
| 66 |
+
if TWITCH_CLIENT_SECRET_INLINE:
|
| 67 |
+
return TWITCH_CLIENT_SECRET_INLINE
|
| 68 |
+
|
| 69 |
+
secret_name = TWITCH_CLIENT_SECRET_NAME or "twitch-client-secret"
|
| 70 |
+
try:
|
| 71 |
+
return get_secret(secret_name=secret_name)
|
| 72 |
+
except Exception as error:
|
| 73 |
+
logger.warning(
|
| 74 |
+
"Nao foi possivel ler segredo '%s' para refresh automatico: %s",
|
| 75 |
+
secret_name,
|
| 76 |
+
error,
|
| 77 |
+
)
|
| 78 |
+
return ""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_irc_token_manager() -> TwitchTokenManager:
|
| 82 |
+
user_token = TWITCH_USER_TOKEN or require_env("TWITCH_USER_TOKEN")
|
| 83 |
+
refresh_token = TWITCH_REFRESH_TOKEN.strip()
|
| 84 |
+
settings = TwitchTokenManagerSettings(
|
| 85 |
+
validate_timeout_seconds=TWITCH_TOKEN_VALIDATE_TIMEOUT_SECONDS,
|
| 86 |
+
refresh_timeout_seconds=TWITCH_TOKEN_REFRESH_TIMEOUT_SECONDS,
|
| 87 |
+
)
|
| 88 |
+
if not refresh_token:
|
| 89 |
+
return TwitchTokenManager(
|
| 90 |
+
access_token=user_token,
|
| 91 |
+
settings=settings,
|
| 92 |
+
observability=observability,
|
| 93 |
+
logger=logger,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
client_id = CLIENT_ID or require_env("TWITCH_CLIENT_ID")
|
| 97 |
+
client_secret = resolve_client_secret_for_irc_refresh()
|
| 98 |
+
if not client_secret:
|
| 99 |
+
logger.warning(
|
| 100 |
+
"TWITCH_REFRESH_TOKEN definido, mas TWITCH_CLIENT_SECRET nao encontrado. "
|
| 101 |
+
"Refresh automatico do token nao funcionara. Configure TWITCH_CLIENT_SECRET na Host."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return TwitchTokenManager(
|
| 105 |
+
access_token=user_token,
|
| 106 |
+
refresh_token=refresh_token,
|
| 107 |
+
client_id=client_id,
|
| 108 |
+
client_secret=client_secret or "",
|
| 109 |
+
refresh_margin_seconds=TWITCH_TOKEN_REFRESH_MARGIN_SECONDS,
|
| 110 |
+
settings=settings,
|
| 111 |
+
observability=observability,
|
| 112 |
+
logger=logger,
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def run_irc_mode() -> None:
|
| 117 |
+
token_manager = build_irc_token_manager()
|
| 118 |
+
channel_logins = resolve_irc_channel_logins()
|
| 119 |
+
bot = IrcByteBot(
|
| 120 |
+
host=TWITCH_IRC_HOST,
|
| 121 |
+
port=TWITCH_IRC_PORT,
|
| 122 |
+
use_tls=TWITCH_IRC_TLS,
|
| 123 |
+
bot_login=TWITCH_BOT_LOGIN or require_env("TWITCH_BOT_LOGIN"),
|
| 124 |
+
channel_logins=channel_logins,
|
| 125 |
+
token_manager=token_manager,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
async def run_with_channel_control() -> None:
|
| 129 |
+
running_loop = asyncio.get_running_loop()
|
| 130 |
+
irc_channel_control.bind(loop=running_loop, bot=bot)
|
| 131 |
+
|
| 132 |
+
async def _check_clips_auth() -> None:
|
| 133 |
+
# Avoid circular import
|
| 134 |
+
from bot.control_plane import control_plane
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
token_valid, has_clips_edit = await token_manager.validate_clips_auth()
|
| 138 |
+
|
| 139 |
+
config = control_plane.get_config()
|
| 140 |
+
if config.get("clip_pipeline_enabled") and not has_clips_edit:
|
| 141 |
+
logger.warning("Pipeline de clips habilitado mas scope 'clips:edit' ausente no token.")
|
| 142 |
+
|
| 143 |
+
except Exception as error:
|
| 144 |
+
logger.error("Erro ao validar auth de clips: %s", error)
|
| 145 |
+
observability.record_error(category="clips_auth", details=str(error))
|
| 146 |
+
|
| 147 |
+
async def verify_clips_auth_loop() -> None:
|
| 148 |
+
# Initial check
|
| 149 |
+
await asyncio.sleep(5) # Wait for boot
|
| 150 |
+
while True:
|
| 151 |
+
await _check_clips_auth()
|
| 152 |
+
await asyncio.sleep(3600)
|
| 153 |
+
|
| 154 |
+
async def send_autonomy_chat(text: str) -> None:
|
| 155 |
+
await bot.send_reply(text)
|
| 156 |
+
|
| 157 |
+
autonomy_runtime.bind(
|
| 158 |
+
loop=running_loop,
|
| 159 |
+
mode="irc",
|
| 160 |
+
auto_chat_dispatcher=send_autonomy_chat,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Clip Jobs Runtime
|
| 164 |
+
clip_jobs.bind_token_provider(token_manager.ensure_token_for_connection)
|
| 165 |
+
clip_jobs.start(running_loop)
|
| 166 |
+
|
| 167 |
+
# Start background task
|
| 168 |
+
asyncio.create_task(verify_clips_auth_loop())
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
await bot.run_forever()
|
| 172 |
+
finally:
|
| 173 |
+
clip_jobs.stop()
|
| 174 |
+
autonomy_runtime.unbind()
|
| 175 |
+
irc_channel_control.unbind()
|
| 176 |
+
|
| 177 |
+
asyncio.run(run_with_channel_control())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def run_eventsub_mode() -> None:
|
| 181 |
+
require_env("TWITCH_CLIENT_ID")
|
| 182 |
+
require_env("TWITCH_BOT_ID")
|
| 183 |
+
require_env("TWITCH_CHANNEL_ID")
|
| 184 |
+
bot = ByteBot(client_secret=get_secret())
|
| 185 |
+
try:
|
| 186 |
+
bot.run()
|
| 187 |
+
finally:
|
| 188 |
+
autonomy_runtime.unbind()
|
bot/byte_semantics.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 8 |
+
is_intro_prompt,
|
| 9 |
+
is_movie_fact_sheet_prompt,
|
| 10 |
+
is_serious_technical_prompt,
|
| 11 |
+
normalize_text_for_scene,
|
| 12 |
+
parse_byte_prompt,
|
| 13 |
+
)
|
| 14 |
+
from bot.byte_semantics_constants import (
|
| 15 |
+
BYTE_HELP_MESSAGE,
|
| 16 |
+
BYTE_INTRO_TEMPLATES,
|
| 17 |
+
MAX_CHAT_MESSAGE_LENGTH,
|
| 18 |
+
MULTIPART_SEPARATOR,
|
| 19 |
+
QUALITY_SAFE_FALLBACK,
|
| 20 |
+
SERIOUS_REPLY_MAX_LENGTH,
|
| 21 |
+
SERIOUS_REPLY_MAX_LINES,
|
| 22 |
+
)
|
| 23 |
+
from bot.byte_semantics_current_events import (
|
| 24 |
+
build_current_events_safe_fallback_reply,
|
| 25 |
+
build_server_time_anchor_instruction,
|
| 26 |
+
build_verifiable_prompt,
|
| 27 |
+
normalize_current_events_reply_contract,
|
| 28 |
+
)
|
| 29 |
+
from bot.byte_semantics_quality import (
|
| 30 |
+
build_adaptive_ai_instruction,
|
| 31 |
+
build_anti_generic_contract_instruction,
|
| 32 |
+
build_direct_answer_instruction,
|
| 33 |
+
build_llm_enhanced_prompt,
|
| 34 |
+
build_quality_prompt_script,
|
| 35 |
+
build_quality_rewrite_prompt,
|
| 36 |
+
build_research_priority_instruction,
|
| 37 |
+
is_low_quality_answer,
|
| 38 |
+
)
|
| 39 |
+
from bot.byte_semantics_reply import (
|
| 40 |
+
build_movie_fact_sheet_query,
|
| 41 |
+
extract_movie_title,
|
| 42 |
+
extract_multi_reply_parts,
|
| 43 |
+
split_text_for_chat,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
__all__ = [
|
| 47 |
+
"BYTE_HELP_MESSAGE",
|
| 48 |
+
"BYTE_INTRO_TEMPLATES",
|
| 49 |
+
"MAX_CHAT_MESSAGE_LENGTH",
|
| 50 |
+
"MULTIPART_SEPARATOR",
|
| 51 |
+
"QUALITY_SAFE_FALLBACK",
|
| 52 |
+
"SERIOUS_REPLY_MAX_LENGTH",
|
| 53 |
+
"SERIOUS_REPLY_MAX_LINES",
|
| 54 |
+
"build_adaptive_ai_instruction",
|
| 55 |
+
"build_anti_generic_contract_instruction",
|
| 56 |
+
"build_current_events_safe_fallback_reply",
|
| 57 |
+
"build_direct_answer_instruction",
|
| 58 |
+
"build_intro_reply",
|
| 59 |
+
"build_llm_enhanced_prompt",
|
| 60 |
+
"build_movie_fact_sheet_query",
|
| 61 |
+
"build_quality_prompt_script",
|
| 62 |
+
"build_quality_rewrite_prompt",
|
| 63 |
+
"build_research_priority_instruction",
|
| 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",
|
| 73 |
+
"is_intro_prompt",
|
| 74 |
+
"is_low_quality_answer",
|
| 75 |
+
"is_movie_fact_sheet_prompt",
|
| 76 |
+
"is_serious_technical_prompt",
|
| 77 |
+
"normalize_current_events_reply_contract",
|
| 78 |
+
"normalize_text_for_scene",
|
| 79 |
+
"parse_byte_prompt",
|
| 80 |
+
"split_text_for_chat",
|
| 81 |
+
]
|
bot/byte_semantics_base.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from bot.byte_semantics_constants import (
|
| 4 |
+
BYTE_INTRO_TEMPLATES,
|
| 5 |
+
BYTE_TRIGGER,
|
| 6 |
+
BYTE_TRIGGER_PATTERN,
|
| 7 |
+
COMPLEX_TECH_HINT_TERMS,
|
| 8 |
+
CURRENT_EVENTS_HINT_TERMS,
|
| 9 |
+
FOLLOW_UP_HINT_TERMS,
|
| 10 |
+
HIGH_RISK_CURRENT_EVENTS_TERMS,
|
| 11 |
+
MAX_CHAT_MESSAGE_LENGTH,
|
| 12 |
+
MULTIPART_SEPARATOR,
|
| 13 |
+
MOVIE_FACT_SHEET_PATTERN,
|
| 14 |
+
RELEVANCE_HINT_TERMS,
|
| 15 |
+
SERIOUS_TECH_TERMS,
|
| 16 |
+
)
|
| 17 |
+
from bot.logic_context import enforce_reply_limits
|
| 18 |
+
|
| 19 |
+
_intro_template_index = 0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def compact_message(text: str, max_len: int = 450) -> str:
|
| 23 |
+
if len(text) <= max_len:
|
| 24 |
+
return text
|
| 25 |
+
head = text[: max_len - 3].rstrip()
|
| 26 |
+
punctuation_positions = [head.rfind(symbol) for symbol in (".", "!", "?", ";", ":")]
|
| 27 |
+
best_punctuation = max(punctuation_positions)
|
| 28 |
+
if best_punctuation >= int(max_len * 0.5):
|
| 29 |
+
return head[: best_punctuation + 1].strip()
|
| 30 |
+
last_space = head.rfind(" ")
|
| 31 |
+
if last_space >= int(max_len * 0.5):
|
| 32 |
+
head = head[:last_space]
|
| 33 |
+
return head.rstrip(" ,;:") + "..."
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalize_text_for_scene(text: str, max_len: int = 120) -> str:
|
| 37 |
+
compact = " ".join((text or "").split())
|
| 38 |
+
if len(compact) <= max_len:
|
| 39 |
+
return compact
|
| 40 |
+
return compact[: max_len - 3].rstrip() + "..."
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def format_chat_reply(text: str) -> str:
|
| 44 |
+
normalized = str(text or "").replace(MULTIPART_SEPARATOR, "\n")
|
| 45 |
+
return compact_message(
|
| 46 |
+
enforce_reply_limits(normalized),
|
| 47 |
+
max_len=MAX_CHAT_MESSAGE_LENGTH,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def parse_byte_prompt(message_text: str) -> str | None:
|
| 52 |
+
text = (message_text or "").strip()
|
| 53 |
+
if not text:
|
| 54 |
+
return None
|
| 55 |
+
normalized_text = text.lower()
|
| 56 |
+
if normalized_text in {BYTE_TRIGGER, f"!{BYTE_TRIGGER}", f"@{BYTE_TRIGGER}"}:
|
| 57 |
+
return ""
|
| 58 |
+
match = BYTE_TRIGGER_PATTERN.match(text)
|
| 59 |
+
if not match:
|
| 60 |
+
return 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))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def is_intro_prompt(prompt: str) -> bool:
|
| 70 |
+
normalized = " ".join((prompt or "").lower().split()).strip(" ?!.,:")
|
| 71 |
+
if not normalized:
|
| 72 |
+
return False
|
| 73 |
+
exact_triggers = {
|
| 74 |
+
"se apresente",
|
| 75 |
+
"apresente se",
|
| 76 |
+
"apresente-se",
|
| 77 |
+
"quem e voce",
|
| 78 |
+
"quem e vc",
|
| 79 |
+
"quem e o byte",
|
| 80 |
+
"o que voce faz",
|
| 81 |
+
}
|
| 82 |
+
if normalized in exact_triggers:
|
| 83 |
+
return True
|
| 84 |
+
return normalized.startswith("se apresente")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def build_intro_reply() -> str:
|
| 88 |
+
global _intro_template_index
|
| 89 |
+
template = BYTE_INTRO_TEMPLATES[_intro_template_index % len(BYTE_INTRO_TEMPLATES)]
|
| 90 |
+
_intro_template_index += 1
|
| 91 |
+
return template
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def is_current_events_prompt(prompt: str) -> bool:
|
| 95 |
+
lowered = (prompt or "").lower()
|
| 96 |
+
return any(hint in lowered for hint in CURRENT_EVENTS_HINT_TERMS)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def is_high_risk_current_events_prompt(prompt: str) -> bool:
|
| 100 |
+
lowered = (prompt or "").lower()
|
| 101 |
+
return any(hint in lowered for hint in HIGH_RISK_CURRENT_EVENTS_TERMS)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def is_serious_technical_prompt(prompt: str) -> bool:
|
| 105 |
+
normalized = " ".join((prompt or "").lower().split()).strip(" ?!.,:")
|
| 106 |
+
if len(normalized) < 24:
|
| 107 |
+
return False
|
| 108 |
+
has_technical_signal = any(term in normalized for term in SERIOUS_TECH_TERMS) or any(
|
| 109 |
+
term in normalized for term in COMPLEX_TECH_HINT_TERMS
|
| 110 |
+
)
|
| 111 |
+
if not has_technical_signal:
|
| 112 |
+
return False
|
| 113 |
+
has_relevance_signal = any(term in normalized for term in RELEVANCE_HINT_TERMS) or is_current_events_prompt(normalized)
|
| 114 |
+
return has_relevance_signal
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def is_follow_up_prompt(prompt: str) -> bool:
|
| 118 |
+
normalized = " ".join((prompt or "").lower().split()).strip(" ?!.,:")
|
| 119 |
+
if not normalized:
|
| 120 |
+
return False
|
| 121 |
+
if normalized in {"e agora", "e ai", "e aí", "e esse", "e essa", "e ele", "e ela"}:
|
| 122 |
+
return True
|
| 123 |
+
for term in FOLLOW_UP_HINT_TERMS:
|
| 124 |
+
boundary_pattern = rf"(^|[^a-z0-9]){re.escape(term)}([^a-z0-9]|$)"
|
| 125 |
+
if re.search(boundary_pattern, normalized):
|
| 126 |
+
return True
|
| 127 |
+
if normalized.startswith(("e ", "entao ", "então ")) and len(normalized.split()) <= 7:
|
| 128 |
+
return True
|
| 129 |
+
words = normalized.split()
|
| 130 |
+
if len(words) <= 5 and any(word in {"isso", "ele", "ela", "esse", "essa"} for word in words):
|
| 131 |
+
return True
|
| 132 |
+
return False
|
bot/byte_semantics_constants.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from bot.logic_constants import BOT_BRAND, MAX_REPLY_LINES
|
| 5 |
+
|
| 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(rf"^\s*[@!]?(?:{re.escape(BYTE_TRIGGER)})\b[\s,:-]*(.*)$", re.IGNORECASE)
|
| 13 |
+
MOVIE_FACT_SHEET_PATTERN = re.compile(r"ficha\s*t[eé]cnica", re.IGNORECASE)
|
| 14 |
+
MOVIE_TITLE_QUOTE_PATTERN = re.compile(r"[\"'`](?P<title>[^\"'`]{2,120})[\"'`]")
|
| 15 |
+
CURRENT_EVENTS_HINT_TERMS = (
|
| 16 |
+
"situacao atual", "situação atual", "como esta", "como está", "atualizacao", "atualização", "agora", "hoje",
|
| 17 |
+
"nesta semana", "nessa semana", "esta semana", "ultima semana", "última semana", "ultimos dias", "últimos dias",
|
| 18 |
+
"noticias", "notícias", "recente", "recentes", "agora mesmo", "tempo real", "breaking", "update", "updates",
|
| 19 |
+
"anunciou", "anunciaram", "novidade", "ultimo anuncio", "último anúncio", "lancamento", "lançamento",
|
| 20 |
+
)
|
| 21 |
+
HIGH_RISK_CURRENT_EVENTS_TERMS = (
|
| 22 |
+
"situacao atual", "situação atual", "agora", "hoje", "nesta semana", "nessa semana", "esta semana",
|
| 23 |
+
"ultima semana", "última semana", "ultimos dias", "últimos dias", "noticias", "notícias", "breaking",
|
| 24 |
+
"update", "updates", "anunciou", "anunciaram",
|
| 25 |
+
)
|
| 26 |
+
GENERIC_RESPONSE_HINT_TERMS = (
|
| 27 |
+
"depende", "pode variar", "em geral", "de forma geral", "geralmente", "na maioria dos casos",
|
| 28 |
+
"vale lembrar", "e importante lembrar", "por outro lado", "cada caso e um caso",
|
| 29 |
+
)
|
| 30 |
+
OVERCONFIDENT_HINT_TERMS = ("com certeza", "sem duvida", "definitivamente", "garantido", "sempre")
|
| 31 |
+
UNCERTAINTY_HINT_TERMS = (
|
| 32 |
+
"nao consegui verificar", "não consegui verificar", "sem confirmacao", "sem confirmação", "sem confirmar", "incerto",
|
| 33 |
+
"nao ha confirmacao", "não há confirmação", "pode estar desatualizado", "baixa confianca", "baixa confiança",
|
| 34 |
+
"conexao com o modelo instavel", "conexão com o modelo instável",
|
| 35 |
+
)
|
| 36 |
+
TEMPORAL_ANCHOR_TERMS = (
|
| 37 |
+
"hoje", "agora", "atualmente", "ate o momento", "até o momento", "nesta semana", "neste momento",
|
| 38 |
+
"semana", "mes", "mês", "2025", "2026",
|
| 39 |
+
)
|
| 40 |
+
SOURCE_ANCHOR_TERMS = (
|
| 41 |
+
"segundo ", "de acordo com", "fonte:", "fontes:", "base:", "reportado por", "confirmado por",
|
| 42 |
+
"comunicado de", "anvisa", "ministerio", "ministério", "governo", "reuters", "bbc", "ap news",
|
| 43 |
+
)
|
| 44 |
+
CONFIDENCE_LABEL_TERMS = ("confianca:", "confiança:")
|
| 45 |
+
QUALITY_STOPWORDS = {
|
| 46 |
+
"byte", "qual", "quais", "como", "onde", "quando", "quem", "porque", "por", "que", "isso", "esse", "essa",
|
| 47 |
+
"agora", "hoje", "sobre", "para", "com", "sem", "uma", "um", "uns", "umas", "dos", "das", "de", "do", "da",
|
| 48 |
+
"the", "and", "tem", "existe", "fala", "diz",
|
| 49 |
+
}
|
| 50 |
+
FOLLOW_UP_HINT_TERMS = (
|
| 51 |
+
"e agora", "e ai", "e aí", "e ele", "e ela", "e isso", "e esse", "e essa", "sobre isso", "sobre ele", "sobre ela",
|
| 52 |
+
"detalha isso", "resume isso", "resuma isso",
|
| 53 |
+
)
|
| 54 |
+
BRIEF_STYLE_TERMS = ("resuma", "resumo", "curto", "curta", "1 linha", "uma linha", "tl;dr", "tldr")
|
| 55 |
+
ANALYTICAL_STYLE_TERMS = ("detalha", "aprofunda", "analisa", "compare", "comparar", "vs", "diferen", "prons", "contras")
|
| 56 |
+
PLAYFUL_STYLE_TERMS = ("zoa", "zoeira", "meme", "engracad", "brinca", "tirada")
|
| 57 |
+
QUESTION_OPENING_TERMS = (
|
| 58 |
+
"existe", "tem", "ha", "há", "qual", "quais", "como", "porque", "por que", "onde", "quando", "quem", "pode", "vale",
|
| 59 |
+
"compensa", "recomenda", "recomendacao", "recomendação", "indica",
|
| 60 |
+
)
|
| 61 |
+
EXISTENCE_QUESTION_TERMS = ("existe", "tem", "ha", "há")
|
| 62 |
+
RECOMMENDATION_HINT_TERMS = ("recomenda", "recomendacao", "recomendação", "indica", "sugere", "lista", "melhores")
|
| 63 |
+
SERIOUS_TECH_TERMS = (
|
| 64 |
+
"laminina", "parapleg", "medicina", "saude", "tratamento", "terapia", "pesquisa", "estudo", "cient", "neurolog",
|
| 65 |
+
"biolog", "genet", "clinico", "evidencia", "paper",
|
| 66 |
+
)
|
| 67 |
+
COMPLEX_TECH_HINT_TERMS = (
|
| 68 |
+
"como funciona", "mecanismo", "explica tecnicamente", "base cientifica", "protocolo", "metodologia", "efeitos", "riscos", "limites",
|
| 69 |
+
)
|
| 70 |
+
RELEVANCE_HINT_TERMS = ("relevante", "impacto", "hoje", "agora", "atual", "urgente", "importante", "cura")
|
| 71 |
+
MAX_CHAT_MESSAGE_LENGTH = 460
|
| 72 |
+
MULTIPART_SEPARATOR = "[BYTE_SPLIT]"
|
| 73 |
+
SERIOUS_REPLY_MAX_LINES = MAX_REPLY_LINES
|
| 74 |
+
SERIOUS_REPLY_MAX_LENGTH = MAX_CHAT_MESSAGE_LENGTH
|
| 75 |
+
QUALITY_SAFE_FALLBACK = "Nao consegui verificar com confianca agora. Me manda 1 link/fonte no chat e eu resumo em ate 3 linhas."
|
| 76 |
+
CURRENT_EVENTS_DEFAULT_SOURCE = "Fonte: pesquisa web em tempo real (DuckDuckGo)."
|
| 77 |
+
CURRENT_EVENTS_PENDING_SOURCE = "Fonte: aguardando 1 link/fonte do chat para confirmar."
|
| 78 |
+
REWRITE_REASON_GUIDANCE = {
|
| 79 |
+
"resposta_generica": "Troque generalidades por fatos especificos ligados ao pedido do usuario.",
|
| 80 |
+
"abertura_generica": "Comece respondendo direto a pergunta principal, sem 'depende' ou introducoes vagas.",
|
| 81 |
+
"resposta_curta_sem_substancia": "Adicione 1 ou 2 detalhes concretos (nome, data, local, obra ou numero).",
|
| 82 |
+
"off_topic": "Retorne ao objeto central da pergunta e remova desvios.",
|
| 83 |
+
"tema_atual_sem_ancora_temporal": "Inclua ancora temporal objetiva baseada no horario do servidor (hoje/nesta semana/mes-ano).",
|
| 84 |
+
"tema_atual_sem_confianca_explicita": "Inclua a linha final 'Confianca: alta|media|baixa'.",
|
| 85 |
+
"tema_atual_sem_base_verificavel": "Inclua a linha final 'Fonte: <origem confiavel>' quando houver afirmacao atual.",
|
| 86 |
+
"incerteza_sem_pedido_de_fonte": "Se houver incerteza, solicite 1 link/fonte para confirmar.",
|
| 87 |
+
"incerteza_fora_fallback_canonico": "Para tema atual de alto risco com incerteza, use apenas fallback canonico sem especulacao extra.",
|
| 88 |
+
"modelo_indisponivel": "Se o modelo estiver instavel/indisponivel, use fallback seguro pedindo 1 fonte do chat.",
|
| 89 |
+
"resposta_existencia_sem_posicao": "Em pergunta de existencia, comece com 'Sim,' ou 'Nao,'.",
|
| 90 |
+
"termina_com_pergunta_aberta": "Finalize com afirmacao objetiva; so faca pergunta se faltar dado essencial.",
|
| 91 |
+
"confianca_absoluta_sem_base": "Evite tom absoluto sem base verificavel; sinalize limite de confianca.",
|
| 92 |
+
}
|
| 93 |
+
BYTE_INTRO_TEMPLATES = (
|
| 94 |
+
"Sou Byte: IA premium da live. Latencia baixa, cerebro afiado e zoeira calibrada. Digita 'byte ajuda'.",
|
| 95 |
+
"Byte on. Modo high-tech + caos controlado: resposta rapida, precisa e sem textao. Usa 'byte ajuda'.",
|
| 96 |
+
"Copiloto oficial do chat: contexto em tempo real, ficha tecnica na veia e humor fino. Manda 'byte ajuda'.",
|
| 97 |
+
"Byte na pista. Se a pergunta vier torta, eu devolvo reta com classe e zoeira. Comandos em 'byte ajuda'.",
|
| 98 |
+
)
|
bot/byte_semantics_current_events.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
from bot.byte_semantics_base import (
|
| 6 |
+
compact_message,
|
| 7 |
+
is_current_events_prompt,
|
| 8 |
+
is_high_risk_current_events_prompt,
|
| 9 |
+
)
|
| 10 |
+
from bot.byte_semantics_constants import (
|
| 11 |
+
CONFIDENCE_LABEL_TERMS,
|
| 12 |
+
CURRENT_EVENTS_DEFAULT_SOURCE,
|
| 13 |
+
CURRENT_EVENTS_PENDING_SOURCE,
|
| 14 |
+
MAX_CHAT_MESSAGE_LENGTH,
|
| 15 |
+
MAX_REPLY_LINES,
|
| 16 |
+
QUALITY_SAFE_FALLBACK,
|
| 17 |
+
SOURCE_ANCHOR_TERMS,
|
| 18 |
+
TEMPORAL_ANCHOR_TERMS,
|
| 19 |
+
UNCERTAINTY_HINT_TERMS,
|
| 20 |
+
)
|
| 21 |
+
from bot.logic import enforce_reply_limits, has_grounding_signal
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_server_time_anchor_instruction(reference_utc_iso: str | None = None) -> str:
|
| 25 |
+
now_utc_iso = (reference_utc_iso or "").strip()
|
| 26 |
+
if not now_utc_iso:
|
| 27 |
+
now_utc = datetime.now(timezone.utc)
|
| 28 |
+
now_utc_iso = now_utc.isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 29 |
+
return f"Timestamp de referencia do servidor (UTC): {now_utc_iso}. Use esse horario para interpretar hoje/agora/nesta semana."
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def build_verifiable_prompt(prompt: str, concise_mode: bool = True, server_time_instruction: str | None = None) -> str:
|
| 33 |
+
clean_prompt = (prompt or "").strip()
|
| 34 |
+
if not clean_prompt:
|
| 35 |
+
return clean_prompt
|
| 36 |
+
is_current_events = is_current_events_prompt(clean_prompt)
|
| 37 |
+
active_server_time_instruction = server_time_instruction or build_server_time_anchor_instruction()
|
| 38 |
+
if not concise_mode:
|
| 39 |
+
return (
|
| 40 |
+
f"{clean_prompt}\n"
|
| 41 |
+
f"{active_server_time_instruction} Instrucoes obrigatorias de confiabilidade: priorize fatos recentes e verificaveis. "
|
| 42 |
+
f"{'Considere prioridade alta para acontecimentos de hoje/agora. ' if is_current_events else ''}"
|
| 43 |
+
"Diferencie o que esta confirmado do que ainda e hipotese. "
|
| 44 |
+
"Se houver ambiguidade no nome (ex.: Push/Punch/Posh), avise explicitamente e peca 1 link para confirmar. "
|
| 45 |
+
"Se nao houver confirmacao forte, diga isso com clareza e solicite fonte."
|
| 46 |
+
)
|
| 47 |
+
if not is_current_events:
|
| 48 |
+
return clean_prompt
|
| 49 |
+
return (
|
| 50 |
+
f"{clean_prompt}\n"
|
| 51 |
+
f"{active_server_time_instruction} Instrucoes obrigatorias de confiabilidade: responda em ate 4 linhas curtas, densas e sem enrolacao. "
|
| 52 |
+
"Use apenas fatos com confirmacao recente em fontes confiaveis. "
|
| 53 |
+
"Inclua ancora temporal explicita (ex.: hoje, nesta semana, mes/ano) quando falar de status atual. "
|
| 54 |
+
"Para noticia/anuncio atual, inclua no final: 'Confianca: alta|media|baixa' e 'Fonte: <origem confiavel>'. "
|
| 55 |
+
"Diferencie o que esta confirmado do que ainda e rumor/hipotese. "
|
| 56 |
+
"Se houver ambiguidade no nome (ex.: Push/Punch/Posh), avise explicitamente e peca 1 link para confirmar. "
|
| 57 |
+
"Se nao houver confirmacao forte, responda exatamente: "
|
| 58 |
+
f"'{QUALITY_SAFE_FALLBACK}'"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _extract_reference_utc_iso(server_time_instruction: str | None) -> str:
|
| 63 |
+
text = (server_time_instruction or "").strip()
|
| 64 |
+
if not text:
|
| 65 |
+
return ""
|
| 66 |
+
match = re.search(r"\b(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\b", text)
|
| 67 |
+
return match.group(1) if match else ""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _extract_grounding_queries(grounding_metadata: dict | None) -> list[str]:
|
| 71 |
+
if not isinstance(grounding_metadata, dict):
|
| 72 |
+
return []
|
| 73 |
+
queries = grounding_metadata.get("web_search_queries", [])
|
| 74 |
+
if not isinstance(queries, list):
|
| 75 |
+
return []
|
| 76 |
+
return [str(query).strip() for query in queries if str(query or "").strip()]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _extract_grounding_source_urls(grounding_metadata: dict | None) -> list[str]:
|
| 80 |
+
if not isinstance(grounding_metadata, dict):
|
| 81 |
+
return []
|
| 82 |
+
source_urls = grounding_metadata.get("source_urls", [])
|
| 83 |
+
if not isinstance(source_urls, list):
|
| 84 |
+
return []
|
| 85 |
+
return [str(source_url).strip() for source_url in source_urls if str(source_url or "").strip()]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _build_grounding_source_line(grounding_metadata: dict | None) -> str:
|
| 89 |
+
source_urls = _extract_grounding_source_urls(grounding_metadata)
|
| 90 |
+
if source_urls:
|
| 91 |
+
hosts: list[str] = []
|
| 92 |
+
for source_url in source_urls:
|
| 93 |
+
parsed_url = urlparse(source_url)
|
| 94 |
+
host = parsed_url.netloc.lower().removeprefix("www.")
|
| 95 |
+
if host and host not in hosts:
|
| 96 |
+
hosts.append(host)
|
| 97 |
+
if len(hosts) >= 2:
|
| 98 |
+
break
|
| 99 |
+
if hosts:
|
| 100 |
+
return f"Fonte: DuckDuckGo em {', '.join(hosts)}."
|
| 101 |
+
queries = _extract_grounding_queries(grounding_metadata)
|
| 102 |
+
if queries:
|
| 103 |
+
query_preview = compact_message(queries[0], max_len=90).strip().rstrip(" ,;:.")
|
| 104 |
+
if query_preview:
|
| 105 |
+
return f"Fonte: DuckDuckGo query: {query_preview}."
|
| 106 |
+
return CURRENT_EVENTS_DEFAULT_SOURCE
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _is_confidence_line(line: str) -> bool:
|
| 110 |
+
lowered = (line or "").strip().lower()
|
| 111 |
+
return lowered.startswith(("confianca:", "confiança:"))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _is_source_line(line: str) -> bool:
|
| 115 |
+
lowered = (line or "").strip().lower()
|
| 116 |
+
return lowered.startswith("fonte:")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _is_temporal_line(line: str) -> bool:
|
| 120 |
+
lowered = (line or "").strip().lower()
|
| 121 |
+
return lowered.startswith("recorte temporal:")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _split_non_empty_lines(text: str) -> list[str]:
|
| 125 |
+
normalized = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
| 126 |
+
return [line.strip() for line in normalized.split("\n") if line.strip()]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _is_canonical_high_risk_fallback(answer: str) -> bool:
|
| 130 |
+
lines = _split_non_empty_lines(answer)
|
| 131 |
+
if not lines or lines[0].lower() != QUALITY_SAFE_FALLBACK.lower():
|
| 132 |
+
return False
|
| 133 |
+
lowered_lines = [line.lower() for line in lines]
|
| 134 |
+
has_low_confidence = any(line.startswith(("confianca: baixa", "confiança: baixa")) for line in lowered_lines)
|
| 135 |
+
has_pending_source = any(line == CURRENT_EVENTS_PENDING_SOURCE.lower() for line in lowered_lines)
|
| 136 |
+
if not has_low_confidence or not has_pending_source:
|
| 137 |
+
return False
|
| 138 |
+
for line in lowered_lines[1:]:
|
| 139 |
+
if _is_temporal_line(line) or _is_confidence_line(line) or _is_source_line(line):
|
| 140 |
+
continue
|
| 141 |
+
return False
|
| 142 |
+
return True
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _fit_high_risk_current_events_reply(body_lines: list[str], confidence_line: str, source_line: str, temporal_line: str = "") -> str:
|
| 146 |
+
normalized_body = [line.strip() for line in body_lines if line and line.strip()]
|
| 147 |
+
tail_lines = [line.strip() for line in (temporal_line, confidence_line, source_line) if line and line.strip()]
|
| 148 |
+
tail_text = "\n".join(tail_lines)
|
| 149 |
+
if not tail_text:
|
| 150 |
+
return enforce_reply_limits("\n".join(normalized_body))
|
| 151 |
+
if not normalized_body:
|
| 152 |
+
return tail_text
|
| 153 |
+
max_body_lines = max(1, MAX_REPLY_LINES - len(tail_lines))
|
| 154 |
+
max_body_length = MAX_CHAT_MESSAGE_LENGTH - len(tail_text) - 1
|
| 155 |
+
if max_body_length <= 0:
|
| 156 |
+
return tail_text[:MAX_CHAT_MESSAGE_LENGTH].strip()
|
| 157 |
+
body_text = enforce_reply_limits("\n".join(normalized_body), max_lines=max_body_lines, max_length=max_body_length).strip()
|
| 158 |
+
combined = f"{body_text}\n{tail_text}" if body_text else tail_text
|
| 159 |
+
if len(combined) <= MAX_CHAT_MESSAGE_LENGTH:
|
| 160 |
+
return combined
|
| 161 |
+
compact_body = compact_message(" ".join(normalized_body), max_len=max_body_length).strip()
|
| 162 |
+
combined = f"{compact_body}\n{tail_text}" if compact_body else tail_text
|
| 163 |
+
if len(combined) <= MAX_CHAT_MESSAGE_LENGTH:
|
| 164 |
+
return combined
|
| 165 |
+
if max_body_length <= 0:
|
| 166 |
+
return tail_text[:MAX_CHAT_MESSAGE_LENGTH].strip()
|
| 167 |
+
cropped_body = compact_body[:max_body_length].rstrip(" ,;:")
|
| 168 |
+
return f"{cropped_body}\n{tail_text}" if cropped_body else tail_text
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def normalize_current_events_reply_contract(
|
| 172 |
+
prompt: str,
|
| 173 |
+
answer: str,
|
| 174 |
+
server_time_instruction: str | None = None,
|
| 175 |
+
grounding_metadata: dict | None = None,
|
| 176 |
+
) -> str:
|
| 177 |
+
clean_prompt = (prompt or "").strip()
|
| 178 |
+
clean_answer = (answer or "").strip()
|
| 179 |
+
if not clean_prompt or not clean_answer or not is_current_events_prompt(clean_prompt):
|
| 180 |
+
return clean_answer
|
| 181 |
+
lines = _split_non_empty_lines(clean_answer)
|
| 182 |
+
if not lines:
|
| 183 |
+
lines = [" ".join(clean_answer.split())]
|
| 184 |
+
merged_answer = "\n".join(lines)
|
| 185 |
+
lowered_answer = merged_answer.lower()
|
| 186 |
+
has_uncertainty = any(term in lowered_answer for term in UNCERTAINTY_HINT_TERMS)
|
| 187 |
+
has_temporal_anchor = any(term in lowered_answer for term in TEMPORAL_ANCHOR_TERMS) or bool(
|
| 188 |
+
re.search(r"\b20(2[5-9]|3[0-9])\b", lowered_answer)
|
| 189 |
+
)
|
| 190 |
+
reference_utc_iso = _extract_reference_utc_iso(server_time_instruction)
|
| 191 |
+
added_temporal_line = ""
|
| 192 |
+
high_risk_current_events = is_high_risk_current_events_prompt(clean_prompt)
|
| 193 |
+
must_validate_grounding = grounding_metadata is not None
|
| 194 |
+
grounding_signal_present = has_grounding_signal(grounding_metadata)
|
| 195 |
+
if high_risk_current_events and has_uncertainty:
|
| 196 |
+
return build_current_events_safe_fallback_reply(clean_prompt, server_time_instruction=server_time_instruction)
|
| 197 |
+
if high_risk_current_events and must_validate_grounding and not grounding_signal_present:
|
| 198 |
+
return build_current_events_safe_fallback_reply(clean_prompt, server_time_instruction=server_time_instruction)
|
| 199 |
+
if not has_temporal_anchor and not has_uncertainty:
|
| 200 |
+
added_temporal_line = f"Recorte temporal: {reference_utc_iso} UTC." if reference_utc_iso else "Recorte temporal: agora (UTC)."
|
| 201 |
+
lines.append(added_temporal_line)
|
| 202 |
+
if not high_risk_current_events:
|
| 203 |
+
return "\n".join(lines)
|
| 204 |
+
|
| 205 |
+
body_lines: list[str] = []
|
| 206 |
+
confidence_line = ""
|
| 207 |
+
source_line = ""
|
| 208 |
+
for line in lines:
|
| 209 |
+
if added_temporal_line and line.strip() == added_temporal_line:
|
| 210 |
+
continue
|
| 211 |
+
if _is_confidence_line(line):
|
| 212 |
+
if not confidence_line:
|
| 213 |
+
confidence_line = line.strip()
|
| 214 |
+
continue
|
| 215 |
+
if _is_source_line(line):
|
| 216 |
+
if not source_line:
|
| 217 |
+
source_line = line.strip()
|
| 218 |
+
continue
|
| 219 |
+
body_lines.append(line.strip())
|
| 220 |
+
|
| 221 |
+
confidence_line = "Confianca: media"
|
| 222 |
+
source_line = _build_grounding_source_line(grounding_metadata)
|
| 223 |
+
return _fit_high_risk_current_events_reply(body_lines, confidence_line, source_line, temporal_line=added_temporal_line)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def build_current_events_safe_fallback_reply(prompt: str, server_time_instruction: str | None = None) -> str:
|
| 227 |
+
clean_prompt = (prompt or "").strip()
|
| 228 |
+
if not is_high_risk_current_events_prompt(clean_prompt):
|
| 229 |
+
return QUALITY_SAFE_FALLBACK
|
| 230 |
+
reference_utc_iso = _extract_reference_utc_iso(server_time_instruction)
|
| 231 |
+
temporal_line = f"Recorte temporal: {reference_utc_iso} UTC." if reference_utc_iso else "Recorte temporal: agora (UTC)."
|
| 232 |
+
return "\n".join([QUALITY_SAFE_FALLBACK, temporal_line, "Confianca: baixa", CURRENT_EVENTS_PENDING_SOURCE])
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def has_current_events_source_anchor(text: str) -> bool:
|
| 236 |
+
lowered = (text or "").lower()
|
| 237 |
+
return any(term in lowered for term in SOURCE_ANCHOR_TERMS)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def has_current_events_confidence_label(text: str) -> bool:
|
| 241 |
+
lowered = (text or "").lower()
|
| 242 |
+
return any(term in lowered for term in CONFIDENCE_LABEL_TERMS)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def has_current_events_temporal_anchor(text: str) -> bool:
|
| 246 |
+
lowered = (text or "").lower()
|
| 247 |
+
return any(term in lowered for term in TEMPORAL_ANCHOR_TERMS) or bool(re.search(r"\b20(2[5-9]|3[0-9])\b", lowered))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def has_current_events_uncertainty(text: str) -> bool:
|
| 251 |
+
lowered = (text or "").lower()
|
| 252 |
+
return any(term in lowered for term in UNCERTAINTY_HINT_TERMS)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def is_canonical_high_risk_fallback(answer: str) -> bool:
|
| 256 |
+
return _is_canonical_high_risk_fallback(answer)
|
bot/byte_semantics_quality.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from bot.byte_semantics_base import (
|
| 4 |
+
is_current_events_prompt,
|
| 5 |
+
is_follow_up_prompt,
|
| 6 |
+
is_high_risk_current_events_prompt,
|
| 7 |
+
is_serious_technical_prompt,
|
| 8 |
+
)
|
| 9 |
+
from bot.byte_semantics_constants import (
|
| 10 |
+
ANALYTICAL_STYLE_TERMS,
|
| 11 |
+
BRIEF_STYLE_TERMS,
|
| 12 |
+
EXISTENCE_QUESTION_TERMS,
|
| 13 |
+
GENERIC_RESPONSE_HINT_TERMS,
|
| 14 |
+
OVERCONFIDENT_HINT_TERMS,
|
| 15 |
+
PLAYFUL_STYLE_TERMS,
|
| 16 |
+
QUALITY_SAFE_FALLBACK,
|
| 17 |
+
QUALITY_STOPWORDS,
|
| 18 |
+
QUESTION_OPENING_TERMS,
|
| 19 |
+
RECOMMENDATION_HINT_TERMS,
|
| 20 |
+
REWRITE_REASON_GUIDANCE,
|
| 21 |
+
)
|
| 22 |
+
from bot.byte_semantics_current_events import (
|
| 23 |
+
build_server_time_anchor_instruction,
|
| 24 |
+
build_verifiable_prompt,
|
| 25 |
+
has_current_events_confidence_label,
|
| 26 |
+
has_current_events_source_anchor,
|
| 27 |
+
has_current_events_temporal_anchor,
|
| 28 |
+
has_current_events_uncertainty,
|
| 29 |
+
is_canonical_high_risk_fallback,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def build_research_priority_instruction(prompt: str) -> str:
|
| 34 |
+
if not is_serious_technical_prompt(prompt):
|
| 35 |
+
return ""
|
| 36 |
+
return (
|
| 37 |
+
"Instrucoes de pesquisa aprofundada: priorize evidencia recente (2025-2026) e fontes confiaveis "
|
| 38 |
+
"(universidades, orgaos de saude, journals). "
|
| 39 |
+
"Quando existir contexto em alta relacionado ao tema, incorpore esse contexto explicitamente. "
|
| 40 |
+
"Se houver contribuicao relevante do Brasil no tema, mencione de forma objetiva."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_anti_generic_contract_instruction(prompt: str) -> str:
|
| 45 |
+
clean_prompt = (prompt or "").strip()
|
| 46 |
+
if not clean_prompt:
|
| 47 |
+
return ""
|
| 48 |
+
return (
|
| 49 |
+
"Contrato anti-generico: responda o caso especifico da pergunta, sem texto enciclopedico amplo. "
|
| 50 |
+
"Nao use frases vagas como 'depende', 'em geral' ou 'cada caso' sem antes entregar resposta objetiva."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_direct_answer_instruction(prompt: str) -> str:
|
| 55 |
+
normalized = " ".join((prompt or "").lower().split()).strip(" ?!.,:")
|
| 56 |
+
if not normalized:
|
| 57 |
+
return ""
|
| 58 |
+
is_question = "?" in (prompt or "")
|
| 59 |
+
starts_like_question = normalized.startswith(QUESTION_OPENING_TERMS)
|
| 60 |
+
if not is_question and not starts_like_question:
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
instructions = ["Formato de resposta obrigatorio: responda a pergunta principal na primeira linha, sem introducao."]
|
| 64 |
+
if normalized.startswith(EXISTENCE_QUESTION_TERMS):
|
| 65 |
+
instructions.append("Se for pergunta de existencia, comece com 'Sim,' ou 'Nao,'.")
|
| 66 |
+
if any(term in normalized for term in RECOMMENDATION_HINT_TERMS):
|
| 67 |
+
instructions.append("Se houver opcoes concretas, cite 1 a 3 nomes diretamente relevantes.")
|
| 68 |
+
instructions.append("Evite texto generico e nao desvie do foco da pergunta.")
|
| 69 |
+
return " ".join(instructions)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def build_adaptive_ai_instruction(prompt: str) -> str:
|
| 73 |
+
lowered = (prompt or "").lower()
|
| 74 |
+
if any(term in lowered for term in BRIEF_STYLE_TERMS):
|
| 75 |
+
return "Estilo de resposta: ultra objetivo, no maximo 3 linhas."
|
| 76 |
+
if any(term in lowered for term in ANALYTICAL_STYLE_TERMS):
|
| 77 |
+
return "Estilo de resposta: analise pratica em ate 4 linhas, com 2 ou 3 pontos claros."
|
| 78 |
+
if any(term in lowered for term in PLAYFUL_STYLE_TERMS):
|
| 79 |
+
return "Estilo de resposta: humor leve e inteligente, sem perder precisao factual."
|
| 80 |
+
return ""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _quality_tokenize(text: str) -> list[str]:
|
| 84 |
+
tokens = re.findall(r"[a-z0-9]{3,}", (text or "").lower())
|
| 85 |
+
normalized: list[str] = []
|
| 86 |
+
for token in tokens:
|
| 87 |
+
if len(token) > 4 and token.endswith("s"):
|
| 88 |
+
token = token[:-1]
|
| 89 |
+
normalized.append(token)
|
| 90 |
+
return normalized
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _extract_focus_terms(prompt: str, max_terms: int = 10) -> list[str]:
|
| 94 |
+
terms: list[str] = []
|
| 95 |
+
for token in _quality_tokenize(prompt):
|
| 96 |
+
if token in QUALITY_STOPWORDS:
|
| 97 |
+
continue
|
| 98 |
+
if token.isdigit() and len(token) < 4:
|
| 99 |
+
continue
|
| 100 |
+
if token in terms:
|
| 101 |
+
continue
|
| 102 |
+
terms.append(token)
|
| 103 |
+
if len(terms) >= max_terms:
|
| 104 |
+
break
|
| 105 |
+
return terms
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _count_focus_overlap(focus_terms: list[str], answer_tokens: set[str]) -> int:
|
| 109 |
+
if not focus_terms or not answer_tokens:
|
| 110 |
+
return 0
|
| 111 |
+
overlap = 0
|
| 112 |
+
for focus_term in focus_terms:
|
| 113 |
+
if focus_term in answer_tokens:
|
| 114 |
+
overlap += 1
|
| 115 |
+
continue
|
| 116 |
+
focus_prefix = focus_term[:5]
|
| 117 |
+
if len(focus_prefix) < 4:
|
| 118 |
+
continue
|
| 119 |
+
if any(token.startswith(focus_prefix) or focus_term.startswith(token[:5]) for token in answer_tokens if len(token) >= 4):
|
| 120 |
+
overlap += 1
|
| 121 |
+
return overlap
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def build_quality_prompt_script(prompt: str, server_time_instruction: str | None = None) -> str:
|
| 125 |
+
clean_prompt = (prompt or "").strip()
|
| 126 |
+
is_current = is_current_events_prompt(clean_prompt)
|
| 127 |
+
is_high_risk_current = is_high_risk_current_events_prompt(clean_prompt)
|
| 128 |
+
active_server_time_instruction = server_time_instruction or build_server_time_anchor_instruction()
|
| 129 |
+
lines = [
|
| 130 |
+
"Script de qualidade obrigatorio:\n"
|
| 131 |
+
"1) Responda a pergunta principal na primeira linha, sem introducao.\n"
|
| 132 |
+
"2) Inclua 1 ou 2 detalhes concretos ligados ao pedido (nome, data, local, obra ou numero).\n"
|
| 133 |
+
"3) Maximize densidade informacional: cada linha precisa trazer dado novo.\n"
|
| 134 |
+
"4) Corte frases vagas/repetitivas que nao agregam informacao.\n"
|
| 135 |
+
"5) Se houver baixa confianca, admita incerteza de forma explicita e peca 1 fonte."
|
| 136 |
+
]
|
| 137 |
+
if is_current:
|
| 138 |
+
lines.append(f"6) {active_server_time_instruction}")
|
| 139 |
+
lines.append("7) Para tema atual, separe explicitamente o que esta confirmado agora e o que ainda e rumor.")
|
| 140 |
+
if is_high_risk_current:
|
| 141 |
+
lines.append("8) Para noticia/anuncio atual, inclua as linhas finais: 'Confianca: alta|media|baixa' e 'Fonte: ...'.")
|
| 142 |
+
lines.append(f"9) Se faltar confirmacao robusta, use exatamente: '{QUALITY_SAFE_FALLBACK}'")
|
| 143 |
+
else:
|
| 144 |
+
lines.append(f"8) Se faltar confirmacao robusta, use exatamente: '{QUALITY_SAFE_FALLBACK}'")
|
| 145 |
+
else:
|
| 146 |
+
lines.append("6) Mantenha foco total no objeto perguntado.")
|
| 147 |
+
lines.append("7) Evite qualquer afirmacao absoluta sem base verificavel.")
|
| 148 |
+
lines.append("10) Nao termine com pergunta aberta, exceto se faltar dado essencial para responder.")
|
| 149 |
+
return "\n".join(lines)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def is_low_quality_answer(prompt: str, answer: str) -> tuple[bool, str]:
|
| 153 |
+
clean_prompt = " ".join((prompt or "").split()).strip()
|
| 154 |
+
answer_lines = [line.strip() for line in (answer or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") if line.strip()]
|
| 155 |
+
if not answer_lines:
|
| 156 |
+
return True, "resposta_vazia"
|
| 157 |
+
clean_answer = "\n".join(answer_lines)
|
| 158 |
+
compact_answer = " ".join(clean_answer.split()).strip()
|
| 159 |
+
if not compact_answer:
|
| 160 |
+
return True, "resposta_vazia"
|
| 161 |
+
|
| 162 |
+
normalized_prompt = " ".join(clean_prompt.lower().split()).strip(" ?!.,:")
|
| 163 |
+
lowered_answer = clean_answer.lower()
|
| 164 |
+
lowered_compact_answer = compact_answer.lower()
|
| 165 |
+
generic_hits = sum(1 for term in GENERIC_RESPONSE_HINT_TERMS if term in lowered_answer)
|
| 166 |
+
overconfident_hits = sum(1 for term in OVERCONFIDENT_HINT_TERMS if term in lowered_answer)
|
| 167 |
+
has_uncertainty = has_current_events_uncertainty(clean_answer)
|
| 168 |
+
has_temporal_anchor = has_current_events_temporal_anchor(clean_answer)
|
| 169 |
+
has_source_anchor = has_current_events_source_anchor(clean_answer)
|
| 170 |
+
has_confidence_label = has_current_events_confidence_label(clean_answer)
|
| 171 |
+
|
| 172 |
+
focus_terms = _extract_focus_terms(clean_prompt)
|
| 173 |
+
answer_tokens = set(_quality_tokenize(compact_answer))
|
| 174 |
+
overlap_count = _count_focus_overlap(focus_terms, answer_tokens)
|
| 175 |
+
first_line = answer_lines[0].strip().lower()
|
| 176 |
+
|
| 177 |
+
starts_too_generic = lowered_answer.startswith(("depende", "em geral", "de forma geral", "geralmente", "na maioria dos casos"))
|
| 178 |
+
prompt_is_complex = len(clean_prompt) >= 40
|
| 179 |
+
answer_is_short = len(clean_answer) < 70
|
| 180 |
+
follow_up_prompt = is_follow_up_prompt(clean_prompt)
|
| 181 |
+
current_events_prompt = is_current_events_prompt(clean_prompt)
|
| 182 |
+
current_events_high_risk = is_high_risk_current_events_prompt(clean_prompt)
|
| 183 |
+
existence_question = normalized_prompt.startswith(EXISTENCE_QUESTION_TERMS)
|
| 184 |
+
|
| 185 |
+
if "conexao com o modelo instavel" in lowered_answer or "conexão com o modelo instável" in lowered_answer:
|
| 186 |
+
return True, "modelo_indisponivel"
|
| 187 |
+
if generic_hits >= 2 and overlap_count <= 1:
|
| 188 |
+
return True, "resposta_generica"
|
| 189 |
+
if starts_too_generic and overlap_count <= 1:
|
| 190 |
+
return True, "abertura_generica"
|
| 191 |
+
if existence_question and not first_line.startswith(("sim", "nao", "não", "existe", "tem", "ha ", "há ")):
|
| 192 |
+
return True, "resposta_existencia_sem_posicao"
|
| 193 |
+
if prompt_is_complex and answer_is_short and overlap_count <= 1:
|
| 194 |
+
return True, "resposta_curta_sem_substancia"
|
| 195 |
+
if len(focus_terms) >= 3 and overlap_count == 0 and (generic_hits > 0 or answer_is_short):
|
| 196 |
+
return True, "off_topic"
|
| 197 |
+
if current_events_prompt and not follow_up_prompt and len(clean_prompt) >= 18 and not has_temporal_anchor and not has_uncertainty:
|
| 198 |
+
return True, "tema_atual_sem_ancora_temporal"
|
| 199 |
+
if current_events_high_risk and has_uncertainty and not is_canonical_high_risk_fallback(clean_answer):
|
| 200 |
+
return True, "incerteza_fora_fallback_canonico"
|
| 201 |
+
if current_events_high_risk and not follow_up_prompt and not has_confidence_label:
|
| 202 |
+
return True, "tema_atual_sem_confianca_explicita"
|
| 203 |
+
if current_events_high_risk and not follow_up_prompt and not has_uncertainty and not has_source_anchor and len(clean_answer) >= 120:
|
| 204 |
+
return True, "tema_atual_sem_base_verificavel"
|
| 205 |
+
if current_events_prompt and has_uncertainty and "link/fonte" not in lowered_answer and "fonte" not in lowered_answer:
|
| 206 |
+
return True, "incerteza_sem_pedido_de_fonte"
|
| 207 |
+
if lowered_compact_answer.endswith("?") and not has_uncertainty and not follow_up_prompt:
|
| 208 |
+
return True, "termina_com_pergunta_aberta"
|
| 209 |
+
if overconfident_hits > 0 and not has_temporal_anchor and not has_uncertainty:
|
| 210 |
+
return True, "confianca_absoluta_sem_base"
|
| 211 |
+
return False, ""
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def build_quality_rewrite_prompt(prompt: str, draft_answer: str, reason: str, server_time_instruction: str | None = None) -> str:
|
| 215 |
+
clean_prompt = (prompt or "").strip()
|
| 216 |
+
clean_draft = (draft_answer or "").strip()
|
| 217 |
+
safe_reason = (reason or "qualidade_insuficiente").strip()
|
| 218 |
+
active_server_time_instruction = server_time_instruction or build_server_time_anchor_instruction()
|
| 219 |
+
reason_fix = REWRITE_REASON_GUIDANCE.get(safe_reason, "Ajuste a resposta para ficar objetiva, verificavel e aderente a pergunta.")
|
| 220 |
+
return (
|
| 221 |
+
f"Pergunta original: {clean_prompt}\n"
|
| 222 |
+
f"Rascunho anterior reprovado ({safe_reason}): {clean_draft}\n"
|
| 223 |
+
"Reescreva para chat Twitch com as regras:\n"
|
| 224 |
+
f"- {active_server_time_instruction}\n"
|
| 225 |
+
f"- Correcao alvo: {reason_fix}\n"
|
| 226 |
+
"- Primeira linha responde direto.\n"
|
| 227 |
+
"- No maximo 4 linhas.\n"
|
| 228 |
+
"- Alta densidade: cada linha deve adicionar informacao nova.\n"
|
| 229 |
+
"- Inclua 1 ou 2 fatos concretos e relevantes.\n"
|
| 230 |
+
"- Para tema atual, diferencie confirmado agora vs rumor.\n"
|
| 231 |
+
"- Nao termine com pergunta aberta.\n"
|
| 232 |
+
"- Nao use frase vaga/generica.\n"
|
| 233 |
+
"- Se nao der para confirmar, use exatamente: "
|
| 234 |
+
f"'{QUALITY_SAFE_FALLBACK}'"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def build_llm_enhanced_prompt(prompt: str, server_time_instruction: str | None = None) -> str:
|
| 239 |
+
clean_prompt = (prompt or "").strip()
|
| 240 |
+
if not clean_prompt:
|
| 241 |
+
return ""
|
| 242 |
+
|
| 243 |
+
serious_mode = is_serious_technical_prompt(clean_prompt)
|
| 244 |
+
active_server_time_instruction = server_time_instruction or build_server_time_anchor_instruction()
|
| 245 |
+
enriched_prompt = build_verifiable_prompt(clean_prompt, concise_mode=not serious_mode, server_time_instruction=active_server_time_instruction)
|
| 246 |
+
extra_instructions: list[str] = []
|
| 247 |
+
if active_server_time_instruction not in enriched_prompt:
|
| 248 |
+
extra_instructions.append(active_server_time_instruction)
|
| 249 |
+
|
| 250 |
+
anti_generic_instruction = build_anti_generic_contract_instruction(clean_prompt)
|
| 251 |
+
if anti_generic_instruction:
|
| 252 |
+
extra_instructions.append(anti_generic_instruction)
|
| 253 |
+
direct_answer_instruction = build_direct_answer_instruction(clean_prompt)
|
| 254 |
+
if direct_answer_instruction:
|
| 255 |
+
extra_instructions.append(direct_answer_instruction)
|
| 256 |
+
|
| 257 |
+
if is_follow_up_prompt(clean_prompt):
|
| 258 |
+
extra_instructions.append(
|
| 259 |
+
"Instrucoes de continuidade: trate esta pergunta como follow-up e resolva referencias vagas "
|
| 260 |
+
"(ex.: ele, ela, isso) usando o historico recente do chat."
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
research_instruction = build_research_priority_instruction(clean_prompt)
|
| 264 |
+
if research_instruction:
|
| 265 |
+
extra_instructions.append(research_instruction)
|
| 266 |
+
|
| 267 |
+
if serious_mode:
|
| 268 |
+
extra_instructions.append(
|
| 269 |
+
"Para tema tecnico serio e relevante, responda em uma unica mensagem. "
|
| 270 |
+
"Contrato obrigatorio: no maximo 4 linhas, alta densidade, sem separador de multiparte."
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
if not is_current_events_prompt(clean_prompt):
|
| 274 |
+
adaptive_instruction = build_adaptive_ai_instruction(clean_prompt)
|
| 275 |
+
if adaptive_instruction:
|
| 276 |
+
extra_instructions.append(adaptive_instruction)
|
| 277 |
+
|
| 278 |
+
quality_script = build_quality_prompt_script(clean_prompt, server_time_instruction=active_server_time_instruction)
|
| 279 |
+
if quality_script:
|
| 280 |
+
extra_instructions.append(quality_script)
|
| 281 |
+
|
| 282 |
+
if not extra_instructions:
|
| 283 |
+
return enriched_prompt
|
| 284 |
+
return f"{enriched_prompt}\n" + "\n".join(extra_instructions)
|
bot/byte_semantics_reply.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from bot.byte_semantics_base import compact_message, format_chat_reply, normalize_text_for_scene
|
| 4 |
+
from bot.byte_semantics_constants import (
|
| 5 |
+
MAX_CHAT_MESSAGE_LENGTH,
|
| 6 |
+
MOVIE_TITLE_QUOTE_PATTERN,
|
| 7 |
+
MULTIPART_SEPARATOR,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def split_text_for_chat(text: str, max_len: int = MAX_CHAT_MESSAGE_LENGTH, max_parts: int = 2) -> list[str]:
|
| 12 |
+
clean_text = (text or "").strip()
|
| 13 |
+
if not clean_text:
|
| 14 |
+
return []
|
| 15 |
+
if len(clean_text) <= max_len:
|
| 16 |
+
return [clean_text]
|
| 17 |
+
|
| 18 |
+
parts: list[str] = []
|
| 19 |
+
remaining = clean_text
|
| 20 |
+
while remaining and len(parts) < max_parts:
|
| 21 |
+
if len(remaining) <= max_len:
|
| 22 |
+
parts.append(remaining.strip())
|
| 23 |
+
remaining = ""
|
| 24 |
+
break
|
| 25 |
+
|
| 26 |
+
cut = remaining.rfind("\n", 0, max_len + 1)
|
| 27 |
+
if cut < int(max_len * 0.5):
|
| 28 |
+
sentence_cuts = [remaining.rfind(symbol, 0, max_len + 1) for symbol in (". ", "? ", "! ")]
|
| 29 |
+
cut = max(sentence_cuts)
|
| 30 |
+
if cut < int(max_len * 0.5):
|
| 31 |
+
cut = remaining.rfind(" ", 0, max_len + 1)
|
| 32 |
+
if cut <= 0:
|
| 33 |
+
cut = max_len
|
| 34 |
+
|
| 35 |
+
chunk = remaining[:cut].strip()
|
| 36 |
+
if chunk:
|
| 37 |
+
parts.append(chunk)
|
| 38 |
+
remaining = remaining[cut:].strip()
|
| 39 |
+
|
| 40 |
+
if remaining:
|
| 41 |
+
if parts:
|
| 42 |
+
parts[-1] = compact_message(f"{parts[-1]} {remaining}", max_len=max_len)
|
| 43 |
+
else:
|
| 44 |
+
parts.append(compact_message(remaining, max_len=max_len))
|
| 45 |
+
|
| 46 |
+
return [part for part in parts if part][:max_parts]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def extract_multi_reply_parts(answer_text: str, max_parts: int = 2) -> list[str]:
|
| 50 |
+
if not answer_text:
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
raw_parts = [segment.strip() for segment in answer_text.split(MULTIPART_SEPARATOR) if segment.strip()]
|
| 54 |
+
if len(raw_parts) >= 2:
|
| 55 |
+
normalized = [format_chat_reply(part) for part in raw_parts[:max_parts]]
|
| 56 |
+
return [part for part in normalized if part]
|
| 57 |
+
|
| 58 |
+
chunks = split_text_for_chat(answer_text, max_len=MAX_CHAT_MESSAGE_LENGTH, max_parts=max_parts)
|
| 59 |
+
normalized = [format_chat_reply(chunk) for chunk in chunks]
|
| 60 |
+
return [part for part in normalized if part]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def extract_movie_title(prompt: str) -> str:
|
| 64 |
+
quote_match = MOVIE_TITLE_QUOTE_PATTERN.search(prompt or "")
|
| 65 |
+
if quote_match:
|
| 66 |
+
return normalize_text_for_scene(quote_match.group("title"), max_len=80)
|
| 67 |
+
|
| 68 |
+
candidate_match = re.search(
|
| 69 |
+
r"ficha\s*t[eé]cnica(?:\s+(?:do|da|de)\s+filme)?(?:\s+(?:do|da|de))?\s+(?P<title>.+)$",
|
| 70 |
+
prompt or "",
|
| 71 |
+
re.IGNORECASE,
|
| 72 |
+
)
|
| 73 |
+
if not candidate_match:
|
| 74 |
+
return ""
|
| 75 |
+
|
| 76 |
+
candidate = candidate_match.group("title").strip(" ?!.:,;")
|
| 77 |
+
if not candidate:
|
| 78 |
+
return ""
|
| 79 |
+
|
| 80 |
+
lowered = candidate.lower()
|
| 81 |
+
generic_fragments = (
|
| 82 |
+
"que estamos vendo",
|
| 83 |
+
"que estamos assistindo",
|
| 84 |
+
"que estamos assistindo hoje",
|
| 85 |
+
"agora",
|
| 86 |
+
)
|
| 87 |
+
if any(fragment in lowered for fragment in generic_fragments):
|
| 88 |
+
return ""
|
| 89 |
+
if lowered in {"filme", "hoje", "agora"}:
|
| 90 |
+
return ""
|
| 91 |
+
|
| 92 |
+
return normalize_text_for_scene(candidate, max_len=80)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def build_movie_fact_sheet_query(movie_title: str) -> str:
|
| 96 |
+
return (
|
| 97 |
+
f"Monte uma ficha tecnica objetiva do filme '{movie_title}' para chat Twitch. "
|
| 98 |
+
"Responda em no maximo 4 linhas, sem markdown, com alta densidade e sem enrolacao. "
|
| 99 |
+
"Formato obrigatorio: "
|
| 100 |
+
"1) Titulo | Ano | Pais. "
|
| 101 |
+
"2) Direcao | Genero | Duracao. "
|
| 102 |
+
"3) Elenco principal (ate 3 nomes). "
|
| 103 |
+
"4) Nota media | Premios-chave (ou N/D). "
|
| 104 |
+
"Se nao houver confianca em algum dado, escreva N/D."
|
| 105 |
+
)
|
bot/channel_control.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import base64
|
| 3 |
+
import binascii
|
| 4 |
+
import hmac
|
| 5 |
+
import inspect
|
| 6 |
+
import threading
|
| 7 |
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
| 8 |
+
from typing import Any, Coroutine, cast
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
CHANNEL_CONTROL_TIMEOUT_SECONDS = 15.0
|
| 12 |
+
SUPPORTED_ACTIONS = {"list", "join", "part"}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def extract_admin_token(headers: Any) -> str:
|
| 16 |
+
direct = str(headers.get("X-Byte-Admin-Token", "") or "").strip()
|
| 17 |
+
if direct:
|
| 18 |
+
return direct
|
| 19 |
+
|
| 20 |
+
authorization = str(headers.get("Authorization", "") or "").strip()
|
| 21 |
+
if authorization.lower().startswith("bearer "):
|
| 22 |
+
return authorization[7:].strip()
|
| 23 |
+
if authorization.lower().startswith("basic "):
|
| 24 |
+
encoded_credentials = authorization[6:].strip()
|
| 25 |
+
if not encoded_credentials:
|
| 26 |
+
return ""
|
| 27 |
+
try:
|
| 28 |
+
decoded_credentials = base64.b64decode(
|
| 29 |
+
encoded_credentials, validate=True
|
| 30 |
+
).decode("utf-8")
|
| 31 |
+
except (binascii.Error, UnicodeDecodeError):
|
| 32 |
+
return ""
|
| 33 |
+
|
| 34 |
+
if ":" in decoded_credentials:
|
| 35 |
+
_, password = decoded_credentials.split(":", 1)
|
| 36 |
+
return password.strip()
|
| 37 |
+
return decoded_credentials.strip()
|
| 38 |
+
return ""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def is_dashboard_admin_authorized(headers: Any, expected_token: str) -> bool:
|
| 42 |
+
configured_token = (expected_token or "").strip()
|
| 43 |
+
if not configured_token:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
provided_token = extract_admin_token(headers)
|
| 47 |
+
if not provided_token:
|
| 48 |
+
return False
|
| 49 |
+
return hmac.compare_digest(provided_token, configured_token)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def parse_terminal_command(command_text: str) -> tuple[str, str]:
|
| 53 |
+
normalized = " ".join((command_text or "").strip().split())
|
| 54 |
+
lowered = normalized.lower()
|
| 55 |
+
if not lowered:
|
| 56 |
+
raise ValueError("Command is required. Use: list | join <channel> | part <channel>.")
|
| 57 |
+
|
| 58 |
+
if lowered in {"list", "channels", "canais"}:
|
| 59 |
+
return ("list", "")
|
| 60 |
+
|
| 61 |
+
for prefix in ("join ", "entrar ", "add "):
|
| 62 |
+
if lowered.startswith(prefix):
|
| 63 |
+
return ("join", normalized[len(prefix):].strip())
|
| 64 |
+
|
| 65 |
+
for prefix in ("part ", "leave ", "sair ", "remove "):
|
| 66 |
+
if lowered.startswith(prefix):
|
| 67 |
+
return ("part", normalized[len(prefix):].strip())
|
| 68 |
+
|
| 69 |
+
raise ValueError("Unsupported command. Use: list | join <channel> | part <channel>.")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class IrcChannelControlBridge:
|
| 73 |
+
def __init__(self, timeout_seconds: float = CHANNEL_CONTROL_TIMEOUT_SECONDS) -> None:
|
| 74 |
+
self._timeout_seconds = max(1.0, float(timeout_seconds))
|
| 75 |
+
self._lock = threading.Lock()
|
| 76 |
+
self._loop: asyncio.AbstractEventLoop | None = None
|
| 77 |
+
self._bot: Any = None
|
| 78 |
+
|
| 79 |
+
def bind(self, *, loop: asyncio.AbstractEventLoop, bot: Any) -> None:
|
| 80 |
+
with self._lock:
|
| 81 |
+
self._loop = loop
|
| 82 |
+
self._bot = bot
|
| 83 |
+
|
| 84 |
+
def unbind(self) -> None:
|
| 85 |
+
with self._lock:
|
| 86 |
+
self._loop = None
|
| 87 |
+
self._bot = None
|
| 88 |
+
|
| 89 |
+
def _snapshot(self) -> tuple[Any, asyncio.AbstractEventLoop | None]:
|
| 90 |
+
with self._lock:
|
| 91 |
+
return self._bot, self._loop
|
| 92 |
+
|
| 93 |
+
def _submit(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
|
| 94 |
+
bot, loop = self._snapshot()
|
| 95 |
+
if bot is None or loop is None or loop.is_closed() or not loop.is_running():
|
| 96 |
+
raise RuntimeError("IRC runtime is not connected yet.")
|
| 97 |
+
|
| 98 |
+
future = asyncio.run_coroutine_threadsafe(coroutine, loop)
|
| 99 |
+
try:
|
| 100 |
+
return future.result(timeout=self._timeout_seconds)
|
| 101 |
+
except FutureTimeoutError as error:
|
| 102 |
+
future.cancel()
|
| 103 |
+
raise TimeoutError("Channel control timeout while waiting for IRC runtime.") from error
|
| 104 |
+
|
| 105 |
+
def execute(self, *, action: str, channel_login: str = "") -> dict[str, Any]:
|
| 106 |
+
normalized_action = (action or "").strip().lower()
|
| 107 |
+
normalized_channel = (channel_login or "").strip()
|
| 108 |
+
if normalized_action not in SUPPORTED_ACTIONS:
|
| 109 |
+
return {
|
| 110 |
+
"ok": False,
|
| 111 |
+
"error": "invalid_action",
|
| 112 |
+
"message": "Unsupported action. Use: list, join, part.",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
if normalized_action == "list":
|
| 117 |
+
channels = self._submit(self._safe_call("admin_list_channels"))
|
| 118 |
+
return {
|
| 119 |
+
"ok": True,
|
| 120 |
+
"action": "list",
|
| 121 |
+
"channels": channels,
|
| 122 |
+
"message": f"Connected channels: {', '.join(f'#{item}' for item in channels) or 'none'}.",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if not normalized_channel:
|
| 126 |
+
return {
|
| 127 |
+
"ok": False,
|
| 128 |
+
"error": "missing_channel",
|
| 129 |
+
"message": f"Action '{normalized_action}' requires a channel login.",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
method_name = "admin_join_channel" if normalized_action == "join" else "admin_part_channel"
|
| 133 |
+
success, message, channels = self._submit(self._safe_call(method_name, normalized_channel))
|
| 134 |
+
return {
|
| 135 |
+
"ok": bool(success),
|
| 136 |
+
"action": normalized_action,
|
| 137 |
+
"channels": channels,
|
| 138 |
+
"message": str(message),
|
| 139 |
+
}
|
| 140 |
+
except TimeoutError as error:
|
| 141 |
+
return {"ok": False, "error": "timeout", "message": str(error)}
|
| 142 |
+
except RuntimeError as error:
|
| 143 |
+
return {"ok": False, "error": "runtime_unavailable", "message": str(error)}
|
| 144 |
+
except Exception as error:
|
| 145 |
+
return {"ok": False, "error": "runtime_error", "message": str(error)}
|
| 146 |
+
|
| 147 |
+
async def _safe_call(self, method_name: str, *args: Any) -> Any:
|
| 148 |
+
bot, _ = self._snapshot()
|
| 149 |
+
if bot is None:
|
| 150 |
+
raise RuntimeError("IRC runtime is not connected yet.")
|
| 151 |
+
|
| 152 |
+
target = getattr(bot, method_name, None)
|
| 153 |
+
if not callable(target):
|
| 154 |
+
raise RuntimeError(f"IRC runtime method not available: {method_name}")
|
| 155 |
+
result = target(*args)
|
| 156 |
+
if not inspect.iscoroutine(result):
|
| 157 |
+
raise RuntimeError(f"IRC runtime method is not awaitable: {method_name}")
|
| 158 |
+
return await cast(Coroutine[Any, Any, Any], result)
|
bot/channel_status.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
TWITCH_CHANNEL_LOGIN_PATTERN = re.compile(r"^[a-z0-9_]{3,25}$")
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def normalize_channel_login(channel_login: str, *, pattern: re.Pattern[str] = TWITCH_CHANNEL_LOGIN_PATTERN) -> str:
|
| 7 |
+
normalized = (channel_login or "").strip().lstrip("#").lower()
|
| 8 |
+
if not normalized:
|
| 9 |
+
return ""
|
| 10 |
+
if not pattern.fullmatch(normalized):
|
| 11 |
+
return ""
|
| 12 |
+
return normalized
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_channel_logins(raw_value: str, *, pattern: re.Pattern[str] = TWITCH_CHANNEL_LOGIN_PATTERN) -> list[str]:
|
| 16 |
+
unique_logins: list[str] = []
|
| 17 |
+
seen: set[str] = set()
|
| 18 |
+
for token in re.split(r"[,\s]+", raw_value or ""):
|
| 19 |
+
normalized = normalize_channel_login(token, pattern=pattern)
|
| 20 |
+
if not normalized or normalized in seen:
|
| 21 |
+
continue
|
| 22 |
+
seen.add(normalized)
|
| 23 |
+
unique_logins.append(normalized)
|
| 24 |
+
return unique_logins
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def format_status_channels(
|
| 28 |
+
channel_logins: list[str] | None = None,
|
| 29 |
+
*,
|
| 30 |
+
fallback_channels: list[str] | None = None,
|
| 31 |
+
fallback_mode: str = "eventsub",
|
| 32 |
+
max_items: int = 3,
|
| 33 |
+
pattern: re.Pattern[str] = TWITCH_CHANNEL_LOGIN_PATTERN,
|
| 34 |
+
) -> str:
|
| 35 |
+
resolved = [normalize_channel_login(login, pattern=pattern) for login in (channel_logins or [])]
|
| 36 |
+
resolved = [login for login in resolved if login]
|
| 37 |
+
if not resolved:
|
| 38 |
+
resolved = [normalize_channel_login(login, pattern=pattern) for login in (fallback_channels or [])]
|
| 39 |
+
resolved = [login for login in resolved if login]
|
| 40 |
+
if not resolved:
|
| 41 |
+
return "eventsub" if fallback_mode == "eventsub" else "n/a"
|
| 42 |
+
|
| 43 |
+
visible = resolved[:max(1, max_items)]
|
| 44 |
+
if len(resolved) <= len(visible):
|
| 45 |
+
return ", ".join(visible)
|
| 46 |
+
return f"{', '.join(visible)} +{len(resolved) - len(visible)}"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compose_status_line(
|
| 50 |
+
*,
|
| 51 |
+
bot_brand: str,
|
| 52 |
+
bot_version: str,
|
| 53 |
+
uptime_minutes: int,
|
| 54 |
+
channels_label: str,
|
| 55 |
+
chat_messages_10m: int,
|
| 56 |
+
active_chatters_10m: int,
|
| 57 |
+
triggers_10m: int,
|
| 58 |
+
p95_latency_ms: float,
|
| 59 |
+
) -> str:
|
| 60 |
+
return (
|
| 61 |
+
f"{bot_brand} v{bot_version} | Uptime: {uptime_minutes}min | Canais: {channels_label} | "
|
| 62 |
+
f"Chat 10m: {chat_messages_10m} msgs/{active_chatters_10m} ativos | Triggers 10m: {triggers_10m} | "
|
| 63 |
+
f"P95: {p95_latency_ms:.1f}ms | Privacidade: metricas agregadas"
|
| 64 |
+
)
|
bot/clip_jobs_runtime.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 6 |
+
|
| 7 |
+
from bot.control_plane import control_plane
|
| 8 |
+
from bot.control_plane_constants import utc_iso
|
| 9 |
+
from bot.runtime_config import CLIENT_ID, EDITOR_ID
|
| 10 |
+
from bot.clip_jobs_store import job_store
|
| 11 |
+
from bot.twitch_clips_api import (
|
| 12 |
+
TwitchClipAuthError,
|
| 13 |
+
TwitchClipError,
|
| 14 |
+
TwitchClipNotFoundError,
|
| 15 |
+
TwitchClipRateLimitError,
|
| 16 |
+
create_clip_live,
|
| 17 |
+
create_clip_from_vod,
|
| 18 |
+
get_clip,
|
| 19 |
+
get_clip_download_url,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("byte.clips.runtime")
|
| 23 |
+
|
| 24 |
+
TokenProvider = Callable[[], Awaitable[str]]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ClipJobsRuntime:
|
| 28 |
+
def __init__(self) -> None:
|
| 29 |
+
self._lock = threading.Lock()
|
| 30 |
+
self._jobs: dict[str, dict[str, Any]] = {}
|
| 31 |
+
self._token_provider: Optional[TokenProvider] = None
|
| 32 |
+
self._loop_task: Optional[asyncio.Task[Any]] = None
|
| 33 |
+
self._running = False
|
| 34 |
+
|
| 35 |
+
# Hydrate from store
|
| 36 |
+
self._load_from_store()
|
| 37 |
+
|
| 38 |
+
def _load_from_store(self) -> None:
|
| 39 |
+
try:
|
| 40 |
+
active_jobs = job_store.load_active_jobs()
|
| 41 |
+
for job in active_jobs:
|
| 42 |
+
action_id = str(job.get("action_id"))
|
| 43 |
+
if action_id:
|
| 44 |
+
self._jobs[action_id] = job
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error("Erro ao hidratar jobs do store: %s", e)
|
| 47 |
+
|
| 48 |
+
def bind_token_provider(self, provider: TokenProvider) -> None:
|
| 49 |
+
with self._lock:
|
| 50 |
+
self._token_provider = provider
|
| 51 |
+
|
| 52 |
+
def start(self, loop: asyncio.AbstractEventLoop) -> None:
|
| 53 |
+
if self._running:
|
| 54 |
+
return
|
| 55 |
+
self._running = True
|
| 56 |
+
self._loop_task = loop.create_task(self._process_loop())
|
| 57 |
+
|
| 58 |
+
def stop(self) -> None:
|
| 59 |
+
self._running = False
|
| 60 |
+
if self._loop_task:
|
| 61 |
+
self._loop_task.cancel()
|
| 62 |
+
self._loop_task = None
|
| 63 |
+
|
| 64 |
+
def get_jobs(self) -> list[dict[str, Any]]:
|
| 65 |
+
with self._lock:
|
| 66 |
+
return sorted(
|
| 67 |
+
self._jobs.values(),
|
| 68 |
+
key=lambda x: str(x.get("created_at", "")),
|
| 69 |
+
reverse=True,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
async def _process_loop(self) -> None:
|
| 73 |
+
logger.info("ClipJobsRuntime loop iniciado.")
|
| 74 |
+
while self._running:
|
| 75 |
+
try:
|
| 76 |
+
await self._sync_from_queue()
|
| 77 |
+
await self._advance_jobs()
|
| 78 |
+
except asyncio.CancelledError:
|
| 79 |
+
break
|
| 80 |
+
except Exception as error:
|
| 81 |
+
logger.error("Erro no loop de clips: %s", error)
|
| 82 |
+
|
| 83 |
+
await asyncio.sleep(2.0)
|
| 84 |
+
|
| 85 |
+
async def _sync_from_queue(self) -> None:
|
| 86 |
+
actions_payload = control_plane.list_actions(status="approved", limit=20)
|
| 87 |
+
items = actions_payload.get("items", [])
|
| 88 |
+
|
| 89 |
+
for item in items:
|
| 90 |
+
if item.get("kind") != "clip_candidate":
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
action_id = str(item.get("id"))
|
| 94 |
+
with self._lock:
|
| 95 |
+
if action_id in self._jobs:
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
now = time.time()
|
| 99 |
+
payload = item.get("payload") or {}
|
| 100 |
+
job = {
|
| 101 |
+
"job_id": f"job_{action_id}",
|
| 102 |
+
"action_id": action_id,
|
| 103 |
+
"broadcaster_id": str(payload.get("broadcaster_id", "")),
|
| 104 |
+
"mode": str(payload.get("mode", "live")),
|
| 105 |
+
"status": "queued",
|
| 106 |
+
"title": str(payload.get("suggested_title", "")),
|
| 107 |
+
# Persist VOD params if present
|
| 108 |
+
"vod_id": payload.get("vod_id"),
|
| 109 |
+
"vod_offset": payload.get("vod_offset"),
|
| 110 |
+
"duration": payload.get("suggested_duration"),
|
| 111 |
+
"created_at": utc_iso(now),
|
| 112 |
+
"updated_at": utc_iso(now),
|
| 113 |
+
"twitch_clip_id": None,
|
| 114 |
+
"edit_url": None,
|
| 115 |
+
"clip_url": None,
|
| 116 |
+
"download_url": None,
|
| 117 |
+
"error": None,
|
| 118 |
+
"attempts": 0,
|
| 119 |
+
"next_poll_at": 0.0,
|
| 120 |
+
"poll_until": 0.0,
|
| 121 |
+
"next_download_poll_at": 0.0,
|
| 122 |
+
}
|
| 123 |
+
self._jobs[action_id] = job
|
| 124 |
+
job_store.save_job(job)
|
| 125 |
+
logger.info("Novo job de clip criado: %s", job["job_id"])
|
| 126 |
+
|
| 127 |
+
async def _advance_jobs(self) -> None:
|
| 128 |
+
with self._lock:
|
| 129 |
+
active_jobs = [
|
| 130 |
+
job for job in self._jobs.values()
|
| 131 |
+
if job["status"] in {"queued", "creating", "polling", "ready"}
|
| 132 |
+
and not (job["status"] == "ready" and job.get("download_url")) # Keep processing ready jobs until download URL fetched
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
for job in active_jobs:
|
| 136 |
+
try:
|
| 137 |
+
if job["status"] == "queued":
|
| 138 |
+
await self._handle_queued(job)
|
| 139 |
+
elif job["status"] == "polling":
|
| 140 |
+
await self._handle_polling(job)
|
| 141 |
+
elif job["status"] == "ready" and not job.get("download_url"):
|
| 142 |
+
await self._handle_download_fetch(job)
|
| 143 |
+
except Exception as error:
|
| 144 |
+
logger.error("Erro ao processar job %s: %s", job["job_id"], error)
|
| 145 |
+
if job["status"] != "ready": # Don't fail a ready job just because download fetch failed
|
| 146 |
+
self._update_job(job["action_id"], status="failed", error=str(error))
|
| 147 |
+
|
| 148 |
+
async def _get_token(self) -> str:
|
| 149 |
+
if not self._token_provider:
|
| 150 |
+
raise RuntimeError("Token provider nao configurado.")
|
| 151 |
+
return await self._token_provider()
|
| 152 |
+
|
| 153 |
+
async def _handle_queued(self, job: dict[str, Any]) -> None:
|
| 154 |
+
action_id = job["action_id"]
|
| 155 |
+
broadcaster_id = job["broadcaster_id"]
|
| 156 |
+
mode = job["mode"]
|
| 157 |
+
|
| 158 |
+
self._update_job(action_id, status="creating")
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
token = await self._get_token()
|
| 162 |
+
client_id = CLIENT_ID or ""
|
| 163 |
+
|
| 164 |
+
resp = {}
|
| 165 |
+
if mode == "live":
|
| 166 |
+
resp = await create_clip_live(
|
| 167 |
+
broadcaster_id=broadcaster_id,
|
| 168 |
+
token=token,
|
| 169 |
+
client_id=client_id,
|
| 170 |
+
title=job.get("title", ""),
|
| 171 |
+
duration=float(job.get("duration") or 30.0),
|
| 172 |
+
)
|
| 173 |
+
elif mode == "vod":
|
| 174 |
+
vod_id = job.get("vod_id")
|
| 175 |
+
vod_offset = job.get("vod_offset")
|
| 176 |
+
duration = job.get("duration", 30)
|
| 177 |
+
title = job.get("title", "")
|
| 178 |
+
|
| 179 |
+
if not vod_id or not vod_offset:
|
| 180 |
+
raise ValueError("Dados de VOD ausentes no job.")
|
| 181 |
+
|
| 182 |
+
resp = await create_clip_from_vod(
|
| 183 |
+
broadcaster_id=broadcaster_id,
|
| 184 |
+
editor_id=EDITOR_ID or "",
|
| 185 |
+
vod_id=vod_id,
|
| 186 |
+
vod_offset=int(vod_offset),
|
| 187 |
+
duration=int(duration),
|
| 188 |
+
token=token,
|
| 189 |
+
client_id=client_id,
|
| 190 |
+
title=title,
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
self._update_job(action_id, status="failed", error=f"mode_not_supported: {mode}")
|
| 194 |
+
return
|
| 195 |
+
|
| 196 |
+
clip_id = resp.get("id")
|
| 197 |
+
edit_url = resp.get("edit_url")
|
| 198 |
+
|
| 199 |
+
if not clip_id:
|
| 200 |
+
raise TwitchClipError("API retornou sucesso mas sem ID.")
|
| 201 |
+
|
| 202 |
+
now = time.time()
|
| 203 |
+
self._update_job(
|
| 204 |
+
action_id,
|
| 205 |
+
status="polling",
|
| 206 |
+
twitch_clip_id=clip_id,
|
| 207 |
+
edit_url=edit_url,
|
| 208 |
+
poll_until=now + 20.0,
|
| 209 |
+
next_poll_at=now + 2.0,
|
| 210 |
+
)
|
| 211 |
+
logger.info("Clip criado na Twitch (%s): %s. Polling...", mode, clip_id)
|
| 212 |
+
|
| 213 |
+
except TwitchClipAuthError as e:
|
| 214 |
+
self._update_job(action_id, status="failed", error=f"auth_error: {e}")
|
| 215 |
+
except TwitchClipNotFoundError as e:
|
| 216 |
+
self._update_job(action_id, status="failed", error=f"not_found: {e}")
|
| 217 |
+
except TwitchClipRateLimitError as e:
|
| 218 |
+
self._update_job(action_id, status="failed", error=f"rate_limit: {e}")
|
| 219 |
+
except Exception as e:
|
| 220 |
+
self._update_job(action_id, status="failed", error=str(e))
|
| 221 |
+
|
| 222 |
+
async def _handle_polling(self, job: dict[str, Any]) -> None:
|
| 223 |
+
now = time.time()
|
| 224 |
+
if now < job["next_poll_at"]:
|
| 225 |
+
return
|
| 226 |
+
|
| 227 |
+
if now > job["poll_until"]:
|
| 228 |
+
self._update_job(job["action_id"], status="failed", error="poll_timeout")
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
action_id = job["action_id"]
|
| 232 |
+
clip_id = job["twitch_clip_id"]
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
token = await self._get_token()
|
| 236 |
+
client_id = CLIENT_ID or ""
|
| 237 |
+
|
| 238 |
+
clip_data = await get_clip(
|
| 239 |
+
clip_id=clip_id,
|
| 240 |
+
token=token,
|
| 241 |
+
client_id=client_id,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
if clip_data:
|
| 245 |
+
clip_url = clip_data.get("url")
|
| 246 |
+
self._update_job(
|
| 247 |
+
action_id,
|
| 248 |
+
status="ready",
|
| 249 |
+
clip_url=clip_url,
|
| 250 |
+
)
|
| 251 |
+
logger.info("Clip pronto: %s", clip_url)
|
| 252 |
+
else:
|
| 253 |
+
self._update_job(
|
| 254 |
+
action_id,
|
| 255 |
+
next_poll_at=now + 3.0,
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.warning("Erro no polling do clip %s: %s", clip_id, e)
|
| 260 |
+
self._update_job(action_id, next_poll_at=now + 3.0)
|
| 261 |
+
|
| 262 |
+
async def _handle_download_fetch(self, job: dict[str, Any]) -> None:
|
| 263 |
+
# Tenta buscar a URL de download para jobs 'ready'
|
| 264 |
+
# Isso é um "best effort" e pode falhar por rate limit
|
| 265 |
+
action_id = job["action_id"]
|
| 266 |
+
clip_id = job["twitch_clip_id"]
|
| 267 |
+
broadcaster_id = job["broadcaster_id"]
|
| 268 |
+
|
| 269 |
+
now = time.time()
|
| 270 |
+
if now < job.get("next_download_poll_at", 0):
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
try:
|
| 274 |
+
token = await self._get_token()
|
| 275 |
+
client_id = CLIENT_ID or ""
|
| 276 |
+
|
| 277 |
+
url = await get_clip_download_url(
|
| 278 |
+
clip_id=clip_id,
|
| 279 |
+
token=token,
|
| 280 |
+
client_id=client_id,
|
| 281 |
+
broadcaster_id=broadcaster_id,
|
| 282 |
+
editor_id=EDITOR_ID or "",
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
if url:
|
| 286 |
+
self._update_job(action_id, download_url=url)
|
| 287 |
+
logger.info("Download URL obtida para clip %s", clip_id)
|
| 288 |
+
else:
|
| 289 |
+
# Nao disponivel ainda, tenta novamente em 60s
|
| 290 |
+
self._update_job(action_id, next_download_poll_at=now + 60.0)
|
| 291 |
+
|
| 292 |
+
except TwitchClipRateLimitError as e:
|
| 293 |
+
# Respeita o reset do rate limit se fornecido, senao espera 60s
|
| 294 |
+
wait_until = getattr(e, "reset_at", now + 60.0)
|
| 295 |
+
self._update_job(action_id, next_download_poll_at=wait_until)
|
| 296 |
+
logger.warning("Rate limit atingido para download do clip %s. Retentando em %ds", clip_id, int(wait_until - now))
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.warning("Falha ao buscar download URL para %s: %s", clip_id, e)
|
| 299 |
+
# Espera um pouco antes de tentar novamente em caso de erro generico
|
| 300 |
+
self._update_job(action_id, next_download_poll_at=now + 120.0)
|
| 301 |
+
|
| 302 |
+
def _update_job(self, action_id: str, **kwargs: Any) -> None:
|
| 303 |
+
with self._lock:
|
| 304 |
+
if action_id not in self._jobs:
|
| 305 |
+
return
|
| 306 |
+
job = self._jobs[action_id]
|
| 307 |
+
job.update(kwargs)
|
| 308 |
+
job["updated_at"] = utc_iso(time.time())
|
| 309 |
+
|
| 310 |
+
# Persist async-ish (fire and forget for now, store handles errors)
|
| 311 |
+
job_store.save_job(job)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
clip_jobs = ClipJobsRuntime()
|
| 315 |
+
|
| 316 |
+
__all__ = ["ClipJobsRuntime", "clip_jobs"]
|
bot/clip_jobs_store.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import threading
|
| 3 |
+
from typing import Any, List, Optional
|
| 4 |
+
import os
|
| 5 |
+
import psycopg2
|
| 6 |
+
from psycopg2.extras import RealDictCursor
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("byte.clips.store")
|
| 9 |
+
|
| 10 |
+
COLLECTION_NAME = "clip_jobs"
|
| 11 |
+
|
| 12 |
+
class SupabaseJobStore:
|
| 13 |
+
def __init__(self) -> None:
|
| 14 |
+
self._db_url = os.environ.get("SUPABASE_DB_URL")
|
| 15 |
+
self._lock = threading.Lock()
|
| 16 |
+
self._initialized = False
|
| 17 |
+
|
| 18 |
+
if not self._db_url:
|
| 19 |
+
logger.warning("SUPABASE_DB_URL nao definido. SupabaseJobStore operando em modo offline (no-op).")
|
| 20 |
+
|
| 21 |
+
def _get_connection(self):
|
| 22 |
+
if not self._db_url:
|
| 23 |
+
return None
|
| 24 |
+
try:
|
| 25 |
+
conn = psycopg2.connect(self._db_url)
|
| 26 |
+
if not self._initialized:
|
| 27 |
+
self._ensure_table(conn)
|
| 28 |
+
return conn
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error("Falha ao conectar no Supabase/Postgres: %s", e)
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
def _ensure_table(self, conn):
|
| 34 |
+
try:
|
| 35 |
+
with self._lock:
|
| 36 |
+
if self._initialized:
|
| 37 |
+
return
|
| 38 |
+
with conn.cursor() as cur:
|
| 39 |
+
cur.execute(f"""
|
| 40 |
+
CREATE TABLE IF NOT EXISTS {COLLECTION_NAME} (
|
| 41 |
+
job_id TEXT PRIMARY KEY,
|
| 42 |
+
status TEXT NOT NULL,
|
| 43 |
+
download_url TEXT,
|
| 44 |
+
metadata JSONB,
|
| 45 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 46 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 47 |
+
);
|
| 48 |
+
""")
|
| 49 |
+
conn.commit()
|
| 50 |
+
self._initialized = True
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error("Erro ao criar tabela no Supabase: %s", e)
|
| 53 |
+
|
| 54 |
+
def save_job(self, job: dict[str, Any]) -> None:
|
| 55 |
+
conn = self._get_connection()
|
| 56 |
+
if not conn:
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
job_id = job.get("job_id")
|
| 60 |
+
status = job.get("status", "unknown")
|
| 61 |
+
download_url = job.get("download_url")
|
| 62 |
+
|
| 63 |
+
# O resto do job vira metadados JSON
|
| 64 |
+
job_metadata = {k: v for k, v in job.items() if k not in ["job_id", "status", "download_url"]}
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
with conn.cursor() as cur:
|
| 68 |
+
cur.execute(f"""
|
| 69 |
+
INSERT INTO {COLLECTION_NAME} (job_id, status, download_url, metadata, updated_at)
|
| 70 |
+
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
| 71 |
+
ON CONFLICT (job_id) DO UPDATE SET
|
| 72 |
+
status = EXCLUDED.status,
|
| 73 |
+
download_url = EXCLUDED.download_url,
|
| 74 |
+
metadata = EXCLUDED.metadata,
|
| 75 |
+
updated_at = CURRENT_TIMESTAMP;
|
| 76 |
+
""", (job_id, status, download_url, psycopg2.extras.Json(job_metadata)))
|
| 77 |
+
conn.commit()
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error("Erro ao salvar job %s no Supabase: %s", job_id, e)
|
| 80 |
+
finally:
|
| 81 |
+
conn.close()
|
| 82 |
+
|
| 83 |
+
def load_active_jobs(self) -> List[dict[str, Any]]:
|
| 84 |
+
conn = self._get_connection()
|
| 85 |
+
if not conn:
|
| 86 |
+
return []
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
| 90 |
+
active_statuses = ["queued", "creating", "polling"]
|
| 91 |
+
cur.execute(f"""
|
| 92 |
+
SELECT * FROM {COLLECTION_NAME}
|
| 93 |
+
WHERE status IN %s
|
| 94 |
+
OR (status = 'ready' AND download_url IS NULL)
|
| 95 |
+
""", (tuple(active_statuses),))
|
| 96 |
+
|
| 97 |
+
rows = cur.fetchall()
|
| 98 |
+
jobs = []
|
| 99 |
+
for row in rows:
|
| 100 |
+
job = dict(row)
|
| 101 |
+
# Mescla o JSONB de metadados de volta no dict principal para paridade com o bot
|
| 102 |
+
if job.get("metadata"):
|
| 103 |
+
meta = job.pop("metadata")
|
| 104 |
+
job.update(meta)
|
| 105 |
+
jobs.append(job)
|
| 106 |
+
|
| 107 |
+
logger.info("Carregados %d jobs ativos do Supabase.", len(jobs))
|
| 108 |
+
return jobs
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error("Erro ao carregar jobs do Supabase: %s", e)
|
| 111 |
+
return []
|
| 112 |
+
finally:
|
| 113 |
+
conn.close()
|
| 114 |
+
|
| 115 |
+
def get_job(self, job_id: str) -> Optional[dict[str, Any]]:
|
| 116 |
+
conn = self._get_connection()
|
| 117 |
+
if not conn:
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
| 122 |
+
cur.execute(f"SELECT * FROM {COLLECTION_NAME} WHERE job_id = %s", (job_id,))
|
| 123 |
+
row = cur.fetchone()
|
| 124 |
+
if row:
|
| 125 |
+
job = dict(row)
|
| 126 |
+
if job.get("metadata"):
|
| 127 |
+
meta = job.pop("metadata")
|
| 128 |
+
job.update(meta)
|
| 129 |
+
return job
|
| 130 |
+
return None
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error("Erro ao buscar job %s no Supabase: %s", job_id, e)
|
| 133 |
+
return None
|
| 134 |
+
finally:
|
| 135 |
+
conn.close()
|
| 136 |
+
|
| 137 |
+
job_store = SupabaseJobStore()
|
bot/control_plane.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from bot.control_plane_actions import ControlPlaneActionQueue
|
| 4 |
+
from bot.control_plane_config import ControlPlaneConfigRuntime
|
| 5 |
+
from bot.control_plane_constants import (
|
| 6 |
+
RISK_AUTO_CHAT,
|
| 7 |
+
RISK_CLIP_CANDIDATE,
|
| 8 |
+
RISK_MODERATION_ACTION,
|
| 9 |
+
RISK_SUGGEST_STREAMER,
|
| 10 |
+
SUPPORTED_DECISIONS,
|
| 11 |
+
SUPPORTED_RISK_LEVELS,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ControlPlaneState:
|
| 16 |
+
MAX_ACTION_ITEMS = 600
|
| 17 |
+
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
self._config_runtime = ControlPlaneConfigRuntime()
|
| 20 |
+
self._action_queue = ControlPlaneActionQueue(max_items=self.MAX_ACTION_ITEMS)
|
| 21 |
+
|
| 22 |
+
def get_config(self) -> dict[str, Any]:
|
| 23 |
+
return self._config_runtime.get_config()
|
| 24 |
+
|
| 25 |
+
def update_config(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 26 |
+
return self._config_runtime.update_config(payload)
|
| 27 |
+
|
| 28 |
+
def set_loop_running(self, running: bool) -> None:
|
| 29 |
+
self._config_runtime.set_loop_running(running)
|
| 30 |
+
|
| 31 |
+
def touch_heartbeat(self, timestamp: float | None = None) -> None:
|
| 32 |
+
self._config_runtime.touch_heartbeat(timestamp=timestamp)
|
| 33 |
+
|
| 34 |
+
def register_tick(self, reason: str, timestamp: float | None = None) -> None:
|
| 35 |
+
self._config_runtime.register_tick(reason=reason, timestamp=timestamp)
|
| 36 |
+
|
| 37 |
+
def register_goal_run(
|
| 38 |
+
self,
|
| 39 |
+
goal_id: str,
|
| 40 |
+
risk: str,
|
| 41 |
+
timestamp: float | None = None,
|
| 42 |
+
) -> None:
|
| 43 |
+
self._config_runtime.register_goal_run(
|
| 44 |
+
goal_id=goal_id,
|
| 45 |
+
risk=risk,
|
| 46 |
+
timestamp=timestamp,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def register_budget_block(self, reason: str, timestamp: float | None = None) -> None:
|
| 50 |
+
self._config_runtime.register_budget_block(reason=reason, timestamp=timestamp)
|
| 51 |
+
|
| 52 |
+
def register_dispatch_failure(self, reason: str, timestamp: float | None = None) -> None:
|
| 53 |
+
self._config_runtime.register_dispatch_failure(reason=reason, timestamp=timestamp)
|
| 54 |
+
|
| 55 |
+
def can_send_auto_chat(self, timestamp: float | None = None) -> tuple[bool, str, dict[str, int]]:
|
| 56 |
+
return self._config_runtime.can_send_auto_chat(timestamp=timestamp)
|
| 57 |
+
|
| 58 |
+
def register_auto_chat_sent(self, timestamp: float | None = None) -> None:
|
| 59 |
+
self._config_runtime.register_auto_chat_sent(timestamp=timestamp)
|
| 60 |
+
|
| 61 |
+
def consume_due_goals(self, *, force: bool = False, timestamp: float | None = None) -> list[dict[str, Any]]:
|
| 62 |
+
return self._config_runtime.consume_due_goals(force=force, timestamp=timestamp)
|
| 63 |
+
|
| 64 |
+
def enqueue_action(
|
| 65 |
+
self,
|
| 66 |
+
*,
|
| 67 |
+
kind: str,
|
| 68 |
+
risk: str,
|
| 69 |
+
title: str,
|
| 70 |
+
body: str,
|
| 71 |
+
payload: dict[str, Any] | None = None,
|
| 72 |
+
created_by: str = "autonomy",
|
| 73 |
+
timestamp: float | None = None,
|
| 74 |
+
) -> dict[str, Any]:
|
| 75 |
+
return self._action_queue.enqueue_action(
|
| 76 |
+
kind=kind,
|
| 77 |
+
risk=risk,
|
| 78 |
+
title=title,
|
| 79 |
+
body=body,
|
| 80 |
+
payload=payload,
|
| 81 |
+
created_by=created_by,
|
| 82 |
+
timestamp=timestamp,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def decide_action(
|
| 86 |
+
self,
|
| 87 |
+
*,
|
| 88 |
+
action_id: str,
|
| 89 |
+
decision: str,
|
| 90 |
+
note: str = "",
|
| 91 |
+
decided_by: str = "dashboard",
|
| 92 |
+
timestamp: float | None = None,
|
| 93 |
+
) -> dict[str, Any]:
|
| 94 |
+
return self._action_queue.decide_action(
|
| 95 |
+
action_id=action_id,
|
| 96 |
+
decision=decision,
|
| 97 |
+
note=note,
|
| 98 |
+
decided_by=decided_by,
|
| 99 |
+
timestamp=timestamp,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def list_actions(
|
| 103 |
+
self,
|
| 104 |
+
*,
|
| 105 |
+
status: str | None = None,
|
| 106 |
+
limit: int = 80,
|
| 107 |
+
timestamp: float | None = None,
|
| 108 |
+
) -> dict[str, Any]:
|
| 109 |
+
return self._action_queue.list_actions(
|
| 110 |
+
status=status,
|
| 111 |
+
limit=limit,
|
| 112 |
+
ignore_after_seconds=self._config_runtime.action_ignore_after_seconds(),
|
| 113 |
+
timestamp=timestamp,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
def build_capabilities(self, *, bot_mode: str) -> dict[str, Any]:
|
| 117 |
+
safe_mode = (bot_mode or "eventsub").strip().lower() or "eventsub"
|
| 118 |
+
channel_enabled = safe_mode == "irc"
|
| 119 |
+
if channel_enabled:
|
| 120 |
+
channel_reason = "Controle de canais ativo no runtime IRC."
|
| 121 |
+
supported_actions = ["list", "join", "part"]
|
| 122 |
+
else:
|
| 123 |
+
channel_reason = (
|
| 124 |
+
"Channel control de runtime so funciona em TWITCH_CHAT_MODE=irc. "
|
| 125 |
+
"No modo eventsub, join/part ficam bloqueados."
|
| 126 |
+
)
|
| 127 |
+
supported_actions = []
|
| 128 |
+
|
| 129 |
+
config = self.get_config()
|
| 130 |
+
return {
|
| 131 |
+
"channel_control": {
|
| 132 |
+
"enabled": channel_enabled,
|
| 133 |
+
"reason": channel_reason,
|
| 134 |
+
"supported_actions": supported_actions,
|
| 135 |
+
},
|
| 136 |
+
"autonomy": {
|
| 137 |
+
"enabled": True,
|
| 138 |
+
"reason": "Autonomia com agenda e budget anti-spam disponivel.",
|
| 139 |
+
"active": bool(config.get("autonomy_enabled", False)),
|
| 140 |
+
},
|
| 141 |
+
"risk_actions": {
|
| 142 |
+
"enabled": True,
|
| 143 |
+
"reason": "Fluxo por risco com fila e decisao approve/reject.",
|
| 144 |
+
"levels": sorted(SUPPORTED_RISK_LEVELS),
|
| 145 |
+
},
|
| 146 |
+
"response_contract": {
|
| 147 |
+
"max_messages": 1,
|
| 148 |
+
"max_lines": 4,
|
| 149 |
+
},
|
| 150 |
+
"clip_pipeline": {
|
| 151 |
+
"enabled": config.get("clip_pipeline_enabled", False),
|
| 152 |
+
"modes": ["live", "vod"],
|
| 153 |
+
"default_mode": config.get("clip_mode_default", "live"),
|
| 154 |
+
},
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
def runtime_snapshot(self, *, timestamp: float | None = None) -> dict[str, Any]:
|
| 158 |
+
runtime = self._config_runtime.runtime_base_snapshot(timestamp=timestamp)
|
| 159 |
+
queue_runtime = self._action_queue.runtime_snapshot(
|
| 160 |
+
ignore_after_seconds=self._config_runtime.action_ignore_after_seconds(),
|
| 161 |
+
timestamp=timestamp,
|
| 162 |
+
)
|
| 163 |
+
runtime["queue"] = queue_runtime.get("queue", {})
|
| 164 |
+
runtime["queue_window_60m"] = queue_runtime.get("queue_window_60m", {})
|
| 165 |
+
return runtime
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
control_plane = ControlPlaneState()
|
| 169 |
+
|
| 170 |
+
__all__ = [
|
| 171 |
+
"RISK_AUTO_CHAT",
|
| 172 |
+
"RISK_CLIP_CANDIDATE",
|
| 173 |
+
"RISK_SUGGEST_STREAMER",
|
| 174 |
+
"RISK_MODERATION_ACTION",
|
| 175 |
+
"SUPPORTED_RISK_LEVELS",
|
| 176 |
+
"SUPPORTED_DECISIONS",
|
| 177 |
+
"ControlPlaneState",
|
| 178 |
+
"control_plane",
|
| 179 |
+
]
|
| 180 |
+
|
bot/control_plane_actions.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
from collections import Counter, deque
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from bot.control_plane_constants import (
|
| 8 |
+
RISK_SUGGEST_STREAMER,
|
| 9 |
+
SUPPORTED_DECISIONS,
|
| 10 |
+
SUPPORTED_RISK_LEVELS,
|
| 11 |
+
clip_text,
|
| 12 |
+
utc_iso,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ControlPlaneActionQueue:
|
| 17 |
+
def __init__(self, *, max_items: int = 600) -> None:
|
| 18 |
+
self._lock = threading.Lock()
|
| 19 |
+
self._max_items = max(10, int(max_items))
|
| 20 |
+
self._action_items: dict[str, dict[str, Any]] = {}
|
| 21 |
+
self._action_order: deque[str] = deque(maxlen=self._max_items)
|
| 22 |
+
self._action_counter = 0
|
| 23 |
+
|
| 24 |
+
def _expire_pending_actions_locked(
|
| 25 |
+
self, now: float, ignore_after_seconds: int
|
| 26 |
+
) -> list[dict[str, Any]]:
|
| 27 |
+
expired: list[dict[str, Any]] = []
|
| 28 |
+
for action_id in list(self._action_order):
|
| 29 |
+
item = self._action_items.get(action_id)
|
| 30 |
+
if not item or item.get("status") != "pending":
|
| 31 |
+
continue
|
| 32 |
+
created_epoch = float(item.get("created_epoch", now))
|
| 33 |
+
if now - created_epoch < ignore_after_seconds:
|
| 34 |
+
continue
|
| 35 |
+
item["status"] = "ignored"
|
| 36 |
+
item["decision"] = "auto_ignore"
|
| 37 |
+
item["decision_note"] = "Sem decisao dentro da janela configurada."
|
| 38 |
+
item["updated_epoch"] = now
|
| 39 |
+
item["updated_at"] = utc_iso(now)
|
| 40 |
+
item["audit"].append(
|
| 41 |
+
{
|
| 42 |
+
"ts": item["updated_at"],
|
| 43 |
+
"event": "ignored_timeout",
|
| 44 |
+
"by": "system",
|
| 45 |
+
"note": item["decision_note"],
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
expired.append(copy.deepcopy(item))
|
| 49 |
+
return expired
|
| 50 |
+
|
| 51 |
+
def _summary_locked(self) -> dict[str, int]:
|
| 52 |
+
summary_counter: Counter[str] = Counter(
|
| 53 |
+
str(self._action_items[action_id].get("status", "pending"))
|
| 54 |
+
for action_id in self._action_order
|
| 55 |
+
if action_id in self._action_items
|
| 56 |
+
)
|
| 57 |
+
return {
|
| 58 |
+
"pending": int(summary_counter.get("pending", 0)),
|
| 59 |
+
"approved": int(summary_counter.get("approved", 0)),
|
| 60 |
+
"rejected": int(summary_counter.get("rejected", 0)),
|
| 61 |
+
"ignored": int(summary_counter.get("ignored", 0)),
|
| 62 |
+
"total": int(sum(summary_counter.values())),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def enqueue_action(
|
| 66 |
+
self,
|
| 67 |
+
*,
|
| 68 |
+
kind: str,
|
| 69 |
+
risk: str,
|
| 70 |
+
title: str,
|
| 71 |
+
body: str,
|
| 72 |
+
payload: dict[str, Any] | None = None,
|
| 73 |
+
created_by: str = "autonomy",
|
| 74 |
+
timestamp: float | None = None,
|
| 75 |
+
) -> dict[str, Any]:
|
| 76 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 77 |
+
safe_risk = (risk or RISK_SUGGEST_STREAMER).strip().lower()
|
| 78 |
+
if safe_risk not in SUPPORTED_RISK_LEVELS:
|
| 79 |
+
safe_risk = RISK_SUGGEST_STREAMER
|
| 80 |
+
|
| 81 |
+
with self._lock:
|
| 82 |
+
self._action_counter += 1
|
| 83 |
+
action_id = f"act_{int(now * 1000)}_{self._action_counter}"
|
| 84 |
+
created_at = utc_iso(now)
|
| 85 |
+
item = {
|
| 86 |
+
"id": action_id,
|
| 87 |
+
"kind": clip_text(kind or "goal", max_chars=60),
|
| 88 |
+
"risk": safe_risk,
|
| 89 |
+
"title": clip_text(title or "Acao sem titulo", max_chars=80),
|
| 90 |
+
"body": clip_text(body or "", max_chars=420),
|
| 91 |
+
"payload": payload or {},
|
| 92 |
+
"status": "pending",
|
| 93 |
+
"decision": "",
|
| 94 |
+
"decision_note": "",
|
| 95 |
+
"created_at": created_at,
|
| 96 |
+
"updated_at": created_at,
|
| 97 |
+
"created_epoch": now,
|
| 98 |
+
"updated_epoch": now,
|
| 99 |
+
"audit": [
|
| 100 |
+
{
|
| 101 |
+
"ts": created_at,
|
| 102 |
+
"event": "created",
|
| 103 |
+
"by": clip_text(created_by or "autonomy", max_chars=40),
|
| 104 |
+
"note": "",
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
max_queue_size = self._action_order.maxlen
|
| 110 |
+
if max_queue_size is not None and len(self._action_order) >= max_queue_size:
|
| 111 |
+
oldest_id = self._action_order.popleft()
|
| 112 |
+
self._action_items.pop(oldest_id, None)
|
| 113 |
+
|
| 114 |
+
self._action_order.append(action_id)
|
| 115 |
+
self._action_items[action_id] = item
|
| 116 |
+
return copy.deepcopy(item)
|
| 117 |
+
|
| 118 |
+
def decide_action(
|
| 119 |
+
self,
|
| 120 |
+
*,
|
| 121 |
+
action_id: str,
|
| 122 |
+
decision: str,
|
| 123 |
+
note: str = "",
|
| 124 |
+
decided_by: str = "dashboard",
|
| 125 |
+
timestamp: float | None = None,
|
| 126 |
+
) -> dict[str, Any]:
|
| 127 |
+
safe_action_id = (action_id or "").strip()
|
| 128 |
+
if not safe_action_id:
|
| 129 |
+
raise ValueError("action_id obrigatorio.")
|
| 130 |
+
|
| 131 |
+
safe_decision = (decision or "").strip().lower()
|
| 132 |
+
if safe_decision not in SUPPORTED_DECISIONS:
|
| 133 |
+
raise ValueError("Decision invalida. Use: approve ou reject.")
|
| 134 |
+
|
| 135 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 136 |
+
with self._lock:
|
| 137 |
+
item = self._action_items.get(safe_action_id)
|
| 138 |
+
if not item:
|
| 139 |
+
raise KeyError("action_not_found")
|
| 140 |
+
if item.get("status") != "pending":
|
| 141 |
+
raise RuntimeError("action_not_pending")
|
| 142 |
+
|
| 143 |
+
item["decision"] = safe_decision
|
| 144 |
+
item["decision_note"] = clip_text(note or "", max_chars=240)
|
| 145 |
+
item["status"] = "approved" if safe_decision == "approve" else "rejected"
|
| 146 |
+
item["updated_epoch"] = now
|
| 147 |
+
item["updated_at"] = utc_iso(now)
|
| 148 |
+
item["audit"].append(
|
| 149 |
+
{
|
| 150 |
+
"ts": item["updated_at"],
|
| 151 |
+
"event": item["status"],
|
| 152 |
+
"by": clip_text(decided_by or "dashboard", max_chars=40),
|
| 153 |
+
"note": item["decision_note"],
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
return copy.deepcopy(item)
|
| 157 |
+
|
| 158 |
+
def list_actions(
|
| 159 |
+
self,
|
| 160 |
+
*,
|
| 161 |
+
status: str | None = None,
|
| 162 |
+
limit: int = 80,
|
| 163 |
+
ignore_after_seconds: int = 900,
|
| 164 |
+
timestamp: float | None = None,
|
| 165 |
+
) -> dict[str, Any]:
|
| 166 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 167 |
+
safe_limit = max(1, min(300, int(limit)))
|
| 168 |
+
safe_status = (status or "").strip().lower()
|
| 169 |
+
ttl_seconds = max(60, int(ignore_after_seconds))
|
| 170 |
+
|
| 171 |
+
with self._lock:
|
| 172 |
+
expired = self._expire_pending_actions_locked(now, ttl_seconds)
|
| 173 |
+
items: list[dict[str, Any]] = []
|
| 174 |
+
for action_id in reversed(self._action_order):
|
| 175 |
+
item = self._action_items.get(action_id)
|
| 176 |
+
if not item:
|
| 177 |
+
continue
|
| 178 |
+
if safe_status and item.get("status") != safe_status:
|
| 179 |
+
continue
|
| 180 |
+
items.append(copy.deepcopy(item))
|
| 181 |
+
if len(items) >= safe_limit:
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
"items": items,
|
| 186 |
+
"summary": self._summary_locked(),
|
| 187 |
+
"expired": expired,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
def runtime_snapshot(
|
| 191 |
+
self,
|
| 192 |
+
*,
|
| 193 |
+
ignore_after_seconds: int = 900,
|
| 194 |
+
timestamp: float | None = None,
|
| 195 |
+
) -> dict[str, Any]:
|
| 196 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 197 |
+
ttl_seconds = max(60, int(ignore_after_seconds))
|
| 198 |
+
window_cutoff = now - 3600
|
| 199 |
+
|
| 200 |
+
with self._lock:
|
| 201 |
+
self._expire_pending_actions_locked(now, ttl_seconds)
|
| 202 |
+
summary = self._summary_locked()
|
| 203 |
+
|
| 204 |
+
decisions_window_counter: Counter[str] = Counter()
|
| 205 |
+
pending_created_60m = 0
|
| 206 |
+
for action_id in self._action_order:
|
| 207 |
+
item = self._action_items.get(action_id)
|
| 208 |
+
if not item:
|
| 209 |
+
continue
|
| 210 |
+
status = str(item.get("status", "pending"))
|
| 211 |
+
created_epoch = float(item.get("created_epoch", 0.0))
|
| 212 |
+
updated_epoch = float(item.get("updated_epoch", 0.0))
|
| 213 |
+
if status == "pending" and created_epoch >= window_cutoff:
|
| 214 |
+
pending_created_60m += 1
|
| 215 |
+
if status in {"approved", "rejected", "ignored"} and updated_epoch >= window_cutoff:
|
| 216 |
+
decisions_window_counter[status] += 1
|
| 217 |
+
|
| 218 |
+
decisions_total_60m = int(sum(decisions_window_counter.values()))
|
| 219 |
+
ignored_60m = int(decisions_window_counter.get("ignored", 0))
|
| 220 |
+
ignored_rate_60m = (
|
| 221 |
+
round((ignored_60m / decisions_total_60m) * 100.0, 1)
|
| 222 |
+
if decisions_total_60m > 0
|
| 223 |
+
else 0.0
|
| 224 |
+
)
|
| 225 |
+
return {
|
| 226 |
+
"queue": summary,
|
| 227 |
+
"queue_window_60m": {
|
| 228 |
+
"pending_created": int(pending_created_60m),
|
| 229 |
+
"approved": int(decisions_window_counter.get("approved", 0)),
|
| 230 |
+
"rejected": int(decisions_window_counter.get("rejected", 0)),
|
| 231 |
+
"ignored": ignored_60m,
|
| 232 |
+
"decisions_total": decisions_total_60m,
|
| 233 |
+
"ignored_rate": ignored_rate_60m,
|
| 234 |
+
},
|
| 235 |
+
}
|
bot/control_plane_config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
from collections import deque
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from bot.control_plane_config_helpers import (
|
| 8 |
+
budget_usage,
|
| 9 |
+
normalize_goals,
|
| 10 |
+
runtime_base_snapshot as build_runtime_base_snapshot,
|
| 11 |
+
)
|
| 12 |
+
from bot.control_plane_constants import (
|
| 13 |
+
clip_text,
|
| 14 |
+
default_goals_copy,
|
| 15 |
+
to_int,
|
| 16 |
+
utc_iso,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ControlPlaneConfigRuntime:
|
| 21 |
+
def __init__(self) -> None:
|
| 22 |
+
self._lock = threading.Lock()
|
| 23 |
+
self._config: dict[str, Any] = {
|
| 24 |
+
"version": 1,
|
| 25 |
+
"autonomy_enabled": False,
|
| 26 |
+
"clip_pipeline_enabled": False,
|
| 27 |
+
"clip_api_enabled": False,
|
| 28 |
+
"clip_mode_default": "live",
|
| 29 |
+
"heartbeat_interval_seconds": 60,
|
| 30 |
+
"min_cooldown_seconds": 90,
|
| 31 |
+
"budget_messages_10m": 2,
|
| 32 |
+
"budget_messages_60m": 8,
|
| 33 |
+
"budget_messages_daily": 30,
|
| 34 |
+
"action_ignore_after_seconds": 900,
|
| 35 |
+
"goals": default_goals_copy(),
|
| 36 |
+
"updated_at": utc_iso(time.time()),
|
| 37 |
+
}
|
| 38 |
+
self._runtime: dict[str, Any] = {
|
| 39 |
+
"loop_running": False,
|
| 40 |
+
"last_heartbeat_at": 0.0,
|
| 41 |
+
"last_tick_at": 0.0,
|
| 42 |
+
"last_tick_reason": "boot",
|
| 43 |
+
"last_goal_id": "",
|
| 44 |
+
"last_goal_risk": "",
|
| 45 |
+
"autonomy_ticks_total": 0,
|
| 46 |
+
"autonomy_goal_runs_total": 0,
|
| 47 |
+
"autonomy_budget_blocked_total": 0,
|
| 48 |
+
"autonomy_dispatch_failures_total": 0,
|
| 49 |
+
"autonomy_auto_chat_sent_total": 0,
|
| 50 |
+
"autonomy_last_block_reason": "",
|
| 51 |
+
}
|
| 52 |
+
self._auto_chat_sent_timestamps: deque[float] = deque(maxlen=3000)
|
| 53 |
+
self._next_goal_due_at: dict[str, float] = {}
|
| 54 |
+
|
| 55 |
+
def get_config(self) -> dict[str, Any]:
|
| 56 |
+
with self._lock:
|
| 57 |
+
return copy.deepcopy(self._config)
|
| 58 |
+
|
| 59 |
+
def update_config(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 60 |
+
if not isinstance(payload, dict):
|
| 61 |
+
raise ValueError("Payload de configuracao invalido.")
|
| 62 |
+
|
| 63 |
+
now = time.time()
|
| 64 |
+
with self._lock:
|
| 65 |
+
config = copy.deepcopy(self._config)
|
| 66 |
+
|
| 67 |
+
if "autonomy_enabled" in payload:
|
| 68 |
+
config["autonomy_enabled"] = bool(payload.get("autonomy_enabled"))
|
| 69 |
+
|
| 70 |
+
if "clip_pipeline_enabled" in payload:
|
| 71 |
+
config["clip_pipeline_enabled"] = bool(payload.get("clip_pipeline_enabled"))
|
| 72 |
+
|
| 73 |
+
if "clip_api_enabled" in payload:
|
| 74 |
+
config["clip_api_enabled"] = bool(payload.get("clip_api_enabled"))
|
| 75 |
+
|
| 76 |
+
if "clip_mode_default" in payload:
|
| 77 |
+
val = str(payload.get("clip_mode_default") or "live").strip().lower()
|
| 78 |
+
if val in ("live", "vod"):
|
| 79 |
+
config["clip_mode_default"] = val
|
| 80 |
+
|
| 81 |
+
if "heartbeat_interval_seconds" in payload:
|
| 82 |
+
config["heartbeat_interval_seconds"] = to_int(
|
| 83 |
+
payload.get("heartbeat_interval_seconds"),
|
| 84 |
+
minimum=15,
|
| 85 |
+
maximum=3600,
|
| 86 |
+
fallback=config["heartbeat_interval_seconds"],
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if "min_cooldown_seconds" in payload:
|
| 90 |
+
config["min_cooldown_seconds"] = to_int(
|
| 91 |
+
payload.get("min_cooldown_seconds"),
|
| 92 |
+
minimum=15,
|
| 93 |
+
maximum=3600,
|
| 94 |
+
fallback=config["min_cooldown_seconds"],
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if "budget_messages_10m" in payload:
|
| 98 |
+
config["budget_messages_10m"] = to_int(
|
| 99 |
+
payload.get("budget_messages_10m"),
|
| 100 |
+
minimum=0,
|
| 101 |
+
maximum=100,
|
| 102 |
+
fallback=config["budget_messages_10m"],
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if "budget_messages_60m" in payload:
|
| 106 |
+
config["budget_messages_60m"] = to_int(
|
| 107 |
+
payload.get("budget_messages_60m"),
|
| 108 |
+
minimum=0,
|
| 109 |
+
maximum=600,
|
| 110 |
+
fallback=config["budget_messages_60m"],
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if "budget_messages_daily" in payload:
|
| 114 |
+
config["budget_messages_daily"] = to_int(
|
| 115 |
+
payload.get("budget_messages_daily"),
|
| 116 |
+
minimum=0,
|
| 117 |
+
maximum=5000,
|
| 118 |
+
fallback=config["budget_messages_daily"],
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if "action_ignore_after_seconds" in payload:
|
| 122 |
+
config["action_ignore_after_seconds"] = to_int(
|
| 123 |
+
payload.get("action_ignore_after_seconds"),
|
| 124 |
+
minimum=60,
|
| 125 |
+
maximum=86_400,
|
| 126 |
+
fallback=config["action_ignore_after_seconds"],
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if "goals" in payload:
|
| 130 |
+
config["goals"] = normalize_goals(payload.get("goals"))
|
| 131 |
+
active_goal_ids = {goal["id"] for goal in config["goals"]}
|
| 132 |
+
self._next_goal_due_at = {
|
| 133 |
+
goal_id: due_at
|
| 134 |
+
for goal_id, due_at in self._next_goal_due_at.items()
|
| 135 |
+
if goal_id in active_goal_ids
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
config["updated_at"] = utc_iso(now)
|
| 139 |
+
self._config = config
|
| 140 |
+
return copy.deepcopy(self._config)
|
| 141 |
+
|
| 142 |
+
def set_loop_running(self, running: bool) -> None:
|
| 143 |
+
with self._lock:
|
| 144 |
+
self._runtime["loop_running"] = bool(running)
|
| 145 |
+
|
| 146 |
+
def touch_heartbeat(self, timestamp: float | None = None) -> None:
|
| 147 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 148 |
+
with self._lock:
|
| 149 |
+
self._runtime["last_heartbeat_at"] = now
|
| 150 |
+
|
| 151 |
+
def register_tick(self, reason: str, timestamp: float | None = None) -> None:
|
| 152 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 153 |
+
with self._lock:
|
| 154 |
+
self._runtime["last_tick_at"] = now
|
| 155 |
+
self._runtime["last_tick_reason"] = clip_text(reason or "tick", max_chars=40)
|
| 156 |
+
self._runtime["autonomy_ticks_total"] += 1
|
| 157 |
+
|
| 158 |
+
def register_goal_run(
|
| 159 |
+
self,
|
| 160 |
+
goal_id: str,
|
| 161 |
+
risk: str,
|
| 162 |
+
timestamp: float | None = None,
|
| 163 |
+
) -> None:
|
| 164 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 165 |
+
with self._lock:
|
| 166 |
+
self._runtime["autonomy_goal_runs_total"] += 1
|
| 167 |
+
self._runtime["last_goal_id"] = clip_text(goal_id, max_chars=60)
|
| 168 |
+
self._runtime["last_goal_risk"] = clip_text(risk, max_chars=32)
|
| 169 |
+
self._runtime["last_heartbeat_at"] = now
|
| 170 |
+
|
| 171 |
+
def register_budget_block(self, reason: str, timestamp: float | None = None) -> None:
|
| 172 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 173 |
+
with self._lock:
|
| 174 |
+
self._runtime["autonomy_budget_blocked_total"] += 1
|
| 175 |
+
self._runtime["autonomy_last_block_reason"] = clip_text(reason, max_chars=80)
|
| 176 |
+
self._runtime["last_heartbeat_at"] = now
|
| 177 |
+
|
| 178 |
+
def register_dispatch_failure(self, reason: str, timestamp: float | None = None) -> None:
|
| 179 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 180 |
+
with self._lock:
|
| 181 |
+
self._runtime["autonomy_dispatch_failures_total"] += 1
|
| 182 |
+
self._runtime["autonomy_last_block_reason"] = clip_text(reason, max_chars=80)
|
| 183 |
+
self._runtime["last_heartbeat_at"] = now
|
| 184 |
+
|
| 185 |
+
def can_send_auto_chat(self, timestamp: float | None = None) -> tuple[bool, str, dict[str, int]]:
|
| 186 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 187 |
+
with self._lock:
|
| 188 |
+
usage = budget_usage(self._auto_chat_sent_timestamps, now)
|
| 189 |
+
cfg = self._config
|
| 190 |
+
|
| 191 |
+
if int(cfg["budget_messages_daily"]) <= 0:
|
| 192 |
+
return False, "budget_daily_disabled", usage
|
| 193 |
+
|
| 194 |
+
last_sent = self._auto_chat_sent_timestamps[-1] if self._auto_chat_sent_timestamps else 0.0
|
| 195 |
+
min_cooldown = int(cfg["min_cooldown_seconds"])
|
| 196 |
+
if last_sent > 0 and now - last_sent < min_cooldown:
|
| 197 |
+
return False, "cooldown_active", usage
|
| 198 |
+
|
| 199 |
+
if int(cfg["budget_messages_10m"]) >= 0 and usage["messages_10m"] >= int(cfg["budget_messages_10m"]):
|
| 200 |
+
return False, "budget_10m_exceeded", usage
|
| 201 |
+
|
| 202 |
+
if int(cfg["budget_messages_60m"]) >= 0 and usage["messages_60m"] >= int(cfg["budget_messages_60m"]):
|
| 203 |
+
return False, "budget_60m_exceeded", usage
|
| 204 |
+
|
| 205 |
+
if usage["messages_daily"] >= int(cfg["budget_messages_daily"]):
|
| 206 |
+
return False, "budget_daily_exceeded", usage
|
| 207 |
+
|
| 208 |
+
return True, "ok", usage
|
| 209 |
+
|
| 210 |
+
def register_auto_chat_sent(self, timestamp: float | None = None) -> None:
|
| 211 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 212 |
+
with self._lock:
|
| 213 |
+
self._auto_chat_sent_timestamps.append(now)
|
| 214 |
+
self._runtime["autonomy_auto_chat_sent_total"] += 1
|
| 215 |
+
self._runtime["last_heartbeat_at"] = now
|
| 216 |
+
|
| 217 |
+
def consume_due_goals(self, *, force: bool = False, timestamp: float | None = None) -> list[dict[str, Any]]:
|
| 218 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 219 |
+
with self._lock:
|
| 220 |
+
if not self._config.get("autonomy_enabled", False) and not force:
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
due_goals: list[dict[str, Any]] = []
|
| 224 |
+
active_goal_ids = set()
|
| 225 |
+
|
| 226 |
+
for goal in self._config.get("goals", []):
|
| 227 |
+
if not isinstance(goal, dict):
|
| 228 |
+
continue
|
| 229 |
+
goal_id = str(goal.get("id", "") or "").strip()
|
| 230 |
+
if not goal_id:
|
| 231 |
+
continue
|
| 232 |
+
active_goal_ids.add(goal_id)
|
| 233 |
+
|
| 234 |
+
if not bool(goal.get("enabled", True)):
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
interval = max(60, int(goal.get("interval_seconds", 600) or 600))
|
| 238 |
+
if force:
|
| 239 |
+
due_goals.append(copy.deepcopy(goal))
|
| 240 |
+
self._next_goal_due_at[goal_id] = now + interval
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
due_at = self._next_goal_due_at.get(goal_id)
|
| 244 |
+
if due_at is None:
|
| 245 |
+
self._next_goal_due_at[goal_id] = now + interval
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
if now >= due_at:
|
| 249 |
+
due_goals.append(copy.deepcopy(goal))
|
| 250 |
+
self._next_goal_due_at[goal_id] = now + interval
|
| 251 |
+
|
| 252 |
+
self._next_goal_due_at = {
|
| 253 |
+
goal_id: due_at
|
| 254 |
+
for goal_id, due_at in self._next_goal_due_at.items()
|
| 255 |
+
if goal_id in active_goal_ids
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
return due_goals
|
| 259 |
+
|
| 260 |
+
def action_ignore_after_seconds(self) -> int:
|
| 261 |
+
with self._lock:
|
| 262 |
+
return max(60, int(self._config.get("action_ignore_after_seconds", 900)))
|
| 263 |
+
|
| 264 |
+
def runtime_base_snapshot(self, *, timestamp: float | None = None) -> dict[str, Any]:
|
| 265 |
+
now = time.time() if timestamp is None else float(timestamp)
|
| 266 |
+
with self._lock:
|
| 267 |
+
usage = budget_usage(self._auto_chat_sent_timestamps, now)
|
| 268 |
+
cfg = dict(self._config)
|
| 269 |
+
rt = dict(self._runtime)
|
| 270 |
+
|
| 271 |
+
return build_runtime_base_snapshot(config=cfg, runtime=rt, usage=usage)
|
bot/control_plane_config_helpers.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import deque
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from bot.control_plane_constants import (
|
| 5 |
+
RISK_SUGGEST_STREAMER,
|
| 6 |
+
SUPPORTED_RISK_LEVELS,
|
| 7 |
+
clip_text,
|
| 8 |
+
default_goals_copy,
|
| 9 |
+
to_int,
|
| 10 |
+
utc_iso,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def normalize_goal(raw_goal: Any, index: int) -> dict[str, Any]:
|
| 15 |
+
goal = raw_goal if isinstance(raw_goal, dict) else {}
|
| 16 |
+
fallback_id = f"goal_{index + 1}"
|
| 17 |
+
raw_id = str(goal.get("id", "") or "").strip().lower().replace(" ", "_")
|
| 18 |
+
goal_id = "".join(ch for ch in raw_id if ch.isalnum() or ch == "_") or fallback_id
|
| 19 |
+
|
| 20 |
+
name = clip_text(str(goal.get("name", "") or goal_id).strip(), max_chars=60)
|
| 21 |
+
prompt = clip_text(str(goal.get("prompt", "") or "").strip(), max_chars=420)
|
| 22 |
+
risk = str(goal.get("risk", "") or "").strip().lower()
|
| 23 |
+
if risk not in SUPPORTED_RISK_LEVELS:
|
| 24 |
+
risk = RISK_SUGGEST_STREAMER
|
| 25 |
+
|
| 26 |
+
interval_seconds = to_int(
|
| 27 |
+
goal.get("interval_seconds", 600),
|
| 28 |
+
minimum=60,
|
| 29 |
+
maximum=86_400,
|
| 30 |
+
fallback=600,
|
| 31 |
+
)
|
| 32 |
+
enabled = bool(goal.get("enabled", True))
|
| 33 |
+
return {
|
| 34 |
+
"id": goal_id,
|
| 35 |
+
"name": name,
|
| 36 |
+
"prompt": prompt,
|
| 37 |
+
"risk": risk,
|
| 38 |
+
"interval_seconds": interval_seconds,
|
| 39 |
+
"enabled": enabled,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def normalize_goals(raw_goals: Any) -> list[dict[str, Any]]:
|
| 44 |
+
if not isinstance(raw_goals, list):
|
| 45 |
+
return default_goals_copy()
|
| 46 |
+
|
| 47 |
+
normalized: list[dict[str, Any]] = []
|
| 48 |
+
seen_ids: set[str] = set()
|
| 49 |
+
for index, item in enumerate(raw_goals):
|
| 50 |
+
safe_goal = normalize_goal(item, index)
|
| 51 |
+
if safe_goal["id"] in seen_ids:
|
| 52 |
+
safe_goal["id"] = f"{safe_goal['id']}_{index + 1}"
|
| 53 |
+
seen_ids.add(safe_goal["id"])
|
| 54 |
+
normalized.append(safe_goal)
|
| 55 |
+
|
| 56 |
+
return normalized or default_goals_copy()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def prune_auto_chat_history(history: deque[float], now: float) -> None:
|
| 60 |
+
daily_cutoff = now - 86_400
|
| 61 |
+
while history and history[0] < daily_cutoff:
|
| 62 |
+
history.popleft()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def budget_usage(history: deque[float], now: float) -> dict[str, int]:
|
| 66 |
+
prune_auto_chat_history(history, now)
|
| 67 |
+
cutoff_10m = now - 600
|
| 68 |
+
cutoff_60m = now - 3600
|
| 69 |
+
usage_10m = 0
|
| 70 |
+
usage_60m = 0
|
| 71 |
+
usage_daily = 0
|
| 72 |
+
for timestamp in history:
|
| 73 |
+
usage_daily += 1
|
| 74 |
+
if timestamp >= cutoff_60m:
|
| 75 |
+
usage_60m += 1
|
| 76 |
+
if timestamp >= cutoff_10m:
|
| 77 |
+
usage_10m += 1
|
| 78 |
+
return {
|
| 79 |
+
"messages_10m": usage_10m,
|
| 80 |
+
"messages_60m": usage_60m,
|
| 81 |
+
"messages_daily": usage_daily,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def runtime_base_snapshot(
|
| 86 |
+
*,
|
| 87 |
+
config: dict[str, Any],
|
| 88 |
+
runtime: dict[str, Any],
|
| 89 |
+
usage: dict[str, int],
|
| 90 |
+
) -> dict[str, Any]:
|
| 91 |
+
return {
|
| 92 |
+
"enabled": bool(config.get("autonomy_enabled", False)),
|
| 93 |
+
"loop_running": bool(runtime.get("loop_running", False)),
|
| 94 |
+
"last_heartbeat_at": utc_iso(float(runtime.get("last_heartbeat_at", 0.0)))
|
| 95 |
+
if float(runtime.get("last_heartbeat_at", 0.0)) > 0
|
| 96 |
+
else "",
|
| 97 |
+
"last_heartbeat_epoch": float(runtime.get("last_heartbeat_at", 0.0)),
|
| 98 |
+
"last_tick_at": utc_iso(float(runtime.get("last_tick_at", 0.0)))
|
| 99 |
+
if float(runtime.get("last_tick_at", 0.0)) > 0
|
| 100 |
+
else "",
|
| 101 |
+
"last_tick_epoch": float(runtime.get("last_tick_at", 0.0)),
|
| 102 |
+
"last_tick_reason": str(runtime.get("last_tick_reason", "")),
|
| 103 |
+
"last_goal_id": str(runtime.get("last_goal_id", "")),
|
| 104 |
+
"last_goal_risk": str(runtime.get("last_goal_risk", "")),
|
| 105 |
+
"autonomy_ticks_total": int(runtime.get("autonomy_ticks_total", 0)),
|
| 106 |
+
"autonomy_goal_runs_total": int(runtime.get("autonomy_goal_runs_total", 0)),
|
| 107 |
+
"autonomy_budget_blocked_total": int(runtime.get("autonomy_budget_blocked_total", 0)),
|
| 108 |
+
"autonomy_dispatch_failures_total": int(runtime.get("autonomy_dispatch_failures_total", 0)),
|
| 109 |
+
"autonomy_auto_chat_sent_total": int(runtime.get("autonomy_auto_chat_sent_total", 0)),
|
| 110 |
+
"autonomy_last_block_reason": str(runtime.get("autonomy_last_block_reason", "")),
|
| 111 |
+
"budget_usage": {
|
| 112 |
+
**usage,
|
| 113 |
+
"limit_10m": int(config.get("budget_messages_10m", 0)),
|
| 114 |
+
"limit_60m": int(config.get("budget_messages_60m", 0)),
|
| 115 |
+
"limit_daily": int(config.get("budget_messages_daily", 0)),
|
| 116 |
+
"cooldown_seconds": int(config.get("min_cooldown_seconds", 0)),
|
| 117 |
+
},
|
| 118 |
+
}
|
| 119 |
+
|
bot/control_plane_constants.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
RISK_AUTO_CHAT = "auto_chat"
|
| 6 |
+
RISK_SUGGEST_STREAMER = "suggest_streamer"
|
| 7 |
+
RISK_MODERATION_ACTION = "moderation_action"
|
| 8 |
+
RISK_CLIP_CANDIDATE = "clip_candidate"
|
| 9 |
+
SUPPORTED_RISK_LEVELS = {
|
| 10 |
+
RISK_AUTO_CHAT,
|
| 11 |
+
RISK_SUGGEST_STREAMER,
|
| 12 |
+
RISK_MODERATION_ACTION,
|
| 13 |
+
RISK_CLIP_CANDIDATE,
|
| 14 |
+
}
|
| 15 |
+
SUPPORTED_DECISIONS = {"approve", "reject"}
|
| 16 |
+
|
| 17 |
+
DEFAULT_GOALS = [
|
| 18 |
+
{
|
| 19 |
+
"id": "chat_pulse",
|
| 20 |
+
"name": "Pulso do chat",
|
| 21 |
+
"prompt": "Resumo autonomo curto com o momento atual da live e um insight util.",
|
| 22 |
+
"risk": RISK_AUTO_CHAT,
|
| 23 |
+
"interval_seconds": 900,
|
| 24 |
+
"enabled": True,
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "streamer_hint",
|
| 28 |
+
"name": "Sugestao ao streamer",
|
| 29 |
+
"prompt": "Sugestao objetiva para aumentar clareza, ritmo ou engajamento da live.",
|
| 30 |
+
"risk": RISK_SUGGEST_STREAMER,
|
| 31 |
+
"interval_seconds": 600,
|
| 32 |
+
"enabled": True,
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "safety_watch",
|
| 36 |
+
"name": "Watch de moderacao",
|
| 37 |
+
"prompt": "Sinalize padroes de risco no chat e recomende acao moderativa conservadora.",
|
| 38 |
+
"risk": RISK_MODERATION_ACTION,
|
| 39 |
+
"interval_seconds": 300,
|
| 40 |
+
"enabled": True,
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "detect_clip",
|
| 44 |
+
"name": "Deteccao de clips",
|
| 45 |
+
"prompt": "Analise o contexto recente (chat e eventos) e identifique um momento digno de clipe.",
|
| 46 |
+
"risk": RISK_CLIP_CANDIDATE,
|
| 47 |
+
"interval_seconds": 600,
|
| 48 |
+
"enabled": False,
|
| 49 |
+
},
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def utc_iso(timestamp: float) -> str:
|
| 54 |
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(
|
| 55 |
+
timespec="seconds"
|
| 56 |
+
).replace("+00:00", "Z")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def clip_text(text: str, max_chars: int = 360) -> str:
|
| 60 |
+
compact = " ".join((text or "").split())
|
| 61 |
+
if len(compact) <= max_chars:
|
| 62 |
+
return compact
|
| 63 |
+
return compact[: max_chars - 3].rstrip() + "..."
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def to_int(value: Any, *, minimum: int, maximum: int, fallback: int) -> int:
|
| 67 |
+
try:
|
| 68 |
+
parsed = int(value)
|
| 69 |
+
except (TypeError, ValueError):
|
| 70 |
+
return fallback
|
| 71 |
+
return max(minimum, min(maximum, parsed))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def default_goals_copy() -> list[dict[str, Any]]:
|
| 75 |
+
return copy.deepcopy(DEFAULT_GOALS)
|
| 76 |
+
|
bot/dashboard_server.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from bot.channel_control import is_dashboard_admin_authorized, parse_terminal_command
|
| 7 |
+
from bot.dashboard_server_routes import (
|
| 8 |
+
CHANNEL_CONTROL_IRC_ONLY_ACTIONS,
|
| 9 |
+
build_observability_payload,
|
| 10 |
+
handle_get,
|
| 11 |
+
handle_post,
|
| 12 |
+
handle_put,
|
| 13 |
+
)
|
| 14 |
+
from bot.runtime_config import (
|
| 15 |
+
BYTE_DASHBOARD_ADMIN_TOKEN,
|
| 16 |
+
DASHBOARD_DIR,
|
| 17 |
+
TWITCH_CHAT_MODE,
|
| 18 |
+
irc_channel_control,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class HealthHandler(BaseHTTPRequestHandler):
|
| 23 |
+
MAX_CONTROL_BODY_BYTES = 4096
|
| 24 |
+
CHANNEL_CONTROL_IRC_ONLY_ACTIONS = CHANNEL_CONTROL_IRC_ONLY_ACTIONS
|
| 25 |
+
|
| 26 |
+
def _send_cors_headers(self) -> None:
|
| 27 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 28 |
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
| 29 |
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Byte-Admin-Token, Authorization")
|
| 30 |
+
|
| 31 |
+
def _send_bytes(
|
| 32 |
+
self, payload: bytes, content_type: str, status_code: int = 200
|
| 33 |
+
) -> None:
|
| 34 |
+
self.send_response(status_code)
|
| 35 |
+
self._send_cors_headers()
|
| 36 |
+
self.send_header("Content-Type", content_type)
|
| 37 |
+
self.send_header("Cache-Control", "no-store")
|
| 38 |
+
self.send_header("Content-Length", str(len(payload)))
|
| 39 |
+
self.end_headers()
|
| 40 |
+
self.wfile.write(payload)
|
| 41 |
+
|
| 42 |
+
def _send_json(self, payload: dict[str, Any], status_code: int = 200) -> None:
|
| 43 |
+
serialized = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
| 44 |
+
self._send_bytes(
|
| 45 |
+
serialized, "application/json; charset=utf-8", status_code=status_code
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def _send_text(self, text: str, status_code: int = 200) -> None:
|
| 49 |
+
self._send_bytes(
|
| 50 |
+
text.encode("utf-8"), "text/plain; charset=utf-8", status_code=status_code
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def _dashboard_authorized(self) -> bool:
|
| 54 |
+
if not BYTE_DASHBOARD_ADMIN_TOKEN:
|
| 55 |
+
return True
|
| 56 |
+
authorized = is_dashboard_admin_authorized(
|
| 57 |
+
self.headers, BYTE_DASHBOARD_ADMIN_TOKEN
|
| 58 |
+
)
|
| 59 |
+
if not authorized:
|
| 60 |
+
from bot.runtime_config import logger
|
| 61 |
+
logger.warning("Auth rejection for route %s from %s", self.path, self.address_string())
|
| 62 |
+
return authorized
|
| 63 |
+
|
| 64 |
+
def _send_dashboard_auth_challenge(self) -> None:
|
| 65 |
+
payload = b"Unauthorized"
|
| 66 |
+
self.send_response(401)
|
| 67 |
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
| 68 |
+
self.send_header("Cache-Control", "no-store")
|
| 69 |
+
self.send_header("WWW-Authenticate", 'Basic realm="Byte Dashboard"')
|
| 70 |
+
self.send_header("Content-Length", str(len(payload)))
|
| 71 |
+
self.end_headers()
|
| 72 |
+
self.wfile.write(payload)
|
| 73 |
+
|
| 74 |
+
def _send_forbidden(self) -> None:
|
| 75 |
+
self._send_json(
|
| 76 |
+
{"ok": False, "error": "forbidden", "message": "Forbidden"},
|
| 77 |
+
status_code=403,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def _read_json_payload(self, *, allow_empty: bool = False) -> dict[str, Any]:
|
| 81 |
+
raw_length = str(self.headers.get("Content-Length", "0") or "0")
|
| 82 |
+
try:
|
| 83 |
+
content_length = int(raw_length)
|
| 84 |
+
except ValueError as error:
|
| 85 |
+
raise ValueError("Invalid Content-Length header.") from error
|
| 86 |
+
if content_length <= 0:
|
| 87 |
+
if allow_empty:
|
| 88 |
+
return {}
|
| 89 |
+
raise ValueError("Request body is required.")
|
| 90 |
+
if content_length > self.MAX_CONTROL_BODY_BYTES:
|
| 91 |
+
raise ValueError("Request body is too large.")
|
| 92 |
+
|
| 93 |
+
payload_bytes = self.rfile.read(content_length)
|
| 94 |
+
if not payload_bytes:
|
| 95 |
+
if allow_empty:
|
| 96 |
+
return {}
|
| 97 |
+
raise ValueError("Request body is empty.")
|
| 98 |
+
try:
|
| 99 |
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
| 100 |
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
| 101 |
+
raise ValueError("Invalid JSON payload.") from error
|
| 102 |
+
if not isinstance(payload, dict):
|
| 103 |
+
raise ValueError("JSON payload must be an object.")
|
| 104 |
+
return payload
|
| 105 |
+
|
| 106 |
+
def _send_dashboard_asset(self, relative_path: str, content_type: str) -> bool:
|
| 107 |
+
target_path = (DASHBOARD_DIR / relative_path).resolve()
|
| 108 |
+
if DASHBOARD_DIR not in target_path.parents:
|
| 109 |
+
self._send_text("Not Found", status_code=404)
|
| 110 |
+
return True
|
| 111 |
+
if not target_path.is_file():
|
| 112 |
+
self._send_text("Not Found", status_code=404)
|
| 113 |
+
return True
|
| 114 |
+
self._send_bytes(
|
| 115 |
+
target_path.read_bytes(), content_type=content_type, status_code=200
|
| 116 |
+
)
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
def _build_observability_payload(self) -> dict[str, Any]:
|
| 120 |
+
return build_observability_payload()
|
| 121 |
+
|
| 122 |
+
def _handle_channel_control(self, payload: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
| 123 |
+
action = str(payload.get("action", "") or "").strip().lower()
|
| 124 |
+
channel_login = str(payload.get("channel", "") or "").strip()
|
| 125 |
+
command_text = str(payload.get("command", "") or "").strip()
|
| 126 |
+
if command_text:
|
| 127 |
+
action, channel_login = parse_terminal_command(command_text)
|
| 128 |
+
|
| 129 |
+
if TWITCH_CHAT_MODE != "irc":
|
| 130 |
+
if action in self.CHANNEL_CONTROL_IRC_ONLY_ACTIONS:
|
| 131 |
+
return (
|
| 132 |
+
{
|
| 133 |
+
"ok": False,
|
| 134 |
+
"error": "unsupported_mode",
|
| 135 |
+
"message": "Channel control de runtime so funciona em TWITCH_CHAT_MODE=irc.",
|
| 136 |
+
"mode": TWITCH_CHAT_MODE,
|
| 137 |
+
"action": action,
|
| 138 |
+
},
|
| 139 |
+
409,
|
| 140 |
+
)
|
| 141 |
+
if action == "list":
|
| 142 |
+
return (
|
| 143 |
+
{
|
| 144 |
+
"ok": True,
|
| 145 |
+
"action": "list",
|
| 146 |
+
"channels": [],
|
| 147 |
+
"mode": TWITCH_CHAT_MODE,
|
| 148 |
+
"message": "Sem runtime IRC ativo neste modo. join/part ficam bloqueados em eventsub.",
|
| 149 |
+
},
|
| 150 |
+
200,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
result = irc_channel_control.execute(action=action, channel_login=channel_login)
|
| 154 |
+
result["mode"] = TWITCH_CHAT_MODE
|
| 155 |
+
if result.get("ok"):
|
| 156 |
+
return result, 200
|
| 157 |
+
|
| 158 |
+
error_code = str(result.get("error", "") or "")
|
| 159 |
+
if error_code in {"runtime_unavailable", "timeout"}:
|
| 160 |
+
status_code = 503
|
| 161 |
+
elif error_code in {"runtime_error"}:
|
| 162 |
+
status_code = 500
|
| 163 |
+
else:
|
| 164 |
+
status_code = 400
|
| 165 |
+
return result, status_code
|
| 166 |
+
|
| 167 |
+
def do_OPTIONS(self) -> None:
|
| 168 |
+
self.send_response(204)
|
| 169 |
+
self._send_cors_headers()
|
| 170 |
+
self.end_headers()
|
| 171 |
+
|
| 172 |
+
def do_GET(self) -> None:
|
| 173 |
+
handle_get(self)
|
| 174 |
+
|
| 175 |
+
def do_PUT(self) -> None:
|
| 176 |
+
handle_put(self)
|
| 177 |
+
|
| 178 |
+
def do_POST(self) -> None:
|
| 179 |
+
handle_post(self)
|
| 180 |
+
|
| 181 |
+
def log_message(self, format: str, *args: object) -> None:
|
| 182 |
+
return
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def run_server() -> None:
|
| 186 |
+
port = int(os.environ.get("PORT", "8080"))
|
| 187 |
+
HTTPServer(("0.0.0.0", port), HealthHandler).serve_forever()
|
bot/dashboard_server_routes.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mimetypes
|
| 2 |
+
from typing import Any
|
| 3 |
+
from urllib.parse import parse_qs, urlparse
|
| 4 |
+
|
| 5 |
+
from bot.clip_jobs_runtime import clip_jobs
|
| 6 |
+
from bot.control_plane import control_plane
|
| 7 |
+
from bot.hud_runtime import hud_runtime
|
| 8 |
+
from bot.logic import BOT_BRAND, context
|
| 9 |
+
from bot.observability import observability
|
| 10 |
+
from bot.runtime_config import BYTE_VERSION, TWITCH_CHAT_MODE
|
| 11 |
+
from bot.sentiment_engine import sentiment_engine
|
| 12 |
+
from bot.vision_runtime import vision_runtime
|
| 13 |
+
|
| 14 |
+
CHANNEL_CONTROL_IRC_ONLY_ACTIONS = {"join", "part"}
|
| 15 |
+
HEALTH_ROUTES = {"/health", "/health/", "/healthz", "/healthz/"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _is_api_route(route: str) -> bool:
|
| 19 |
+
return route.startswith("/api/")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_observability_payload() -> dict[str, Any]:
|
| 23 |
+
snapshot = observability.snapshot(
|
| 24 |
+
bot_brand=BOT_BRAND,
|
| 25 |
+
bot_version=BYTE_VERSION,
|
| 26 |
+
bot_mode=TWITCH_CHAT_MODE,
|
| 27 |
+
stream_context=context,
|
| 28 |
+
)
|
| 29 |
+
capabilities = control_plane.build_capabilities(bot_mode=TWITCH_CHAT_MODE)
|
| 30 |
+
autonomy = control_plane.runtime_snapshot()
|
| 31 |
+
queue_window_60m = autonomy.get("queue_window_60m", {})
|
| 32 |
+
current_outcomes = snapshot.get("agent_outcomes", {}) or {}
|
| 33 |
+
snapshot["agent_outcomes"] = {
|
| 34 |
+
**current_outcomes,
|
| 35 |
+
"ignored_rate_60m": float(queue_window_60m.get("ignored_rate", 0.0)),
|
| 36 |
+
"ignored_total_60m": int(queue_window_60m.get("ignored", 0)),
|
| 37 |
+
"decisions_total_60m": int(queue_window_60m.get("decisions_total", 0)),
|
| 38 |
+
}
|
| 39 |
+
snapshot["capabilities"] = capabilities
|
| 40 |
+
snapshot["autonomy"] = autonomy
|
| 41 |
+
snapshot["ok"] = True
|
| 42 |
+
return snapshot
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _dashboard_asset_route(handler: Any, route: str) -> bool:
|
| 46 |
+
if route in {"/", "/dashboard", "/dashboard/"}:
|
| 47 |
+
handler._send_dashboard_asset("index.html", "text/html; charset=utf-8")
|
| 48 |
+
return True
|
| 49 |
+
if route.startswith("/dashboard/"):
|
| 50 |
+
relative_path = route[len("/dashboard/") :]
|
| 51 |
+
guessed_content_type, _ = mimetypes.guess_type(relative_path)
|
| 52 |
+
content_type = guessed_content_type or "application/octet-stream"
|
| 53 |
+
if content_type == "text/javascript":
|
| 54 |
+
content_type = "application/javascript"
|
| 55 |
+
if content_type.startswith("text/") or content_type in {
|
| 56 |
+
"application/javascript",
|
| 57 |
+
"application/json",
|
| 58 |
+
}:
|
| 59 |
+
content_type = f"{content_type}; charset=utf-8"
|
| 60 |
+
handler._send_dashboard_asset(relative_path, content_type)
|
| 61 |
+
return True
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def handle_get(handler: Any) -> None:
|
| 66 |
+
parsed_path = urlparse(handler.path or "/")
|
| 67 |
+
route = parsed_path.path or "/"
|
| 68 |
+
query = parse_qs(parsed_path.query or "")
|
| 69 |
+
if route in HEALTH_ROUTES:
|
| 70 |
+
handler._send_text("AGENT_ONLINE", status_code=200)
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
is_dashboard_route = route in {"/dashboard", "/dashboard/"} or route.startswith(
|
| 74 |
+
"/dashboard/"
|
| 75 |
+
)
|
| 76 |
+
if _is_api_route(route) and not handler._dashboard_authorized():
|
| 77 |
+
handler._send_forbidden()
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
if route == "/api/observability":
|
| 81 |
+
handler._send_json(handler._build_observability_payload(), status_code=200)
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
if route == "/api/control-plane":
|
| 85 |
+
handler._send_json(
|
| 86 |
+
{
|
| 87 |
+
"ok": True,
|
| 88 |
+
"mode": TWITCH_CHAT_MODE,
|
| 89 |
+
"config": control_plane.get_config(),
|
| 90 |
+
"autonomy": control_plane.runtime_snapshot(),
|
| 91 |
+
"capabilities": control_plane.build_capabilities(bot_mode=TWITCH_CHAT_MODE),
|
| 92 |
+
},
|
| 93 |
+
status_code=200,
|
| 94 |
+
)
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
if route == "/api/action-queue":
|
| 98 |
+
status_filter = str((query.get("status") or [""])[0] or "").strip().lower()
|
| 99 |
+
limit_raw = str((query.get("limit") or ["80"])[0] or "80")
|
| 100 |
+
try:
|
| 101 |
+
limit = int(limit_raw)
|
| 102 |
+
except ValueError:
|
| 103 |
+
limit = 80
|
| 104 |
+
queue_payload = control_plane.list_actions(
|
| 105 |
+
status=status_filter or None,
|
| 106 |
+
limit=limit,
|
| 107 |
+
)
|
| 108 |
+
handler._send_json(
|
| 109 |
+
{
|
| 110 |
+
"ok": True,
|
| 111 |
+
"mode": TWITCH_CHAT_MODE,
|
| 112 |
+
**queue_payload,
|
| 113 |
+
},
|
| 114 |
+
status_code=200,
|
| 115 |
+
)
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
if route == "/api/clip-jobs":
|
| 119 |
+
jobs = clip_jobs.get_jobs()
|
| 120 |
+
handler._send_json(
|
| 121 |
+
{
|
| 122 |
+
"ok": True,
|
| 123 |
+
"mode": TWITCH_CHAT_MODE,
|
| 124 |
+
"items": jobs,
|
| 125 |
+
},
|
| 126 |
+
status_code=200,
|
| 127 |
+
)
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
if route == "/api/hud/messages":
|
| 131 |
+
since_raw = str((query.get("since") or ["0"])[0] or "0")
|
| 132 |
+
try:
|
| 133 |
+
since = float(since_raw)
|
| 134 |
+
except ValueError:
|
| 135 |
+
since = 0.0
|
| 136 |
+
messages = hud_runtime.get_messages(since=since)
|
| 137 |
+
handler._send_json({"ok": True, "messages": messages}, status_code=200)
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
if route == "/api/sentiment/scores":
|
| 141 |
+
scores = sentiment_engine.get_scores()
|
| 142 |
+
scores["vibe"] = sentiment_engine.get_vibe()
|
| 143 |
+
handler._send_json({"ok": True, **scores}, status_code=200)
|
| 144 |
+
return
|
| 145 |
+
|
| 146 |
+
if route == "/api/vision/status":
|
| 147 |
+
handler._send_json({"ok": True, **vision_runtime.get_status()}, status_code=200)
|
| 148 |
+
return
|
| 149 |
+
|
| 150 |
+
if _dashboard_asset_route(handler, route):
|
| 151 |
+
return
|
| 152 |
+
|
| 153 |
+
handler._send_text("Not Found", status_code=404)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def handle_put(handler: Any) -> None:
|
| 157 |
+
parsed_path = urlparse(handler.path or "/")
|
| 158 |
+
route = parsed_path.path or "/"
|
| 159 |
+
if route != "/api/control-plane":
|
| 160 |
+
handler._send_text("Not Found", status_code=404)
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
if not handler._dashboard_authorized():
|
| 164 |
+
handler._send_forbidden()
|
| 165 |
+
return
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
payload = handler._read_json_payload()
|
| 169 |
+
except ValueError as error:
|
| 170 |
+
handler._send_json(
|
| 171 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 172 |
+
status_code=400,
|
| 173 |
+
)
|
| 174 |
+
return
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
updated_config = control_plane.update_config(payload)
|
| 178 |
+
except ValueError as error:
|
| 179 |
+
handler._send_json(
|
| 180 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 181 |
+
status_code=400,
|
| 182 |
+
)
|
| 183 |
+
return
|
| 184 |
+
|
| 185 |
+
handler._send_json(
|
| 186 |
+
{
|
| 187 |
+
"ok": True,
|
| 188 |
+
"mode": TWITCH_CHAT_MODE,
|
| 189 |
+
"config": updated_config,
|
| 190 |
+
"autonomy": control_plane.runtime_snapshot(),
|
| 191 |
+
"capabilities": control_plane.build_capabilities(bot_mode=TWITCH_CHAT_MODE),
|
| 192 |
+
},
|
| 193 |
+
status_code=200,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
from bot.dashboard_server_routes_post import handle_post # noqa: E402, F401
|
| 198 |
+
|
| 199 |
+
__all__ = [
|
| 200 |
+
"build_observability_payload",
|
| 201 |
+
"handle_get",
|
| 202 |
+
"handle_put",
|
| 203 |
+
"handle_post",
|
| 204 |
+
]
|
| 205 |
+
|
bot/dashboard_server_routes_post.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from urllib.parse import urlparse
|
| 3 |
+
|
| 4 |
+
from bot.autonomy_runtime import autonomy_runtime
|
| 5 |
+
from bot.control_plane import control_plane
|
| 6 |
+
from bot.runtime_config import TWITCH_CHAT_MODE
|
| 7 |
+
from bot.vision_runtime import vision_runtime
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def handle_post(handler: Any) -> None:
|
| 11 |
+
parsed_path = urlparse(handler.path or "/")
|
| 12 |
+
route = parsed_path.path or "/"
|
| 13 |
+
|
| 14 |
+
if route == "/api/channel-control":
|
| 15 |
+
_handle_channel_control_post(handler)
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
if route == "/api/autonomy/tick":
|
| 19 |
+
_handle_autonomy_tick(handler)
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
if route.startswith("/api/action-queue/") and route.endswith("/decision"):
|
| 23 |
+
_handle_action_decision(handler, route)
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
if route == "/api/vision/ingest":
|
| 27 |
+
_handle_vision_ingest(handler)
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
handler._send_text("Not Found", status_code=404)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _handle_channel_control_post(handler: Any) -> None:
|
| 34 |
+
if not handler._dashboard_authorized():
|
| 35 |
+
handler._send_forbidden()
|
| 36 |
+
return
|
| 37 |
+
try:
|
| 38 |
+
payload = handler._read_json_payload()
|
| 39 |
+
except ValueError as error:
|
| 40 |
+
handler._send_json(
|
| 41 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 42 |
+
status_code=400,
|
| 43 |
+
)
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
response_payload, status_code = handler._handle_channel_control(payload)
|
| 48 |
+
except ValueError as error:
|
| 49 |
+
handler._send_json(
|
| 50 |
+
{"ok": False, "error": "invalid_command", "message": str(error)},
|
| 51 |
+
status_code=400,
|
| 52 |
+
)
|
| 53 |
+
return
|
| 54 |
+
handler._send_json(response_payload, status_code=status_code)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _handle_autonomy_tick(handler: Any) -> None:
|
| 58 |
+
if not handler._dashboard_authorized():
|
| 59 |
+
handler._send_forbidden()
|
| 60 |
+
return
|
| 61 |
+
try:
|
| 62 |
+
payload = handler._read_json_payload(allow_empty=True)
|
| 63 |
+
except ValueError as error:
|
| 64 |
+
handler._send_json(
|
| 65 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 66 |
+
status_code=400,
|
| 67 |
+
)
|
| 68 |
+
return
|
| 69 |
+
force = bool(payload.get("force", True))
|
| 70 |
+
reason = str(payload.get("reason", "manual") or "manual")
|
| 71 |
+
try:
|
| 72 |
+
tick_result = autonomy_runtime.run_manual_tick(force=force, reason=reason)
|
| 73 |
+
except TimeoutError as error:
|
| 74 |
+
handler._send_json(
|
| 75 |
+
{"ok": False, "error": "timeout", "message": str(error)},
|
| 76 |
+
status_code=503,
|
| 77 |
+
)
|
| 78 |
+
return
|
| 79 |
+
handler._send_json(tick_result, status_code=200)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _handle_action_decision(handler: Any, route: str) -> None:
|
| 83 |
+
if not handler._dashboard_authorized():
|
| 84 |
+
handler._send_forbidden()
|
| 85 |
+
return
|
| 86 |
+
action_id = route.removeprefix("/api/action-queue/").removesuffix("/decision").strip()
|
| 87 |
+
if not action_id:
|
| 88 |
+
handler._send_json(
|
| 89 |
+
{"ok": False, "error": "invalid_request", "message": "Action id obrigatorio."},
|
| 90 |
+
status_code=400,
|
| 91 |
+
)
|
| 92 |
+
return
|
| 93 |
+
try:
|
| 94 |
+
payload = handler._read_json_payload()
|
| 95 |
+
except ValueError as error:
|
| 96 |
+
handler._send_json(
|
| 97 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 98 |
+
status_code=400,
|
| 99 |
+
)
|
| 100 |
+
return
|
| 101 |
+
decision = str(payload.get("decision", "") or "").strip().lower()
|
| 102 |
+
note = str(payload.get("note", "") or "").strip()
|
| 103 |
+
try:
|
| 104 |
+
updated_item = control_plane.decide_action(
|
| 105 |
+
action_id=action_id,
|
| 106 |
+
decision=decision,
|
| 107 |
+
note=note,
|
| 108 |
+
)
|
| 109 |
+
except ValueError as error:
|
| 110 |
+
handler._send_json(
|
| 111 |
+
{"ok": False, "error": "invalid_request", "message": str(error)},
|
| 112 |
+
status_code=400,
|
| 113 |
+
)
|
| 114 |
+
return
|
| 115 |
+
except KeyError:
|
| 116 |
+
handler._send_json(
|
| 117 |
+
{"ok": False, "error": "action_not_found", "message": "Action nao encontrada."},
|
| 118 |
+
status_code=404,
|
| 119 |
+
)
|
| 120 |
+
return
|
| 121 |
+
except RuntimeError as error:
|
| 122 |
+
handler._send_json(
|
| 123 |
+
{"ok": False, "error": "action_not_pending", "message": str(error)},
|
| 124 |
+
status_code=409,
|
| 125 |
+
)
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
handler._send_json(
|
| 129 |
+
{
|
| 130 |
+
"ok": True,
|
| 131 |
+
"item": updated_item,
|
| 132 |
+
"mode": TWITCH_CHAT_MODE,
|
| 133 |
+
},
|
| 134 |
+
status_code=200,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _handle_vision_ingest(handler: Any) -> None:
|
| 139 |
+
if not handler._dashboard_authorized():
|
| 140 |
+
handler._send_forbidden()
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
content_type = str(handler.headers.get("Content-Type", "") or "").strip().lower()
|
| 144 |
+
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
| 145 |
+
handler._send_json(
|
| 146 |
+
{"ok": False, "error": "invalid_content_type", "message": "Use image/jpeg, image/png or image/webp."},
|
| 147 |
+
status_code=400,
|
| 148 |
+
)
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
content_length = int(handler.headers.get("Content-Length", 0) or 0)
|
| 152 |
+
if content_length <= 0:
|
| 153 |
+
handler._send_json(
|
| 154 |
+
{"ok": False, "error": "empty_body", "message": "Frame body vazio."},
|
| 155 |
+
status_code=400,
|
| 156 |
+
)
|
| 157 |
+
return
|
| 158 |
+
|
| 159 |
+
frame_bytes = handler.rfile.read(content_length)
|
| 160 |
+
result = vision_runtime.ingest_frame(frame_bytes, mime_type=content_type)
|
| 161 |
+
status_code = 200 if result.get("ok") else 429 if result.get("reason") == "rate_limited" else 400
|
| 162 |
+
handler._send_json(result, status_code=status_code)
|
bot/eventsub_runtime.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Any, cast
|
| 4 |
+
|
| 5 |
+
import twitchio # pyright: ignore[reportMissingImports]
|
| 6 |
+
from twitchio import eventsub # pyright: ignore[reportMissingImports]
|
| 7 |
+
from twitchio.ext import commands # pyright: ignore[reportMissingImports]
|
| 8 |
+
|
| 9 |
+
from bot.access_control import is_owner
|
| 10 |
+
from bot.autonomy_runtime import autonomy_runtime
|
| 11 |
+
from bot.logic import BOT_BRAND, OBSERVABILITY_TYPES, agent_inference, context
|
| 12 |
+
from bot.observability import observability
|
| 13 |
+
from bot.prompt_runtime import format_chat_reply, handle_byte_prompt_text
|
| 14 |
+
from bot.runtime_config import (
|
| 15 |
+
BOT_ID,
|
| 16 |
+
CHANNEL_ID,
|
| 17 |
+
CLIENT_ID,
|
| 18 |
+
ENABLE_LIVE_CONTEXT_LEARNING,
|
| 19 |
+
OWNER_ID,
|
| 20 |
+
client,
|
| 21 |
+
logger,
|
| 22 |
+
)
|
| 23 |
+
from bot.scene_runtime import auto_update_scene_from_message
|
| 24 |
+
from bot.status_runtime import build_status_line
|
| 25 |
+
from bot import byte_semantics
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _require_env(name: str) -> str:
|
| 29 |
+
value = (os.environ.get(name) or "").strip()
|
| 30 |
+
if not value:
|
| 31 |
+
raise RuntimeError(f"Variavel obrigatoria ausente: {name}")
|
| 32 |
+
return value
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_ctx_message_text(ctx: commands.Context) -> str:
|
| 36 |
+
message = getattr(ctx, "message", None)
|
| 37 |
+
return str(getattr(message, "text", "") or "")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_ctx_author(ctx: commands.Context) -> Any:
|
| 41 |
+
message = getattr(ctx, "message", None)
|
| 42 |
+
return getattr(message, "author", None)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
parse_byte_prompt = byte_semantics.parse_byte_prompt
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class AgentComponent(commands.Component):
|
| 49 |
+
def __init__(self, bot: "ByteBot") -> None:
|
| 50 |
+
self.bot = bot
|
| 51 |
+
|
| 52 |
+
async def component_check(self, ctx: commands.Context) -> bool:
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
@commands.command(name="ask")
|
| 56 |
+
async def ask(self, ctx: commands.Context) -> None:
|
| 57 |
+
query = get_ctx_message_text(ctx).removeprefix("!ask").strip()
|
| 58 |
+
if not query:
|
| 59 |
+
return
|
| 60 |
+
author_name = str(getattr(get_ctx_author(ctx), "name", "viewer") or "viewer")
|
| 61 |
+
ans = await agent_inference(
|
| 62 |
+
query,
|
| 63 |
+
author_name,
|
| 64 |
+
client,
|
| 65 |
+
context,
|
| 66 |
+
enable_live_context=ENABLE_LIVE_CONTEXT_LEARNING,
|
| 67 |
+
)
|
| 68 |
+
await ctx.reply(format_chat_reply(ans))
|
| 69 |
+
|
| 70 |
+
@commands.command(name="vibe")
|
| 71 |
+
async def vibe(self, ctx: commands.Context) -> None:
|
| 72 |
+
author_id = str(getattr(get_ctx_author(ctx), "id", "") or "")
|
| 73 |
+
if is_owner(author_id, OWNER_ID):
|
| 74 |
+
new_vibe = get_ctx_message_text(ctx).removeprefix("!vibe").strip()
|
| 75 |
+
context.stream_vibe = new_vibe or "Conversa"
|
| 76 |
+
context.last_event = "Vibe atualizada"
|
| 77 |
+
await ctx.reply(f"Vibe atualizada para: {context.stream_vibe}")
|
| 78 |
+
|
| 79 |
+
@commands.command(name="style")
|
| 80 |
+
async def style(self, ctx: commands.Context) -> None:
|
| 81 |
+
style_text = get_ctx_message_text(ctx).removeprefix("!style").strip()
|
| 82 |
+
author_id = str(getattr(get_ctx_author(ctx), "id", "") or "")
|
| 83 |
+
if not is_owner(author_id, OWNER_ID):
|
| 84 |
+
await ctx.reply("Somente o dono do canal pode ajustar o estilo.")
|
| 85 |
+
return
|
| 86 |
+
if not style_text:
|
| 87 |
+
await ctx.reply(format_chat_reply(f"Estilo atual: {context.style_profile}"))
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
context.style_profile = style_text
|
| 91 |
+
context.last_event = "Estilo de conversa atualizado"
|
| 92 |
+
await ctx.reply("Estilo de conversa atualizado.")
|
| 93 |
+
|
| 94 |
+
@commands.command(name="scene")
|
| 95 |
+
async def scene(self, ctx: commands.Context) -> None:
|
| 96 |
+
payload = get_ctx_message_text(ctx).removeprefix("!scene").strip()
|
| 97 |
+
if not payload:
|
| 98 |
+
observability_text = context.format_observability()
|
| 99 |
+
await ctx.reply(
|
| 100 |
+
format_chat_reply(f"Observabilidade da live: {observability_text}")
|
| 101 |
+
)
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
author_id = str(getattr(get_ctx_author(ctx), "id", "") or "")
|
| 105 |
+
if not is_owner(author_id, OWNER_ID):
|
| 106 |
+
await ctx.reply("Somente o dono do canal pode atualizar a observabilidade.")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
tokens = payload.split(maxsplit=1)
|
| 110 |
+
action_or_type = tokens[0].lower()
|
| 111 |
+
if action_or_type == "clear":
|
| 112 |
+
if len(tokens) < 2:
|
| 113 |
+
await ctx.reply(
|
| 114 |
+
f"Uso: !scene clear <tipo>. Tipos: {context.list_supported_content_types()}"
|
| 115 |
+
)
|
| 116 |
+
return
|
| 117 |
+
content_type = tokens[1].strip().lower()
|
| 118 |
+
if not context.clear_content(content_type):
|
| 119 |
+
await ctx.reply(
|
| 120 |
+
f"Tipo invalido. Tipos: {context.list_supported_content_types()}"
|
| 121 |
+
)
|
| 122 |
+
return
|
| 123 |
+
label = OBSERVABILITY_TYPES.get(content_type, content_type)
|
| 124 |
+
await ctx.reply(f"Contexto removido: {label}.")
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
if len(tokens) < 2:
|
| 128 |
+
await ctx.reply(
|
| 129 |
+
f"Uso: !scene <tipo> <descricao>. Tipos: {context.list_supported_content_types()}"
|
| 130 |
+
)
|
| 131 |
+
return
|
| 132 |
+
|
| 133 |
+
content_type = action_or_type
|
| 134 |
+
description = tokens[1].strip()
|
| 135 |
+
if not context.update_content(content_type, description):
|
| 136 |
+
await ctx.reply(
|
| 137 |
+
f"Tipo invalido ou descricao vazia. Tipos: {context.list_supported_content_types()}"
|
| 138 |
+
)
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
label = OBSERVABILITY_TYPES.get(content_type, content_type)
|
| 142 |
+
await ctx.reply(format_chat_reply(f"Contexto atualizado: {label} -> {description}"))
|
| 143 |
+
|
| 144 |
+
@commands.command(name="status")
|
| 145 |
+
async def status(self, ctx: commands.Context) -> None:
|
| 146 |
+
await ctx.reply(format_chat_reply(self.bot.build_status_line()))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class ByteBot(commands.Bot):
|
| 150 |
+
def __init__(self, client_secret: str) -> None:
|
| 151 |
+
resolved_client_id = CLIENT_ID or _require_env("TWITCH_CLIENT_ID")
|
| 152 |
+
resolved_bot_id = BOT_ID or _require_env("TWITCH_BOT_ID")
|
| 153 |
+
super().__init__(
|
| 154 |
+
client_id=resolved_client_id,
|
| 155 |
+
client_secret=client_secret,
|
| 156 |
+
bot_id=resolved_bot_id,
|
| 157 |
+
owner_id=OWNER_ID or "",
|
| 158 |
+
prefix="!",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
async def setup_hook(self) -> None:
|
| 162 |
+
autonomy_runtime.bind(
|
| 163 |
+
loop=asyncio.get_running_loop(),
|
| 164 |
+
mode="eventsub",
|
| 165 |
+
auto_chat_dispatcher=None,
|
| 166 |
+
)
|
| 167 |
+
await self.add_component(AgentComponent(self))
|
| 168 |
+
if not CHANNEL_ID:
|
| 169 |
+
raise RuntimeError("TWITCH_CHANNEL_ID e obrigatorio no modo eventsub.")
|
| 170 |
+
payload = eventsub.ChatMessageSubscription(
|
| 171 |
+
broadcaster_user_id=str(CHANNEL_ID),
|
| 172 |
+
user_id=str(self.bot_id),
|
| 173 |
+
)
|
| 174 |
+
await self.subscribe_websocket(payload=payload, as_bot=True)
|
| 175 |
+
|
| 176 |
+
async def close(self, **options: Any) -> None:
|
| 177 |
+
autonomy_runtime.unbind()
|
| 178 |
+
await super().close(**options)
|
| 179 |
+
|
| 180 |
+
async def event_ready(self) -> None:
|
| 181 |
+
logger.info("%s pronto no chat. Bot ID: %s", BOT_BRAND, self.bot_id)
|
| 182 |
+
|
| 183 |
+
def build_status_line(self) -> str:
|
| 184 |
+
return build_status_line()
|
| 185 |
+
|
| 186 |
+
async def handle_byte_prompt(self, message: Any, prompt: str) -> None:
|
| 187 |
+
author = getattr(message, "author", None)
|
| 188 |
+
author_name = str(getattr(author, "name", "viewer") or "viewer")
|
| 189 |
+
reply_fn = getattr(message, "reply", None)
|
| 190 |
+
if callable(reply_fn):
|
| 191 |
+
await handle_byte_prompt_text(
|
| 192 |
+
prompt,
|
| 193 |
+
author_name,
|
| 194 |
+
reply_fn,
|
| 195 |
+
status_line_factory=self.build_status_line,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
async def event_message(self, payload: twitchio.ChatMessage) -> None:
|
| 199 |
+
message = cast(Any, payload)
|
| 200 |
+
if message.echo:
|
| 201 |
+
return
|
| 202 |
+
|
| 203 |
+
raw_text = message.text or ""
|
| 204 |
+
author = getattr(message, "author", None)
|
| 205 |
+
author_name = str(getattr(author, "name", "viewer") or "viewer")
|
| 206 |
+
byte_prompt = parse_byte_prompt(raw_text)
|
| 207 |
+
if raw_text and (not raw_text.startswith("!") or byte_prompt is not None):
|
| 208 |
+
if ENABLE_LIVE_CONTEXT_LEARNING:
|
| 209 |
+
context.remember_user_message(author_name, raw_text)
|
| 210 |
+
observability.record_chat_message(
|
| 211 |
+
author_name=author_name, source="eventsub", text=raw_text
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
updates: list[str] = []
|
| 215 |
+
if ENABLE_LIVE_CONTEXT_LEARNING:
|
| 216 |
+
updates = await auto_update_scene_from_message(message)
|
| 217 |
+
if updates:
|
| 218 |
+
labels = ", ".join(
|
| 219 |
+
OBSERVABILITY_TYPES.get(content_type, content_type)
|
| 220 |
+
for content_type in updates
|
| 221 |
+
)
|
| 222 |
+
logger.info("Observabilidade automatica atualizada: %s", labels)
|
| 223 |
+
observability.record_auto_scene_update(update_types=updates)
|
| 224 |
+
|
| 225 |
+
if byte_prompt is not None:
|
| 226 |
+
observability.record_byte_trigger(
|
| 227 |
+
prompt=byte_prompt, source="eventsub", author_name=author_name
|
| 228 |
+
)
|
| 229 |
+
await self.handle_byte_prompt(message, byte_prompt)
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
await cast(Any, self).handle_commands(message)
|
bot/heartbeat.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-heartbeat to keep HF Spaces alive.
|
| 2 |
+
|
| 3 |
+
Pings the bot's own /health endpoint every HEARTBEAT_INTERVAL_SECONDS
|
| 4 |
+
to prevent HF Spaces from putting the container to sleep.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import http.client
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger("ByteBot")
|
| 14 |
+
|
| 15 |
+
HEARTBEAT_INTERVAL_SECONDS = 180 # 3 minutes
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _heartbeat_loop() -> None:
|
| 19 |
+
"""Background loop that pings localhost health endpoint."""
|
| 20 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 21 |
+
while True:
|
| 22 |
+
time.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
| 23 |
+
try:
|
| 24 |
+
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
| 25 |
+
conn.request("GET", "/health")
|
| 26 |
+
resp = conn.getresponse()
|
| 27 |
+
resp.read()
|
| 28 |
+
conn.close()
|
| 29 |
+
logger.debug("Heartbeat OK (%d)", resp.status)
|
| 30 |
+
except Exception:
|
| 31 |
+
logger.debug("Heartbeat ping failed (server may be starting)")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def start_heartbeat() -> threading.Thread:
|
| 35 |
+
"""Start the heartbeat daemon thread. Returns the thread for reference."""
|
| 36 |
+
thread = threading.Thread(target=_heartbeat_loop, name="heartbeat", daemon=True)
|
| 37 |
+
thread.start()
|
| 38 |
+
logger.info("Heartbeat started (every %ds)", HEARTBEAT_INTERVAL_SECONDS)
|
| 39 |
+
return thread
|
bot/hud_runtime.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
from collections import deque
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
MAX_HUD_MESSAGES = 20
|
| 8 |
+
HUD_MESSAGE_TTL_SECONDS = 600.0 # 10 minutos
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class HudRuntime:
|
| 12 |
+
"""Buffer FIFO de mensagens privadas para o streamer (HUD overlay)."""
|
| 13 |
+
|
| 14 |
+
def __init__(self) -> None:
|
| 15 |
+
self._lock = threading.Lock()
|
| 16 |
+
self._messages: deque[dict[str, Any]] = deque(maxlen=MAX_HUD_MESSAGES)
|
| 17 |
+
|
| 18 |
+
def push_message(self, text: str, source: str = "autonomy") -> dict[str, Any]:
|
| 19 |
+
if not text or not text.strip():
|
| 20 |
+
return {"ok": False, "reason": "empty_message"}
|
| 21 |
+
|
| 22 |
+
now = time.time()
|
| 23 |
+
entry: dict[str, Any] = {
|
| 24 |
+
"ts": now,
|
| 25 |
+
"text": text.strip()[:300],
|
| 26 |
+
"source": (source or "autonomy").strip().lower(),
|
| 27 |
+
}
|
| 28 |
+
with self._lock:
|
| 29 |
+
self._messages.append(entry)
|
| 30 |
+
return {"ok": True, "entry": entry}
|
| 31 |
+
|
| 32 |
+
def get_messages(self, since: float = 0.0) -> list[dict[str, Any]]:
|
| 33 |
+
now = time.time()
|
| 34 |
+
cutoff = max(since, now - HUD_MESSAGE_TTL_SECONDS)
|
| 35 |
+
with self._lock:
|
| 36 |
+
return [m for m in self._messages if float(m.get("ts", 0)) > cutoff]
|
| 37 |
+
|
| 38 |
+
def clear(self) -> None:
|
| 39 |
+
with self._lock:
|
| 40 |
+
self._messages.clear()
|
| 41 |
+
|
| 42 |
+
def get_status(self) -> dict[str, Any]:
|
| 43 |
+
with self._lock:
|
| 44 |
+
return {
|
| 45 |
+
"count": len(self._messages),
|
| 46 |
+
"max": MAX_HUD_MESSAGES,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
hud_runtime = HudRuntime()
|
| 51 |
+
|
| 52 |
+
__all__ = ["HudRuntime", "hud_runtime", "MAX_HUD_MESSAGES"]
|
bot/irc_connection.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import ssl
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any, TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
from bot.irc_protocol import IRC_WELCOME_PATTERN
|
| 7 |
+
from bot.logic import BOT_BRAND
|
| 8 |
+
from bot.observability import observability
|
| 9 |
+
from bot.runtime_config import logger
|
| 10 |
+
from bot.twitch_tokens import TwitchAuthError, is_irc_auth_failure_line
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class IrcConnectionMixin:
|
| 14 |
+
host: str
|
| 15 |
+
port: int
|
| 16 |
+
use_tls: bool
|
| 17 |
+
bot_login: str
|
| 18 |
+
channel_logins: list[str]
|
| 19 |
+
reader: asyncio.StreamReader | None
|
| 20 |
+
writer: Any
|
| 21 |
+
token_manager: Any
|
| 22 |
+
_line_reader_running: bool
|
| 23 |
+
_line_reader_task: asyncio.Task[Any] | None
|
| 24 |
+
_pending_join_events: dict[str, asyncio.Event]
|
| 25 |
+
_pending_part_events: dict[str, asyncio.Event]
|
| 26 |
+
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
async def _handle_membership_event(self, line: str) -> None: ...
|
| 29 |
+
async def _handle_notice_line(self, line: str) -> None: ...
|
| 30 |
+
async def _handle_privmsg(self, line: str) -> None: ...
|
| 31 |
+
async def _recover_authentication(self, auth_error: Exception) -> bool: ...
|
| 32 |
+
def _raise_auth_error(self, line: str) -> None: ...
|
| 33 |
+
|
| 34 |
+
async def _send_raw(self, line: str) -> None:
|
| 35 |
+
if self.writer is None:
|
| 36 |
+
raise RuntimeError("Conexao IRC nao inicializada.")
|
| 37 |
+
self.writer.write(f"{line}\r\n".encode("utf-8"))
|
| 38 |
+
await self.writer.drain()
|
| 39 |
+
|
| 40 |
+
async def _await_login_confirmation(self, timeout_seconds: float = 15.0) -> None:
|
| 41 |
+
deadline = time.monotonic() + timeout_seconds
|
| 42 |
+
while True:
|
| 43 |
+
remaining = deadline - time.monotonic()
|
| 44 |
+
if remaining <= 0:
|
| 45 |
+
raise TimeoutError("Timeout aguardando confirmacao de login IRC.")
|
| 46 |
+
if self.reader is None:
|
| 47 |
+
raise RuntimeError("Leitor IRC indisponivel durante login.")
|
| 48 |
+
|
| 49 |
+
payload = await asyncio.wait_for(self.reader.readline(), timeout=remaining)
|
| 50 |
+
if not payload:
|
| 51 |
+
raise ConnectionError("Conexao IRC encerrada durante autenticacao.")
|
| 52 |
+
|
| 53 |
+
line = payload.decode("utf-8", errors="ignore").rstrip("\r\n")
|
| 54 |
+
if not line:
|
| 55 |
+
continue
|
| 56 |
+
if line.startswith("PING"):
|
| 57 |
+
await self._send_raw(line.replace("PING", "PONG", 1))
|
| 58 |
+
continue
|
| 59 |
+
if is_irc_auth_failure_line(line):
|
| 60 |
+
raise TwitchAuthError(f"Falha de autenticacao IRC: {line}")
|
| 61 |
+
if IRC_WELCOME_PATTERN.search(line):
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
async def _connect(self) -> None:
|
| 65 |
+
access_token = await self.token_manager.ensure_token_for_connection()
|
| 66 |
+
ssl_context = ssl.create_default_context() if self.use_tls else None
|
| 67 |
+
self.reader, self.writer = await asyncio.open_connection(
|
| 68 |
+
self.host, self.port, ssl=ssl_context
|
| 69 |
+
)
|
| 70 |
+
await self._send_raw("CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands")
|
| 71 |
+
await self._send_raw(f"PASS oauth:{access_token}")
|
| 72 |
+
await self._send_raw(f"NICK {self.bot_login}")
|
| 73 |
+
await self._await_login_confirmation()
|
| 74 |
+
for channel_login in self.channel_logins:
|
| 75 |
+
await self._send_raw(f"JOIN #{channel_login}")
|
| 76 |
+
channels_summary = ", ".join(f"#{channel}" for channel in self.channel_logins)
|
| 77 |
+
logger.info("%s conectado via IRC em %s", BOT_BRAND, channels_summary)
|
| 78 |
+
|
| 79 |
+
async def _close(self) -> None:
|
| 80 |
+
if self.writer is None:
|
| 81 |
+
return
|
| 82 |
+
try:
|
| 83 |
+
self.writer.close()
|
| 84 |
+
await self.writer.wait_closed()
|
| 85 |
+
finally:
|
| 86 |
+
self.reader = None
|
| 87 |
+
self.writer = None
|
| 88 |
+
|
| 89 |
+
async def run_forever(self) -> None:
|
| 90 |
+
while True:
|
| 91 |
+
reconnect_delay_seconds = 5
|
| 92 |
+
self._line_reader_running = False
|
| 93 |
+
self._line_reader_task = None
|
| 94 |
+
try:
|
| 95 |
+
await self._connect()
|
| 96 |
+
self._line_reader_running = True
|
| 97 |
+
self._line_reader_task = asyncio.current_task()
|
| 98 |
+
while True:
|
| 99 |
+
if self.reader is None:
|
| 100 |
+
raise RuntimeError("Leitor IRC indisponivel.")
|
| 101 |
+
payload = await self.reader.readline()
|
| 102 |
+
if not payload:
|
| 103 |
+
raise ConnectionError("Conexao IRC encerrada.")
|
| 104 |
+
line = payload.decode("utf-8", errors="ignore").rstrip("\r\n")
|
| 105 |
+
if not line:
|
| 106 |
+
continue
|
| 107 |
+
if line.startswith("PING"):
|
| 108 |
+
await self._send_raw(line.replace("PING", "PONG", 1))
|
| 109 |
+
continue
|
| 110 |
+
logger.info("IRC RAW: %s", line)
|
| 111 |
+
if is_irc_auth_failure_line(line):
|
| 112 |
+
self._raise_auth_error(line)
|
| 113 |
+
if line.startswith("RECONNECT"):
|
| 114 |
+
raise ConnectionError("Servidor IRC solicitou reconexao.")
|
| 115 |
+
await self._handle_membership_event(line)
|
| 116 |
+
await self._handle_notice_line(line)
|
| 117 |
+
await self._handle_privmsg(line)
|
| 118 |
+
except asyncio.CancelledError:
|
| 119 |
+
raise
|
| 120 |
+
except TwitchAuthError as auth_error:
|
| 121 |
+
recovered = await self._recover_authentication(auth_error)
|
| 122 |
+
reconnect_delay_seconds = 2 if recovered else 20
|
| 123 |
+
except Exception as error:
|
| 124 |
+
logger.warning("Conexao IRC instavel: %s", error)
|
| 125 |
+
observability.record_error(
|
| 126 |
+
category="irc_connection", details=str(error)
|
| 127 |
+
)
|
| 128 |
+
reconnect_delay_seconds = 5
|
| 129 |
+
finally:
|
| 130 |
+
self._line_reader_running = False
|
| 131 |
+
self._line_reader_task = None
|
| 132 |
+
self._pending_join_events.clear()
|
| 133 |
+
self._pending_part_events.clear()
|
| 134 |
+
await self._close()
|
| 135 |
+
await asyncio.sleep(reconnect_delay_seconds)
|
bot/irc_handlers.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import TYPE_CHECKING, Any
|
| 3 |
+
|
| 4 |
+
from bot import byte_semantics
|
| 5 |
+
from bot.irc_protocol import (
|
| 6 |
+
IRC_JOIN_PATTERN,
|
| 7 |
+
IRC_NOTICE_PATTERN,
|
| 8 |
+
IRC_PART_PATTERN,
|
| 9 |
+
IRC_PRIVMSG_PATTERN,
|
| 10 |
+
IrcAuthor,
|
| 11 |
+
IrcMessageAdapter,
|
| 12 |
+
is_irc_notice_delivery_block,
|
| 13 |
+
parse_irc_tags,
|
| 14 |
+
)
|
| 15 |
+
from bot.logic import OBSERVABILITY_TYPES, context
|
| 16 |
+
from bot.observability import observability
|
| 17 |
+
from bot.prompt_runtime import handle_byte_prompt_text
|
| 18 |
+
from bot.runtime_config import ENABLE_LIVE_CONTEXT_LEARNING, logger
|
| 19 |
+
from bot.scene_runtime import auto_update_scene_from_message
|
| 20 |
+
from bot.sentiment_engine import sentiment_engine
|
| 21 |
+
from bot.status_runtime import normalize_channel_login
|
| 22 |
+
from bot.twitch_tokens import TwitchAuthError
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
parse_byte_prompt = byte_semantics.parse_byte_prompt
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class IrcLineHandlersMixin:
|
| 29 |
+
bot_login: str
|
| 30 |
+
joined_channels: set[str]
|
| 31 |
+
|
| 32 |
+
if TYPE_CHECKING:
|
| 33 |
+
_pending_join_events: dict[str, asyncio.Event]
|
| 34 |
+
_pending_part_events: dict[str, asyncio.Event]
|
| 35 |
+
token_manager: Any
|
| 36 |
+
|
| 37 |
+
def _mark_channel_joined(self, channel_login: str) -> bool: ...
|
| 38 |
+
def _mark_channel_parted(self, channel_login: str) -> bool: ...
|
| 39 |
+
def _signal_pending_channel_action(
|
| 40 |
+
self, pending_map: dict[str, asyncio.Event], channel_login: str
|
| 41 |
+
) -> None: ...
|
| 42 |
+
async def _handle_channel_management_prompt(
|
| 43 |
+
self, prompt: str, author: Any, source_channel: str
|
| 44 |
+
) -> bool: ...
|
| 45 |
+
async def send_reply(self, text: str, channel_login: str | None = None) -> None: ...
|
| 46 |
+
def build_status_line(self) -> str: ...
|
| 47 |
+
|
| 48 |
+
async def _handle_membership_event(self, line: str) -> None:
|
| 49 |
+
join_match = IRC_JOIN_PATTERN.match(line)
|
| 50 |
+
if join_match:
|
| 51 |
+
author_login = (join_match.group("author") or "").strip().lower()
|
| 52 |
+
target_channel = normalize_channel_login(join_match.group("channel") or "")
|
| 53 |
+
if author_login == self.bot_login and target_channel:
|
| 54 |
+
changed = self._mark_channel_joined(target_channel)
|
| 55 |
+
self._signal_pending_channel_action(
|
| 56 |
+
self._pending_join_events, target_channel
|
| 57 |
+
)
|
| 58 |
+
if changed:
|
| 59 |
+
logger.info("Byte entrou no canal IRC #%s", target_channel)
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
part_match = IRC_PART_PATTERN.match(line)
|
| 63 |
+
if not part_match:
|
| 64 |
+
return
|
| 65 |
+
author_login = (part_match.group("author") or "").strip().lower()
|
| 66 |
+
target_channel = normalize_channel_login(part_match.group("channel") or "")
|
| 67 |
+
if author_login == self.bot_login and target_channel:
|
| 68 |
+
changed = self._mark_channel_parted(target_channel)
|
| 69 |
+
self._signal_pending_channel_action(self._pending_part_events, target_channel)
|
| 70 |
+
if changed:
|
| 71 |
+
logger.info("Byte saiu do canal IRC #%s", target_channel)
|
| 72 |
+
|
| 73 |
+
async def _handle_notice_line(self, line: str) -> None:
|
| 74 |
+
notice_match = IRC_NOTICE_PATTERN.match(line)
|
| 75 |
+
if not notice_match:
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
tags = parse_irc_tags(notice_match.group("tags") or "")
|
| 79 |
+
msg_id = (tags.get("msg-id") or "").strip().lower()
|
| 80 |
+
target = (notice_match.group("target") or "").strip()
|
| 81 |
+
message = (notice_match.group("message") or "").strip()
|
| 82 |
+
if target.startswith("#"):
|
| 83 |
+
target_channel = normalize_channel_login(target[1:])
|
| 84 |
+
target_label = f"#{target_channel}" if target_channel else target
|
| 85 |
+
else:
|
| 86 |
+
target_label = target or "*"
|
| 87 |
+
|
| 88 |
+
if msg_id:
|
| 89 |
+
logger.warning(
|
| 90 |
+
"IRC NOTICE target=%s msg_id=%s message=%s",
|
| 91 |
+
target_label,
|
| 92 |
+
msg_id,
|
| 93 |
+
message,
|
| 94 |
+
)
|
| 95 |
+
else:
|
| 96 |
+
logger.warning("IRC NOTICE target=%s message=%s", target_label, message)
|
| 97 |
+
|
| 98 |
+
if is_irc_notice_delivery_block(msg_id, message):
|
| 99 |
+
observability.record_error(
|
| 100 |
+
category="irc_notice",
|
| 101 |
+
details=f"{target_label} {msg_id or 'notice'}: {message}",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
async def _handle_privmsg(self, line: str) -> None:
|
| 105 |
+
match = IRC_PRIVMSG_PATTERN.match(line)
|
| 106 |
+
if not match:
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
channel = match.group("channel").lstrip("#").lower()
|
| 110 |
+
if channel not in self.joined_channels:
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
author_login = (match.group("author") or "").strip().lower()
|
| 114 |
+
if not author_login or author_login == self.bot_login:
|
| 115 |
+
return
|
| 116 |
+
|
| 117 |
+
text = (match.group("message") or "").strip()
|
| 118 |
+
if not text:
|
| 119 |
+
return
|
| 120 |
+
|
| 121 |
+
tags = parse_irc_tags(match.group("tags") or "")
|
| 122 |
+
author = IrcAuthor(author_login, tags)
|
| 123 |
+
message = IrcMessageAdapter(text, author)
|
| 124 |
+
byte_prompt = parse_byte_prompt(text)
|
| 125 |
+
if not text.startswith("!") or byte_prompt is not None:
|
| 126 |
+
if ENABLE_LIVE_CONTEXT_LEARNING:
|
| 127 |
+
context.remember_user_message(author.name, text)
|
| 128 |
+
observability.record_chat_message(
|
| 129 |
+
author_name=author.name, source="irc", text=text
|
| 130 |
+
)
|
| 131 |
+
sentiment_engine.ingest_message(text)
|
| 132 |
+
|
| 133 |
+
updates: list[str] = []
|
| 134 |
+
if ENABLE_LIVE_CONTEXT_LEARNING:
|
| 135 |
+
updates = await auto_update_scene_from_message(message)
|
| 136 |
+
context.stream_vibe = sentiment_engine.get_vibe()
|
| 137 |
+
if updates:
|
| 138 |
+
labels = ", ".join(
|
| 139 |
+
OBSERVABILITY_TYPES.get(content_type, content_type)
|
| 140 |
+
for content_type in updates
|
| 141 |
+
)
|
| 142 |
+
logger.info("Observabilidade automatica atualizada: %s", labels)
|
| 143 |
+
observability.record_auto_scene_update(update_types=updates)
|
| 144 |
+
|
| 145 |
+
if byte_prompt is None:
|
| 146 |
+
return
|
| 147 |
+
observability.record_byte_trigger(
|
| 148 |
+
prompt=byte_prompt, source="irc", author_name=author.name
|
| 149 |
+
)
|
| 150 |
+
management_handled = await self._handle_channel_management_prompt(
|
| 151 |
+
byte_prompt, author, channel
|
| 152 |
+
)
|
| 153 |
+
if management_handled:
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
async def reply_in_source_channel(text: str) -> None:
|
| 157 |
+
await self.send_reply(text, channel_login=channel)
|
| 158 |
+
|
| 159 |
+
await handle_byte_prompt_text(
|
| 160 |
+
byte_prompt,
|
| 161 |
+
author.name,
|
| 162 |
+
reply_in_source_channel,
|
| 163 |
+
status_line_factory=self.build_status_line,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
async def _recover_authentication(self, auth_error: Exception) -> bool:
|
| 167 |
+
logger.warning("Falha de autenticacao IRC: %s", auth_error)
|
| 168 |
+
observability.record_auth_failure(details=str(auth_error))
|
| 169 |
+
try:
|
| 170 |
+
await self.token_manager.force_refresh("falha de autenticacao IRC")
|
| 171 |
+
logger.info("Reconectando com token renovado automaticamente.")
|
| 172 |
+
return True
|
| 173 |
+
except Exception as refresh_error:
|
| 174 |
+
logger.error("Refresh automatico falhou: %s", refresh_error)
|
| 175 |
+
observability.record_error(
|
| 176 |
+
category="irc_refresh", details=str(refresh_error)
|
| 177 |
+
)
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
def _raise_auth_error(self, line: str) -> None:
|
| 181 |
+
raise TwitchAuthError(line)
|
bot/irc_management.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TYPE_CHECKING, Any
|
| 2 |
+
|
| 3 |
+
from bot.access_control import is_owner
|
| 4 |
+
from bot.status_runtime import normalize_channel_login
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class IrcChannelManagementMixin:
|
| 8 |
+
owner_id: str
|
| 9 |
+
channel_logins: list[str]
|
| 10 |
+
joined_channels: set[str]
|
| 11 |
+
|
| 12 |
+
if TYPE_CHECKING:
|
| 13 |
+
async def _send_tracked_channel_reply(self, channel_login: str, text: str) -> None: ...
|
| 14 |
+
async def _join_channel(self, channel_login: str) -> bool: ...
|
| 15 |
+
async def _part_channel(self, channel_login: str) -> bool: ...
|
| 16 |
+
def _can_wait_for_channel_confirmation(self) -> bool: ...
|
| 17 |
+
|
| 18 |
+
def _parse_channel_management_prompt(self, prompt: str) -> tuple[str, str] | None:
|
| 19 |
+
normalized_prompt = " ".join((prompt or "").strip().split())
|
| 20 |
+
if not normalized_prompt:
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
lowered_prompt = normalized_prompt.lower()
|
| 24 |
+
if lowered_prompt in {
|
| 25 |
+
"canais",
|
| 26 |
+
"canal",
|
| 27 |
+
"channels",
|
| 28 |
+
"channel",
|
| 29 |
+
"list channels",
|
| 30 |
+
"listar canais",
|
| 31 |
+
}:
|
| 32 |
+
return ("list", "")
|
| 33 |
+
for prefix in ("join ", "entrar ", "add "):
|
| 34 |
+
if lowered_prompt.startswith(prefix):
|
| 35 |
+
return ("join", normalized_prompt[len(prefix):].strip())
|
| 36 |
+
for prefix in ("part ", "leave ", "sair ", "remove "):
|
| 37 |
+
if lowered_prompt.startswith(prefix):
|
| 38 |
+
return ("part", normalized_prompt[len(prefix):].strip())
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
async def _handle_channel_management_prompt(
|
| 42 |
+
self, prompt: str, author: Any, source_channel: str
|
| 43 |
+
) -> bool:
|
| 44 |
+
command = self._parse_channel_management_prompt(prompt)
|
| 45 |
+
if command is None:
|
| 46 |
+
return False
|
| 47 |
+
if not is_owner(getattr(author, "id", ""), self.owner_id):
|
| 48 |
+
await self._send_tracked_channel_reply(
|
| 49 |
+
source_channel,
|
| 50 |
+
"Somente o owner pode gerenciar canais do Byte.",
|
| 51 |
+
)
|
| 52 |
+
return True
|
| 53 |
+
|
| 54 |
+
action, raw_target = command
|
| 55 |
+
if action == "list":
|
| 56 |
+
channels = ", ".join(f"#{channel}" for channel in self.channel_logins)
|
| 57 |
+
await self._send_tracked_channel_reply(source_channel, f"Canais ativos: {channels}.")
|
| 58 |
+
return True
|
| 59 |
+
if action == "join":
|
| 60 |
+
target_channel = normalize_channel_login(raw_target)
|
| 61 |
+
if not target_channel:
|
| 62 |
+
await self._send_tracked_channel_reply(source_channel, "Uso: byte join <canal>.")
|
| 63 |
+
return True
|
| 64 |
+
if target_channel in self.joined_channels:
|
| 65 |
+
await self._send_tracked_channel_reply(
|
| 66 |
+
source_channel, f"Ja estou no canal #{target_channel}."
|
| 67 |
+
)
|
| 68 |
+
return True
|
| 69 |
+
can_wait_confirmation = self._can_wait_for_channel_confirmation()
|
| 70 |
+
joined = await self._join_channel(target_channel)
|
| 71 |
+
if joined and can_wait_confirmation:
|
| 72 |
+
message = (
|
| 73 |
+
f"Canal adicionado: #{target_channel}. Byte responde onde for acionado."
|
| 74 |
+
)
|
| 75 |
+
elif joined:
|
| 76 |
+
message = (
|
| 77 |
+
f"Solicitacao enviada para entrar em #{target_channel}. "
|
| 78 |
+
"Aguarde confirmacao do IRC."
|
| 79 |
+
)
|
| 80 |
+
else:
|
| 81 |
+
message = f"Nao consegui entrar em #{target_channel}."
|
| 82 |
+
await self._send_tracked_channel_reply(source_channel, message)
|
| 83 |
+
return True
|
| 84 |
+
if action != "part":
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
target_channel = normalize_channel_login(raw_target or source_channel)
|
| 88 |
+
if not target_channel:
|
| 89 |
+
await self._send_tracked_channel_reply(source_channel, "Uso: byte part <canal>.")
|
| 90 |
+
return True
|
| 91 |
+
if target_channel not in self.joined_channels:
|
| 92 |
+
await self._send_tracked_channel_reply(
|
| 93 |
+
source_channel, f"Nao estou no canal #{target_channel}."
|
| 94 |
+
)
|
| 95 |
+
return True
|
| 96 |
+
if len(self.channel_logins) <= 1:
|
| 97 |
+
await self._send_tracked_channel_reply(
|
| 98 |
+
source_channel,
|
| 99 |
+
"Nao posso sair do ultimo canal ativo. Entre em outro canal primeiro com 'byte join <canal>'.",
|
| 100 |
+
)
|
| 101 |
+
return True
|
| 102 |
+
if target_channel == source_channel:
|
| 103 |
+
await self._send_tracked_channel_reply(
|
| 104 |
+
source_channel,
|
| 105 |
+
f"Solicitacao recebida: tentando sair de #{target_channel}.",
|
| 106 |
+
)
|
| 107 |
+
parted = await self._part_channel(target_channel)
|
| 108 |
+
if not parted:
|
| 109 |
+
await self._send_tracked_channel_reply(
|
| 110 |
+
source_channel,
|
| 111 |
+
f"Nao consegui sair de #{target_channel}.",
|
| 112 |
+
)
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
can_wait_confirmation = self._can_wait_for_channel_confirmation()
|
| 116 |
+
parted = await self._part_channel(target_channel)
|
| 117 |
+
if parted and can_wait_confirmation:
|
| 118 |
+
message = f"Canal removido: #{target_channel}."
|
| 119 |
+
elif parted:
|
| 120 |
+
message = (
|
| 121 |
+
f"Solicitacao enviada para sair de #{target_channel}. "
|
| 122 |
+
"Aguarde confirmacao do IRC."
|
| 123 |
+
)
|
| 124 |
+
else:
|
| 125 |
+
message = f"Nao consegui sair de #{target_channel}."
|
| 126 |
+
await self._send_tracked_channel_reply(source_channel, message)
|
| 127 |
+
return True
|
bot/irc_protocol.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
IRC_PRIVMSG_PATTERN = re.compile(
|
| 4 |
+
r"^(?:@(?P<tags>[^ ]+) )?:(?P<author>[^!]+)![^ ]+ PRIVMSG #(?P<channel>[^ ]+) :(?P<message>.*)$"
|
| 5 |
+
)
|
| 6 |
+
IRC_NOTICE_PATTERN = re.compile(
|
| 7 |
+
r"^(?:@(?P<tags>[^ ]+) )?:[^ ]+ NOTICE (?P<target>[^ ]+) :(?P<message>.*)$",
|
| 8 |
+
re.IGNORECASE,
|
| 9 |
+
)
|
| 10 |
+
IRC_JOIN_PATTERN = re.compile(
|
| 11 |
+
r"^(?:@(?P<tags>[^ ]+) )?:(?P<author>[^!]+)![^ ]+ JOIN #(?P<channel>[^ ]+)$",
|
| 12 |
+
re.IGNORECASE,
|
| 13 |
+
)
|
| 14 |
+
IRC_PART_PATTERN = re.compile(
|
| 15 |
+
r"^(?:@(?P<tags>[^ ]+) )?:(?P<author>[^!]+)![^ ]+ PART #(?P<channel>[^ ]+)(?: :(?P<reason>.*))?$",
|
| 16 |
+
re.IGNORECASE,
|
| 17 |
+
)
|
| 18 |
+
IRC_WELCOME_PATTERN = re.compile(r"^:[^ ]+\s001\s", re.IGNORECASE)
|
| 19 |
+
IRC_NOTICE_DELIVERY_BLOCK_IDS = {
|
| 20 |
+
"msg_bad_characters",
|
| 21 |
+
"msg_banned",
|
| 22 |
+
"msg_channel_blocked",
|
| 23 |
+
"msg_channel_suspended",
|
| 24 |
+
"msg_duplicate",
|
| 25 |
+
"msg_emoteonly",
|
| 26 |
+
"msg_followersonly",
|
| 27 |
+
"msg_followersonly_followed",
|
| 28 |
+
"msg_r9k",
|
| 29 |
+
"msg_requires_verified_phone_number",
|
| 30 |
+
"msg_slowmode",
|
| 31 |
+
"msg_subsonly",
|
| 32 |
+
"msg_suspended",
|
| 33 |
+
"msg_timedout",
|
| 34 |
+
}
|
| 35 |
+
IRC_NOTICE_DELIVERY_BLOCK_HINTS = (
|
| 36 |
+
"banned",
|
| 37 |
+
"followers-only",
|
| 38 |
+
"followers only",
|
| 39 |
+
"phone number must be verified",
|
| 40 |
+
"requires a verified phone number",
|
| 41 |
+
"room is no longer available",
|
| 42 |
+
"subscribers-only",
|
| 43 |
+
"subscribers only",
|
| 44 |
+
"timed out",
|
| 45 |
+
"you are permanently banned",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class IrcAuthor:
|
| 50 |
+
def __init__(self, login: str, tags: dict[str, str]) -> None:
|
| 51 |
+
self.login = login
|
| 52 |
+
self.name = tags.get("display-name") or login
|
| 53 |
+
self.id = tags.get("user-id", "")
|
| 54 |
+
is_mod = tags.get("mod") == "1" or "moderator/" in tags.get("badges", "")
|
| 55 |
+
self.is_mod = is_mod
|
| 56 |
+
self.is_moderator = is_mod
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class IrcMessageAdapter:
|
| 60 |
+
def __init__(self, text: str, author: IrcAuthor) -> None:
|
| 61 |
+
self.text = text
|
| 62 |
+
self.author = author
|
| 63 |
+
self.echo = False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def parse_irc_tags(raw_tags: str) -> dict[str, str]:
|
| 67 |
+
if not raw_tags:
|
| 68 |
+
return {}
|
| 69 |
+
parsed: dict[str, str] = {}
|
| 70 |
+
for item in raw_tags.split(";"):
|
| 71 |
+
if "=" in item:
|
| 72 |
+
key, value = item.split("=", maxsplit=1)
|
| 73 |
+
else:
|
| 74 |
+
key, value = item, ""
|
| 75 |
+
parsed[key] = value.replace(r"\s", " ").replace(r"\:", ";").replace(r"\\", "\\")
|
| 76 |
+
return parsed
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def flatten_chat_text(text: str) -> str:
|
| 80 |
+
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
|
| 81 |
+
return " | ".join(lines)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_irc_notice_delivery_block(msg_id: str, message: str) -> bool:
|
| 85 |
+
normalized_id = (msg_id or "").strip().lower()
|
| 86 |
+
if normalized_id in IRC_NOTICE_DELIVERY_BLOCK_IDS:
|
| 87 |
+
return True
|
| 88 |
+
|
| 89 |
+
lowered_message = (message or "").strip().lower()
|
| 90 |
+
return any(marker in lowered_message for marker in IRC_NOTICE_DELIVERY_BLOCK_HINTS)
|
bot/irc_runtime.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from bot.irc_connection import IrcConnectionMixin
|
| 5 |
+
from bot.irc_handlers import IrcLineHandlersMixin
|
| 6 |
+
from bot.irc_management import IrcChannelManagementMixin
|
| 7 |
+
from bot.irc_state import IrcChannelStateMixin
|
| 8 |
+
from bot.observability import observability
|
| 9 |
+
from bot.runtime_config import (
|
| 10 |
+
OWNER_ID,
|
| 11 |
+
TWITCH_TOKEN_REFRESH_TIMEOUT_SECONDS,
|
| 12 |
+
TWITCH_TOKEN_VALIDATE_TIMEOUT_SECONDS,
|
| 13 |
+
logger,
|
| 14 |
+
)
|
| 15 |
+
from bot.status_runtime import normalize_channel_login
|
| 16 |
+
from bot.twitch_tokens import TwitchTokenManager, TwitchTokenManagerSettings
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class IrcByteBot(
|
| 20 |
+
IrcChannelManagementMixin,
|
| 21 |
+
IrcChannelStateMixin,
|
| 22 |
+
IrcLineHandlersMixin,
|
| 23 |
+
IrcConnectionMixin,
|
| 24 |
+
):
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
*,
|
| 28 |
+
host: str,
|
| 29 |
+
port: int,
|
| 30 |
+
use_tls: bool,
|
| 31 |
+
bot_login: str,
|
| 32 |
+
channel_login: str | None = None,
|
| 33 |
+
channel_logins: list[str] | None = None,
|
| 34 |
+
user_token: str = "",
|
| 35 |
+
token_manager: TwitchTokenManager | None = None,
|
| 36 |
+
) -> None:
|
| 37 |
+
provided_channels = list(channel_logins or [])
|
| 38 |
+
if channel_login:
|
| 39 |
+
provided_channels.append(channel_login)
|
| 40 |
+
|
| 41 |
+
resolved_channels: list[str] = []
|
| 42 |
+
seen_channels: set[str] = set()
|
| 43 |
+
for candidate in provided_channels:
|
| 44 |
+
normalized = normalize_channel_login(candidate)
|
| 45 |
+
if not normalized or normalized in seen_channels:
|
| 46 |
+
continue
|
| 47 |
+
seen_channels.add(normalized)
|
| 48 |
+
resolved_channels.append(normalized)
|
| 49 |
+
if not resolved_channels:
|
| 50 |
+
raise RuntimeError("Defina ao menos 1 canal valido para o modo IRC.")
|
| 51 |
+
|
| 52 |
+
self.host = host
|
| 53 |
+
self.port = port
|
| 54 |
+
self.use_tls = use_tls
|
| 55 |
+
self.bot_login = bot_login.lower()
|
| 56 |
+
self.owner_id = OWNER_ID
|
| 57 |
+
self.channel_logins = resolved_channels
|
| 58 |
+
self.joined_channels = set(resolved_channels)
|
| 59 |
+
self.primary_channel_login = resolved_channels[0]
|
| 60 |
+
self.token_manager = token_manager or TwitchTokenManager(
|
| 61 |
+
access_token=user_token,
|
| 62 |
+
settings=TwitchTokenManagerSettings(
|
| 63 |
+
validate_timeout_seconds=TWITCH_TOKEN_VALIDATE_TIMEOUT_SECONDS,
|
| 64 |
+
refresh_timeout_seconds=TWITCH_TOKEN_REFRESH_TIMEOUT_SECONDS,
|
| 65 |
+
),
|
| 66 |
+
observability=observability,
|
| 67 |
+
logger=logger,
|
| 68 |
+
)
|
| 69 |
+
self.reader: asyncio.StreamReader | None = None
|
| 70 |
+
self.writer: Any = None
|
| 71 |
+
self._line_reader_running = False
|
| 72 |
+
self._line_reader_task: asyncio.Task[Any] | None = None
|
| 73 |
+
self._pending_join_events: dict[str, asyncio.Event] = {}
|
| 74 |
+
self._pending_part_events: dict[str, asyncio.Event] = {}
|
bot/irc_state.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import TYPE_CHECKING, Any
|
| 3 |
+
|
| 4 |
+
from bot.irc_protocol import flatten_chat_text
|
| 5 |
+
from bot.logic import context
|
| 6 |
+
from bot.observability import observability
|
| 7 |
+
from bot.prompt_runtime import format_chat_reply
|
| 8 |
+
from bot.runtime_config import TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS, logger
|
| 9 |
+
from bot.status_runtime import build_status_line, normalize_channel_login
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class IrcChannelStateMixin:
|
| 13 |
+
channel_logins: list[str]
|
| 14 |
+
joined_channels: set[str]
|
| 15 |
+
primary_channel_login: str
|
| 16 |
+
reader: asyncio.StreamReader | None
|
| 17 |
+
_line_reader_running: bool
|
| 18 |
+
_line_reader_task: asyncio.Task[Any] | None
|
| 19 |
+
_pending_join_events: dict[str, asyncio.Event]
|
| 20 |
+
_pending_part_events: dict[str, asyncio.Event]
|
| 21 |
+
|
| 22 |
+
if TYPE_CHECKING:
|
| 23 |
+
async def _send_raw(self, line: str) -> None: ...
|
| 24 |
+
|
| 25 |
+
def build_status_line(self) -> str:
|
| 26 |
+
return build_status_line(channel_logins=self.channel_logins)
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def channel_action_timeout_seconds(self) -> float:
|
| 30 |
+
return max(0.1, float(TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS))
|
| 31 |
+
|
| 32 |
+
def _prepare_reply_text(self, text: str) -> str:
|
| 33 |
+
return flatten_chat_text(format_chat_reply(text))
|
| 34 |
+
|
| 35 |
+
async def send_reply(self, text: str, channel_login: str | None = None) -> None:
|
| 36 |
+
target_channel = normalize_channel_login(
|
| 37 |
+
channel_login or self.primary_channel_login
|
| 38 |
+
)
|
| 39 |
+
if not target_channel:
|
| 40 |
+
target_channel = self.primary_channel_login
|
| 41 |
+
if target_channel not in self.joined_channels:
|
| 42 |
+
target_channel = self.primary_channel_login
|
| 43 |
+
|
| 44 |
+
safe_text = self._prepare_reply_text(text)
|
| 45 |
+
if not safe_text:
|
| 46 |
+
return
|
| 47 |
+
await self._send_raw(f"PRIVMSG #{target_channel} :{safe_text}")
|
| 48 |
+
|
| 49 |
+
async def _send_tracked_channel_reply(self, channel_login: str, text: str) -> None:
|
| 50 |
+
target_channel = normalize_channel_login(channel_login)
|
| 51 |
+
if not target_channel or target_channel not in self.joined_channels:
|
| 52 |
+
return
|
| 53 |
+
safe_text = self._prepare_reply_text(text)
|
| 54 |
+
if not safe_text:
|
| 55 |
+
return
|
| 56 |
+
context.remember_bot_reply(safe_text)
|
| 57 |
+
observability.record_reply(text=safe_text)
|
| 58 |
+
await self._send_raw(f"PRIVMSG #{target_channel} :{safe_text}")
|
| 59 |
+
|
| 60 |
+
def _mark_channel_joined(self, channel_login: str) -> bool:
|
| 61 |
+
target_channel = normalize_channel_login(channel_login)
|
| 62 |
+
if not target_channel:
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
changed = False
|
| 66 |
+
if target_channel not in self.joined_channels:
|
| 67 |
+
self.joined_channels.add(target_channel)
|
| 68 |
+
changed = True
|
| 69 |
+
if target_channel not in self.channel_logins:
|
| 70 |
+
self.channel_logins.append(target_channel)
|
| 71 |
+
changed = True
|
| 72 |
+
if (
|
| 73 |
+
not self.primary_channel_login
|
| 74 |
+
or self.primary_channel_login not in self.joined_channels
|
| 75 |
+
):
|
| 76 |
+
self.primary_channel_login = self.channel_logins[0]
|
| 77 |
+
changed = True
|
| 78 |
+
return changed
|
| 79 |
+
|
| 80 |
+
def _mark_channel_parted(self, channel_login: str) -> bool:
|
| 81 |
+
target_channel = normalize_channel_login(channel_login)
|
| 82 |
+
if not target_channel:
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
changed = False
|
| 86 |
+
if target_channel in self.joined_channels:
|
| 87 |
+
self.joined_channels.remove(target_channel)
|
| 88 |
+
changed = True
|
| 89 |
+
filtered_channels = [
|
| 90 |
+
channel for channel in self.channel_logins if channel != target_channel
|
| 91 |
+
]
|
| 92 |
+
if len(filtered_channels) != len(self.channel_logins):
|
| 93 |
+
self.channel_logins = filtered_channels
|
| 94 |
+
changed = True
|
| 95 |
+
if not self.channel_logins and self.joined_channels:
|
| 96 |
+
self.channel_logins = sorted(self.joined_channels)
|
| 97 |
+
changed = True
|
| 98 |
+
if self.primary_channel_login == target_channel:
|
| 99 |
+
self.primary_channel_login = self.channel_logins[0] if self.channel_logins else ""
|
| 100 |
+
changed = True
|
| 101 |
+
elif (
|
| 102 |
+
self.primary_channel_login
|
| 103 |
+
and self.primary_channel_login not in self.joined_channels
|
| 104 |
+
):
|
| 105 |
+
self.primary_channel_login = self.channel_logins[0] if self.channel_logins else ""
|
| 106 |
+
changed = True
|
| 107 |
+
elif not self.primary_channel_login and self.channel_logins:
|
| 108 |
+
self.primary_channel_login = self.channel_logins[0]
|
| 109 |
+
changed = True
|
| 110 |
+
return changed
|
| 111 |
+
|
| 112 |
+
def _signal_pending_channel_action(
|
| 113 |
+
self, pending_map: dict[str, asyncio.Event], channel_login: str
|
| 114 |
+
) -> None:
|
| 115 |
+
event = pending_map.get(channel_login)
|
| 116 |
+
if event is not None and not event.is_set():
|
| 117 |
+
event.set()
|
| 118 |
+
|
| 119 |
+
def _can_wait_for_channel_confirmation(self) -> bool:
|
| 120 |
+
if self.reader is None or not self._line_reader_running:
|
| 121 |
+
return False
|
| 122 |
+
current_task = asyncio.current_task()
|
| 123 |
+
if self._line_reader_task is not None and current_task is self._line_reader_task:
|
| 124 |
+
return False
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
+
async def _wait_for_channel_confirmation(
|
| 128 |
+
self,
|
| 129 |
+
*,
|
| 130 |
+
channel_login: str,
|
| 131 |
+
action: str,
|
| 132 |
+
pending_map: dict[str, asyncio.Event],
|
| 133 |
+
) -> bool:
|
| 134 |
+
event = pending_map.get(channel_login)
|
| 135 |
+
if event is None:
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
await asyncio.wait_for(event.wait(), timeout=self.channel_action_timeout_seconds)
|
| 140 |
+
return True
|
| 141 |
+
except TimeoutError:
|
| 142 |
+
logger.warning(
|
| 143 |
+
"Timeout aguardando confirmacao de %s no IRC para #%s.",
|
| 144 |
+
action,
|
| 145 |
+
channel_login,
|
| 146 |
+
)
|
| 147 |
+
observability.record_error(
|
| 148 |
+
category="irc_channel_control",
|
| 149 |
+
details=f"timeout aguardando {action} #{channel_login}",
|
| 150 |
+
)
|
| 151 |
+
return False
|
| 152 |
+
finally:
|
| 153 |
+
current_event = pending_map.get(channel_login)
|
| 154 |
+
if current_event is event:
|
| 155 |
+
pending_map.pop(channel_login, None)
|
| 156 |
+
|
| 157 |
+
async def _join_channel(self, channel_login: str) -> bool:
|
| 158 |
+
target_channel = normalize_channel_login(channel_login)
|
| 159 |
+
if not target_channel:
|
| 160 |
+
return False
|
| 161 |
+
if target_channel in self.joined_channels:
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
should_wait_confirmation = self._can_wait_for_channel_confirmation()
|
| 165 |
+
if should_wait_confirmation and target_channel not in self._pending_join_events:
|
| 166 |
+
self._pending_join_events[target_channel] = asyncio.Event()
|
| 167 |
+
|
| 168 |
+
logger.info("IRC SEND: JOIN #%s", target_channel)
|
| 169 |
+
await self._send_raw(f"JOIN #{target_channel}")
|
| 170 |
+
|
| 171 |
+
if should_wait_confirmation:
|
| 172 |
+
return await self._wait_for_channel_confirmation(
|
| 173 |
+
channel_login=target_channel,
|
| 174 |
+
action="JOIN",
|
| 175 |
+
pending_map=self._pending_join_events,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
logger.info(
|
| 179 |
+
"JOIN enviado para #%s sem espera de confirmacao imediata.",
|
| 180 |
+
target_channel,
|
| 181 |
+
)
|
| 182 |
+
return True
|
| 183 |
+
|
| 184 |
+
async def _part_channel(self, channel_login: str) -> bool:
|
| 185 |
+
target_channel = normalize_channel_login(channel_login)
|
| 186 |
+
if not target_channel:
|
| 187 |
+
return False
|
| 188 |
+
if target_channel not in self.joined_channels:
|
| 189 |
+
return False
|
| 190 |
+
if len(self.channel_logins) <= 1:
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
should_wait_confirmation = self._can_wait_for_channel_confirmation()
|
| 194 |
+
if should_wait_confirmation and target_channel not in self._pending_part_events:
|
| 195 |
+
self._pending_part_events[target_channel] = asyncio.Event()
|
| 196 |
+
await self._send_raw(f"PART #{target_channel}")
|
| 197 |
+
|
| 198 |
+
if should_wait_confirmation:
|
| 199 |
+
return await self._wait_for_channel_confirmation(
|
| 200 |
+
channel_login=target_channel,
|
| 201 |
+
action="PART",
|
| 202 |
+
pending_map=self._pending_part_events,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
logger.info(
|
| 206 |
+
"PART enviado para #%s sem espera de confirmacao imediata.",
|
| 207 |
+
target_channel,
|
| 208 |
+
)
|
| 209 |
+
return True
|
| 210 |
+
|
| 211 |
+
async def admin_list_channels(self) -> list[str]:
|
| 212 |
+
return list(self.channel_logins)
|
| 213 |
+
|
| 214 |
+
async def admin_join_channel(self, channel_login: str) -> tuple[bool, str, list[str]]:
|
| 215 |
+
target_channel = normalize_channel_login(channel_login)
|
| 216 |
+
if not target_channel:
|
| 217 |
+
return (
|
| 218 |
+
False,
|
| 219 |
+
"Invalid channel login. Use Twitch login format (no #).",
|
| 220 |
+
list(self.channel_logins),
|
| 221 |
+
)
|
| 222 |
+
if target_channel in self.joined_channels:
|
| 223 |
+
return (
|
| 224 |
+
True,
|
| 225 |
+
f"Already connected to #{target_channel}.",
|
| 226 |
+
list(self.channel_logins),
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Optimistic Update: Mark as joined in memory immediately for instant UX
|
| 230 |
+
self._mark_channel_joined(target_channel)
|
| 231 |
+
|
| 232 |
+
# Trigger the actual IRC join (fire and forget via background logic)
|
| 233 |
+
await self._join_channel(target_channel)
|
| 234 |
+
|
| 235 |
+
return (True, f"Joined #{target_channel}.", list(self.channel_logins))
|
| 236 |
+
|
| 237 |
+
async def admin_part_channel(self, channel_login: str) -> tuple[bool, str, list[str]]:
|
| 238 |
+
target_channel = normalize_channel_login(channel_login)
|
| 239 |
+
if not target_channel:
|
| 240 |
+
return (
|
| 241 |
+
False,
|
| 242 |
+
"Invalid channel login. Use Twitch login format (no #).",
|
| 243 |
+
list(self.channel_logins),
|
| 244 |
+
)
|
| 245 |
+
if target_channel not in self.joined_channels:
|
| 246 |
+
return (
|
| 247 |
+
False,
|
| 248 |
+
f"Channel not connected: #{target_channel}.",
|
| 249 |
+
list(self.channel_logins),
|
| 250 |
+
)
|
| 251 |
+
if len(self.channel_logins) <= 1:
|
| 252 |
+
return (
|
| 253 |
+
False,
|
| 254 |
+
"Cannot leave the last active channel. Join another one first.",
|
| 255 |
+
list(self.channel_logins),
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# Optimistic Update: Remove from memory immediately
|
| 259 |
+
self._mark_channel_parted(target_channel)
|
| 260 |
+
|
| 261 |
+
# Trigger actual IRC part
|
| 262 |
+
await self._part_channel(target_channel)
|
| 263 |
+
|
| 264 |
+
return (True, f"Left #{target_channel}.", list(self.channel_logins))
|
bot/logic.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from bot.logic_constants import (
|
| 2 |
+
BOT_BRAND,
|
| 3 |
+
EMPTY_RESPONSE_FALLBACK,
|
| 4 |
+
MAX_REPLY_LENGTH,
|
| 5 |
+
MAX_REPLY_LINES,
|
| 6 |
+
MODEL_RATE_LIMIT_BACKOFF_SECONDS,
|
| 7 |
+
MODEL_RATE_LIMIT_MAX_RETRIES,
|
| 8 |
+
OBSERVABILITY_TYPES,
|
| 9 |
+
UNSTABLE_CONNECTION_FALLBACK,
|
| 10 |
+
)
|
| 11 |
+
from bot.logic_context import (
|
| 12 |
+
StreamContext,
|
| 13 |
+
build_dynamic_prompt,
|
| 14 |
+
build_system_instruction,
|
| 15 |
+
context,
|
| 16 |
+
enforce_reply_limits,
|
| 17 |
+
get_server_clock_snapshot,
|
| 18 |
+
normalize_memory_excerpt,
|
| 19 |
+
)
|
| 20 |
+
from bot.logic_grounding import (
|
| 21 |
+
GroundingMetadata,
|
| 22 |
+
empty_grounding_metadata,
|
| 23 |
+
extract_grounding_metadata,
|
| 24 |
+
extract_response_text,
|
| 25 |
+
has_grounding_signal,
|
| 26 |
+
)
|
| 27 |
+
from bot.logic_inference import agent_inference, is_rate_limited_inference_error
|
| 28 |
+
|
| 29 |
+
__all__ = [
|
| 30 |
+
"BOT_BRAND",
|
| 31 |
+
"EMPTY_RESPONSE_FALLBACK",
|
| 32 |
+
"MAX_REPLY_LENGTH",
|
| 33 |
+
"MAX_REPLY_LINES",
|
| 34 |
+
"MODEL_RATE_LIMIT_BACKOFF_SECONDS",
|
| 35 |
+
"MODEL_RATE_LIMIT_MAX_RETRIES",
|
| 36 |
+
"OBSERVABILITY_TYPES",
|
| 37 |
+
"UNSTABLE_CONNECTION_FALLBACK",
|
| 38 |
+
"StreamContext",
|
| 39 |
+
"build_dynamic_prompt",
|
| 40 |
+
"build_system_instruction",
|
| 41 |
+
"context",
|
| 42 |
+
"enforce_reply_limits",
|
| 43 |
+
"get_server_clock_snapshot",
|
| 44 |
+
"normalize_memory_excerpt",
|
| 45 |
+
"GroundingMetadata",
|
| 46 |
+
"empty_grounding_metadata",
|
| 47 |
+
"extract_grounding_metadata",
|
| 48 |
+
"extract_response_text",
|
| 49 |
+
"has_grounding_signal",
|
| 50 |
+
"agent_inference",
|
| 51 |
+
"is_rate_limited_inference_error",
|
| 52 |
+
]
|
bot/logic_constants.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
BOT_BRAND = "Byte"
|
| 4 |
+
MAX_REPLY_LINES = 4
|
| 5 |
+
MAX_REPLY_LENGTH = 460
|
| 6 |
+
MAX_RECENT_CHAT_ENTRIES = 12
|
| 7 |
+
MAX_RECENT_CHAT_PREVIEW_CHARS = 140
|
| 8 |
+
MAX_RECENT_CHAT_PROMPT_ENTRIES = 5
|
| 9 |
+
MAX_GROUNDING_QUERIES = 3
|
| 10 |
+
MAX_GROUNDING_URLS = 3
|
| 11 |
+
|
| 12 |
+
OBSERVABILITY_TYPES = {
|
| 13 |
+
"game": "Jogo",
|
| 14 |
+
"movie": "Filme",
|
| 15 |
+
"series": "Serie",
|
| 16 |
+
"youtube": "Video YouTube",
|
| 17 |
+
"x": "Post X",
|
| 18 |
+
"topic": "Tema",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
DEFAULT_STYLE_PROFILE = "Tom generalista, claro e natural em PT-BR, sem giria gamer forcada."
|
| 22 |
+
|
| 23 |
+
SYSTEM_INSTRUCTION_TEMPLATE = (
|
| 24 |
+
"Voce e Byte, chatbot de uma live na Twitch. "
|
| 25 |
+
"Responda de forma objetiva para os assuntos ativos da live, incluindo jogos, filmes, series, videos do YouTube, posts do X e temas gerais. "
|
| 26 |
+
"Regras: 1) no maximo 4 linhas. 2) cada linha deve trazer informacao nova, concreta e relevante. "
|
| 27 |
+
"3) sem markdown. 4) nao use linguagem ofensiva nem invente fatos sem sinalizar incerteza. "
|
| 28 |
+
"5) se faltar contexto essencial, faca uma pergunta curta. "
|
| 29 |
+
"6) para assunto atual, priorize informacao recente e verificavel. "
|
| 30 |
+
"7) responda primeiro a pergunta principal, sem rodeio."
|
| 31 |
+
)
|
| 32 |
+
MODEL_NAME = "gemini-3-flash-preview"
|
| 33 |
+
MODEL_MAX_OUTPUT_TOKENS = 320
|
| 34 |
+
MODEL_TEMPERATURE = 0.15
|
| 35 |
+
MODEL_INPUT_COST_PER_1M_USD = max(0.0, float(os.environ.get("MODEL_INPUT_COST_PER_1M_USD", "0.10")))
|
| 36 |
+
MODEL_OUTPUT_COST_PER_1M_USD = max(0.0, float(os.environ.get("MODEL_OUTPUT_COST_PER_1M_USD", "0.40")))
|
| 37 |
+
MODEL_RATE_LIMIT_MAX_RETRIES = max(0, int(os.environ.get("MODEL_RATE_LIMIT_MAX_RETRIES", "1")))
|
| 38 |
+
MODEL_RATE_LIMIT_BACKOFF_SECONDS = max(0.0, float(os.environ.get("MODEL_RATE_LIMIT_BACKOFF_SECONDS", "0.8")))
|
| 39 |
+
MODEL_INFERENCE_TIMEOUT_SECONDS = max(2.0, float(os.environ.get("MODEL_INFERENCE_TIMEOUT_SECONDS", "12.0")))
|
| 40 |
+
EMPTY_RESPONSE_FALLBACK = "Nao consegui consolidar a resposta agora. Tente reformular em uma frase."
|
| 41 |
+
UNSTABLE_CONNECTION_FALLBACK = "Conexao com o modelo instavel. Tente novamente em instantes."
|
bot/logic_context.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
from bot.logic_constants import (
|
| 6 |
+
BOT_BRAND,
|
| 7 |
+
DEFAULT_STYLE_PROFILE,
|
| 8 |
+
MAX_RECENT_CHAT_ENTRIES,
|
| 9 |
+
MAX_RECENT_CHAT_PREVIEW_CHARS,
|
| 10 |
+
MAX_RECENT_CHAT_PROMPT_ENTRIES,
|
| 11 |
+
MAX_REPLY_LENGTH,
|
| 12 |
+
MAX_REPLY_LINES,
|
| 13 |
+
OBSERVABILITY_TYPES,
|
| 14 |
+
SYSTEM_INSTRUCTION_TEMPLATE,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_memory_excerpt(text: str, max_length: int = MAX_RECENT_CHAT_PREVIEW_CHARS) -> str:
|
| 19 |
+
compact = " ".join((text or "").split())
|
| 20 |
+
if not compact:
|
| 21 |
+
return ""
|
| 22 |
+
if len(compact) <= max_length:
|
| 23 |
+
return compact
|
| 24 |
+
|
| 25 |
+
head = compact[: max_length - 3].rstrip()
|
| 26 |
+
last_space = head.rfind(" ")
|
| 27 |
+
if last_space >= int(max_length * 0.5):
|
| 28 |
+
head = head[:last_space]
|
| 29 |
+
return head.rstrip(" ,;:") + "..."
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class StreamContext:
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.current_game = "N/A"
|
| 35 |
+
self.stream_vibe = "Conversa"
|
| 36 |
+
self.last_event = "Bot Online"
|
| 37 |
+
self.style_profile = DEFAULT_STYLE_PROFILE
|
| 38 |
+
self.live_observability: Dict[str, str] = {
|
| 39 |
+
"game": "",
|
| 40 |
+
"movie": "",
|
| 41 |
+
"series": "",
|
| 42 |
+
"youtube": "",
|
| 43 |
+
"x": "",
|
| 44 |
+
"topic": "",
|
| 45 |
+
}
|
| 46 |
+
self.recent_chat_entries: list[str] = []
|
| 47 |
+
self.last_byte_reply = ""
|
| 48 |
+
self.start_time = time.time()
|
| 49 |
+
|
| 50 |
+
def get_uptime_minutes(self) -> int:
|
| 51 |
+
return int((time.time() - self.start_time) / 60)
|
| 52 |
+
|
| 53 |
+
def update_content(self, content_type: str, description: str) -> bool:
|
| 54 |
+
normalized_type = content_type.strip().lower()
|
| 55 |
+
cleaned_description = description.strip()
|
| 56 |
+
if normalized_type not in OBSERVABILITY_TYPES or not cleaned_description:
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
self.live_observability[normalized_type] = cleaned_description
|
| 60 |
+
if normalized_type == "game":
|
| 61 |
+
self.current_game = cleaned_description
|
| 62 |
+
self.last_event = f"Contexto atualizado: {OBSERVABILITY_TYPES[normalized_type]}"
|
| 63 |
+
return True
|
| 64 |
+
|
| 65 |
+
def clear_content(self, content_type: str) -> bool:
|
| 66 |
+
normalized_type = content_type.strip().lower()
|
| 67 |
+
if normalized_type not in OBSERVABILITY_TYPES:
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
self.live_observability[normalized_type] = ""
|
| 71 |
+
if normalized_type == "game":
|
| 72 |
+
self.current_game = "N/A"
|
| 73 |
+
self.last_event = f"Contexto removido: {OBSERVABILITY_TYPES[normalized_type]}"
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
def list_supported_content_types(self) -> str:
|
| 77 |
+
return ", ".join(OBSERVABILITY_TYPES.keys())
|
| 78 |
+
|
| 79 |
+
def format_observability(self) -> str:
|
| 80 |
+
entries = []
|
| 81 |
+
for content_type, label in OBSERVABILITY_TYPES.items():
|
| 82 |
+
value = self.live_observability.get(content_type, "").strip()
|
| 83 |
+
if value:
|
| 84 |
+
entries.append(f"{label}: {value}")
|
| 85 |
+
return " | ".join(entries) if entries else "Sem conteudo registrado."
|
| 86 |
+
|
| 87 |
+
def status_snapshot(self) -> str:
|
| 88 |
+
active_count = sum(1 for value in self.live_observability.values() if value)
|
| 89 |
+
return f"Vibe: {self.stream_vibe} | Contextos ativos: {active_count}"
|
| 90 |
+
|
| 91 |
+
def remember_user_message(self, author_name: str, message_text: str) -> None:
|
| 92 |
+
safe_author = normalize_memory_excerpt(author_name or "viewer", max_length=32)
|
| 93 |
+
safe_message = normalize_memory_excerpt(message_text)
|
| 94 |
+
if not safe_message:
|
| 95 |
+
return
|
| 96 |
+
self.recent_chat_entries.append(f"{safe_author}: {safe_message}")
|
| 97 |
+
self.recent_chat_entries = self.recent_chat_entries[-MAX_RECENT_CHAT_ENTRIES:]
|
| 98 |
+
|
| 99 |
+
def remember_bot_reply(self, reply_text: str) -> None:
|
| 100 |
+
safe_reply = normalize_memory_excerpt(reply_text, max_length=180)
|
| 101 |
+
if not safe_reply:
|
| 102 |
+
return
|
| 103 |
+
self.last_byte_reply = safe_reply
|
| 104 |
+
self.recent_chat_entries.append(f"{BOT_BRAND}: {safe_reply}")
|
| 105 |
+
self.recent_chat_entries = self.recent_chat_entries[-MAX_RECENT_CHAT_ENTRIES:]
|
| 106 |
+
|
| 107 |
+
def format_recent_chat(self, limit: int = MAX_RECENT_CHAT_PROMPT_ENTRIES) -> str:
|
| 108 |
+
if not self.recent_chat_entries:
|
| 109 |
+
return "Sem historico recente."
|
| 110 |
+
selected = self.recent_chat_entries[-max(1, limit):]
|
| 111 |
+
return " || ".join(selected)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
context = StreamContext()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_system_instruction(ctx: StreamContext) -> str:
|
| 118 |
+
return f"{SYSTEM_INSTRUCTION_TEMPLATE} Estilo ativo: {ctx.style_profile}"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def get_server_clock_snapshot() -> tuple[str, int]:
|
| 122 |
+
now_utc = datetime.now(timezone.utc)
|
| 123 |
+
now_utc_iso = now_utc.isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 124 |
+
return now_utc_iso, int(now_utc.timestamp())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def build_dynamic_prompt(
|
| 128 |
+
user_msg: str,
|
| 129 |
+
author_name: str,
|
| 130 |
+
ctx: StreamContext,
|
| 131 |
+
*,
|
| 132 |
+
include_live_context: bool = True,
|
| 133 |
+
) -> str:
|
| 134 |
+
server_utc_iso, server_epoch = get_server_clock_snapshot()
|
| 135 |
+
if not include_live_context:
|
| 136 |
+
return (
|
| 137 |
+
"Modo contexto estatico ativo. Ignore historico da live e responda somente ao pedido atual.\n"
|
| 138 |
+
f"Relogio servidor UTC: {server_utc_iso} | Epoch: {server_epoch}\n"
|
| 139 |
+
f"Usuario {author_name}: {user_msg}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
uptime = ctx.get_uptime_minutes()
|
| 143 |
+
observability = ctx.format_observability()
|
| 144 |
+
recent_chat = ctx.format_recent_chat()
|
| 145 |
+
last_reply = ctx.last_byte_reply or "N/A"
|
| 146 |
+
return (
|
| 147 |
+
"Contexto Atual da Live: "
|
| 148 |
+
f"[Vibe: {ctx.stream_vibe} | Uptime: {uptime}min | "
|
| 149 |
+
f"Observabilidade: {observability} | Ultimo evento: {ctx.last_event} | "
|
| 150 |
+
f"Relogio servidor UTC: {server_utc_iso} | Epoch: {server_epoch}]\n"
|
| 151 |
+
"Use o relogio do servidor como referencia para termos temporais (hoje/agora/nesta semana).\n"
|
| 152 |
+
f"Historico recente: {recent_chat}\n"
|
| 153 |
+
f"Ultima resposta do {BOT_BRAND}: {last_reply}\n"
|
| 154 |
+
f"Usuario {author_name}: {user_msg}"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def enforce_reply_limits(text: str, max_lines: int = MAX_REPLY_LINES, max_length: int = MAX_REPLY_LENGTH) -> str:
|
| 159 |
+
cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
| 160 |
+
if not cleaned:
|
| 161 |
+
return ""
|
| 162 |
+
|
| 163 |
+
normalized_lines = []
|
| 164 |
+
for raw_line in cleaned.split("\n"):
|
| 165 |
+
line = " ".join(raw_line.split())
|
| 166 |
+
if line:
|
| 167 |
+
normalized_lines.append(line)
|
| 168 |
+
|
| 169 |
+
if not normalized_lines:
|
| 170 |
+
normalized_lines = [" ".join(cleaned.split())]
|
| 171 |
+
|
| 172 |
+
limited_lines = normalized_lines[:max_lines]
|
| 173 |
+
result = "\n".join(limited_lines).strip()
|
| 174 |
+
if len(result) <= max_length:
|
| 175 |
+
return result
|
| 176 |
+
|
| 177 |
+
def close_sentence(fragment: str) -> str:
|
| 178 |
+
cleaned_fragment = fragment.strip().rstrip(" ,;:")
|
| 179 |
+
if not cleaned_fragment:
|
| 180 |
+
return ""
|
| 181 |
+
if cleaned_fragment[-1] in ".!?":
|
| 182 |
+
return cleaned_fragment
|
| 183 |
+
if len(cleaned_fragment) >= max_length:
|
| 184 |
+
cleaned_fragment = cleaned_fragment[: max_length - 1].rstrip(" ,;:")
|
| 185 |
+
if not cleaned_fragment:
|
| 186 |
+
return ""
|
| 187 |
+
return cleaned_fragment + "."
|
| 188 |
+
|
| 189 |
+
head = result[:max_length].rstrip()
|
| 190 |
+
punctuation_positions = [head.rfind(symbol) for symbol in (".", "!", "?")]
|
| 191 |
+
best_punctuation = max(punctuation_positions)
|
| 192 |
+
if best_punctuation >= int(max_length * 0.35):
|
| 193 |
+
return head[: best_punctuation + 1].strip()
|
| 194 |
+
|
| 195 |
+
last_space = head.rfind(" ")
|
| 196 |
+
if last_space >= int(max_length * 0.55):
|
| 197 |
+
return close_sentence(head[:last_space])
|
| 198 |
+
return close_sentence(head)
|
bot/logic_grounding.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Mapping, TypedDict
|
| 2 |
+
|
| 3 |
+
from bot.logic_constants import MAX_GROUNDING_QUERIES, MAX_GROUNDING_URLS
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class GroundingMetadata(TypedDict):
|
| 7 |
+
enabled: bool
|
| 8 |
+
has_grounding_signal: bool
|
| 9 |
+
query_count: int
|
| 10 |
+
source_count: int
|
| 11 |
+
chunk_count: int
|
| 12 |
+
web_search_queries: list[str]
|
| 13 |
+
source_urls: list[str]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def empty_grounding_metadata(enabled: bool = False) -> GroundingMetadata:
|
| 17 |
+
return {
|
| 18 |
+
"enabled": bool(enabled),
|
| 19 |
+
"has_grounding_signal": False,
|
| 20 |
+
"query_count": 0,
|
| 21 |
+
"source_count": 0,
|
| 22 |
+
"chunk_count": 0,
|
| 23 |
+
"web_search_queries": [],
|
| 24 |
+
"source_urls": [],
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _read_field(source: Any, key: str, default: Any = None) -> Any:
|
| 29 |
+
if isinstance(source, dict):
|
| 30 |
+
return source.get(key, default)
|
| 31 |
+
return getattr(source, key, default)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _append_unique_text(values: list[str], raw_value: Any, max_items: int) -> None:
|
| 35 |
+
normalized = " ".join(str(raw_value or "").split()).strip()
|
| 36 |
+
if not normalized:
|
| 37 |
+
return
|
| 38 |
+
if normalized in values:
|
| 39 |
+
return
|
| 40 |
+
values.append(normalized)
|
| 41 |
+
if len(values) > max_items:
|
| 42 |
+
del values[max_items:]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def extract_grounding_metadata(response: Any, use_grounding: bool) -> GroundingMetadata:
|
| 46 |
+
metadata = empty_grounding_metadata(enabled=use_grounding)
|
| 47 |
+
candidates = _read_field(response, "candidates", []) or []
|
| 48 |
+
if not candidates:
|
| 49 |
+
return metadata
|
| 50 |
+
|
| 51 |
+
web_search_queries: list[str] = []
|
| 52 |
+
source_urls: list[str] = []
|
| 53 |
+
chunk_count = 0
|
| 54 |
+
|
| 55 |
+
for candidate in candidates:
|
| 56 |
+
grounding = _read_field(candidate, "grounding_metadata")
|
| 57 |
+
if grounding is not None:
|
| 58 |
+
for query in _read_field(grounding, "web_search_queries", []) or []:
|
| 59 |
+
_append_unique_text(web_search_queries, query, max_items=MAX_GROUNDING_QUERIES)
|
| 60 |
+
|
| 61 |
+
grounding_chunks = _read_field(grounding, "grounding_chunks", []) or []
|
| 62 |
+
chunk_count += len(grounding_chunks)
|
| 63 |
+
for chunk in grounding_chunks:
|
| 64 |
+
web_chunk = _read_field(chunk, "web")
|
| 65 |
+
uri = _read_field(web_chunk, "uri", "") if web_chunk is not None else ""
|
| 66 |
+
_append_unique_text(source_urls, uri, max_items=MAX_GROUNDING_URLS)
|
| 67 |
+
|
| 68 |
+
citation_metadata = _read_field(candidate, "citation_metadata")
|
| 69 |
+
citations = _read_field(citation_metadata, "citations", []) if citation_metadata is not None else []
|
| 70 |
+
for citation in citations or []:
|
| 71 |
+
_append_unique_text(source_urls, _read_field(citation, "uri", ""), max_items=MAX_GROUNDING_URLS)
|
| 72 |
+
|
| 73 |
+
metadata["web_search_queries"] = web_search_queries
|
| 74 |
+
metadata["source_urls"] = source_urls
|
| 75 |
+
metadata["chunk_count"] = max(0, int(chunk_count))
|
| 76 |
+
metadata["query_count"] = len(web_search_queries)
|
| 77 |
+
metadata["source_count"] = len(source_urls)
|
| 78 |
+
metadata["has_grounding_signal"] = bool(web_search_queries or source_urls or chunk_count > 0)
|
| 79 |
+
return metadata
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def has_grounding_signal(metadata: Mapping[str, Any] | None) -> bool:
|
| 83 |
+
if not metadata:
|
| 84 |
+
return False
|
| 85 |
+
if bool(metadata.get("has_grounding_signal")):
|
| 86 |
+
return True
|
| 87 |
+
return bool(
|
| 88 |
+
metadata.get("web_search_queries")
|
| 89 |
+
or metadata.get("source_urls")
|
| 90 |
+
or int(metadata.get("chunk_count", 0) or 0) > 0
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def extract_response_text(response: Any) -> str:
|
| 95 |
+
direct_text = getattr(response, "text", None)
|
| 96 |
+
if isinstance(direct_text, str) and direct_text.strip():
|
| 97 |
+
return direct_text.strip()
|
| 98 |
+
|
| 99 |
+
candidates = getattr(response, "candidates", None) or []
|
| 100 |
+
for candidate in candidates:
|
| 101 |
+
content = getattr(candidate, "content", None)
|
| 102 |
+
parts = getattr(content, "parts", None) or []
|
| 103 |
+
text_parts = []
|
| 104 |
+
for part in parts:
|
| 105 |
+
part_text = getattr(part, "text", None)
|
| 106 |
+
if isinstance(part_text, str) and part_text.strip():
|
| 107 |
+
text_parts.append(part_text.strip())
|
| 108 |
+
if text_parts:
|
| 109 |
+
return "\n".join(text_parts).strip()
|
| 110 |
+
return ""
|
bot/logic_inference.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any, Literal, overload
|
| 4 |
+
|
| 5 |
+
from bot.logic_constants import (
|
| 6 |
+
EMPTY_RESPONSE_FALLBACK,
|
| 7 |
+
MAX_REPLY_LENGTH,
|
| 8 |
+
MAX_REPLY_LINES,
|
| 9 |
+
MODEL_RATE_LIMIT_BACKOFF_SECONDS,
|
| 10 |
+
MODEL_RATE_LIMIT_MAX_RETRIES,
|
| 11 |
+
MODEL_TEMPERATURE,
|
| 12 |
+
UNSTABLE_CONNECTION_FALLBACK,
|
| 13 |
+
)
|
| 14 |
+
from bot.logic_context import build_dynamic_prompt, build_system_instruction, enforce_reply_limits
|
| 15 |
+
from bot.logic_grounding import (
|
| 16 |
+
GroundingMetadata,
|
| 17 |
+
empty_grounding_metadata,
|
| 18 |
+
)
|
| 19 |
+
from bot.observability import observability
|
| 20 |
+
from bot.web_search import format_search_context, search_web
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("ByteBot")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def is_rate_limited_inference_error(error: Exception) -> bool:
|
| 26 |
+
message = str(error).lower()
|
| 27 |
+
return "429" in message or "rate limit" in message
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_timeout_inference_error(error: Exception) -> bool:
|
| 31 |
+
if isinstance(error, asyncio.TimeoutError):
|
| 32 |
+
return True
|
| 33 |
+
message = str(error).lower()
|
| 34 |
+
return "timed out" in message or "timeout" in message
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _extract_usage(response: Any) -> tuple[int, int]:
|
| 38 |
+
usage = getattr(response, "usage", None)
|
| 39 |
+
if usage is None:
|
| 40 |
+
return 0, 0
|
| 41 |
+
return getattr(usage, "prompt_tokens", 0), getattr(usage, "completion_tokens", 0)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _select_model(enable_grounding: bool, is_serious: bool) -> str:
|
| 45 |
+
"""Select the optimal Nebius model for the request type."""
|
| 46 |
+
from bot.runtime_config import NEBIUS_MODEL_DEFAULT, NEBIUS_MODEL_REASONING, NEBIUS_MODEL_SEARCH
|
| 47 |
+
|
| 48 |
+
if enable_grounding:
|
| 49 |
+
return NEBIUS_MODEL_SEARCH
|
| 50 |
+
if is_serious:
|
| 51 |
+
return NEBIUS_MODEL_REASONING
|
| 52 |
+
return NEBIUS_MODEL_DEFAULT
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _extract_search_query(user_msg: str) -> str:
|
| 56 |
+
"""Extract a clean search query from the user message.
|
| 57 |
+
|
| 58 |
+
Strips LLM instruction noise, keeps the factual question core.
|
| 59 |
+
"""
|
| 60 |
+
clean = (user_msg or "").strip()
|
| 61 |
+
# If the message has embedded instructions (from build_llm_enhanced_prompt),
|
| 62 |
+
# use only the first line which is the actual user question.
|
| 63 |
+
if "\n" in clean:
|
| 64 |
+
clean = clean.split("\n")[0].strip()
|
| 65 |
+
# Cap length for DDG
|
| 66 |
+
if len(clean) > 200:
|
| 67 |
+
clean = clean[:200].rsplit(" ", 1)[0]
|
| 68 |
+
return clean
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _build_grounding_metadata(
|
| 72 |
+
results: list[Any],
|
| 73 |
+
enabled: bool,
|
| 74 |
+
) -> GroundingMetadata:
|
| 75 |
+
"""Build real grounding metadata from DDG search results."""
|
| 76 |
+
if not results:
|
| 77 |
+
return empty_grounding_metadata(enabled=enabled)
|
| 78 |
+
return {
|
| 79 |
+
"enabled": enabled,
|
| 80 |
+
"has_grounding_signal": True,
|
| 81 |
+
"query_count": 1,
|
| 82 |
+
"source_count": len(results),
|
| 83 |
+
"chunk_count": len(results),
|
| 84 |
+
"web_search_queries": [results[0].snippet[:80] if results else ""],
|
| 85 |
+
"source_urls": [r.url for r in results if r.url],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@overload
|
| 90 |
+
async def agent_inference(
|
| 91 |
+
user_msg: str,
|
| 92 |
+
author_name: str,
|
| 93 |
+
client: Any,
|
| 94 |
+
context: Any,
|
| 95 |
+
enable_grounding: bool = False,
|
| 96 |
+
enable_live_context: bool = True,
|
| 97 |
+
max_lines: int = MAX_REPLY_LINES,
|
| 98 |
+
max_length: int = MAX_REPLY_LENGTH,
|
| 99 |
+
return_metadata: Literal[False] = False,
|
| 100 |
+
) -> str: ...
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@overload
|
| 104 |
+
async def agent_inference(
|
| 105 |
+
user_msg: str,
|
| 106 |
+
author_name: str,
|
| 107 |
+
client: Any,
|
| 108 |
+
context: Any,
|
| 109 |
+
enable_grounding: bool = False,
|
| 110 |
+
enable_live_context: bool = True,
|
| 111 |
+
max_lines: int = MAX_REPLY_LINES,
|
| 112 |
+
max_length: int = MAX_REPLY_LENGTH,
|
| 113 |
+
*,
|
| 114 |
+
return_metadata: Literal[True],
|
| 115 |
+
) -> tuple[str, GroundingMetadata]: ...
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
async def agent_inference(
|
| 119 |
+
user_msg: str,
|
| 120 |
+
author_name: str,
|
| 121 |
+
client: Any,
|
| 122 |
+
context: Any,
|
| 123 |
+
enable_grounding: bool = False,
|
| 124 |
+
enable_live_context: bool = True,
|
| 125 |
+
max_lines: int = MAX_REPLY_LINES,
|
| 126 |
+
max_length: int = MAX_REPLY_LENGTH,
|
| 127 |
+
return_metadata: bool = False,
|
| 128 |
+
) -> str | tuple[str, GroundingMetadata]:
|
| 129 |
+
from bot.logic_constants import MODEL_INPUT_COST_PER_1M_USD, MODEL_OUTPUT_COST_PER_1M_USD
|
| 130 |
+
|
| 131 |
+
if not user_msg:
|
| 132 |
+
if return_metadata:
|
| 133 |
+
return "", empty_grounding_metadata(enabled=False)
|
| 134 |
+
return ""
|
| 135 |
+
|
| 136 |
+
# --- Web Search (DDG) para current events ---
|
| 137 |
+
search_results: list[Any] = []
|
| 138 |
+
if enable_grounding:
|
| 139 |
+
search_query = _extract_search_query(user_msg)
|
| 140 |
+
search_results = await search_web(search_query)
|
| 141 |
+
|
| 142 |
+
grounding_metadata = _build_grounding_metadata(search_results, enabled=enable_grounding)
|
| 143 |
+
|
| 144 |
+
# --- Model selection ---
|
| 145 |
+
is_serious = not enable_grounding and len(user_msg) >= 40
|
| 146 |
+
model = _select_model(enable_grounding, is_serious)
|
| 147 |
+
|
| 148 |
+
# --- Build messages ---
|
| 149 |
+
system_instr = build_system_instruction(context)
|
| 150 |
+
user_prompt = build_dynamic_prompt(
|
| 151 |
+
user_msg,
|
| 152 |
+
author_name,
|
| 153 |
+
context,
|
| 154 |
+
include_live_context=enable_live_context,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Inject search context into system prompt
|
| 158 |
+
if search_results:
|
| 159 |
+
search_context = format_search_context(search_results)
|
| 160 |
+
system_instr += f"\n\n{search_context}"
|
| 161 |
+
|
| 162 |
+
messages = [
|
| 163 |
+
{"role": "system", "content": system_instr},
|
| 164 |
+
{"role": "user", "content": user_prompt}
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
for attempt in range(MODEL_RATE_LIMIT_MAX_RETRIES + 1):
|
| 168 |
+
try:
|
| 169 |
+
response = await asyncio.wait_for(
|
| 170 |
+
asyncio.to_thread(
|
| 171 |
+
client.chat.completions.create,
|
| 172 |
+
model=model,
|
| 173 |
+
messages=messages,
|
| 174 |
+
temperature=MODEL_TEMPERATURE,
|
| 175 |
+
max_tokens=2048,
|
| 176 |
+
),
|
| 177 |
+
timeout=120.0,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
input_tokens, output_tokens = _extract_usage(response)
|
| 181 |
+
if input_tokens > 0 or output_tokens > 0:
|
| 182 |
+
cost = (input_tokens / 1_000_000.0) * MODEL_INPUT_COST_PER_1M_USD + \
|
| 183 |
+
(output_tokens / 1_000_000.0) * MODEL_OUTPUT_COST_PER_1M_USD
|
| 184 |
+
|
| 185 |
+
observability.record_token_usage(
|
| 186 |
+
input_tokens=input_tokens,
|
| 187 |
+
output_tokens=output_tokens,
|
| 188 |
+
estimated_cost_usd=cost,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
if not response.choices:
|
| 192 |
+
logger.warning("Nebius retornou resposta sem escolhas (model=%s).", model)
|
| 193 |
+
break
|
| 194 |
+
|
| 195 |
+
reply_text = response.choices[0].message.content
|
| 196 |
+
if reply_text:
|
| 197 |
+
final_reply = enforce_reply_limits(reply_text, max_lines=max_lines, max_length=max_length)
|
| 198 |
+
if return_metadata:
|
| 199 |
+
return final_reply, grounding_metadata
|
| 200 |
+
return final_reply
|
| 201 |
+
|
| 202 |
+
break
|
| 203 |
+
except Exception as error:
|
| 204 |
+
if is_timeout_inference_error(error):
|
| 205 |
+
logger.warning("Inference timeout (attempt %d, model=%s).", attempt + 1, model)
|
| 206 |
+
if attempt >= MODEL_RATE_LIMIT_MAX_RETRIES:
|
| 207 |
+
if return_metadata:
|
| 208 |
+
return UNSTABLE_CONNECTION_FALLBACK, empty_grounding_metadata(enabled=False)
|
| 209 |
+
return UNSTABLE_CONNECTION_FALLBACK
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
if is_rate_limited_inference_error(error) and attempt < MODEL_RATE_LIMIT_MAX_RETRIES:
|
| 213 |
+
backoff_seconds = MODEL_RATE_LIMIT_BACKOFF_SECONDS * (attempt + 1)
|
| 214 |
+
logger.warning("Inference rate-limited. Retrying in %.2fs (%d/%d).", backoff_seconds, attempt + 1, MODEL_RATE_LIMIT_MAX_RETRIES + 1)
|
| 215 |
+
await asyncio.sleep(backoff_seconds)
|
| 216 |
+
continue
|
| 217 |
+
|
| 218 |
+
logger.error("Inference Error (model=%s): %s", model, error)
|
| 219 |
+
if return_metadata:
|
| 220 |
+
return UNSTABLE_CONNECTION_FALLBACK, empty_grounding_metadata(enabled=False)
|
| 221 |
+
return UNSTABLE_CONNECTION_FALLBACK
|
| 222 |
+
|
| 223 |
+
if return_metadata:
|
| 224 |
+
return EMPTY_RESPONSE_FALLBACK, empty_grounding_metadata(enabled=False)
|
| 225 |
+
return EMPTY_RESPONSE_FALLBACK
|
bot/main.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
|
| 3 |
+
from bot import byte_semantics
|
| 4 |
+
from bot.bootstrap_runtime import (
|
| 5 |
+
build_irc_token_manager,
|
| 6 |
+
get_secret,
|
| 7 |
+
resolve_irc_channel_logins,
|
| 8 |
+
run_eventsub_mode,
|
| 9 |
+
run_irc_mode,
|
| 10 |
+
)
|
| 11 |
+
from bot.dashboard_server import HealthHandler, run_server
|
| 12 |
+
from bot.eventsub_runtime import AgentComponent, ByteBot
|
| 13 |
+
from bot.irc_protocol import is_irc_notice_delivery_block
|
| 14 |
+
from bot.irc_runtime import IrcByteBot
|
| 15 |
+
from bot.logic import context
|
| 16 |
+
from bot.observability import observability
|
| 17 |
+
from bot.prompt_runtime import (
|
| 18 |
+
build_intro_reply,
|
| 19 |
+
build_quality_rewrite_prompt,
|
| 20 |
+
build_verifiable_prompt,
|
| 21 |
+
extract_movie_title,
|
| 22 |
+
handle_byte_prompt_text,
|
| 23 |
+
is_current_events_prompt,
|
| 24 |
+
is_follow_up_prompt,
|
| 25 |
+
is_intro_prompt,
|
| 26 |
+
is_low_quality_answer,
|
| 27 |
+
is_serious_technical_prompt,
|
| 28 |
+
)
|
| 29 |
+
from bot.runtime_config import (
|
| 30 |
+
AUTO_SCENE_REQUIRE_METADATA,
|
| 31 |
+
BOT_ID,
|
| 32 |
+
BYTE_DASHBOARD_ADMIN_TOKEN,
|
| 33 |
+
BYTE_HELP_MESSAGE,
|
| 34 |
+
BYTE_INTRO_TEMPLATES,
|
| 35 |
+
BYTE_VERSION,
|
| 36 |
+
CHANNEL_ID,
|
| 37 |
+
CLIENT_ID,
|
| 38 |
+
ENABLE_LIVE_CONTEXT_LEARNING,
|
| 39 |
+
MAX_CHAT_MESSAGE_LENGTH,
|
| 40 |
+
MULTIPART_SEPARATOR,
|
| 41 |
+
OWNER_ID,
|
| 42 |
+
QUALITY_SAFE_FALLBACK,
|
| 43 |
+
SERIOUS_REPLY_MAX_LENGTH,
|
| 44 |
+
SERIOUS_REPLY_MAX_LINES,
|
| 45 |
+
TWITCH_BOT_LOGIN,
|
| 46 |
+
TWITCH_CHANNEL_LOGIN,
|
| 47 |
+
TWITCH_CHANNEL_LOGINS_RAW,
|
| 48 |
+
TWITCH_CHAT_MODE,
|
| 49 |
+
TWITCH_CLIENT_SECRET_INLINE,
|
| 50 |
+
TWITCH_CLIENT_SECRET_NAME,
|
| 51 |
+
TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS,
|
| 52 |
+
TWITCH_IRC_HOST,
|
| 53 |
+
TWITCH_IRC_PORT,
|
| 54 |
+
TWITCH_IRC_TLS,
|
| 55 |
+
TWITCH_REFRESH_TOKEN,
|
| 56 |
+
TWITCH_TOKEN_REFRESH_MARGIN_SECONDS,
|
| 57 |
+
TWITCH_USER_TOKEN,
|
| 58 |
+
irc_channel_control,
|
| 59 |
+
logger,
|
| 60 |
+
)
|
| 61 |
+
from bot.scene_runtime import auto_update_scene_from_message, resolve_scene_metadata
|
| 62 |
+
from bot.status_runtime import build_status_line
|
| 63 |
+
from bot.twitch_tokens import TwitchTokenManager, is_irc_auth_failure_line
|
| 64 |
+
|
| 65 |
+
parse_byte_prompt = byte_semantics.parse_byte_prompt
|
| 66 |
+
build_llm_enhanced_prompt = byte_semantics.build_llm_enhanced_prompt
|
| 67 |
+
build_direct_answer_instruction = byte_semantics.build_direct_answer_instruction
|
| 68 |
+
extract_multi_reply_parts = byte_semantics.extract_multi_reply_parts
|
| 69 |
+
|
| 70 |
+
__all__ = [
|
| 71 |
+
"main",
|
| 72 |
+
"run_server",
|
| 73 |
+
"run_eventsub_mode",
|
| 74 |
+
"run_irc_mode",
|
| 75 |
+
"get_secret",
|
| 76 |
+
"build_irc_token_manager",
|
| 77 |
+
"resolve_irc_channel_logins",
|
| 78 |
+
"HealthHandler",
|
| 79 |
+
"AgentComponent",
|
| 80 |
+
"ByteBot",
|
| 81 |
+
"IrcByteBot",
|
| 82 |
+
"context",
|
| 83 |
+
"observability",
|
| 84 |
+
"build_intro_reply",
|
| 85 |
+
"build_quality_rewrite_prompt",
|
| 86 |
+
"build_verifiable_prompt",
|
| 87 |
+
"extract_movie_title",
|
| 88 |
+
"handle_byte_prompt_text",
|
| 89 |
+
"is_current_events_prompt",
|
| 90 |
+
"is_follow_up_prompt",
|
| 91 |
+
"is_intro_prompt",
|
| 92 |
+
"is_low_quality_answer",
|
| 93 |
+
"is_serious_technical_prompt",
|
| 94 |
+
"auto_update_scene_from_message",
|
| 95 |
+
"resolve_scene_metadata",
|
| 96 |
+
"build_status_line",
|
| 97 |
+
"TwitchTokenManager",
|
| 98 |
+
"is_irc_auth_failure_line",
|
| 99 |
+
"is_irc_notice_delivery_block",
|
| 100 |
+
"parse_byte_prompt",
|
| 101 |
+
"build_llm_enhanced_prompt",
|
| 102 |
+
"build_direct_answer_instruction",
|
| 103 |
+
"extract_multi_reply_parts",
|
| 104 |
+
"AUTO_SCENE_REQUIRE_METADATA",
|
| 105 |
+
"BOT_ID",
|
| 106 |
+
"BYTE_DASHBOARD_ADMIN_TOKEN",
|
| 107 |
+
"BYTE_HELP_MESSAGE",
|
| 108 |
+
"BYTE_INTRO_TEMPLATES",
|
| 109 |
+
"BYTE_VERSION",
|
| 110 |
+
"CHANNEL_ID",
|
| 111 |
+
"CLIENT_ID",
|
| 112 |
+
"ENABLE_LIVE_CONTEXT_LEARNING",
|
| 113 |
+
"MAX_CHAT_MESSAGE_LENGTH",
|
| 114 |
+
"MULTIPART_SEPARATOR",
|
| 115 |
+
"OWNER_ID",
|
| 116 |
+
"QUALITY_SAFE_FALLBACK",
|
| 117 |
+
"SERIOUS_REPLY_MAX_LENGTH",
|
| 118 |
+
"SERIOUS_REPLY_MAX_LINES",
|
| 119 |
+
"TWITCH_BOT_LOGIN",
|
| 120 |
+
"TWITCH_CHANNEL_LOGIN",
|
| 121 |
+
"TWITCH_CHANNEL_LOGINS_RAW",
|
| 122 |
+
"TWITCH_CHAT_MODE",
|
| 123 |
+
"TWITCH_CLIENT_SECRET_INLINE",
|
| 124 |
+
"TWITCH_CLIENT_SECRET_NAME",
|
| 125 |
+
"TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS",
|
| 126 |
+
"TWITCH_IRC_HOST",
|
| 127 |
+
"TWITCH_IRC_PORT",
|
| 128 |
+
"TWITCH_IRC_TLS",
|
| 129 |
+
"TWITCH_REFRESH_TOKEN",
|
| 130 |
+
"TWITCH_TOKEN_REFRESH_MARGIN_SECONDS",
|
| 131 |
+
"TWITCH_USER_TOKEN",
|
| 132 |
+
"irc_channel_control",
|
| 133 |
+
"logger",
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main() -> None:
|
| 138 |
+
from bot.heartbeat import start_heartbeat
|
| 139 |
+
|
| 140 |
+
start_heartbeat()
|
| 141 |
+
threading.Thread(target=run_server, daemon=True).start()
|
| 142 |
+
try:
|
| 143 |
+
if TWITCH_CHAT_MODE == "irc":
|
| 144 |
+
run_irc_mode()
|
| 145 |
+
else:
|
| 146 |
+
run_eventsub_mode()
|
| 147 |
+
except Exception as error:
|
| 148 |
+
logger.critical("Fatal Byte Error: %s", error)
|
| 149 |
+
observability.record_error(category="fatal", details=str(error))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
main()
|
bot/observability.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from bot.observability_state import ObservabilityState
|
| 2 |
+
|
| 3 |
+
observability = ObservabilityState()
|
| 4 |
+
|
| 5 |
+
__all__ = ["ObservabilityState", "observability"]
|
bot/observability_analytics.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from bot.observability_helpers import (
|
| 5 |
+
LEADERBOARD_LIMIT,
|
| 6 |
+
percentage,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
def compute_chat_metrics(
|
| 10 |
+
chat_events: list[dict[str, Any]],
|
| 11 |
+
now: float,
|
| 12 |
+
) -> dict[str, Any]:
|
| 13 |
+
cutoff_10m = now - 600
|
| 14 |
+
cutoff_60m = now - 3600
|
| 15 |
+
|
| 16 |
+
events_10m = [e for e in chat_events if float(e.get("ts", 0.0)) >= cutoff_10m]
|
| 17 |
+
events_60m = [e for e in chat_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 18 |
+
|
| 19 |
+
count_10m = len(events_10m)
|
| 20 |
+
count_60m = len(events_60m)
|
| 21 |
+
|
| 22 |
+
avg_len_10m = round(sum(int(e.get("length", 0)) for e in events_10m) / count_10m, 1) if count_10m else 0.0
|
| 23 |
+
avg_len_60m = round(sum(int(e.get("length", 0)) for e in events_60m) / count_60m, 1) if count_60m else 0.0
|
| 24 |
+
|
| 25 |
+
command_60m = sum(1 for e in events_60m if bool(e.get("is_command", False)))
|
| 26 |
+
url_60m = sum(1 for e in events_60m if bool(e.get("has_url", False)))
|
| 27 |
+
|
| 28 |
+
return {
|
| 29 |
+
"messages_10m": count_10m,
|
| 30 |
+
"messages_60m": count_60m,
|
| 31 |
+
"messages_per_minute_10m": round(count_10m / 10, 2),
|
| 32 |
+
"messages_per_minute_60m": round(count_60m / 60, 2),
|
| 33 |
+
"avg_message_length_10m": avg_len_10m,
|
| 34 |
+
"avg_message_length_60m": avg_len_60m,
|
| 35 |
+
"prefixed_commands_60m": command_60m,
|
| 36 |
+
"prefixed_command_ratio_60m": percentage(command_60m, count_60m),
|
| 37 |
+
"url_messages_60m": url_60m,
|
| 38 |
+
"url_ratio_60m": percentage(url_60m, count_60m),
|
| 39 |
+
"events_60m": events_60m, # return for further processing (e.g. source counts)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
def compute_interaction_metrics(
|
| 43 |
+
interaction_events: list[dict[str, Any]],
|
| 44 |
+
now: float,
|
| 45 |
+
) -> dict[str, Any]:
|
| 46 |
+
cutoff_60m = now - 3600
|
| 47 |
+
events_60m = [e for e in interaction_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 48 |
+
|
| 49 |
+
llm_60m = sum(1 for e in events_60m if bool(e.get("is_llm", False)))
|
| 50 |
+
useful_60m = sum(1 for e in events_60m if bool(e.get("is_useful_llm", False)))
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
"llm_interactions_60m": llm_60m,
|
| 54 |
+
"useful_llm_interactions_60m": useful_60m,
|
| 55 |
+
"useful_engagement_rate_60m": percentage(useful_60m, llm_60m),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
def compute_quality_metrics(
|
| 59 |
+
quality_events: list[dict[str, Any]],
|
| 60 |
+
llm_interactions_60m: int,
|
| 61 |
+
now: float,
|
| 62 |
+
) -> dict[str, Any]:
|
| 63 |
+
cutoff_60m = now - 3600
|
| 64 |
+
events_60m = [e for e in quality_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 65 |
+
|
| 66 |
+
retry_60m = sum(1 for e in events_60m if str(e.get("outcome", "")).strip().lower() == "retry")
|
| 67 |
+
success_60m = sum(1 for e in events_60m if str(e.get("outcome", "")).strip().lower() == "retry_success")
|
| 68 |
+
fallback_60m = sum(1 for e in events_60m if str(e.get("outcome", "")).strip().lower() == "fallback")
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
"quality_retry_60m": retry_60m,
|
| 72 |
+
"quality_retry_success_60m": success_60m,
|
| 73 |
+
"quality_fallback_60m": fallback_60m,
|
| 74 |
+
"correction_rate_60m": percentage(success_60m, retry_60m),
|
| 75 |
+
"correction_trigger_rate_60m": percentage(retry_60m, llm_interactions_60m),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
def compute_token_metrics(
|
| 79 |
+
token_usage_events: list[dict[str, Any]],
|
| 80 |
+
now: float,
|
| 81 |
+
) -> dict[str, Any]:
|
| 82 |
+
cutoff_60m = now - 3600
|
| 83 |
+
events_60m = [e for e in token_usage_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 84 |
+
|
| 85 |
+
input_60m = sum(max(0, int(e.get("input_tokens", 0) or 0)) for e in events_60m)
|
| 86 |
+
output_60m = sum(max(0, int(e.get("output_tokens", 0) or 0)) for e in events_60m)
|
| 87 |
+
cost_60m = sum(max(0.0, float(e.get("estimated_cost_usd", 0.0) or 0.0)) for e in events_60m)
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"token_input_60m": input_60m,
|
| 91 |
+
"token_output_60m": output_60m,
|
| 92 |
+
"estimated_cost_usd_60m": cost_60m,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
def compute_autonomy_metrics(
|
| 96 |
+
autonomy_events: list[dict[str, Any]],
|
| 97 |
+
now: float,
|
| 98 |
+
) -> dict[str, Any]:
|
| 99 |
+
cutoff_60m = now - 3600
|
| 100 |
+
events_60m = [e for e in autonomy_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 101 |
+
|
| 102 |
+
total_60m = len(events_60m)
|
| 103 |
+
ignored_60m = sum(1 for e in events_60m if str(e.get("outcome", "")).strip().lower() == "ignored")
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
"autonomy_goals_60m": total_60m,
|
| 107 |
+
"ignored_total_60m": ignored_60m,
|
| 108 |
+
"ignored_rate_60m": percentage(ignored_60m, total_60m),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
def compute_leaderboards(
|
| 112 |
+
chat_events_60m: list[dict[str, Any]],
|
| 113 |
+
trigger_events_60m: list[dict[str, Any]],
|
| 114 |
+
chatter_totals: dict[str, int],
|
| 115 |
+
trigger_totals: dict[str, int],
|
| 116 |
+
) -> dict[str, Any]:
|
| 117 |
+
chatters_60m: Counter[str] = Counter()
|
| 118 |
+
source_counts_60m: Counter[str] = Counter()
|
| 119 |
+
|
| 120 |
+
for e in chat_events_60m:
|
| 121 |
+
author = str(e.get("author", "") or "").strip().lower()
|
| 122 |
+
source = str(e.get("source", "unknown") or "unknown")
|
| 123 |
+
if author:
|
| 124 |
+
chatters_60m[author] += 1
|
| 125 |
+
source_counts_60m[source] += 1
|
| 126 |
+
|
| 127 |
+
triggers_60m: Counter[str] = Counter()
|
| 128 |
+
for e in trigger_events_60m:
|
| 129 |
+
author = str(e.get("author", "") or "").strip().lower()
|
| 130 |
+
if author:
|
| 131 |
+
triggers_60m[author] += 1
|
| 132 |
+
|
| 133 |
+
top_chatters_60m = [{"author": a, "messages": c} for a, c in chatters_60m.most_common(LEADERBOARD_LIMIT)]
|
| 134 |
+
top_chatters_total = [{"author": a, "messages": c} for a, c in Counter(chatter_totals).most_common(LEADERBOARD_LIMIT)]
|
| 135 |
+
top_triggers_60m = [{"author": a, "triggers": c} for a, c in triggers_60m.most_common(LEADERBOARD_LIMIT)]
|
| 136 |
+
top_triggers_total = [{"author": a, "triggers": c} for a, c in Counter(trigger_totals).most_common(LEADERBOARD_LIMIT)]
|
| 137 |
+
|
| 138 |
+
return {
|
| 139 |
+
"top_chatters_60m": top_chatters_60m,
|
| 140 |
+
"top_chatters_total": top_chatters_total,
|
| 141 |
+
"top_trigger_users_60m": top_triggers_60m,
|
| 142 |
+
"top_trigger_users_total": top_triggers_total,
|
| 143 |
+
"source_counts_60m": source_counts_60m,
|
| 144 |
+
}
|
bot/observability_helpers.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
TIMELINE_RETENTION_MINUTES = 180
|
| 4 |
+
TIMELINE_WINDOW_MINUTES = 30
|
| 5 |
+
EVENT_LOG_MAX_ITEMS = 120
|
| 6 |
+
LATENCY_WINDOW_MAX_ITEMS = 300
|
| 7 |
+
CHAT_EVENTS_RETENTION_SECONDS = 6 * 3600
|
| 8 |
+
BYTE_TRIGGER_EVENTS_RETENTION_SECONDS = 6 * 3600
|
| 9 |
+
CHAT_EVENTS_MAX_ITEMS = 30_000
|
| 10 |
+
BYTE_TRIGGER_EVENTS_MAX_ITEMS = 12_000
|
| 11 |
+
LEADERBOARD_LIMIT = 8
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def utc_iso(timestamp: float) -> str:
|
| 15 |
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def clip_preview(text: str, max_chars: int = 120) -> str:
|
| 19 |
+
compact = " ".join((text or "").split())
|
| 20 |
+
if len(compact) <= max_chars:
|
| 21 |
+
return compact
|
| 22 |
+
return compact[: max_chars - 3].rstrip() + "..."
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_p95(values: list[float]) -> float:
|
| 26 |
+
if not values:
|
| 27 |
+
return 0.0
|
| 28 |
+
ordered = sorted(values)
|
| 29 |
+
index = int(0.95 * (len(ordered) - 1))
|
| 30 |
+
return round(ordered[index], 1)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def percentage(value: int, total: int) -> float:
|
| 34 |
+
if total <= 0:
|
| 35 |
+
return 0.0
|
| 36 |
+
return round((value / total) * 100, 1)
|
bot/observability_snapshot.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from typing import Any
|
| 3 |
+
from bot.observability_helpers import (
|
| 4 |
+
TIMELINE_WINDOW_MINUTES,
|
| 5 |
+
clip_preview,
|
| 6 |
+
compute_p95,
|
| 7 |
+
utc_iso,
|
| 8 |
+
)
|
| 9 |
+
from bot.observability_analytics import (
|
| 10 |
+
compute_chat_metrics,
|
| 11 |
+
compute_interaction_metrics,
|
| 12 |
+
compute_quality_metrics,
|
| 13 |
+
compute_token_metrics,
|
| 14 |
+
compute_autonomy_metrics,
|
| 15 |
+
compute_leaderboards,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
def build_observability_snapshot(
|
| 19 |
+
*,
|
| 20 |
+
now: float,
|
| 21 |
+
started_at: float,
|
| 22 |
+
counters: dict[str, int],
|
| 23 |
+
route_counts: dict[str, int],
|
| 24 |
+
latencies_ms: list[float],
|
| 25 |
+
minute_buckets: dict[int, dict[str, int]],
|
| 26 |
+
recent_events: list[dict[str, Any]],
|
| 27 |
+
chatter_last_seen: dict[str, float],
|
| 28 |
+
chat_events: list[dict[str, Any]],
|
| 29 |
+
byte_trigger_events: list[dict[str, Any]],
|
| 30 |
+
interaction_events: list[dict[str, Any]],
|
| 31 |
+
quality_events: list[dict[str, Any]],
|
| 32 |
+
token_usage_events: list[dict[str, Any]],
|
| 33 |
+
autonomy_goal_events: list[dict[str, Any]],
|
| 34 |
+
chatter_message_totals: dict[str, int],
|
| 35 |
+
trigger_user_totals: dict[str, int],
|
| 36 |
+
unique_chatters_total: int,
|
| 37 |
+
last_prompt: str,
|
| 38 |
+
last_reply: str,
|
| 39 |
+
estimated_cost_usd_total: float,
|
| 40 |
+
clips_status: dict[str, bool],
|
| 41 |
+
bot_brand: str,
|
| 42 |
+
bot_version: str,
|
| 43 |
+
bot_mode: str,
|
| 44 |
+
stream_context: Any,
|
| 45 |
+
) -> dict[str, Any]:
|
| 46 |
+
# Active users
|
| 47 |
+
active_chatters_10m = sum(1 for value in chatter_last_seen.values() if now - value <= 600)
|
| 48 |
+
active_chatters_60m = sum(1 for value in chatter_last_seen.values() if now - value <= 3600)
|
| 49 |
+
|
| 50 |
+
# Latency
|
| 51 |
+
avg_latency_ms = round(sum(latencies_ms) / len(latencies_ms), 1) if latencies_ms else 0.0
|
| 52 |
+
p95_latency_ms = compute_p95(latencies_ms)
|
| 53 |
+
|
| 54 |
+
# Analytics
|
| 55 |
+
chat_metrics = compute_chat_metrics(chat_events, now)
|
| 56 |
+
interaction_metrics = compute_interaction_metrics(interaction_events, now)
|
| 57 |
+
quality_metrics = compute_quality_metrics(
|
| 58 |
+
quality_events,
|
| 59 |
+
interaction_metrics["llm_interactions_60m"],
|
| 60 |
+
now
|
| 61 |
+
)
|
| 62 |
+
token_metrics = compute_token_metrics(token_usage_events, now)
|
| 63 |
+
autonomy_metrics = compute_autonomy_metrics(autonomy_goal_events, now)
|
| 64 |
+
|
| 65 |
+
# Leaderboards
|
| 66 |
+
# Need byte_trigger_events filtered for 60m first
|
| 67 |
+
cutoff_60m = now - 3600
|
| 68 |
+
trigger_events_60m = [e for e in byte_trigger_events if float(e.get("ts", 0.0)) >= cutoff_60m]
|
| 69 |
+
|
| 70 |
+
leaderboards = compute_leaderboards(
|
| 71 |
+
chat_metrics.pop("events_60m"), # Pop to remove from result dict
|
| 72 |
+
trigger_events_60m,
|
| 73 |
+
chatter_message_totals,
|
| 74 |
+
trigger_user_totals,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
source_counts = leaderboards.pop("source_counts_60m")
|
| 78 |
+
chat_metrics["source_counts_60m"] = {
|
| 79 |
+
"irc": int(source_counts.get("irc", 0)),
|
| 80 |
+
"eventsub": int(source_counts.get("eventsub", 0)),
|
| 81 |
+
"unknown": int(source_counts.get("unknown", 0)),
|
| 82 |
+
}
|
| 83 |
+
chat_metrics["byte_triggers_10m"] = len([e for e in byte_trigger_events if float(e.get("ts", 0.0)) >= now - 600])
|
| 84 |
+
chat_metrics["byte_triggers_60m"] = len(trigger_events_60m)
|
| 85 |
+
|
| 86 |
+
# Context
|
| 87 |
+
context_vibe = str(getattr(stream_context, "stream_vibe", "Conversa") or "Conversa")
|
| 88 |
+
context_last_event = str(getattr(stream_context, "last_event", "Bot Online") or "Bot Online")
|
| 89 |
+
raw_observability = getattr(stream_context, "live_observability", {}) or {}
|
| 90 |
+
context_items = {str(key): str(value) for key, value in dict(raw_observability).items() if str(value).strip()}
|
| 91 |
+
context_active_count = len(context_items)
|
| 92 |
+
|
| 93 |
+
get_uptime_minutes = getattr(stream_context, "get_uptime_minutes", None)
|
| 94 |
+
if callable(get_uptime_minutes):
|
| 95 |
+
uptime_value = get_uptime_minutes()
|
| 96 |
+
if isinstance(uptime_value, (int, float, str)):
|
| 97 |
+
uptime_minutes = max(0, int(uptime_value))
|
| 98 |
+
else:
|
| 99 |
+
uptime_minutes = max(0, int((now - started_at) / 60))
|
| 100 |
+
else:
|
| 101 |
+
uptime_minutes = max(0, int((now - started_at) / 60))
|
| 102 |
+
|
| 103 |
+
# Timeline
|
| 104 |
+
now_minute = int(now // 60)
|
| 105 |
+
timeline = []
|
| 106 |
+
for minute_key in range(now_minute - TIMELINE_WINDOW_MINUTES + 1, now_minute + 1):
|
| 107 |
+
bucket: dict[str, int] = minute_buckets.get(minute_key) or {}
|
| 108 |
+
timeline.append(
|
| 109 |
+
{
|
| 110 |
+
"minute_epoch": minute_key * 60,
|
| 111 |
+
"label": datetime.fromtimestamp(minute_key * 60, tz=timezone.utc).strftime("%H:%M"),
|
| 112 |
+
"chat_messages": int(bucket.get("chat_messages", 0)),
|
| 113 |
+
"byte_triggers": int(bucket.get("byte_triggers", 0)),
|
| 114 |
+
"replies_sent": int(bucket.get("replies_sent", 0)),
|
| 115 |
+
"llm_requests": int(bucket.get("llm_requests", 0)),
|
| 116 |
+
"errors": int(bucket.get("errors", 0)),
|
| 117 |
+
}
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
sorted_routes = sorted(route_counts.items(), key=lambda item: item[1], reverse=True)
|
| 121 |
+
route_rows = [{"route": route, "count": count} for route, count in sorted_routes]
|
| 122 |
+
events_desc = list(reversed(recent_events[-40:]))
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"timestamp": utc_iso(now),
|
| 126 |
+
"bot": {
|
| 127 |
+
"brand": bot_brand,
|
| 128 |
+
"version": bot_version,
|
| 129 |
+
"mode": bot_mode,
|
| 130 |
+
"status": "online",
|
| 131 |
+
"uptime_minutes": uptime_minutes,
|
| 132 |
+
},
|
| 133 |
+
"metrics": {
|
| 134 |
+
"chat_messages_total": int(counters.get("chat_messages_total", 0)),
|
| 135 |
+
"chat_messages_irc_total": int(counters.get("chat_messages_irc", 0)),
|
| 136 |
+
"chat_messages_eventsub_total": int(counters.get("chat_messages_eventsub", 0)),
|
| 137 |
+
"chat_prefixed_messages_total": int(counters.get("chat_prefixed_messages", 0)),
|
| 138 |
+
"chat_messages_with_url_total": int(counters.get("chat_messages_with_url", 0)),
|
| 139 |
+
"byte_triggers_total": int(counters.get("byte_triggers_total", 0)),
|
| 140 |
+
"interactions_total": int(counters.get("interactions_total", 0)),
|
| 141 |
+
"replies_total": int(counters.get("replies_total", 0)),
|
| 142 |
+
"llm_interactions_total": int(counters.get("llm_interactions_total", 0)),
|
| 143 |
+
"serious_interactions_total": int(counters.get("serious_interactions_total", 0)),
|
| 144 |
+
"current_events_interactions_total": int(counters.get("current_events_interactions_total", 0)),
|
| 145 |
+
"follow_up_interactions_total": int(counters.get("follow_up_interactions_total", 0)),
|
| 146 |
+
"quality_checks_total": int(counters.get("quality_checks_total", 0)),
|
| 147 |
+
"quality_retry_total": int(counters.get("quality_retry_total", 0)),
|
| 148 |
+
"quality_retry_success_total": int(counters.get("quality_retry_success_total", 0)),
|
| 149 |
+
"quality_fallback_total": int(counters.get("quality_fallback_total", 0)),
|
| 150 |
+
"auto_scene_updates_total": int(counters.get("auto_scene_updates_total", 0)),
|
| 151 |
+
"token_input_total": int(counters.get("token_input_total", 0)),
|
| 152 |
+
"token_output_total": int(counters.get("token_output_total", 0)),
|
| 153 |
+
"estimated_cost_usd_total": round(max(0.0, float(estimated_cost_usd_total or 0.0)), 6),
|
| 154 |
+
"token_refreshes_total": int(counters.get("token_refreshes_total", 0)),
|
| 155 |
+
"auth_failures_total": int(counters.get("auth_failures_total", 0)),
|
| 156 |
+
"errors_total": int(counters.get("errors_total", 0)),
|
| 157 |
+
"vision_frames_total": int(counters.get("vision_frames_total", 0)),
|
| 158 |
+
"avg_latency_ms": avg_latency_ms,
|
| 159 |
+
"p95_latency_ms": p95_latency_ms,
|
| 160 |
+
},
|
| 161 |
+
"chatters": {
|
| 162 |
+
"unique_total": unique_chatters_total,
|
| 163 |
+
"active_10m": active_chatters_10m,
|
| 164 |
+
"active_60m": active_chatters_60m,
|
| 165 |
+
},
|
| 166 |
+
"chat_analytics": chat_metrics,
|
| 167 |
+
"leaderboards": leaderboards,
|
| 168 |
+
"agent_outcomes": {
|
| 169 |
+
**interaction_metrics,
|
| 170 |
+
**quality_metrics,
|
| 171 |
+
**autonomy_metrics,
|
| 172 |
+
"token_input_total": int(counters.get("token_input_total", 0)),
|
| 173 |
+
"token_output_total": int(counters.get("token_output_total", 0)),
|
| 174 |
+
**token_metrics,
|
| 175 |
+
"estimated_cost_usd_total": round(max(0.0, float(estimated_cost_usd_total or 0.0)), 6),
|
| 176 |
+
},
|
| 177 |
+
"context": {
|
| 178 |
+
"stream_vibe": context_vibe,
|
| 179 |
+
"last_event": context_last_event,
|
| 180 |
+
"active_contexts": context_active_count,
|
| 181 |
+
"items": context_items,
|
| 182 |
+
"last_prompt": clip_preview(last_prompt, max_chars=120),
|
| 183 |
+
"last_reply": clip_preview(last_reply, max_chars=140),
|
| 184 |
+
"clips_status": clips_status,
|
| 185 |
+
},
|
| 186 |
+
"counters": {key: int(value) for key, value in counters.items()},
|
| 187 |
+
"routes": route_rows,
|
| 188 |
+
"timeline": timeline,
|
| 189 |
+
"recent_events": events_desc,
|
| 190 |
+
"sentiment": _build_sentiment_block(),
|
| 191 |
+
"vision": _build_vision_block(),
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _build_sentiment_block() -> dict[str, Any]:
|
| 196 |
+
from bot.sentiment_engine import sentiment_engine # lazy: avoid circular
|
| 197 |
+
|
| 198 |
+
scores = sentiment_engine.get_scores()
|
| 199 |
+
return {
|
| 200 |
+
"vibe": sentiment_engine.get_vibe(),
|
| 201 |
+
"avg": scores["avg"],
|
| 202 |
+
"count": scores["count"],
|
| 203 |
+
"positive": scores["positive"],
|
| 204 |
+
"negative": scores["negative"],
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _build_vision_block() -> dict[str, Any]:
|
| 209 |
+
from bot.vision_runtime import vision_runtime # lazy: avoid circular
|
| 210 |
+
|
| 211 |
+
return vision_runtime.get_status()
|
bot/observability_state.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
from collections import Counter, deque
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from bot.observability_helpers import (
|
| 7 |
+
BYTE_TRIGGER_EVENTS_MAX_ITEMS,
|
| 8 |
+
CHAT_EVENTS_MAX_ITEMS,
|
| 9 |
+
EVENT_LOG_MAX_ITEMS,
|
| 10 |
+
LATENCY_WINDOW_MAX_ITEMS,
|
| 11 |
+
)
|
| 12 |
+
from bot.observability_snapshot import build_observability_snapshot
|
| 13 |
+
from bot.observability_state_core import prune_locked, resolve_now
|
| 14 |
+
from bot.observability_state_recorders import (
|
| 15 |
+
record_auth_failure_locked,
|
| 16 |
+
record_autonomy_goal_locked,
|
| 17 |
+
record_auto_scene_update_locked,
|
| 18 |
+
record_byte_interaction_locked,
|
| 19 |
+
record_byte_trigger_locked,
|
| 20 |
+
record_chat_message_locked,
|
| 21 |
+
record_error_locked,
|
| 22 |
+
record_quality_gate_locked,
|
| 23 |
+
record_reply_locked,
|
| 24 |
+
record_token_refresh_locked,
|
| 25 |
+
record_token_usage_locked,
|
| 26 |
+
record_vision_frame_locked,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ObservabilityState:
|
| 31 |
+
def __init__(self) -> None:
|
| 32 |
+
self._lock = threading.Lock()
|
| 33 |
+
self._started_at = time.time()
|
| 34 |
+
self._counters: Counter[str] = Counter()
|
| 35 |
+
self._route_counts: Counter[str] = Counter()
|
| 36 |
+
self._minute_buckets: dict[int, dict[str, int]] = {}
|
| 37 |
+
self._latencies_ms: deque[float] = deque(maxlen=LATENCY_WINDOW_MAX_ITEMS)
|
| 38 |
+
self._recent_events: deque[dict[str, Any]] = deque(maxlen=EVENT_LOG_MAX_ITEMS)
|
| 39 |
+
self._chatter_last_seen: dict[str, float] = {}
|
| 40 |
+
self._known_chatters: set[str] = set()
|
| 41 |
+
self._chat_events: deque[dict[str, Any]] = deque(maxlen=CHAT_EVENTS_MAX_ITEMS)
|
| 42 |
+
self._byte_trigger_events: deque[dict[str, Any]] = deque(maxlen=BYTE_TRIGGER_EVENTS_MAX_ITEMS)
|
| 43 |
+
self._interaction_events: deque[dict[str, Any]] = deque(maxlen=BYTE_TRIGGER_EVENTS_MAX_ITEMS)
|
| 44 |
+
self._quality_events: deque[dict[str, Any]] = deque(maxlen=BYTE_TRIGGER_EVENTS_MAX_ITEMS)
|
| 45 |
+
self._token_usage_events: deque[dict[str, Any]] = deque(maxlen=BYTE_TRIGGER_EVENTS_MAX_ITEMS)
|
| 46 |
+
self._autonomy_goal_events: deque[dict[str, Any]] = deque(maxlen=BYTE_TRIGGER_EVENTS_MAX_ITEMS)
|
| 47 |
+
self._chatter_message_totals: Counter[str] = Counter()
|
| 48 |
+
self._trigger_user_totals: Counter[str] = Counter()
|
| 49 |
+
self._last_prompt = ""
|
| 50 |
+
self._last_reply = ""
|
| 51 |
+
self._estimated_cost_usd_total = 0.0
|
| 52 |
+
self._clips_status: dict[str, bool] = {
|
| 53 |
+
"token_valid": False,
|
| 54 |
+
"scope_ok": False,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
def update_clips_auth_status(self, *, token_valid: bool, scope_ok: bool, timestamp: float | None = None) -> None:
|
| 58 |
+
with self._lock:
|
| 59 |
+
self._clips_status["token_valid"] = bool(token_valid)
|
| 60 |
+
self._clips_status["scope_ok"] = bool(scope_ok)
|
| 61 |
+
# We could record an event here if needed, but the requirement is just status.
|
| 62 |
+
|
| 63 |
+
def record_chat_message(self, *, author_name: str, source: str, text: str = "", timestamp: float | None = None) -> None:
|
| 64 |
+
now = resolve_now(timestamp)
|
| 65 |
+
with self._lock:
|
| 66 |
+
record_chat_message_locked(
|
| 67 |
+
self,
|
| 68 |
+
now=now,
|
| 69 |
+
author_name=author_name,
|
| 70 |
+
source=source,
|
| 71 |
+
text=text,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def record_byte_trigger(self, *, prompt: str, source: str, author_name: str = "", timestamp: float | None = None) -> None:
|
| 75 |
+
now = resolve_now(timestamp)
|
| 76 |
+
with self._lock:
|
| 77 |
+
record_byte_trigger_locked(
|
| 78 |
+
self,
|
| 79 |
+
now=now,
|
| 80 |
+
prompt=prompt,
|
| 81 |
+
source=source,
|
| 82 |
+
author_name=author_name,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def record_reply(self, *, text: str, timestamp: float | None = None) -> None:
|
| 86 |
+
now = resolve_now(timestamp)
|
| 87 |
+
with self._lock:
|
| 88 |
+
record_reply_locked(self, now=now, text=text)
|
| 89 |
+
|
| 90 |
+
def record_quality_gate(self, *, outcome: str, reason: str, timestamp: float | None = None) -> None:
|
| 91 |
+
now = resolve_now(timestamp)
|
| 92 |
+
with self._lock:
|
| 93 |
+
record_quality_gate_locked(self, now=now, outcome=outcome, reason=reason)
|
| 94 |
+
|
| 95 |
+
def record_byte_interaction(
|
| 96 |
+
self,
|
| 97 |
+
*,
|
| 98 |
+
route: str,
|
| 99 |
+
author_name: str,
|
| 100 |
+
prompt_chars: int,
|
| 101 |
+
reply_parts: int,
|
| 102 |
+
reply_chars: int,
|
| 103 |
+
serious: bool,
|
| 104 |
+
follow_up: bool,
|
| 105 |
+
current_events: bool,
|
| 106 |
+
latency_ms: float,
|
| 107 |
+
timestamp: float | None = None,
|
| 108 |
+
) -> None:
|
| 109 |
+
now = resolve_now(timestamp)
|
| 110 |
+
with self._lock:
|
| 111 |
+
record_byte_interaction_locked(
|
| 112 |
+
self,
|
| 113 |
+
now=now,
|
| 114 |
+
route=route,
|
| 115 |
+
author_name=author_name,
|
| 116 |
+
prompt_chars=prompt_chars,
|
| 117 |
+
reply_parts=reply_parts,
|
| 118 |
+
reply_chars=reply_chars,
|
| 119 |
+
serious=serious,
|
| 120 |
+
follow_up=follow_up,
|
| 121 |
+
current_events=current_events,
|
| 122 |
+
latency_ms=latency_ms,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def record_token_usage(
|
| 126 |
+
self,
|
| 127 |
+
*,
|
| 128 |
+
input_tokens: int,
|
| 129 |
+
output_tokens: int,
|
| 130 |
+
estimated_cost_usd: float,
|
| 131 |
+
timestamp: float | None = None,
|
| 132 |
+
) -> None:
|
| 133 |
+
now = resolve_now(timestamp)
|
| 134 |
+
with self._lock:
|
| 135 |
+
record_token_usage_locked(
|
| 136 |
+
self,
|
| 137 |
+
now=now,
|
| 138 |
+
input_tokens=input_tokens,
|
| 139 |
+
output_tokens=output_tokens,
|
| 140 |
+
estimated_cost_usd=estimated_cost_usd,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
def record_autonomy_goal(
|
| 144 |
+
self,
|
| 145 |
+
*,
|
| 146 |
+
risk: str,
|
| 147 |
+
outcome: str,
|
| 148 |
+
details: str = "",
|
| 149 |
+
timestamp: float | None = None,
|
| 150 |
+
) -> None:
|
| 151 |
+
now = resolve_now(timestamp)
|
| 152 |
+
with self._lock:
|
| 153 |
+
record_autonomy_goal_locked(
|
| 154 |
+
self,
|
| 155 |
+
now=now,
|
| 156 |
+
risk=risk,
|
| 157 |
+
outcome=outcome,
|
| 158 |
+
details=details,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def record_auto_scene_update(self, *, update_types: list[str], timestamp: float | None = None) -> None:
|
| 162 |
+
now = resolve_now(timestamp)
|
| 163 |
+
with self._lock:
|
| 164 |
+
record_auto_scene_update_locked(self, now=now, update_types=update_types)
|
| 165 |
+
|
| 166 |
+
def record_token_refresh(self, *, reason: str, timestamp: float | None = None) -> None:
|
| 167 |
+
now = resolve_now(timestamp)
|
| 168 |
+
with self._lock:
|
| 169 |
+
record_token_refresh_locked(self, now=now, reason=reason)
|
| 170 |
+
|
| 171 |
+
def record_auth_failure(self, *, details: str, timestamp: float | None = None) -> None:
|
| 172 |
+
now = resolve_now(timestamp)
|
| 173 |
+
with self._lock:
|
| 174 |
+
record_auth_failure_locked(self, now=now, details=details)
|
| 175 |
+
|
| 176 |
+
def record_error(self, *, category: str, details: str, timestamp: float | None = None) -> None:
|
| 177 |
+
now = resolve_now(timestamp)
|
| 178 |
+
with self._lock:
|
| 179 |
+
record_error_locked(self, now=now, category=category, details=details)
|
| 180 |
+
|
| 181 |
+
def record_vision_frame(self, *, analysis: str, timestamp: float | None = None) -> None:
|
| 182 |
+
now = resolve_now(timestamp)
|
| 183 |
+
with self._lock:
|
| 184 |
+
record_vision_frame_locked(self, now=now, analysis=analysis)
|
| 185 |
+
|
| 186 |
+
def snapshot(
|
| 187 |
+
self,
|
| 188 |
+
*,
|
| 189 |
+
bot_brand: str,
|
| 190 |
+
bot_version: str,
|
| 191 |
+
bot_mode: str,
|
| 192 |
+
stream_context: Any,
|
| 193 |
+
timestamp: float | None = None,
|
| 194 |
+
) -> dict[str, Any]:
|
| 195 |
+
now = resolve_now(timestamp)
|
| 196 |
+
with self._lock:
|
| 197 |
+
prune_locked(self, now)
|
| 198 |
+
return build_observability_snapshot(
|
| 199 |
+
now=now,
|
| 200 |
+
started_at=self._started_at,
|
| 201 |
+
counters=dict(self._counters),
|
| 202 |
+
route_counts=dict(self._route_counts),
|
| 203 |
+
latencies_ms=list(self._latencies_ms),
|
| 204 |
+
minute_buckets={key: value.copy() for key, value in self._minute_buckets.items()},
|
| 205 |
+
recent_events=list(self._recent_events),
|
| 206 |
+
chatter_last_seen=dict(self._chatter_last_seen),
|
| 207 |
+
chat_events=list(self._chat_events),
|
| 208 |
+
byte_trigger_events=list(self._byte_trigger_events),
|
| 209 |
+
interaction_events=list(self._interaction_events),
|
| 210 |
+
quality_events=list(self._quality_events),
|
| 211 |
+
token_usage_events=list(self._token_usage_events),
|
| 212 |
+
autonomy_goal_events=list(self._autonomy_goal_events),
|
| 213 |
+
chatter_message_totals=dict(self._chatter_message_totals),
|
| 214 |
+
trigger_user_totals=dict(self._trigger_user_totals),
|
| 215 |
+
unique_chatters_total=len(self._known_chatters),
|
| 216 |
+
last_prompt=self._last_prompt,
|
| 217 |
+
last_reply=self._last_reply,
|
| 218 |
+
estimated_cost_usd_total=float(self._estimated_cost_usd_total),
|
| 219 |
+
clips_status=dict(self._clips_status),
|
| 220 |
+
bot_brand=bot_brand,
|
| 221 |
+
bot_version=bot_version,
|
| 222 |
+
bot_mode=bot_mode,
|
| 223 |
+
stream_context=stream_context,
|
| 224 |
+
)
|
| 225 |
+
|
bot/observability_state_core.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from bot.observability_helpers import (
|
| 5 |
+
BYTE_TRIGGER_EVENTS_RETENTION_SECONDS,
|
| 6 |
+
CHAT_EVENTS_RETENTION_SECONDS,
|
| 7 |
+
TIMELINE_RETENTION_MINUTES,
|
| 8 |
+
clip_preview,
|
| 9 |
+
utc_iso,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def resolve_now(timestamp: float | None) -> float:
|
| 14 |
+
return time.time() if timestamp is None else float(timestamp)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def touch_bucket_locked(state: Any, now: float) -> dict[str, int]:
|
| 18 |
+
minute_key = int(now // 60)
|
| 19 |
+
bucket = state._minute_buckets.get(minute_key)
|
| 20 |
+
if bucket is None:
|
| 21 |
+
bucket = {
|
| 22 |
+
"chat_messages": 0,
|
| 23 |
+
"byte_triggers": 0,
|
| 24 |
+
"replies_sent": 0,
|
| 25 |
+
"llm_requests": 0,
|
| 26 |
+
"errors": 0,
|
| 27 |
+
}
|
| 28 |
+
state._minute_buckets[minute_key] = bucket
|
| 29 |
+
return bucket
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def bump_timeline_locked(state: Any, now: float, **increments: int) -> None:
|
| 33 |
+
bucket = touch_bucket_locked(state, now)
|
| 34 |
+
for key, amount in increments.items():
|
| 35 |
+
if amount > 0:
|
| 36 |
+
bucket[key] = bucket.get(key, 0) + int(amount)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def append_event_locked(state: Any, now: float, level: str, event: str, message: str) -> None:
|
| 40 |
+
state._recent_events.append(
|
| 41 |
+
{
|
| 42 |
+
"ts": utc_iso(now),
|
| 43 |
+
"level": level,
|
| 44 |
+
"event": event,
|
| 45 |
+
"message": clip_preview(message, max_chars=180),
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def prune_locked(state: Any, now: float) -> None:
|
| 51 |
+
oldest_minute = int(now // 60) - TIMELINE_RETENTION_MINUTES
|
| 52 |
+
for minute_key in list(state._minute_buckets):
|
| 53 |
+
if minute_key < oldest_minute:
|
| 54 |
+
del state._minute_buckets[minute_key]
|
| 55 |
+
|
| 56 |
+
oldest_chatter_cutoff = now - 86400
|
| 57 |
+
for chatter, last_seen in list(state._chatter_last_seen.items()):
|
| 58 |
+
if last_seen < oldest_chatter_cutoff:
|
| 59 |
+
del state._chatter_last_seen[chatter]
|
| 60 |
+
|
| 61 |
+
chat_cutoff = now - CHAT_EVENTS_RETENTION_SECONDS
|
| 62 |
+
while state._chat_events and float(state._chat_events[0].get("ts", 0.0)) < chat_cutoff:
|
| 63 |
+
state._chat_events.popleft()
|
| 64 |
+
|
| 65 |
+
trigger_cutoff = now - BYTE_TRIGGER_EVENTS_RETENTION_SECONDS
|
| 66 |
+
while state._byte_trigger_events and float(state._byte_trigger_events[0].get("ts", 0.0)) < trigger_cutoff:
|
| 67 |
+
state._byte_trigger_events.popleft()
|
| 68 |
+
while state._interaction_events and float(state._interaction_events[0].get("ts", 0.0)) < trigger_cutoff:
|
| 69 |
+
state._interaction_events.popleft()
|
| 70 |
+
while state._quality_events and float(state._quality_events[0].get("ts", 0.0)) < trigger_cutoff:
|
| 71 |
+
state._quality_events.popleft()
|
| 72 |
+
while state._token_usage_events and float(state._token_usage_events[0].get("ts", 0.0)) < trigger_cutoff:
|
| 73 |
+
state._token_usage_events.popleft()
|
| 74 |
+
while state._autonomy_goal_events and float(state._autonomy_goal_events[0].get("ts", 0.0)) < trigger_cutoff:
|
| 75 |
+
state._autonomy_goal_events.popleft()
|
| 76 |
+
|
bot/observability_state_recorders.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from bot.observability_helpers import clip_preview
|
| 4 |
+
from bot.observability_state_core import append_event_locked, bump_timeline_locked, prune_locked
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def record_chat_message_locked(
|
| 8 |
+
state: Any,
|
| 9 |
+
*,
|
| 10 |
+
now: float,
|
| 11 |
+
author_name: str,
|
| 12 |
+
source: str,
|
| 13 |
+
text: str = "",
|
| 14 |
+
) -> None:
|
| 15 |
+
safe_source = (source or "unknown").strip().lower() or "unknown"
|
| 16 |
+
safe_author = (author_name or "").strip().lower()
|
| 17 |
+
message_text = (text or "").strip()
|
| 18 |
+
is_command = bool(message_text.startswith("!"))
|
| 19 |
+
lowered_text = message_text.lower()
|
| 20 |
+
has_url = "http://" in lowered_text or "https://" in lowered_text
|
| 21 |
+
|
| 22 |
+
state._counters["chat_messages_total"] += 1
|
| 23 |
+
state._counters[f"chat_messages_{safe_source}"] += 1
|
| 24 |
+
bump_timeline_locked(state, now, chat_messages=1)
|
| 25 |
+
state._chat_events.append(
|
| 26 |
+
{
|
| 27 |
+
"ts": now,
|
| 28 |
+
"source": safe_source,
|
| 29 |
+
"author": safe_author,
|
| 30 |
+
"length": len(message_text),
|
| 31 |
+
"is_command": is_command,
|
| 32 |
+
"has_url": has_url,
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
if safe_author:
|
| 36 |
+
state._known_chatters.add(safe_author)
|
| 37 |
+
state._chatter_last_seen[safe_author] = now
|
| 38 |
+
state._chatter_message_totals[safe_author] += 1
|
| 39 |
+
if is_command:
|
| 40 |
+
state._counters["chat_prefixed_messages"] += 1
|
| 41 |
+
if has_url:
|
| 42 |
+
state._counters["chat_messages_with_url"] += 1
|
| 43 |
+
prune_locked(state, now)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def record_byte_trigger_locked(
|
| 47 |
+
state: Any,
|
| 48 |
+
*,
|
| 49 |
+
now: float,
|
| 50 |
+
prompt: str,
|
| 51 |
+
source: str,
|
| 52 |
+
author_name: str = "",
|
| 53 |
+
) -> None:
|
| 54 |
+
safe_source = (source or "unknown").strip().lower() or "unknown"
|
| 55 |
+
safe_author_key = (author_name or "").strip().lower() or "viewer"
|
| 56 |
+
safe_author = clip_preview(author_name or "viewer", max_chars=32)
|
| 57 |
+
prompt_preview = clip_preview(prompt or "(empty prompt)", max_chars=120)
|
| 58 |
+
|
| 59 |
+
state._counters["byte_triggers_total"] += 1
|
| 60 |
+
state._counters[f"byte_triggers_{safe_source}"] += 1
|
| 61 |
+
state._trigger_user_totals[safe_author_key] += 1
|
| 62 |
+
state._byte_trigger_events.append({"ts": now, "author": safe_author_key, "source": safe_source})
|
| 63 |
+
state._last_prompt = prompt_preview
|
| 64 |
+
bump_timeline_locked(state, now, byte_triggers=1)
|
| 65 |
+
append_event_locked(state, now, "INFO", "byte_trigger", f"{safe_author}: {prompt_preview}")
|
| 66 |
+
prune_locked(state, now)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def record_reply_locked(state: Any, *, now: float, text: str) -> None:
|
| 70 |
+
reply_preview = clip_preview(text or "", max_chars=140)
|
| 71 |
+
if not reply_preview:
|
| 72 |
+
return
|
| 73 |
+
state._counters["replies_total"] += 1
|
| 74 |
+
state._counters["reply_chars_total"] += len(reply_preview)
|
| 75 |
+
state._last_reply = reply_preview
|
| 76 |
+
bump_timeline_locked(state, now, replies_sent=1)
|
| 77 |
+
prune_locked(state, now)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def record_quality_gate_locked(
|
| 81 |
+
state: Any,
|
| 82 |
+
*,
|
| 83 |
+
now: float,
|
| 84 |
+
outcome: str,
|
| 85 |
+
reason: str,
|
| 86 |
+
) -> None:
|
| 87 |
+
safe_outcome = (outcome or "unknown").strip().lower() or "unknown"
|
| 88 |
+
safe_reason = clip_preview(reason or "n/a", max_chars=120)
|
| 89 |
+
event_level = "WARN" if safe_outcome in {"retry", "fallback"} else "INFO"
|
| 90 |
+
|
| 91 |
+
state._counters["quality_checks_total"] += 1
|
| 92 |
+
state._counters[f"quality_{safe_outcome}_total"] += 1
|
| 93 |
+
state._quality_events.append(
|
| 94 |
+
{
|
| 95 |
+
"ts": now,
|
| 96 |
+
"outcome": safe_outcome,
|
| 97 |
+
"reason": safe_reason,
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
append_event_locked(state, now, event_level, "quality_gate", f"{safe_outcome}: {safe_reason}")
|
| 101 |
+
prune_locked(state, now)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def record_byte_interaction_locked(
|
| 105 |
+
state: Any,
|
| 106 |
+
*,
|
| 107 |
+
now: float,
|
| 108 |
+
route: str,
|
| 109 |
+
author_name: str,
|
| 110 |
+
prompt_chars: int,
|
| 111 |
+
reply_parts: int,
|
| 112 |
+
reply_chars: int,
|
| 113 |
+
serious: bool,
|
| 114 |
+
follow_up: bool,
|
| 115 |
+
current_events: bool,
|
| 116 |
+
latency_ms: float,
|
| 117 |
+
) -> None:
|
| 118 |
+
safe_route = (route or "unknown").strip().lower() or "unknown"
|
| 119 |
+
safe_author = clip_preview(author_name or "viewer", max_chars=32)
|
| 120 |
+
|
| 121 |
+
state._counters["interactions_total"] += 1
|
| 122 |
+
state._route_counts[safe_route] += 1
|
| 123 |
+
state._counters["prompt_chars_total"] += max(0, int(prompt_chars))
|
| 124 |
+
state._counters["interaction_reply_parts_total"] += max(0, int(reply_parts))
|
| 125 |
+
state._counters["interaction_reply_chars_total"] += max(0, int(reply_chars))
|
| 126 |
+
llm_route = safe_route.startswith("llm")
|
| 127 |
+
useful_llm = llm_route and "fallback" not in safe_route
|
| 128 |
+
state._interaction_events.append(
|
| 129 |
+
{
|
| 130 |
+
"ts": now,
|
| 131 |
+
"route": safe_route,
|
| 132 |
+
"is_llm": llm_route,
|
| 133 |
+
"is_useful_llm": useful_llm,
|
| 134 |
+
}
|
| 135 |
+
)
|
| 136 |
+
if safe_route.startswith("llm"):
|
| 137 |
+
state._counters["llm_interactions_total"] += 1
|
| 138 |
+
bump_timeline_locked(state, now, llm_requests=1)
|
| 139 |
+
if serious:
|
| 140 |
+
state._counters["serious_interactions_total"] += 1
|
| 141 |
+
if follow_up:
|
| 142 |
+
state._counters["follow_up_interactions_total"] += 1
|
| 143 |
+
if current_events:
|
| 144 |
+
state._counters["current_events_interactions_total"] += 1
|
| 145 |
+
if latency_ms >= 0:
|
| 146 |
+
state._latencies_ms.append(float(latency_ms))
|
| 147 |
+
details = (
|
| 148 |
+
f"{safe_route} by {safe_author} | prompt={max(0, int(prompt_chars))} chars | "
|
| 149 |
+
f"replies={max(0, int(reply_parts))} | latency={round(max(0.0, latency_ms), 1)}ms"
|
| 150 |
+
)
|
| 151 |
+
append_event_locked(state, now, "INFO", "byte_interaction", details)
|
| 152 |
+
prune_locked(state, now)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def record_token_usage_locked(
|
| 156 |
+
state: Any,
|
| 157 |
+
*,
|
| 158 |
+
now: float,
|
| 159 |
+
input_tokens: int,
|
| 160 |
+
output_tokens: int,
|
| 161 |
+
estimated_cost_usd: float,
|
| 162 |
+
) -> None:
|
| 163 |
+
safe_input = max(0, int(input_tokens))
|
| 164 |
+
safe_output = max(0, int(output_tokens))
|
| 165 |
+
safe_cost = max(0.0, float(estimated_cost_usd))
|
| 166 |
+
|
| 167 |
+
state._counters["token_input_total"] += safe_input
|
| 168 |
+
state._counters["token_output_total"] += safe_output
|
| 169 |
+
state._estimated_cost_usd_total += safe_cost
|
| 170 |
+
state._token_usage_events.append(
|
| 171 |
+
{
|
| 172 |
+
"ts": now,
|
| 173 |
+
"input_tokens": safe_input,
|
| 174 |
+
"output_tokens": safe_output,
|
| 175 |
+
"estimated_cost_usd": safe_cost,
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
prune_locked(state, now)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def record_autonomy_goal_locked(
|
| 182 |
+
state: Any,
|
| 183 |
+
*,
|
| 184 |
+
now: float,
|
| 185 |
+
risk: str,
|
| 186 |
+
outcome: str,
|
| 187 |
+
details: str = "",
|
| 188 |
+
) -> None:
|
| 189 |
+
safe_risk = (risk or "unknown").strip().lower() or "unknown"
|
| 190 |
+
safe_outcome = (outcome or "unknown").strip().lower() or "unknown"
|
| 191 |
+
details_preview = clip_preview(details or "", max_chars=100)
|
| 192 |
+
|
| 193 |
+
state._counters["autonomy_goals_total"] += 1
|
| 194 |
+
state._counters[f"autonomy_{safe_risk}_{safe_outcome}_total"] += 1
|
| 195 |
+
state._autonomy_goal_events.append(
|
| 196 |
+
{
|
| 197 |
+
"ts": now,
|
| 198 |
+
"risk": safe_risk,
|
| 199 |
+
"outcome": safe_outcome,
|
| 200 |
+
}
|
| 201 |
+
)
|
| 202 |
+
append_event_locked(
|
| 203 |
+
state,
|
| 204 |
+
now,
|
| 205 |
+
"INFO",
|
| 206 |
+
"autonomy_goal",
|
| 207 |
+
f"{safe_risk}/{safe_outcome}: {details_preview or 'ok'}",
|
| 208 |
+
)
|
| 209 |
+
prune_locked(state, now)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def record_auto_scene_update_locked(
|
| 213 |
+
state: Any,
|
| 214 |
+
*,
|
| 215 |
+
now: float,
|
| 216 |
+
update_types: list[str],
|
| 217 |
+
) -> None:
|
| 218 |
+
if not update_types:
|
| 219 |
+
return
|
| 220 |
+
safe_types = sorted({(item or "").strip().lower() for item in update_types if item})
|
| 221 |
+
if not safe_types:
|
| 222 |
+
return
|
| 223 |
+
state._counters["auto_scene_updates_total"] += len(safe_types)
|
| 224 |
+
append_event_locked(state, now, "INFO", "scene_update", f"updated: {', '.join(safe_types)}")
|
| 225 |
+
prune_locked(state, now)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def record_token_refresh_locked(state: Any, *, now: float, reason: str) -> None:
|
| 229 |
+
safe_reason = clip_preview(reason or "n/a", max_chars=100)
|
| 230 |
+
state._counters["token_refreshes_total"] += 1
|
| 231 |
+
append_event_locked(state, now, "WARN", "token_refresh", safe_reason)
|
| 232 |
+
prune_locked(state, now)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def record_auth_failure_locked(state: Any, *, now: float, details: str) -> None:
|
| 236 |
+
safe_details = clip_preview(details or "n/a", max_chars=120)
|
| 237 |
+
state._counters["auth_failures_total"] += 1
|
| 238 |
+
state._counters["errors_total"] += 1
|
| 239 |
+
bump_timeline_locked(state, now, errors=1)
|
| 240 |
+
append_event_locked(state, now, "ERROR", "auth_failure", safe_details)
|
| 241 |
+
prune_locked(state, now)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def record_error_locked(state: Any, *, now: float, category: str, details: str) -> None:
|
| 245 |
+
safe_category = (category or "unknown").strip().lower() or "unknown"
|
| 246 |
+
safe_details = clip_preview(details or "n/a", max_chars=140)
|
| 247 |
+
state._counters["errors_total"] += 1
|
| 248 |
+
state._counters[f"errors_{safe_category}"] += 1
|
| 249 |
+
bump_timeline_locked(state, now, errors=1)
|
| 250 |
+
append_event_locked(state, now, "ERROR", safe_category, safe_details)
|
| 251 |
+
prune_locked(state, now)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def record_vision_frame_locked(state: Any, *, now: float, analysis: str) -> None:
|
| 255 |
+
safe_analysis = clip_preview(analysis or "", max_chars=120)
|
| 256 |
+
state._counters["vision_frames_total"] += 1
|
| 257 |
+
append_event_locked(state, now, "INFO", "vision_frame", safe_analysis or "frame ingested")
|
| 258 |
+
prune_locked(state, now)
|
| 259 |
+
|
bot/prompt_flow.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Any, Awaitable, Callable, Mapping
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
ReplyFn = Callable[[str], Awaitable[None]]
|
| 7 |
+
InferenceFn = Callable[..., Awaitable[Any]]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class BytePromptRuntime:
|
| 12 |
+
agent_inference: InferenceFn
|
| 13 |
+
client: Any
|
| 14 |
+
context: Any
|
| 15 |
+
observability: Any
|
| 16 |
+
logger: Any
|
| 17 |
+
byte_help_message: str
|
| 18 |
+
max_reply_lines: int
|
| 19 |
+
max_chat_message_length: int
|
| 20 |
+
serious_reply_max_lines: int
|
| 21 |
+
serious_reply_max_length: int
|
| 22 |
+
quality_safe_fallback: str
|
| 23 |
+
format_chat_reply: Callable[[str], str]
|
| 24 |
+
is_serious_technical_prompt: Callable[[str], bool]
|
| 25 |
+
is_follow_up_prompt: Callable[[str], bool]
|
| 26 |
+
is_current_events_prompt: Callable[[str], bool]
|
| 27 |
+
is_high_risk_current_events_prompt: Callable[[str], bool]
|
| 28 |
+
build_server_time_anchor_instruction: Callable[[], str]
|
| 29 |
+
is_intro_prompt: Callable[[str], bool]
|
| 30 |
+
build_intro_reply: Callable[[], str]
|
| 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]
|
| 37 |
+
is_low_quality_answer: Callable[[str, str], tuple[bool, str]]
|
| 38 |
+
build_quality_rewrite_prompt: Callable[..., str]
|
| 39 |
+
build_current_events_safe_fallback_reply: Callable[..., str]
|
| 40 |
+
extract_multi_reply_parts: Callable[..., list[str]]
|
| 41 |
+
enable_live_context_learning: bool
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def unwrap_inference_result(result: Any) -> tuple[str, dict | None]:
|
| 45 |
+
if isinstance(result, tuple) and len(result) == 2:
|
| 46 |
+
text_result, metadata_result = result
|
| 47 |
+
safe_text = text_result if isinstance(text_result, str) else str(text_result or "")
|
| 48 |
+
safe_metadata = metadata_result if isinstance(metadata_result, dict) else None
|
| 49 |
+
return safe_text, safe_metadata
|
| 50 |
+
|
| 51 |
+
if isinstance(result, str):
|
| 52 |
+
return result, None
|
| 53 |
+
return str(result or ""), None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def handle_movie_fact_sheet_prompt(
|
| 57 |
+
prompt: str,
|
| 58 |
+
author_name: str,
|
| 59 |
+
reply_fn: ReplyFn,
|
| 60 |
+
*,
|
| 61 |
+
runtime: BytePromptRuntime,
|
| 62 |
+
) -> None:
|
| 63 |
+
movie_title_from_prompt = runtime.extract_movie_title(prompt)
|
| 64 |
+
if movie_title_from_prompt:
|
| 65 |
+
runtime.context.update_content("movie", movie_title_from_prompt)
|
| 66 |
+
|
| 67 |
+
active_movie = runtime.context.live_observability.get("movie", "").strip()
|
| 68 |
+
movie_title = movie_title_from_prompt or active_movie
|
| 69 |
+
if not movie_title:
|
| 70 |
+
await reply_fn("Preciso do nome do filme. Ex.: byte ficha tecnica de Duna Parte 2")
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
query = runtime.build_movie_fact_sheet_query(movie_title)
|
| 74 |
+
answer = await runtime.agent_inference(
|
| 75 |
+
query,
|
| 76 |
+
author_name,
|
| 77 |
+
runtime.client,
|
| 78 |
+
runtime.context,
|
| 79 |
+
enable_live_context=runtime.enable_live_context_learning,
|
| 80 |
+
)
|
| 81 |
+
await reply_fn(runtime.format_chat_reply(answer))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def handle_byte_prompt_text(
|
| 85 |
+
prompt: str,
|
| 86 |
+
author_name: str,
|
| 87 |
+
reply_fn: ReplyFn,
|
| 88 |
+
*,
|
| 89 |
+
runtime: BytePromptRuntime,
|
| 90 |
+
status_line_factory: Callable[[], str] | None = None,
|
| 91 |
+
) -> None:
|
| 92 |
+
normalized_prompt = (prompt or "").strip()
|
| 93 |
+
lowered_prompt = normalized_prompt.lower()
|
| 94 |
+
prompt_length = len(normalized_prompt)
|
| 95 |
+
serious_mode = runtime.is_serious_technical_prompt(normalized_prompt)
|
| 96 |
+
follow_up_mode = runtime.is_follow_up_prompt(normalized_prompt)
|
| 97 |
+
current_events_mode = runtime.is_current_events_prompt(normalized_prompt)
|
| 98 |
+
high_risk_current_events_mode = current_events_mode and runtime.is_high_risk_current_events_prompt(
|
| 99 |
+
normalized_prompt
|
| 100 |
+
)
|
| 101 |
+
sent_replies: list[str] = []
|
| 102 |
+
interaction_started_at = time.perf_counter()
|
| 103 |
+
server_time_instruction = runtime.build_server_time_anchor_instruction()
|
| 104 |
+
|
| 105 |
+
async def tracked_reply(text: str) -> None:
|
| 106 |
+
final_text = runtime.format_chat_reply(text)
|
| 107 |
+
if not final_text:
|
| 108 |
+
return
|
| 109 |
+
sent_replies.append(final_text)
|
| 110 |
+
runtime.context.remember_bot_reply(final_text)
|
| 111 |
+
runtime.observability.record_reply(text=final_text)
|
| 112 |
+
await reply_fn(final_text)
|
| 113 |
+
|
| 114 |
+
def log_interaction(route: str) -> None:
|
| 115 |
+
total_reply_chars = sum(len(reply) for reply in sent_replies)
|
| 116 |
+
latency_ms = (time.perf_counter() - interaction_started_at) * 1000
|
| 117 |
+
runtime.logger.info(
|
| 118 |
+
"ByteInteraction route=%s author=%s prompt_chars=%d reply_parts=%d reply_chars=%d serious=%s follow_up=%s current_events=%s latency_ms=%.1f",
|
| 119 |
+
route,
|
| 120 |
+
author_name,
|
| 121 |
+
prompt_length,
|
| 122 |
+
len(sent_replies),
|
| 123 |
+
total_reply_chars,
|
| 124 |
+
serious_mode,
|
| 125 |
+
follow_up_mode,
|
| 126 |
+
current_events_mode,
|
| 127 |
+
latency_ms,
|
| 128 |
+
)
|
| 129 |
+
runtime.observability.record_byte_interaction(
|
| 130 |
+
route=route,
|
| 131 |
+
author_name=author_name,
|
| 132 |
+
prompt_chars=prompt_length,
|
| 133 |
+
reply_parts=len(sent_replies),
|
| 134 |
+
reply_chars=total_reply_chars,
|
| 135 |
+
serious=serious_mode,
|
| 136 |
+
follow_up=follow_up_mode,
|
| 137 |
+
current_events=current_events_mode,
|
| 138 |
+
latency_ms=latency_ms,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
if not normalized_prompt or lowered_prompt in {"ajuda", "help", "comandos"}:
|
| 142 |
+
await tracked_reply(runtime.byte_help_message)
|
| 143 |
+
log_interaction("help")
|
| 144 |
+
return
|
| 145 |
+
|
| 146 |
+
if runtime.is_intro_prompt(normalized_prompt):
|
| 147 |
+
await tracked_reply(runtime.build_intro_reply())
|
| 148 |
+
log_interaction("intro")
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
if lowered_prompt.startswith("status"):
|
| 152 |
+
status_text = "status indisponivel"
|
| 153 |
+
if callable(status_line_factory):
|
| 154 |
+
try:
|
| 155 |
+
status_text = str(status_line_factory())
|
| 156 |
+
except Exception as error:
|
| 157 |
+
runtime.logger.warning("Falha ao montar status customizado: %s", error)
|
| 158 |
+
await tracked_reply(str(status_text))
|
| 159 |
+
log_interaction("status")
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
if runtime.is_movie_fact_sheet_prompt(normalized_prompt):
|
| 163 |
+
await handle_movie_fact_sheet_prompt(
|
| 164 |
+
normalized_prompt,
|
| 165 |
+
author_name,
|
| 166 |
+
tracked_reply,
|
| 167 |
+
runtime=runtime,
|
| 168 |
+
)
|
| 169 |
+
log_interaction("movie_fact_sheet")
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
quality_route_suffix = ""
|
| 173 |
+
inference_prompt = runtime.build_llm_enhanced_prompt(
|
| 174 |
+
normalized_prompt,
|
| 175 |
+
server_time_instruction=server_time_instruction,
|
| 176 |
+
)
|
| 177 |
+
if high_risk_current_events_mode:
|
| 178 |
+
inference_prompt = (
|
| 179 |
+
f"{inference_prompt}\n"
|
| 180 |
+
"Instrucao tecnica obrigatoria: use os dados do contexto web atualizado para responder. "
|
| 181 |
+
f"Se nao houver evidencia verificavel, retorne exatamente: '{runtime.quality_safe_fallback}'"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Follow-up curto privilegia continuidade e baixa latencia; grounding fica para temas serios ou evento atual direto.
|
| 185 |
+
enable_grounding = serious_mode or (current_events_mode and not follow_up_mode)
|
| 186 |
+
inference_result = await runtime.agent_inference(
|
| 187 |
+
inference_prompt,
|
| 188 |
+
author_name,
|
| 189 |
+
runtime.client,
|
| 190 |
+
runtime.context,
|
| 191 |
+
enable_live_context=runtime.enable_live_context_learning,
|
| 192 |
+
enable_grounding=enable_grounding,
|
| 193 |
+
max_lines=runtime.serious_reply_max_lines if serious_mode else runtime.max_reply_lines,
|
| 194 |
+
max_length=runtime.serious_reply_max_length if serious_mode else runtime.max_chat_message_length,
|
| 195 |
+
return_metadata=True,
|
| 196 |
+
)
|
| 197 |
+
answer, grounding_metadata = unwrap_inference_result(inference_result)
|
| 198 |
+
|
| 199 |
+
answer = runtime.normalize_current_events_reply_contract(
|
| 200 |
+
normalized_prompt,
|
| 201 |
+
answer,
|
| 202 |
+
server_time_instruction=server_time_instruction,
|
| 203 |
+
grounding_metadata=grounding_metadata,
|
| 204 |
+
)
|
| 205 |
+
quality_failed, quality_reason = runtime.is_low_quality_answer(normalized_prompt, answer)
|
| 206 |
+
if quality_failed:
|
| 207 |
+
runtime.observability.record_quality_gate(outcome="retry", reason=quality_reason)
|
| 208 |
+
retry_prompt = runtime.build_quality_rewrite_prompt(
|
| 209 |
+
normalized_prompt,
|
| 210 |
+
answer,
|
| 211 |
+
quality_reason,
|
| 212 |
+
server_time_instruction=server_time_instruction,
|
| 213 |
+
)
|
| 214 |
+
retry_inference_result = await runtime.agent_inference(
|
| 215 |
+
retry_prompt,
|
| 216 |
+
author_name,
|
| 217 |
+
runtime.client,
|
| 218 |
+
runtime.context,
|
| 219 |
+
enable_live_context=runtime.enable_live_context_learning,
|
| 220 |
+
enable_grounding=enable_grounding,
|
| 221 |
+
max_lines=runtime.serious_reply_max_lines if serious_mode else runtime.max_reply_lines,
|
| 222 |
+
max_length=runtime.serious_reply_max_length if serious_mode else runtime.max_chat_message_length,
|
| 223 |
+
return_metadata=True,
|
| 224 |
+
)
|
| 225 |
+
retry_answer, retry_grounding_metadata = unwrap_inference_result(retry_inference_result)
|
| 226 |
+
retry_answer = runtime.normalize_current_events_reply_contract(
|
| 227 |
+
normalized_prompt,
|
| 228 |
+
retry_answer,
|
| 229 |
+
server_time_instruction=server_time_instruction,
|
| 230 |
+
grounding_metadata=retry_grounding_metadata,
|
| 231 |
+
)
|
| 232 |
+
retry_failed, retry_reason = runtime.is_low_quality_answer(normalized_prompt, retry_answer)
|
| 233 |
+
if retry_answer and not retry_failed:
|
| 234 |
+
answer = retry_answer
|
| 235 |
+
quality_route_suffix = "_quality_retry"
|
| 236 |
+
runtime.observability.record_quality_gate(outcome="retry_success", reason=quality_reason)
|
| 237 |
+
else:
|
| 238 |
+
answer = runtime.build_current_events_safe_fallback_reply(
|
| 239 |
+
normalized_prompt,
|
| 240 |
+
server_time_instruction=server_time_instruction,
|
| 241 |
+
)
|
| 242 |
+
quality_route_suffix = "_quality_fallback"
|
| 243 |
+
runtime.observability.record_quality_gate(outcome="fallback", reason=retry_reason or quality_reason)
|
| 244 |
+
else:
|
| 245 |
+
runtime.observability.record_quality_gate(outcome="pass", reason="ok")
|
| 246 |
+
|
| 247 |
+
await tracked_reply(answer)
|
| 248 |
+
route_prefix = "llm_serious" if serious_mode else "llm_default"
|
| 249 |
+
log_interaction(f"{route_prefix}{quality_route_suffix}")
|