JuanCS-Dev commited on
Commit
89b6a21
·
0 Parent(s):

fix(deploy): fix 403 Forbidden and AttributeError in dashboard

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +36 -0
  2. Dockerfile +10 -0
  3. README.md +32 -0
  4. bot/Dockerfile +8 -0
  5. bot/__init__.py +0 -0
  6. bot/access_control.py +15 -0
  7. bot/autonomy_logic.py +269 -0
  8. bot/autonomy_runtime.py +136 -0
  9. bot/bootstrap_runtime.py +188 -0
  10. bot/byte_semantics.py +81 -0
  11. bot/byte_semantics_base.py +132 -0
  12. bot/byte_semantics_constants.py +98 -0
  13. bot/byte_semantics_current_events.py +256 -0
  14. bot/byte_semantics_quality.py +284 -0
  15. bot/byte_semantics_reply.py +105 -0
  16. bot/channel_control.py +158 -0
  17. bot/channel_status.py +64 -0
  18. bot/clip_jobs_runtime.py +316 -0
  19. bot/clip_jobs_store.py +137 -0
  20. bot/control_plane.py +180 -0
  21. bot/control_plane_actions.py +235 -0
  22. bot/control_plane_config.py +271 -0
  23. bot/control_plane_config_helpers.py +119 -0
  24. bot/control_plane_constants.py +76 -0
  25. bot/dashboard_server.py +187 -0
  26. bot/dashboard_server_routes.py +205 -0
  27. bot/dashboard_server_routes_post.py +162 -0
  28. bot/eventsub_runtime.py +232 -0
  29. bot/heartbeat.py +39 -0
  30. bot/hud_runtime.py +52 -0
  31. bot/irc_connection.py +135 -0
  32. bot/irc_handlers.py +181 -0
  33. bot/irc_management.py +127 -0
  34. bot/irc_protocol.py +90 -0
  35. bot/irc_runtime.py +74 -0
  36. bot/irc_state.py +264 -0
  37. bot/logic.py +52 -0
  38. bot/logic_constants.py +41 -0
  39. bot/logic_context.py +198 -0
  40. bot/logic_grounding.py +110 -0
  41. bot/logic_inference.py +225 -0
  42. bot/main.py +153 -0
  43. bot/observability.py +5 -0
  44. bot/observability_analytics.py +144 -0
  45. bot/observability_helpers.py +36 -0
  46. bot/observability_snapshot.py +211 -0
  47. bot/observability_state.py +225 -0
  48. bot/observability_state_core.py +76 -0
  49. bot/observability_state_recorders.py +259 -0
  50. 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}")