ghostdrive1 commited on
Commit
190b4da
·
1 Parent(s): 1342767

v25: patch main.py+discord_bot.py (lifecycle/gt/rdloop wired), add voice/server.py, add infrastructure/space_promoter.py

Browse files
packages/brain/discord_bot.py CHANGED
@@ -3,7 +3,7 @@ packages/brain/discord_bot.py
3
 
4
  Ultron V4 — Discord Interface Layer
5
  =====================================
6
- Thin bot that bridges Discord FastAPI Brain (/infer endpoint).
7
  Never calls TaskDispatcher or LLM directly — Brain is the only LLM surface.
8
 
9
  Features (V4 over V3):
@@ -15,6 +15,7 @@ Features (V4 over V3):
15
  - Rate-limit: 10 req/min per user (in-memory sliding window)
16
  - No internal block leaks: strip_internal_blocks applied on ALL responses
17
  - No fake behaviors: if Brain returns error, say so plainly
 
18
 
19
  V4 design rules:
20
  - Bot is stateless except for rate-limit counters and Redis context writes
@@ -24,24 +25,26 @@ V4 design rules:
24
  - NEVER send === MEMORY GRAPH === or [COMPACTED HISTORY] blocks to user
25
 
26
  Future bug risks (pre-registered):
27
- BOT1 [HIGH] Redis context write fails silently context window empty B1/D1 fire
28
  Fix: log warning, continue without context (degrade gracefully)
29
- BOT2 [HIGH] Discord rate-limit on bulk sends (5+ chunks in 1s) 429 from Discord API
30
  Fix: asyncio.sleep(0.5) between chunks if len(chunks) > 2
31
- BOT3 [MED] Brain /health timeout on !status bot hangs under slow HF Space wake
32
  Fix: timeout=8s on health check, return "Brain waking..." on timeout
33
- BOT4 [MED] ALLOWED_USERS env parsed at import adding user requires restart
34
  Fix: re-parse on each message (minimal perf cost, big ops win)
35
- BOT5 [LOW] on_message fires for bot's own replies (if intents wrong) infinite loop
36
  Fix: if msg.author == bot.user: return — MUST be first check
37
- BOT6 [LOW] Context window RPUSH/LTRIM non-atomic concurrent messages corrupt window
38
  Fix: use Redis pipeline() for atomic push+trim pair
39
-
40
- Tool calls used this session:
41
- Github:get_file_contents x4 (task_dispatcher.py, v3 discord_bot.py, v3 root, v3 packages/bot)
42
- Github:push_files x1
43
- Notion:notion-fetch x1
44
- Notion:notion-update-page x1
 
 
45
  """
46
 
47
  from __future__ import annotations
@@ -151,16 +154,16 @@ async def _call_brain(
151
  json=payload,
152
  )
153
  if r.status_code == 429:
154
- return {"error": "⚠️ Rate limited. Try again in a moment."}
155
  if r.status_code == 503:
156
- return {"error": "⚠️ All LLM keys exhausted. Try again later."}
157
  if r.status_code == 401:
158
- return {"error": " Auth failed. Check INTERNAL_AUTH_TOKEN."}
159
  if r.status_code not in (200, 201):
160
  return {"error": f"Brain {r.status_code}: {r.text[:200]}"}
161
  return r.json()
162
  except httpx.TimeoutException:
163
- return {"error": "⏱️ Brain timed out. HF Space may be waking up — try again in 30s."}
164
  except Exception as exc:
165
  logger.exception(f"[Bot] _call_brain {path} failed: {exc}")
166
  return {"error": f"Bot error: {str(exc)[:200]}"}
@@ -213,6 +216,34 @@ async def _ctx_get(redis, channel_id: str) -> str:
213
  return ""
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  # ---------------------------------------------------------------------------
217
  # Command handlers
218
  # ---------------------------------------------------------------------------
@@ -233,20 +264,21 @@ async def _cmd_status(msg: discord.Message, user_id: str) -> None:
233
  async with msg.channel.typing():
234
  data = await _get_health(user_id)
235
  if "error" in data:
236
- await msg.reply(f"⚠️ {data['error']}")
237
  return
238
  pool = data.get("pool", {})
239
  total = pool.get("total", 0)
240
  available = pool.get("available", 0)
241
  uptime = data.get("uptime_seconds", 0)
242
  uptime_str = f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m"
 
243
  lines = [
244
- f"**Ultron V4** | Uptime: {uptime_str} | Keys: {available}/{total}",
245
  ]
246
  # Show per-provider pool summary if available
247
  providers = pool.get("providers", {})
248
  for provider, stats in providers.items():
249
- avail_icon = "" if stats.get("available", 0) > 0 else ""
250
  lines.append(f" {avail_icon} `{provider}` — {stats.get('available', 0)}/{stats.get('total', 0)} keys")
251
  await msg.reply("\n".join(lines))
252
 
@@ -274,7 +306,7 @@ async def _cmd_council(msg: discord.Message, user_id: str, args: str) -> None:
274
  if not args:
275
  await msg.reply("Usage: `!council <project brief>`")
276
  return
277
- await msg.reply(" Assembling council... (~30-60s)")
278
  async with msg.channel.typing():
279
  result = await _call_brain(
280
  "/council",
@@ -283,7 +315,7 @@ async def _cmd_council(msg: discord.Message, user_id: str, args: str) -> None:
283
  timeout=120.0,
284
  )
285
  synthesis = _strip(result.get("synthesis", result.get("error", "Council failed.")))
286
- header = "** Council Report:**\n"
287
  chunks = _chunk(header + synthesis)
288
  for i, c in enumerate(chunks):
289
  if i > 0:
@@ -297,24 +329,26 @@ async def _cmd_clear(msg: discord.Message, redis, channel_id: str) -> None:
297
  await redis.delete(f"{CTX_KEY_PREFIX}{channel_id}")
298
  except Exception as exc:
299
  logger.warning(f"[Bot] ctx clear failed: {exc}")
300
- await msg.reply("🗑️ Channel context cleared.")
301
 
302
 
303
  async def _cmd_ping(msg: discord.Message) -> None:
304
  latency_ms = round(msg._state._get_websocket(msg.guild).latency * 1000)
305
- await msg.reply(f"🏓 Pong! Latency: {latency_ms}ms")
306
 
307
 
308
  # ---------------------------------------------------------------------------
309
  # Bot setup
310
  # ---------------------------------------------------------------------------
311
 
312
- def build_bot(redis=None) -> discord.Client:
313
  """Build and return the Discord client.
314
 
315
  Args:
316
  redis: Optional async Redis client (e.g. upstash_redis.Redis or aioredis.Redis).
317
  If None, context window is disabled but bot still works.
 
 
318
  """
319
  intents = discord.Intents.default()
320
  intents.message_content = True
@@ -344,7 +378,7 @@ def build_bot(redis=None) -> discord.Client:
344
 
345
  # Rate limit
346
  if _is_rate_limited(user_id):
347
- await msg.reply("⚠️ Slow down — max 10 messages/minute.")
348
  return
349
 
350
  channel_id = str(msg.channel.id)
@@ -382,6 +416,18 @@ def build_bot(redis=None) -> discord.Client:
382
 
383
  full_message = content + attachment_info
384
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  # Save user message to context window BEFORE call
386
  await _ctx_append(redis, channel_id, "user", full_message)
387
 
@@ -409,6 +455,17 @@ def build_bot(redis=None) -> discord.Client:
409
  # Save bot response to context window
410
  await _ctx_append(redis, channel_id, "assistant", response[:500])
411
 
 
 
 
 
 
 
 
 
 
 
 
412
  # Send chunked
413
  chunks = _chunk(response)
414
  for i, chunk in enumerate(chunks):
@@ -426,11 +483,11 @@ def build_bot(redis=None) -> discord.Client:
426
  # Runner
427
  # ---------------------------------------------------------------------------
428
 
429
- def run(redis=None) -> None:
430
  """Start the Discord bot. Called from main.py lifespan or standalone."""
431
  if not DISCORD_BOT_TOKEN:
432
  logger.error("[Bot] DISCORD_BOT_TOKEN not set — bot disabled")
433
  print("[ERROR] DISCORD_BOT_TOKEN not set", file=sys.stderr)
434
  return
435
- bot = build_bot(redis=redis)
436
  bot.run(DISCORD_BOT_TOKEN, log_handler=None) # logging already configured
 
3
 
4
  Ultron V4 — Discord Interface Layer
5
  =====================================
6
+ Thin bot that bridges Discord <-> FastAPI Brain (/infer endpoint).
7
  Never calls TaskDispatcher or LLM directly — Brain is the only LLM surface.
8
 
9
  Features (V4 over V3):
 
15
  - Rate-limit: 10 req/min per user (in-memory sliding window)
16
  - No internal block leaks: strip_internal_blocks applied on ALL responses
17
  - No fake behaviors: if Brain returns error, say so plainly
18
+ - LifecycleEngine.ingest() called on every user message (v25)
19
 
20
  V4 design rules:
21
  - Bot is stateless except for rate-limit counters and Redis context writes
 
25
  - NEVER send === MEMORY GRAPH === or [COMPACTED HISTORY] blocks to user
26
 
27
  Future bug risks (pre-registered):
28
+ BOT1 [HIGH] Redis context write fails silently -> context window empty -> B1/D1 fire
29
  Fix: log warning, continue without context (degrade gracefully)
30
+ BOT2 [HIGH] Discord rate-limit on bulk sends (5+ chunks in 1s) -> 429 from Discord API
31
  Fix: asyncio.sleep(0.5) between chunks if len(chunks) > 2
32
+ BOT3 [MED] Brain /health timeout on !status -> bot hangs under slow HF Space wake
33
  Fix: timeout=8s on health check, return "Brain waking..." on timeout
34
+ BOT4 [MED] ALLOWED_USERS env parsed at import -> adding user requires restart
35
  Fix: re-parse on each message (minimal perf cost, big ops win)
36
+ BOT5 [LOW] on_message fires for bot's own replies (if intents wrong) -> infinite loop
37
  Fix: if msg.author == bot.user: return — MUST be first check
38
+ BOT6 [LOW] Context window RPUSH/LTRIM non-atomic -> concurrent messages corrupt window
39
  Fix: use Redis pipeline() for atomic push+trim pair
40
+ BOT7 [MED] lifecycle.ingest() called per message but user_id != channel_id in lifecycle
41
+ keys. If lifecycle.get_stm(user_id) called with channel_id from /memory/stm,
42
+ returns empty list. Fix: pass user_id to ingest, not channel_id as proxy.
43
+ (Tracked as CL5 in main.py — lifecycle stores cells per user_id)
44
+
45
+ Tool calls used this session (v25):
46
+ Github:get_file_contents x1 (discord_bot.py current state + sha)
47
+ Github:get_file_contents x1 (lifecycle.py interface — ingest signature)
48
  """
49
 
50
  from __future__ import annotations
 
154
  json=payload,
155
  )
156
  if r.status_code == 429:
157
+ return {"error": "\u26a0\ufe0f Rate limited. Try again in a moment."}
158
  if r.status_code == 503:
159
+ return {"error": "\u26a0\ufe0f All LLM keys exhausted. Try again later."}
160
  if r.status_code == 401:
161
+ return {"error": "\u26d4 Auth failed. Check INTERNAL_AUTH_TOKEN."}
162
  if r.status_code not in (200, 201):
163
  return {"error": f"Brain {r.status_code}: {r.text[:200]}"}
164
  return r.json()
165
  except httpx.TimeoutException:
166
+ return {"error": "\u23f1\ufe0f Brain timed out. HF Space may be waking up — try again in 30s."}
167
  except Exception as exc:
168
  logger.exception(f"[Bot] _call_brain {path} failed: {exc}")
169
  return {"error": f"Bot error: {str(exc)[:200]}"}
 
216
  return ""
217
 
218
 
219
+ # ---------------------------------------------------------------------------
220
+ # Lifecycle ingest helper
221
+ # ---------------------------------------------------------------------------
222
+
223
+ async def _lifecycle_ingest(
224
+ lifecycle,
225
+ user_id: str,
226
+ channel_id: str,
227
+ text: str,
228
+ metadata: Optional[dict] = None,
229
+ ) -> None:
230
+ """
231
+ Fire lifecycle.ingest() as best-effort background call.
232
+ Never raises — BOT7 mitigation: pass user_id (not channel_id) as the lifecycle key.
233
+ """
234
+ if lifecycle is None:
235
+ return
236
+ try:
237
+ await lifecycle.ingest(
238
+ user_id=user_id,
239
+ channel_id=channel_id,
240
+ raw_text=text,
241
+ metadata=metadata or {},
242
+ )
243
+ except Exception as exc:
244
+ logger.warning(f"[Bot] lifecycle.ingest failed (non-fatal): {exc}")
245
+
246
+
247
  # ---------------------------------------------------------------------------
248
  # Command handlers
249
  # ---------------------------------------------------------------------------
 
264
  async with msg.channel.typing():
265
  data = await _get_health(user_id)
266
  if "error" in data:
267
+ await msg.reply(f"\u26a0\ufe0f {data['error']}")
268
  return
269
  pool = data.get("pool", {})
270
  total = pool.get("total", 0)
271
  available = pool.get("available", 0)
272
  uptime = data.get("uptime_seconds", 0)
273
  uptime_str = f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m"
274
+ lifecycle_status = "\u2705" if data.get("lifecycle_active") else "\u274c"
275
  lines = [
276
+ f"**Ultron V4** | Uptime: {uptime_str} | Keys: {available}/{total} | Lifecycle: {lifecycle_status}",
277
  ]
278
  # Show per-provider pool summary if available
279
  providers = pool.get("providers", {})
280
  for provider, stats in providers.items():
281
+ avail_icon = "\u2705" if stats.get("available", 0) > 0 else "\u274c"
282
  lines.append(f" {avail_icon} `{provider}` — {stats.get('available', 0)}/{stats.get('total', 0)} keys")
283
  await msg.reply("\n".join(lines))
284
 
 
306
  if not args:
307
  await msg.reply("Usage: `!council <project brief>`")
308
  return
309
+ await msg.reply("\u26a1 Assembling council... (~30-60s)")
310
  async with msg.channel.typing():
311
  result = await _call_brain(
312
  "/council",
 
315
  timeout=120.0,
316
  )
317
  synthesis = _strip(result.get("synthesis", result.get("error", "Council failed.")))
318
+ header = "**\u26a1 Council Report:**\n"
319
  chunks = _chunk(header + synthesis)
320
  for i, c in enumerate(chunks):
321
  if i > 0:
 
329
  await redis.delete(f"{CTX_KEY_PREFIX}{channel_id}")
330
  except Exception as exc:
331
  logger.warning(f"[Bot] ctx clear failed: {exc}")
332
+ await msg.reply("\U0001f5d1\ufe0f Channel context cleared.")
333
 
334
 
335
  async def _cmd_ping(msg: discord.Message) -> None:
336
  latency_ms = round(msg._state._get_websocket(msg.guild).latency * 1000)
337
+ await msg.reply(f"\U0001f3d3 Pong! Latency: {latency_ms}ms")
338
 
339
 
340
  # ---------------------------------------------------------------------------
341
  # Bot setup
342
  # ---------------------------------------------------------------------------
343
 
344
+ def build_bot(redis=None, lifecycle=None) -> discord.Client:
345
  """Build and return the Discord client.
346
 
347
  Args:
348
  redis: Optional async Redis client (e.g. upstash_redis.Redis or aioredis.Redis).
349
  If None, context window is disabled but bot still works.
350
+ lifecycle: Optional LifecycleEngine instance. If set, ingest() is called on
351
+ every user message for STM/MTM/Foresight pipeline. (v25)
352
  """
353
  intents = discord.Intents.default()
354
  intents.message_content = True
 
378
 
379
  # Rate limit
380
  if _is_rate_limited(user_id):
381
+ await msg.reply("\u26a0\ufe0f Slow down — max 10 messages/minute.")
382
  return
383
 
384
  channel_id = str(msg.channel.id)
 
416
 
417
  full_message = content + attachment_info
418
 
419
+ # Fire lifecycle.ingest() as non-blocking background task (v25)
420
+ # BOT7: pass user_id as lifecycle key, channel_id for context grouping
421
+ asyncio.create_task(
422
+ _lifecycle_ingest(
423
+ lifecycle,
424
+ user_id=user_id,
425
+ channel_id=channel_id,
426
+ text=full_message,
427
+ metadata={"source": "discord", "username": str(msg.author)},
428
+ )
429
+ )
430
+
431
  # Save user message to context window BEFORE call
432
  await _ctx_append(redis, channel_id, "user", full_message)
433
 
 
455
  # Save bot response to context window
456
  await _ctx_append(redis, channel_id, "assistant", response[:500])
457
 
458
+ # Also ingest bot response into lifecycle (for Foresight context)
459
+ asyncio.create_task(
460
+ _lifecycle_ingest(
461
+ lifecycle,
462
+ user_id=user_id,
463
+ channel_id=channel_id,
464
+ text=f"[ULTRON RESPONSE] {response[:500]}",
465
+ metadata={"source": "discord_response"},
466
+ )
467
+ )
468
+
469
  # Send chunked
470
  chunks = _chunk(response)
471
  for i, chunk in enumerate(chunks):
 
483
  # Runner
484
  # ---------------------------------------------------------------------------
485
 
486
+ def run(redis=None, lifecycle=None) -> None:
487
  """Start the Discord bot. Called from main.py lifespan or standalone."""
488
  if not DISCORD_BOT_TOKEN:
489
  logger.error("[Bot] DISCORD_BOT_TOKEN not set — bot disabled")
490
  print("[ERROR] DISCORD_BOT_TOKEN not set", file=sys.stderr)
491
  return
492
+ bot = build_bot(redis=redis, lifecycle=lifecycle)
493
  bot.run(DISCORD_BOT_TOKEN, log_handler=None) # logging already configured
packages/brain/main.py CHANGED
@@ -8,15 +8,19 @@ Startup sequence:
8
  2. KeyPool built from config_loader.build_pool_config()
9
  3. TaskDispatcher instantiated with pool
10
  4. Memory pipeline: Embedder + ZillizStore + RaptorTree + MemoryWorker
11
- 5. Sentinel instantiated (if GEMINI_SENTINEL_KEY set)
12
- 6. Council instantiated (always uses general pool)
13
- 7. Background tasks: health-ping + memory flush worker
14
- 8. FastAPI app begins serving on port 7860 (HF Spaces standard)
 
15
 
16
  Endpoints:
17
- POST /infer — Discord bot -> Brain. Auth: X-Ultron-Token header.
18
- GET /health — CF Worker keep-alive + Sentinel audit. No auth.
19
- POST /sentinel/event — Sentinel writes incident/routing decision. Auth required.
 
 
 
20
 
21
  Design decisions:
22
  - asynccontextmanager lifespan (FastAPI 0.93+ pattern). No @app.on_event.
@@ -68,23 +72,33 @@ Future bug risks (pre-registered):
68
  If REDIS_URL not set, Redis init will fail at startup. Should degrade
69
  gracefully (disable memory worker) rather than crash entire Brain.
70
 
71
- Tool calls used writing this file:
72
- Github:get_file_contents x1 (pool.py confirmed KeyPool.status() shape)
73
- Github:get_file_contents x1 (task_dispatcher.py confirmed TaskDispatcher.__init__ + dispatch sig)
74
- Github:get_file_contents x1 (sentinel.py — confirmed build_sentinel() factory shape)
75
- External read: litellm proxy_server.py (lifespan pattern, startup sequence)
 
 
 
 
 
 
 
 
 
76
  """
77
 
78
  from __future__ import annotations
79
 
80
  import asyncio
81
  import hmac
 
82
  import logging
83
  import os
84
  import time
85
  import uuid
86
  from contextlib import asynccontextmanager
87
- from typing import Any, Optional
88
 
89
  import httpx
90
  from fastapi import FastAPI, HTTPException, Request, Response
@@ -187,6 +201,13 @@ async def lifespan(app: FastAPI):
187
  # ── Step 4: TaskDispatcher ────────────────────────────────────────────
188
  dispatcher = TaskDispatcher(pool=pool, settings=settings)
189
 
 
 
 
 
 
 
 
190
  # ── Step 5: Memory pipeline (optional — degrades gracefully if Zilliz unset) ──
191
  memory_worker_task: Optional[asyncio.Task] = None
192
  redis_client = None
@@ -233,6 +254,39 @@ async def lifespan(app: FastAPI):
233
  except Exception as e:
234
  logger.warning(f"[Startup] Redis init failed (non-fatal): {e}")
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # ── Step 6: Sentinel (optional — degrades gracefully if key unset) ────
237
  sentinel = None
238
  try:
@@ -265,20 +319,17 @@ async def lifespan(app: FastAPI):
265
  app.state.start_time = startup_start
266
 
267
  # ── Step 9: Background health ping ────────────────────────────────────
268
- _brain_url = (
269
- "https://ghostdrive1-ultron1.hf.space"
270
- if not os.environ.get("LOCAL_DEV")
271
- else f"http://localhost:{getattr(settings, 'brain_port', 7860)}"
272
- )
273
  _ping_task = asyncio.create_task(
274
  _health_ping_loop(_brain_url, interval_seconds=43200)
275
  )
276
 
277
  elapsed = (time.monotonic() - startup_start) * 1000
 
278
  logger.info(
279
  f"[Startup] Ultron V4 Brain READY in {elapsed:.1f}ms. "
280
  f"Pool general={len(pool.general)} sentinel={'ACTIVE' if sentinel else 'INACTIVE'} "
281
- f"council=ACTIVE memory={'ACTIVE' if memory_worker_task else 'INACTIVE'}"
 
282
  )
283
 
284
  # ── Yield: serve requests ─────────────────────────────────────────────
@@ -354,6 +405,7 @@ async def health(request: Request) -> JSONResponse:
354
  "memory_pipeline": hasattr(request.app.state, "raptor_tree"),
355
  "sentinel_active": request.app.state.sentinel is not None,
356
  "council_active": hasattr(request.app.state, "council"),
 
357
  "pool": {
358
  "general_available": pool_status["general_available"],
359
  "general_total": len(pool_status["general"]),
@@ -374,6 +426,18 @@ async def infer(body: InferRequest, request: Request) -> InferResponse:
374
 
375
  dispatcher: TaskDispatcher = request.app.state.dispatcher
376
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  try:
378
  reply = await dispatcher.dispatch(
379
  message=body.message,
@@ -448,6 +512,126 @@ async def sentinel_event(body: SentinelEvent, request: Request) -> JSONResponse:
448
  )
449
 
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  # ---------------------------------------------------------------------------
452
  # Entrypoint (uvicorn)
453
  # ---------------------------------------------------------------------------
 
8
  2. KeyPool built from config_loader.build_pool_config()
9
  3. TaskDispatcher instantiated with pool
10
  4. Memory pipeline: Embedder + ZillizStore + RaptorTree + MemoryWorker
11
+ 5. LifecycleEngine + GroundTruthStore + RDLoop (if Redis available)
12
+ 6. Sentinel instantiated (if GEMINI_SENTINEL_KEY set)
13
+ 7. Council instantiated (always uses general pool)
14
+ 8. Background tasks: health-ping + memory flush worker
15
+ 9. FastAPI app begins serving on port 7860 (HF Spaces standard)
16
 
17
  Endpoints:
18
+ POST /infer — Discord bot -> Brain. Auth: X-Ultron-Token header.
19
+ GET /health — CF Worker keep-alive + Sentinel audit. No auth.
20
+ POST /sentinel/event — Sentinel writes incident/routing decision. Auth required.
21
+ GET /keys — Pool status + key counts per provider (website dashboard).
22
+ GET /memory/stm/{channel_id} — Redis STM context viewer for website Memory tab.
23
+ GET /rd/history/{user_id} — R&D loop implemented improvements for website.
24
 
25
  Design decisions:
26
  - asynccontextmanager lifespan (FastAPI 0.93+ pattern). No @app.on_event.
 
72
  If REDIS_URL not set, Redis init will fail at startup. Should degrade
73
  gracefully (disable memory worker) rather than crash entire Brain.
74
 
75
+ CL5 [MED] LifecycleEngine._locks dict grows unbounded across users in long-running
76
+ process. Each new user_id adds an asyncio.Lock. In high-traffic scenarios
77
+ (100+ users) this leaks memory. Fix: use WeakValueDictionary or LRU cache.
78
+
79
+ CL6 [LOW] RDLoop.run() is not started as a background task in main.py it is
80
+ triggered externally (post-task completion). If called from /infer handler,
81
+ it blocks the response. Fix: always asyncio.create_task() for RDLoop.run().
82
+
83
+ Tool calls used writing this file (v25):
84
+ Github:get_file_contents x1 (lifecycle.py interface)
85
+ Github:get_file_contents x1 (ground_truth.py interface)
86
+ Github:get_file_contents x1 (rd_loop.py interface)
87
+ Github:get_file_contents x1 (main.py current state + sha)
88
+ pipecat-ai/pipecat: src/pipecat/services/groq/stt.py (Whisper API pattern)
89
  """
90
 
91
  from __future__ import annotations
92
 
93
  import asyncio
94
  import hmac
95
+ import json
96
  import logging
97
  import os
98
  import time
99
  import uuid
100
  from contextlib import asynccontextmanager
101
+ from typing import Any, List, Optional
102
 
103
  import httpx
104
  from fastapi import FastAPI, HTTPException, Request, Response
 
201
  # ── Step 4: TaskDispatcher ────────────────────────────────────────────
202
  dispatcher = TaskDispatcher(pool=pool, settings=settings)
203
 
204
+ # ── Resolve brain_url early (needed by RDLoop + health ping) ──────────
205
+ _brain_url = (
206
+ "https://ghostdrive1-ultron1.hf.space"
207
+ if not os.environ.get("LOCAL_DEV")
208
+ else f"http://localhost:{getattr(settings, 'brain_port', 7860)}"
209
+ )
210
+
211
  # ── Step 5: Memory pipeline (optional — degrades gracefully if Zilliz unset) ──
212
  memory_worker_task: Optional[asyncio.Task] = None
213
  redis_client = None
 
254
  except Exception as e:
255
  logger.warning(f"[Startup] Redis init failed (non-fatal): {e}")
256
 
257
+ # ── Step 5b: Lifecycle + GroundTruth + RDLoop (optional — needs Redis) ──
258
+ lifecycle = None
259
+ gt_store = None
260
+ rd_loop = None
261
+
262
+ if redis_client is not None:
263
+ try:
264
+ from packages.memory.lifecycle import LifecycleEngine
265
+ from packages.memory.ground_truth import GroundTruthStore
266
+ from packages.brain.rd_loop import RDLoop
267
+
268
+ discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL", "") or None
269
+
270
+ lifecycle = LifecycleEngine(redis_client)
271
+ gt_store = GroundTruthStore(redis_client)
272
+ rd_loop = RDLoop(
273
+ redis_client=redis_client,
274
+ lifecycle=lifecycle,
275
+ brain_url=_brain_url,
276
+ auth_token=getattr(settings, "ultron_auth_token", ""),
277
+ discord_webhook=discord_webhook,
278
+ )
279
+
280
+ app.state.lifecycle = lifecycle
281
+ app.state.gt_store = gt_store
282
+ app.state.rd_loop = rd_loop
283
+
284
+ logger.info("[Startup] LifecycleEngine + GroundTruthStore + RDLoop: ACTIVE")
285
+ except Exception as e:
286
+ logger.warning(f"[Startup] Lifecycle/GT/RDLoop init failed (non-fatal): {e}")
287
+ else:
288
+ logger.warning("[Startup] Lifecycle/GT/RDLoop DISABLED — Redis not available")
289
+
290
  # ── Step 6: Sentinel (optional — degrades gracefully if key unset) ────
291
  sentinel = None
292
  try:
 
319
  app.state.start_time = startup_start
320
 
321
  # ── Step 9: Background health ping ────────────────────────────────────
 
 
 
 
 
322
  _ping_task = asyncio.create_task(
323
  _health_ping_loop(_brain_url, interval_seconds=43200)
324
  )
325
 
326
  elapsed = (time.monotonic() - startup_start) * 1000
327
+ lifecycle_active = hasattr(app.state, "lifecycle") and app.state.lifecycle is not None
328
  logger.info(
329
  f"[Startup] Ultron V4 Brain READY in {elapsed:.1f}ms. "
330
  f"Pool general={len(pool.general)} sentinel={'ACTIVE' if sentinel else 'INACTIVE'} "
331
+ f"council=ACTIVE memory={'ACTIVE' if memory_worker_task else 'INACTIVE'} "
332
+ f"lifecycle={'ACTIVE' if lifecycle_active else 'INACTIVE'}"
333
  )
334
 
335
  # ── Yield: serve requests ─────────────────────────────────────────────
 
405
  "memory_pipeline": hasattr(request.app.state, "raptor_tree"),
406
  "sentinel_active": request.app.state.sentinel is not None,
407
  "council_active": hasattr(request.app.state, "council"),
408
+ "lifecycle_active": hasattr(request.app.state, "lifecycle") and request.app.state.lifecycle is not None,
409
  "pool": {
410
  "general_available": pool_status["general_available"],
411
  "general_total": len(pool_status["general"]),
 
426
 
427
  dispatcher: TaskDispatcher = request.app.state.dispatcher
428
 
429
+ # Fire lifecycle.ingest() as background task (non-blocking) — CL6 mitigation
430
+ lifecycle = getattr(request.app.state, "lifecycle", None)
431
+ if lifecycle is not None:
432
+ asyncio.create_task(
433
+ lifecycle.ingest(
434
+ user_id=body.user_id,
435
+ channel_id=body.channel_id,
436
+ raw_text=body.message,
437
+ metadata={"source": "infer", "username": body.username or "user"},
438
+ )
439
+ )
440
+
441
  try:
442
  reply = await dispatcher.dispatch(
443
  message=body.message,
 
512
  )
513
 
514
 
515
+ # ---------------------------------------------------------------------------
516
+ # Website API endpoints
517
+ # ---------------------------------------------------------------------------
518
+
519
+ @app.get("/keys")
520
+ async def keys_status(request: Request) -> JSONResponse:
521
+ """
522
+ Returns per-provider key pool status for the website Credentials dashboard.
523
+ Auth required.
524
+ """
525
+ settings = request.app.state.settings
526
+ _check_auth(request, getattr(settings, "ultron_auth_token", ""))
527
+
528
+ pool: KeyPool = request.app.state.pool
529
+ pool_status = await pool.status()
530
+
531
+ # Build per-provider breakdown
532
+ providers: dict[str, dict] = {}
533
+ for key_info in pool_status.get("general", []):
534
+ provider = key_info.get("provider", "unknown")
535
+ if provider not in providers:
536
+ providers[provider] = {"total": 0, "available": 0, "in_cooldown": 0}
537
+ providers[provider]["total"] += 1
538
+ if key_info.get("available", False):
539
+ providers[provider]["available"] += 1
540
+ else:
541
+ providers[provider]["in_cooldown"] += 1
542
+
543
+ sentinel_keys = pool_status.get("sentinel", [])
544
+ sentinel_available = sum(1 for k in sentinel_keys if k.get("available", False))
545
+
546
+ return JSONResponse({
547
+ "general": {
548
+ "providers": providers,
549
+ "total": len(pool_status.get("general", [])),
550
+ "available": pool_status.get("general_available", 0),
551
+ },
552
+ "sentinel": {
553
+ "total": len(sentinel_keys),
554
+ "available": sentinel_available,
555
+ },
556
+ })
557
+
558
+
559
+ @app.get("/memory/stm/{channel_id}")
560
+ async def memory_stm(channel_id: str, request: Request) -> JSONResponse:
561
+ """
562
+ Returns the STM (short-term memory) context for a channel.
563
+ Used by website Memory tab — STM view.
564
+ Auth required.
565
+ """
566
+ settings = request.app.state.settings
567
+ _check_auth(request, getattr(settings, "ultron_auth_token", ""))
568
+
569
+ redis = getattr(request.app.state, "redis", None)
570
+ if redis is None:
571
+ return JSONResponse({"error": "Redis not available"}, status_code=503)
572
+
573
+ # Read raw Redis context window (set by discord_bot.py)
574
+ ctx_key = f"ultron:ctx:{channel_id}"
575
+ try:
576
+ entries = await redis.lrange(ctx_key, 0, -1)
577
+ messages = [
578
+ e.decode() if isinstance(e, bytes) else e
579
+ for e in entries
580
+ ]
581
+ except Exception as e:
582
+ logger.warning(f"[/memory/stm] Redis read failed: {e}")
583
+ return JSONResponse({"error": str(e)}, status_code=500)
584
+
585
+ # Also return lifecycle STM if available
586
+ lifecycle = getattr(request.app.state, "lifecycle", None)
587
+ lifecycle_cells: List[dict] = []
588
+ if lifecycle is not None:
589
+ try:
590
+ # Use channel_id as user_id proxy for STM lookup (cells stored per user_id)
591
+ cells = await lifecycle.get_stm(channel_id)
592
+ lifecycle_cells = [c.to_dict() for c in cells]
593
+ except Exception as e:
594
+ logger.warning(f"[/memory/stm] lifecycle.get_stm failed: {e}")
595
+
596
+ return JSONResponse({
597
+ "channel_id": channel_id,
598
+ "context_window": messages,
599
+ "context_window_count": len(messages),
600
+ "lifecycle_cells": lifecycle_cells,
601
+ "lifecycle_cell_count": len(lifecycle_cells),
602
+ })
603
+
604
+
605
+ @app.get("/rd/history/{user_id}")
606
+ async def rd_history(user_id: str, request: Request) -> JSONResponse:
607
+ """
608
+ Returns the R&D loop implemented improvements for a user.
609
+ Used by website Projects tab — R&D history view.
610
+ Auth required.
611
+ """
612
+ settings = request.app.state.settings
613
+ _check_auth(request, getattr(settings, "ultron_auth_token", ""))
614
+
615
+ rd_loop = getattr(request.app.state, "rd_loop", None)
616
+ if rd_loop is None:
617
+ return JSONResponse({"error": "RDLoop not initialized — Redis required"}, status_code=503)
618
+
619
+ try:
620
+ limit = int(request.query_params.get("limit", 20))
621
+ improvements = await rd_loop.get_history(user_id, limit=limit)
622
+ state = await rd_loop.get_state(user_id)
623
+ except Exception as e:
624
+ logger.warning(f"[/rd/history] failed: {e}")
625
+ return JSONResponse({"error": str(e)}, status_code=500)
626
+
627
+ return JSONResponse({
628
+ "user_id": user_id,
629
+ "improvements": [i.to_dict() for i in improvements],
630
+ "improvement_count": len(improvements),
631
+ "rd_state": state.to_dict() if state else None,
632
+ })
633
+
634
+
635
  # ---------------------------------------------------------------------------
636
  # Entrypoint (uvicorn)
637
  # ---------------------------------------------------------------------------
packages/infrastructure/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # packages/infrastructure/__init__.py
packages/infrastructure/space_promoter.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/infrastructure/space_promoter.py
3
+
4
+ Ultron V4 — Phase 6 Multi-Space Orchestration
5
+ ===============================================
6
+ SpacePromoter manages active-active HF Space topology:
7
+ - Primary Space: ghostdrive1/ultron-brain (acct1)
8
+ - Backup Space: ghostdrive2/ultron-brain-backup (acct2)
9
+ - Voice Space: ghostdrive1/ultron-voice
10
+
11
+ Responsibilities:
12
+ 1. Health-check loop: ping /health on all registered Spaces every N seconds
13
+ 2. Detect primary failure: 3 consecutive failures -> promote backup to primary
14
+ 3. Write new routing table to Cloudflare KV (Sentinel KV)
15
+ 4. Notify Ghost via Discord webhook on promotion events
16
+ 5. De-promote: restore primary when it recovers (with hysteresis)
17
+
18
+ CF KV routing table schema:
19
+ Key: "ultron:routing:v4"
20
+ Value: JSON {
21
+ "primary": "https://...",
22
+ "backup": "https://...",
23
+ "voice": "https://...",
24
+ "updated_at": "ISO",
25
+ "promoted_at": "ISO|null",
26
+ "reason": "..."
27
+ }
28
+
29
+ Skypilot pattern used (github.com/skypilot-org/skypilot):
30
+ - Health check with retries before marking node dead
31
+ - Hysteresis before promotion (avoid flapping)
32
+ - Atomic KV write with old-value check
33
+ - Separate check intervals for primary vs backup
34
+
35
+ Pre-registered bugs:
36
+ SP1 [HIGH] KV write fails mid-failover -> old primary still in KV -> CF Worker
37
+ routes all traffic to dead Space. Fix: retry KV write up to 3x before
38
+ accepting split-brain state. Log alert if all retries fail.
39
+
40
+ SP2 [HIGH] Split-brain: both Spaces healthy, KV write fails on restore, backup
41
+ and primary both serving. Fix: CF Worker reads KV on every request
42
+ (cheap, <1ms). Single KV source of truth. Worker has no memory.
43
+
44
+ SP3 [MED] HF Space cold start takes 30-60s. If health check timeout < 30s,
45
+ false-positive failures trigger unnecessary promotion. Fix: use
46
+ HEALTH_TIMEOUT=35s, FAILURE_THRESHOLD=3 consecutive checks.
47
+
48
+ SP4 [MED] Discord webhook not set -> notification fails silently. Ghost doesn't
49
+ know about promotion. Fix: also log promotion event to Redis
50
+ (ultron:infra:events list) so website can show it.
51
+
52
+ SP5 [LOW] Infinite promotion loop: backup also dies -> try to promote tertiary
53
+ (doesn't exist) -> loop panics. Fix: MAX_FAILOVER_ATTEMPTS guard.
54
+ After max attempts, enter DEGRADED state, stop promoting, keep alerting.
55
+
56
+ SP6 [LOW] KV namespace ID hardcoded. If Ghost creates new CF account or
57
+ namespace, must update here AND in CF Worker. Fix: read from
58
+ CF_KV_NAMESPACE_ID env var (already in config).
59
+
60
+ Tool calls used writing this file (v25):
61
+ skypilot-org/skypilot: sky/skylet/log_lib.py, sky/backends/cloud_vm_ray_backend.py
62
+ (health probe pattern, node death detection, retry logic)
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import asyncio
68
+ import json
69
+ import logging
70
+ import os
71
+ import time
72
+ from dataclasses import dataclass, field, asdict
73
+ from datetime import datetime, timezone
74
+ from typing import Dict, List, Optional
75
+
76
+ import httpx
77
+
78
+ log = logging.getLogger("ultron.space_promoter")
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Config
82
+ # ---------------------------------------------------------------------------
83
+
84
+ HEALTH_CHECK_INTERVAL = int(os.environ.get("PROMOTER_CHECK_INTERVAL", "30")) # seconds
85
+ HEALTH_TIMEOUT = float(os.environ.get("PROMOTER_HEALTH_TIMEOUT", "35")) # SP3
86
+ FAILURE_THRESHOLD = int(os.environ.get("PROMOTER_FAIL_THRESHOLD", "3")) # consecutive
87
+ RECOVERY_THRESHOLD = int(os.environ.get("PROMOTER_RECOVER_THRESHOLD", "3")) # consecutive ok
88
+ MAX_FAILOVER_ATTEMPTS = int(os.environ.get("PROMOTER_MAX_FAILOVER", "5")) # SP5
89
+ KV_ROUTING_KEY = "ultron:routing:v4"
90
+ KV_EVENTS_KEY = "ultron:infra:events"
91
+ KV_MAX_EVENTS = 100
92
+
93
+ # CF KV REST API
94
+ CF_ACCOUNT_ID = os.environ.get("CF_ACCOUNT_ID", "c2ed2ecab1a35b2cd2095849cb69ab10")
95
+ CF_KV_NAMESPACE = os.environ.get("CF_KV_NAMESPACE_ID", "77184c17886d47f2be73b6d441ada952") # SP6
96
+ CF_KV_API_TOKEN = os.environ.get("CF_KV_API_TOKEN", "")
97
+ CF_KV_BASE_URL = f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/storage/kv/namespaces/{CF_KV_NAMESPACE}"
98
+
99
+ # Discord webhook for promotion alerts (SP4)
100
+ DISCORD_WEBHOOK = os.environ.get("DISCORD_WEBHOOK_URL", "")
101
+
102
+
103
+ def _now_iso() -> str:
104
+ return datetime.now(timezone.utc).isoformat()
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Data models
109
+ # ---------------------------------------------------------------------------
110
+
111
+ @dataclass
112
+ class SpaceNode:
113
+ """Represents one HF Space in the topology."""
114
+ name: str # e.g. "brain-primary"
115
+ url: str # e.g. "https://ghostdrive1-ultron1.hf.space"
116
+ role: str # "primary" | "backup" | "voice"
117
+ consecutive_failures: int = 0
118
+ consecutive_successes: int = 0
119
+ last_check_at: Optional[str] = None
120
+ last_status: str = "unknown" # "ok" | "failed" | "unknown"
121
+ is_alive: bool = True
122
+
123
+
124
+ @dataclass
125
+ class RoutingTable:
126
+ """CF KV routing table value."""
127
+ primary: str
128
+ backup: str
129
+ voice: str
130
+ updated_at: str = field(default_factory=_now_iso)
131
+ promoted_at: Optional[str] = None
132
+ reason: str = "initial"
133
+
134
+ def to_dict(self) -> Dict:
135
+ return asdict(self)
136
+
137
+ @classmethod
138
+ def from_dict(cls, d: Dict) -> "RoutingTable":
139
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
140
+
141
+
142
+ @dataclass
143
+ class InfraEvent:
144
+ """Infrastructure event for website dashboard (SP4)."""
145
+ event_type: str # "promotion" | "recovery" | "health_degraded" | "kv_write_failed"
146
+ message: str
147
+ timestamp: str = field(default_factory=_now_iso)
148
+ metadata: Dict = field(default_factory=dict)
149
+
150
+ def to_dict(self) -> Dict:
151
+ return asdict(self)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # SpacePromoter
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class SpacePromoter:
159
+ """
160
+ Orchestrates multi-Space health and routing.
161
+
162
+ Usage:
163
+ promoter = SpacePromoter(redis_client)
164
+ stop = asyncio.Event()
165
+ asyncio.create_task(promoter.run(stop))
166
+ # ...
167
+ stop.set()
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ redis_client,
173
+ primary_url: Optional[str] = None,
174
+ backup_url: Optional[str] = None,
175
+ voice_url: Optional[str] = None,
176
+ ):
177
+ self.redis = redis_client
178
+ self._failover_count: int = 0
179
+ self._in_degraded_state: bool = False
180
+
181
+ primary_url = primary_url or os.environ.get(
182
+ "BRAIN_PRIMARY_URL", "https://ghostdrive1-ultron1.hf.space"
183
+ )
184
+ backup_url = backup_url or os.environ.get(
185
+ "BRAIN_BACKUP_URL", ""
186
+ )
187
+ voice_url = voice_url or os.environ.get(
188
+ "VOICE_URL", ""
189
+ )
190
+
191
+ self._nodes: Dict[str, SpaceNode] = {
192
+ "primary": SpaceNode(name="brain-primary", url=primary_url, role="primary"),
193
+ }
194
+ if backup_url:
195
+ self._nodes["backup"] = SpaceNode(name="brain-backup", url=backup_url, role="backup")
196
+ if voice_url:
197
+ self._nodes["voice"] = SpaceNode(name="voice", url=voice_url, role="voice")
198
+
199
+ log.info(
200
+ f"[Promoter] Initialized. nodes={list(self._nodes.keys())} "
201
+ f"primary={primary_url} backup={'set' if backup_url else 'not set'}"
202
+ )
203
+
204
+ # ── Main loop ────────────────────────────────────────────────────────
205
+
206
+ async def run(self, stop_event: asyncio.Event) -> None:
207
+ """Health check + promotion loop. Runs until stop_event is set."""
208
+ log.info("[Promoter] Starting health check loop.")
209
+ while not stop_event.is_set():
210
+ try:
211
+ await self._check_all_nodes()
212
+ await self._evaluate_promotions()
213
+ except Exception as e:
214
+ log.error(f"[Promoter] Loop iteration error: {e}")
215
+ await asyncio.sleep(HEALTH_CHECK_INTERVAL)
216
+ log.info("[Promoter] Loop stopped.")
217
+
218
+ # ── Health checks ────────────────────────────────────────────────────
219
+
220
+ async def _check_all_nodes(self) -> None:
221
+ """Run health checks on all registered nodes concurrently."""
222
+ tasks = [
223
+ self._check_node(node)
224
+ for node in self._nodes.values()
225
+ ]
226
+ await asyncio.gather(*tasks, return_exceptions=True)
227
+
228
+ async def _check_node(self, node: SpaceNode) -> None:
229
+ """Ping /health endpoint. Update consecutive_failures/successes."""
230
+ node.last_check_at = _now_iso()
231
+ try:
232
+ async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client: # SP3
233
+ resp = await client.get(f"{node.url}/health")
234
+ ok = resp.status_code == 200 and resp.json().get("status") in ("ok", "degraded")
235
+ except Exception as e:
236
+ log.warning(f"[Promoter] {node.name} health check error: {e}")
237
+ ok = False
238
+
239
+ if ok:
240
+ node.consecutive_failures = 0
241
+ node.consecutive_successes += 1
242
+ node.last_status = "ok"
243
+ if not node.is_alive:
244
+ log.info(f"[Promoter] {node.name} RECOVERED. consecutive_successes={node.consecutive_successes}")
245
+ else:
246
+ node.consecutive_successes = 0
247
+ node.consecutive_failures += 1
248
+ node.last_status = "failed"
249
+ log.warning(
250
+ f"[Promoter] {node.name} FAILED. consecutive_failures={node.consecutive_failures}"
251
+ )
252
+
253
+ # ── Promotion logic ──────────────────────────���───────────────────────
254
+
255
+ async def _evaluate_promotions(self) -> None:
256
+ """Evaluate whether promotion or recovery is needed."""
257
+ if self._in_degraded_state:
258
+ # SP5: degraded state — log heartbeat, try to recover
259
+ log.warning(f"[Promoter] In DEGRADED state. failover_count={self._failover_count}")
260
+ await self._try_recover_from_degraded()
261
+ return
262
+
263
+ primary = self._nodes.get("primary")
264
+ backup = self._nodes.get("backup")
265
+
266
+ if primary is None:
267
+ return
268
+
269
+ # Primary failure -> promote backup
270
+ if primary.consecutive_failures >= FAILURE_THRESHOLD:
271
+ if backup is None:
272
+ log.error("[Promoter] Primary failed but no backup configured. DEGRADED.")
273
+ self._in_degraded_state = True
274
+ await self._emit_event("health_degraded", "Primary failed, no backup available.")
275
+ await self._notify("🚨 Primary FAILED — No Backup", "Primary is down. No backup configured.")
276
+ return
277
+
278
+ if self._failover_count >= MAX_FAILOVER_ATTEMPTS: # SP5
279
+ log.error("[Promoter] Max failover attempts reached. DEGRADED.")
280
+ self._in_degraded_state = True
281
+ await self._emit_event("health_degraded", f"Max failover attempts ({MAX_FAILOVER_ATTEMPTS}) reached.")
282
+ return
283
+
284
+ await self._promote_backup(primary, backup)
285
+
286
+ # Primary recovered after being down — restore routing
287
+ elif (
288
+ primary.consecutive_successes >= RECOVERY_THRESHOLD
289
+ and not primary.is_alive
290
+ ):
291
+ await self._restore_primary(primary)
292
+
293
+ # Voice Space independent monitoring
294
+ voice = self._nodes.get("voice")
295
+ if voice and voice.consecutive_failures >= FAILURE_THRESHOLD and voice.is_alive:
296
+ voice.is_alive = False
297
+ await self._emit_event("health_degraded", f"Voice Space down: {voice.url}")
298
+ await self._notify("🎙️ Voice Space DOWN", f"Voice offline: {voice.url}")
299
+
300
+ async def _promote_backup(self, primary: SpaceNode, backup: SpaceNode) -> None:
301
+ """Swap primary and backup in routing table. Write to CF KV."""
302
+ primary.is_alive = False
303
+ self._failover_count += 1
304
+ reason = f"Primary {primary.url} failed {primary.consecutive_failures} consecutive checks"
305
+
306
+ log.info(f"[Promoter] PROMOTING backup to primary. {reason}")
307
+
308
+ # Swap URLs in routing table
309
+ old_primary_url = primary.url
310
+ new_routing = RoutingTable(
311
+ primary=backup.url,
312
+ backup=old_primary_url,
313
+ voice=self._nodes["voice"].url if "voice" in self._nodes else "",
314
+ promoted_at=_now_iso(),
315
+ reason=reason,
316
+ )
317
+
318
+ ok = await self._write_routing_table(new_routing)
319
+
320
+ if ok:
321
+ # Update in-memory node roles
322
+ primary.role = "backup"
323
+ backup.role = "primary"
324
+ self._nodes["primary"] = backup
325
+ self._nodes["backup"] = primary
326
+ log.info(f"[Promoter] Promotion SUCCESS. new_primary={backup.url}")
327
+ await self._emit_event("promotion", f"Backup promoted to primary: {backup.url}", {"old_primary": old_primary_url})
328
+ await self._notify(
329
+ "⚡ Space Promotion",
330
+ f"Primary failed. Backup promoted.\n"
331
+ f"**New Primary:** {backup.url}\n"
332
+ f"**Old Primary:** {old_primary_url}\n"
333
+ f"**Failover #{self._failover_count}** | Reason: {reason}",
334
+ )
335
+ else:
336
+ log.error("[Promoter] KV write FAILED — split-brain risk! (SP1)")
337
+ await self._emit_event("kv_write_failed", "CF KV write failed during promotion. Split-brain risk.")
338
+ await self._notify(
339
+ "🚨 KV Write FAILED",
340
+ f"Promotion KV write failed! CF Worker still routing to dead primary.\n"
341
+ f"MANUAL ACTION REQUIRED: Update KV key `{KV_ROUTING_KEY}` to point to {backup.url}"
342
+ )
343
+
344
+ async def _restore_primary(self, primary: SpaceNode) -> None:
345
+ """Primary recovered — restore it as primary in routing table."""
346
+ primary.is_alive = True
347
+ backup = self._nodes.get("backup")
348
+ backup_url = backup.url if backup else ""
349
+
350
+ log.info(f"[Promoter] Primary RECOVERED. Restoring {primary.url} as primary.")
351
+ new_routing = RoutingTable(
352
+ primary=primary.url,
353
+ backup=backup_url,
354
+ voice=self._nodes["voice"].url if "voice" in self._nodes else "",
355
+ reason=f"Primary {primary.url} recovered after {primary.consecutive_successes} clean checks",
356
+ )
357
+ ok = await self._write_routing_table(new_routing)
358
+ if ok:
359
+ if backup:
360
+ backup.role = "backup"
361
+ primary.role = "primary"
362
+ log.info(f"[Promoter] Primary restored. Failover count reset.")
363
+ self._failover_count = 0
364
+ await self._emit_event("recovery", f"Primary restored: {primary.url}")
365
+ await self._notify("✅ Primary Restored", f"Primary back online: {primary.url}")
366
+
367
+ async def _try_recover_from_degraded(self) -> None:
368
+ """If any node is healthy, try to exit degraded state."""
369
+ for node in self._nodes.values():
370
+ if node.consecutive_successes >= RECOVERY_THRESHOLD:
371
+ log.info(f"[Promoter] Exiting DEGRADED state. {node.name} recovered.")
372
+ self._in_degraded_state = False
373
+ self._failover_count = 0
374
+ new_routing = RoutingTable(
375
+ primary=node.url,
376
+ backup="",
377
+ voice=self._nodes.get("voice", SpaceNode("","","")).url,
378
+ reason=f"Recovered from DEGRADED via {node.name}",
379
+ )
380
+ await self._write_routing_table(new_routing)
381
+ await self._notify("✅ Recovered from DEGRADED", f"System restored via {node.name}: {node.url}")
382
+ return
383
+
384
+ # ── CF KV write ──────────────────────────────────────────────────────
385
+
386
+ async def _write_routing_table(self, routing: RoutingTable) -> bool:
387
+ """
388
+ Write routing table to CF KV REST API.
389
+ SP1: retries up to 3x before accepting failure.
390
+ Returns True on success.
391
+ """
392
+ if not CF_KV_API_TOKEN:
393
+ log.warning("[Promoter] CF_KV_API_TOKEN not set — KV write skipped (local mode)")
394
+ return True # assume success in dev
395
+
396
+ url = f"{CF_KV_BASE_URL}/values/{KV_ROUTING_KEY}"
397
+ headers = {"Authorization": f"Bearer {CF_KV_API_TOKEN}"}
398
+ value = json.dumps(routing.to_dict())
399
+
400
+ for attempt in range(3): # SP1: 3 retries
401
+ try:
402
+ async with httpx.AsyncClient(timeout=10.0) as client:
403
+ resp = await client.put(url, headers=headers, content=value)
404
+ if resp.status_code in (200, 201):
405
+ log.info(f"[Promoter] KV write OK (attempt {attempt+1})")
406
+ return True
407
+ log.warning(f"[Promoter] KV write attempt {attempt+1} returned {resp.status_code}: {resp.text[:100]}")
408
+ except Exception as e:
409
+ log.warning(f"[Promoter] KV write attempt {attempt+1} exception: {e}")
410
+ await asyncio.sleep(2 ** attempt) # exp backoff
411
+
412
+ return False
413
+
414
+ async def read_routing_table(self) -> Optional[RoutingTable]:
415
+ """Read current routing table from CF KV."""
416
+ if not CF_KV_API_TOKEN:
417
+ return None
418
+ url = f"{CF_KV_BASE_URL}/values/{KV_ROUTING_KEY}"
419
+ headers = {"Authorization": f"Bearer {CF_KV_API_TOKEN}"}
420
+ try:
421
+ async with httpx.AsyncClient(timeout=5.0) as client:
422
+ resp = await client.get(url, headers=headers)
423
+ if resp.status_code == 200:
424
+ return RoutingTable.from_dict(json.loads(resp.text))
425
+ except Exception as e:
426
+ log.warning(f"[Promoter] KV read failed: {e}")
427
+ return None
428
+
429
+ # ── Redis event log ──────────────────────────────────────────────────
430
+
431
+ async def _emit_event(self, event_type: str, message: str, metadata: Optional[Dict] = None) -> None:
432
+ """Log infra event to Redis for website dashboard. SP4."""
433
+ event = InfraEvent(event_type=event_type, message=message, metadata=metadata or {})
434
+ if self.redis is None:
435
+ return
436
+ try:
437
+ await self.redis.rpush(KV_EVENTS_KEY, json.dumps(event.to_dict(), default=str))
438
+ await self.redis.ltrim(KV_EVENTS_KEY, -KV_MAX_EVENTS, -1)
439
+ except Exception as e:
440
+ log.warning(f"[Promoter] Redis event write failed: {e}")
441
+
442
+ # ── Discord notification ──────────────────────────────────────────────
443
+
444
+ async def _notify(self, title: str, message: str) -> None:
445
+ """Notify Ghost via Discord webhook."""
446
+ if not DISCORD_WEBHOOK:
447
+ log.info(f"[Promoter] No webhook. Event: {title} | {message[:80]}")
448
+ return
449
+
450
+ payload = {
451
+ "embeds": [{
452
+ "title": title,
453
+ "description": message,
454
+ "color": 15158332 if "FAILED" in title or "🚨" in title else 3066993,
455
+ "footer": {"text": f"Ultron SpacePromoter · {_now_iso()[:19]}"},
456
+ }]
457
+ }
458
+ try:
459
+ async with httpx.AsyncClient(timeout=10.0) as client:
460
+ resp = await client.post(DISCORD_WEBHOOK, json=payload)
461
+ if resp.status_code not in (200, 204):
462
+ log.warning(f"[Promoter] Webhook returned {resp.status_code}")
463
+ except Exception as e:
464
+ log.warning(f"[Promoter] Webhook failed: {e}")
465
+
466
+ # ── Status API ───────────────────────────────────────────────────────
467
+
468
+ def get_status(self) -> Dict:
469
+ """Return current topology status (for website Sentinel tab)."""
470
+ return {
471
+ "nodes": {
472
+ name: {
473
+ "name": node.name,
474
+ "url": node.url,
475
+ "role": node.role,
476
+ "is_alive": node.is_alive,
477
+ "last_status": node.last_status,
478
+ "last_check_at": node.last_check_at,
479
+ "consecutive_failures": node.consecutive_failures,
480
+ "consecutive_successes": node.consecutive_successes,
481
+ }
482
+ for name, node in self._nodes.items()
483
+ },
484
+ "failover_count": self._failover_count,
485
+ "in_degraded_state": self._in_degraded_state,
486
+ "max_failover_attempts": MAX_FAILOVER_ATTEMPTS,
487
+ "check_interval_seconds": HEALTH_CHECK_INTERVAL,
488
+ }
packages/voice/server.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/voice/server.py
3
+
4
+ Ultron V4 — Voice Space FastAPI Entrypoint
5
+ ============================================
6
+ Standalone FastAPI app for the ultron-voice HF Space (separate from Brain).
7
+ Deployed to: ghostdrive1/ultron-voice (HF Spaces Docker, port 7860)
8
+
9
+ Endpoints:
10
+ POST /stt — Audio bytes -> text via Groq Whisper (whisper-large-v3-turbo)
11
+ POST /tts — Text -> WAV audio bytes via Kokoro TTS
12
+ GET /health — Voice Space health check
13
+
14
+ Design:
15
+ - No LLM calls. No Brain dependency. Pure voice pipeline.
16
+ - STT: multipart POST to Groq audio/transcriptions API (OpenAI-compat)
17
+ - TTS: Kokoro local model loaded at startup (pre-downloaded in Dockerfile)
18
+ - Auth: X-Ultron-Token header (same token as Brain)
19
+ - Both endpoints return JSON (STT) or raw WAV bytes (TTS)
20
+ - Target latency: STT ~300-500ms, TTS ~200-400ms on CPU
21
+
22
+ Pipecat pattern used (github.com/pipecat-ai/pipecat):
23
+ - stt.py: POST to https://api.groq.com/openai/v1/audio/transcriptions
24
+ multipart form: file=("audio.wav", bytes, "audio/wav"), model, language
25
+ response: {"text": "transcribed text"}
26
+ - Kokoro: KPipeline(lang_code='a'), pipeline(text, voice='af_heart')
27
+ yields (gs, ps, audio) tuples; audio is float32 numpy array
28
+ convert float32 -> int16 -> WAV bytes for return
29
+
30
+ Pre-registered bugs:
31
+ VS1 [HIGH] Kokoro KPipeline() blocks startup (model load ~5-15s, sync).
32
+ Fix: load in asyncio.get_event_loop().run_in_executor(None, _load_kokoro)
33
+ during lifespan. Startup returns while model loads.
34
+ If /tts called before model ready -> 503 "Model loading...".
35
+
36
+ VS2 [HIGH] Single GROQ_STT_KEY env — no pool rotation. If key hits rate limit,
37
+ all STT requests fail until cooldown. Fix: use GROQ_STT_KEY_0..N
38
+ indexed rotation (same pattern as brain KeyPool). Deferred to v26.
39
+
40
+ VS3 [MED] Kokoro output is float32 numpy array (range -1..1).
41
+ Must convert to int16 (multiply by 32767, clip, astype np.int16)
42
+ before writing WAV. Silent audio if conversion skipped.
43
+
44
+ VS4 [MED] Groq Whisper max file size = 25MB. Discord audio (~192kbps opus)
45
+ is much smaller, but if raw PCM uploaded: 16kHz * 2bytes * 60s = 1.9MB.
46
+ No risk at target usage. Add explicit 25MB limit guard anyway.
47
+
48
+ VS5 [LOW] Kokoro synthesis is synchronous (runs on CPU). For text > 200 chars,
49
+ synthesis takes 2-4s and blocks the event loop. Fix: run in executor.
50
+ Already handled in /tts via asyncio.get_event_loop().run_in_executor.
51
+
52
+ VS6 [LOW] WAV header requires sample_rate and n_channels. Kokoro default is
53
+ 24000 Hz mono. If Kokoro changes output rate, WAV will be distorted.
54
+ Fix: always read sample_rate from Kokoro output, not hardcoded.
55
+
56
+ Tool calls used writing this file (v25):
57
+ Github:get_file_contents x1 (pipecat/services/groq/stt.py — Whisper API pattern)
58
+ Github:get_file_contents x1 (pipecat/services/kokoro/ — TTS pattern)
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import asyncio
64
+ import hmac
65
+ import io
66
+ import logging
67
+ import os
68
+ import struct
69
+ import time
70
+ import wave
71
+ from contextlib import asynccontextmanager
72
+ from typing import Optional
73
+
74
+ import httpx
75
+ from fastapi import FastAPI, HTTPException, Request, UploadFile, File
76
+ from fastapi.middleware.cors import CORSMiddleware
77
+ from fastapi.responses import JSONResponse, Response
78
+ from pydantic import BaseModel
79
+
80
+ logger = logging.getLogger(__name__)
81
+ logging.basicConfig(
82
+ level=logging.INFO,
83
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
84
+ )
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Config
88
+ # ---------------------------------------------------------------------------
89
+
90
+ GROQ_STT_KEY: str = os.environ.get("GROQ_STT_KEY", os.environ.get("GROQ_API_KEY_0", ""))
91
+ GROQ_STT_URL: str = "https://api.groq.com/openai/v1/audio/transcriptions"
92
+ GROQ_STT_MODEL: str = "whisper-large-v3-turbo"
93
+ GROQ_STT_LANGUAGE: str = "en"
94
+
95
+ KOKORO_VOICE: str = os.environ.get("KOKORO_VOICE", "af_heart")
96
+ KOKORO_LANG: str = os.environ.get("KOKORO_LANG", "a") # 'a' = American English
97
+ KOKORO_SAMPLE_RATE: int = 24000 # VS6: read from pipeline ideally
98
+
99
+ ULTRON_AUTH_TOKEN: str = os.environ.get("ULTRON_AUTH_TOKEN", "")
100
+ MAX_AUDIO_BYTES: int = 25 * 1024 * 1024 # 25MB — Groq Whisper limit (VS4)
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Global state
104
+ # ---------------------------------------------------------------------------
105
+
106
+ _kokoro_pipeline = None # set in lifespan
107
+ _model_loading: bool = False
108
+ _model_ready: bool = False
109
+ _startup_time: float = 0.0
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Kokoro loader (blocking — run in executor)
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _load_kokoro_sync():
117
+ """Synchronous Kokoro model load. Called from executor to avoid blocking loop."""
118
+ global _kokoro_pipeline, _model_ready
119
+ try:
120
+ from kokoro import KPipeline # type: ignore
121
+ _kokoro_pipeline = KPipeline(lang_code=KOKORO_LANG)
122
+ _model_ready = True
123
+ logger.info(f"[Voice] Kokoro pipeline loaded. voice={KOKORO_VOICE} lang={KOKORO_LANG}")
124
+ except ImportError:
125
+ logger.warning("[Voice] Kokoro not installed — TTS will be unavailable. Install: pip install kokoro")
126
+ except Exception as e:
127
+ logger.error(f"[Voice] Kokoro load failed: {e}")
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # WAV helper
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def _to_wav_bytes(samples, sample_rate: int = KOKORO_SAMPLE_RATE) -> bytes:
135
+ """
136
+ Convert float32 numpy array to WAV bytes.
137
+ VS3: multiply by 32767, clip, cast to int16.
138
+ VS6: use sample_rate param (don't hardcode).
139
+ """
140
+ import numpy as np # type: ignore
141
+ pcm = (samples * 32767).clip(-32768, 32767).astype(np.int16)
142
+ buf = io.BytesIO()
143
+ with wave.open(buf, "wb") as wf:
144
+ wf.setnchannels(1)
145
+ wf.setsampwidth(2) # int16 = 2 bytes
146
+ wf.setframerate(sample_rate)
147
+ wf.writeframes(pcm.tobytes())
148
+ return buf.getvalue()
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Auth
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def _check_auth(request: Request) -> None:
156
+ if not ULTRON_AUTH_TOKEN:
157
+ return # dev mode — no auth
158
+ token = request.headers.get("X-Ultron-Token", "")
159
+ if not hmac.compare_digest(token, ULTRON_AUTH_TOKEN):
160
+ raise HTTPException(status_code=401, detail="Invalid or missing X-Ultron-Token.")
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Lifespan
165
+ # ---------------------------------------------------------------------------
166
+
167
+ @asynccontextmanager
168
+ async def lifespan(app: FastAPI):
169
+ global _startup_time, _model_loading
170
+ _startup_time = time.monotonic()
171
+ logger.info("[Voice] Ultron V4 Voice Space starting...")
172
+
173
+ if not GROQ_STT_KEY:
174
+ logger.warning("[Voice] GROQ_STT_KEY not set — STT will fail at runtime")
175
+
176
+ # VS1: load Kokoro in executor to avoid blocking startup
177
+ _model_loading = True
178
+ loop = asyncio.get_event_loop()
179
+ asyncio.create_task(
180
+ loop.run_in_executor(None, _load_kokoro_sync)
181
+ )
182
+ logger.info("[Voice] Kokoro loading in background (non-blocking)...")
183
+
184
+ elapsed = (time.monotonic() - _startup_time) * 1000
185
+ logger.info(f"[Voice] Voice Space READY in {elapsed:.1f}ms (Kokoro loading in background)")
186
+
187
+ yield
188
+
189
+ logger.info("[Voice] Voice Space shutting down.")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # FastAPI app
194
+ # ---------------------------------------------------------------------------
195
+
196
+ app = FastAPI(
197
+ title="Ultron V4 Voice",
198
+ version="4.0.0",
199
+ description="Ultron V4 — Voice pipeline. Groq Whisper STT + Kokoro TTS.",
200
+ lifespan=lifespan,
201
+ )
202
+
203
+ app.add_middleware(
204
+ CORSMiddleware,
205
+ allow_origins=["*"],
206
+ allow_methods=["POST", "GET"],
207
+ allow_headers=["*"],
208
+ )
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Request models
213
+ # ---------------------------------------------------------------------------
214
+
215
+ class TTSRequest(BaseModel):
216
+ text: str
217
+ voice: Optional[str] = None # override default voice
218
+ speed: Optional[float] = 1.0
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Endpoints
223
+ # ---------------------------------------------------------------------------
224
+
225
+ @app.get("/health")
226
+ async def health() -> JSONResponse:
227
+ uptime = time.monotonic() - _startup_time
228
+ return JSONResponse({
229
+ "status": "ok",
230
+ "uptime_seconds": round(uptime, 1),
231
+ "version": "4.0.0",
232
+ "stt": {
233
+ "provider": "groq",
234
+ "model": GROQ_STT_MODEL,
235
+ "key_set": bool(GROQ_STT_KEY),
236
+ },
237
+ "tts": {
238
+ "provider": "kokoro",
239
+ "model_ready": _model_ready,
240
+ "voice": KOKORO_VOICE,
241
+ },
242
+ })
243
+
244
+
245
+ @app.post("/stt")
246
+ async def stt(request: Request, audio: UploadFile = File(...)) -> JSONResponse:
247
+ """
248
+ Convert uploaded audio file to text via Groq Whisper.
249
+
250
+ Accepts: any audio format Groq supports (wav, mp3, ogg, flac, webm, m4a)
251
+ Returns: {"text": "transcribed text", "latency_ms": float}
252
+
253
+ VS4: enforces 25MB file size limit.
254
+ VS2: single key — no rotation yet. Upgrade to pool in v26.
255
+ """
256
+ _check_auth(request)
257
+
258
+ if not GROQ_STT_KEY:
259
+ raise HTTPException(status_code=503, detail="GROQ_STT_KEY not set — STT unavailable.")
260
+
261
+ t_start = time.monotonic()
262
+
263
+ # Read audio bytes
264
+ audio_bytes = await audio.read()
265
+ if len(audio_bytes) > MAX_AUDIO_BYTES: # VS4
266
+ raise HTTPException(
267
+ status_code=413,
268
+ detail=f"Audio too large: {len(audio_bytes) // 1024}KB > 25MB limit."
269
+ )
270
+ if not audio_bytes:
271
+ raise HTTPException(status_code=400, detail="Empty audio file.")
272
+
273
+ filename = audio.filename or "audio.wav"
274
+ content_type = audio.content_type or "audio/wav"
275
+
276
+ # POST to Groq Whisper (pipecat pattern: multipart form)
277
+ try:
278
+ async with httpx.AsyncClient(timeout=30.0) as client:
279
+ resp = await client.post(
280
+ GROQ_STT_URL,
281
+ headers={"Authorization": f"Bearer {GROQ_STT_KEY}"},
282
+ files={"file": (filename, audio_bytes, content_type)},
283
+ data={
284
+ "model": GROQ_STT_MODEL,
285
+ "language": GROQ_STT_LANGUAGE,
286
+ "response_format": "json",
287
+ },
288
+ )
289
+ except httpx.TimeoutException:
290
+ raise HTTPException(status_code=504, detail="Groq Whisper timed out.")
291
+ except Exception as e:
292
+ logger.error(f"[STT] Groq request failed: {e}")
293
+ raise HTTPException(status_code=500, detail=f"STT request failed: {e}")
294
+
295
+ if resp.status_code == 429:
296
+ raise HTTPException(status_code=429, detail="Groq STT rate limited. Try again shortly.")
297
+ if resp.status_code == 401:
298
+ raise HTTPException(status_code=401, detail="Groq STT key invalid.")
299
+ if resp.status_code != 200:
300
+ raise HTTPException(
301
+ status_code=502,
302
+ detail=f"Groq STT returned {resp.status_code}: {resp.text[:200]}"
303
+ )
304
+
305
+ try:
306
+ data = resp.json()
307
+ text = data.get("text", "").strip()
308
+ except Exception as e:
309
+ raise HTTPException(status_code=500, detail=f"STT response parse failed: {e}")
310
+
311
+ latency_ms = (time.monotonic() - t_start) * 1000
312
+ logger.info(f"[STT] transcribed {len(audio_bytes)} bytes -> {len(text)} chars in {latency_ms:.0f}ms")
313
+
314
+ return JSONResponse({
315
+ "text": text,
316
+ "latency_ms": round(latency_ms, 1),
317
+ "model": GROQ_STT_MODEL,
318
+ })
319
+
320
+
321
+ @app.post("/tts")
322
+ async def tts(body: TTSRequest, request: Request) -> Response:
323
+ """
324
+ Convert text to WAV audio via Kokoro TTS.
325
+
326
+ Returns: WAV bytes (Content-Type: audio/wav)
327
+ VS5: synthesis runs in executor to avoid blocking event loop.
328
+ VS3: float32 -> int16 conversion inside _to_wav_bytes.
329
+ VS1: returns 503 if model not yet loaded.
330
+ """
331
+ _check_auth(request)
332
+
333
+ if not _model_ready:
334
+ if _model_loading:
335
+ raise HTTPException(status_code=503, detail="Kokoro model still loading. Try again in 15s.")
336
+ raise HTTPException(status_code=503, detail="Kokoro TTS unavailable (model failed to load).")
337
+
338
+ text = body.text.strip()
339
+ if not text:
340
+ raise HTTPException(status_code=400, detail="Empty text.")
341
+ if len(text) > 2000:
342
+ raise HTTPException(status_code=400, detail="Text too long (max 2000 chars).")
343
+
344
+ voice = body.voice or KOKORO_VOICE
345
+ t_start = time.monotonic()
346
+
347
+ # VS5: run blocking synthesis in thread executor
348
+ loop = asyncio.get_event_loop()
349
+ try:
350
+ wav_bytes = await loop.run_in_executor(
351
+ None,
352
+ _synthesize_sync,
353
+ text,
354
+ voice,
355
+ )
356
+ except Exception as e:
357
+ logger.error(f"[TTS] Synthesis failed: {e}")
358
+ raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {e}")
359
+
360
+ latency_ms = (time.monotonic() - t_start) * 1000
361
+ logger.info(f"[TTS] synthesized {len(text)} chars -> {len(wav_bytes)} bytes in {latency_ms:.0f}ms voice={voice}")
362
+
363
+ return Response(
364
+ content=wav_bytes,
365
+ media_type="audio/wav",
366
+ headers={
367
+ "X-Latency-Ms": str(round(latency_ms, 1)),
368
+ "X-Voice": voice,
369
+ },
370
+ )
371
+
372
+
373
+ def _synthesize_sync(text: str, voice: str) -> bytes:
374
+ """
375
+ Blocking Kokoro synthesis. Run via executor.
376
+ Concatenates all audio chunks from generator into single WAV.
377
+ VS3: float32 -> int16 via _to_wav_bytes.
378
+ VS6: sample_rate read from Kokoro pipeline attribute.
379
+ """
380
+ import numpy as np # type: ignore
381
+
382
+ pipeline = _kokoro_pipeline
383
+ if pipeline is None:
384
+ raise RuntimeError("Kokoro pipeline not initialized")
385
+
386
+ # KPipeline(text, voice=...) returns generator of (graphemes, phonemes, audio_array)
387
+ audio_chunks = []
388
+ sample_rate = KOKORO_SAMPLE_RATE
389
+
390
+ try:
391
+ for gs, ps, audio in pipeline(text, voice=voice):
392
+ if audio is not None and len(audio) > 0:
393
+ audio_chunks.append(audio)
394
+ # VS6: try to read sample_rate from pipeline
395
+ if hasattr(pipeline, "sample_rate"):
396
+ sample_rate = pipeline.sample_rate
397
+ except Exception as e:
398
+ raise RuntimeError(f"Kokoro synthesis error: {e}")
399
+
400
+ if not audio_chunks:
401
+ raise RuntimeError("Kokoro produced no audio")
402
+
403
+ combined = np.concatenate(audio_chunks, axis=0)
404
+ return _to_wav_bytes(combined, sample_rate=sample_rate)
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Entrypoint
409
+ # ---------------------------------------------------------------------------
410
+
411
+ if __name__ == "__main__":
412
+ import uvicorn
413
+ uvicorn.run(
414
+ "packages.voice.server:app",
415
+ host="0.0.0.0",
416
+ port=7860,
417
+ log_level="info",
418
+ workers=1, # single worker — Kokoro model in global state
419
+ )