ghostdrive1 commited on
Commit
1415cd6
·
1 Parent(s): b0e15a8

feat: Phase 4 sentinel.py — V4 Sentinel God Layer with KV routing, incident reporting, weekly audit, Notion write

Browse files
Files changed (1) hide show
  1. packages/brain/sentinel.py +551 -0
packages/brain/sentinel.py ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/brain/sentinel.py
3
+
4
+ Ultron V4 — Sentinel God Layer
5
+ ===============================
6
+ Authority: Highest. Never answers user queries. Only watches, decides, writes, repairs.
7
+
8
+ Responsibilities:
9
+ - KV routing table read/write (CF Worker routes based on this)
10
+ - Space health checks (<50ms, every request path)
11
+ - Failure detection → promote backup → write incident to Notion → Discord DM Ghost
12
+ - Weekly audit cron (triggered by GH Actions): reads logs → Gemini 1M context → Notion page
13
+ - /sentinel/event handler integration: receives structured events from main.py
14
+
15
+ Design:
16
+ - Dedicated Gemini 2.5 Pro key (GEMINI_SENTINEL_KEY). NEVER shares with general pool.
17
+ - All CF KV ops via REST API (no SDK dependency).
18
+ - Notion writes via direct REST API (Notion-Version: 2022-06-28).
19
+ - Discord DM to Ghost on any critical event.
20
+ - All methods are async. Sentinel is instantiated once in main.py lifespan.
21
+
22
+ Future bug risks (pre-registered):
23
+ S1 [HIGH] Gemini 2.5 Pro rate limit: 2 RPM on free tier. Weekly audit = 1 call.
24
+ But concurrent failure events (burst) can cause 429.
25
+ Fix: asyncio.Semaphore(1) on _call_sentinel(). Queues instead of drops.
26
+
27
+ S2 [HIGH] CF KV _kv_put with stale routing table: if two Sentinel instances (multi-worker
28
+ M1 scenario) both detect failure simultaneously, both promote backup.
29
+ Second write is safe (idempotent) but both fire Discord DMs.
30
+ Fix: use CF KV conditional write (If-Match ETag) when available.
31
+
32
+ S3 [MED] Notion REST write requires NOTION_SENTINEL_PAGE_ID and NOTION_TOKEN env vars.
33
+ If missing, incident write silently skips. Sentinel still DMs Ghost.
34
+ Fix: log loud warning at startup if Notion vars unset.
35
+
36
+ S4 [MED] Discord DM: if bot token revoked, DM fails silently. Sentinel still writes Notion.
37
+ Fix: add fallback webhook URL (SENTINEL_WEBHOOK_URL) as second DM channel.
38
+
39
+ S5 [LOW] weekly_audit() fetches Supabase logs up to 800k chars. Gemini 1M context
40
+ can handle but API timeout=120s may be insufficient for large log sets.
41
+ Fix: increase timeout to 240s for weekly audit call specifically.
42
+
43
+ Tool calls used writing this file:
44
+ Github:get_file_contents x1 (ultron-v3/packages/brain/sentinel.py — reference patterns)
45
+ Github:get_file_contents x1 (ultron-v4/packages/brain/main.py — confirmed app.state shape)
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import asyncio
51
+ import json
52
+ import logging
53
+ import time
54
+ from typing import Any, Optional
55
+
56
+ import httpx
57
+
58
+ log = logging.getLogger("sentinel")
59
+
60
+ SENTINEL_MODEL = "gemini-2.5-pro-preview-05-06"
61
+ ROUTING_TABLE_KEY = "ultron:routing:v4"
62
+
63
+ # Notion API constants
64
+ NOTION_API_BASE = "https://api.notion.com/v1"
65
+ NOTION_API_VERSION = "2022-06-28"
66
+
67
+
68
+ class Sentinel:
69
+ """
70
+ God Layer. Non-technical CTO. Watches everything. Controls routing.
71
+ Detects failures. Writes Notion. DMs Ghost. Zero user-facing latency.
72
+
73
+ Instantiated in main.py lifespan if GEMINI_SENTINEL_KEY is set.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ sentinel_key: str,
79
+ *,
80
+ cf_account_id: str = "",
81
+ cf_namespace_id: str = "",
82
+ cf_kv_token: str = "",
83
+ hf_primary_url: str = "",
84
+ hf_backup_url: str = "",
85
+ discord_bot_token: str = "",
86
+ discord_ghost_uid: str = "",
87
+ notion_token: str = "",
88
+ notion_incident_page_id: str = "",
89
+ supabase_url: str = "",
90
+ supabase_key: str = "",
91
+ ) -> None:
92
+ self._key = sentinel_key
93
+ self._cf_account_id = cf_account_id
94
+ self._cf_namespace_id = cf_namespace_id
95
+ self._cf_kv_token = cf_kv_token
96
+ self._hf_primary_url = hf_primary_url
97
+ self._hf_backup_url = hf_backup_url
98
+ self._discord_bot_token = discord_bot_token
99
+ self._discord_ghost_uid = discord_ghost_uid
100
+ self._notion_token = notion_token
101
+ self._notion_incident_pid = notion_incident_page_id
102
+ self._supabase_url = supabase_url
103
+ self._supabase_key = supabase_key
104
+ self._gemini_lock = asyncio.Semaphore(1) # S1 guard: 1 Gemini call at a time
105
+
106
+ # ──────────────────────────────────────────────────────────────────────
107
+ # Gemini 2.5 Pro — dedicated call
108
+ # ──────────────────────────────────────────────────────────────────────
109
+
110
+ async def _call_sentinel(
111
+ self,
112
+ prompt: str,
113
+ max_tokens: int = 2048,
114
+ timeout: float = 120.0,
115
+ ) -> str:
116
+ """Call Gemini 2.5 Pro via sentinel key. Rate-limited by asyncio.Semaphore(1)."""
117
+ async with self._gemini_lock: # S1 guard
118
+ url = (
119
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
120
+ f"{SENTINEL_MODEL}:generateContent"
121
+ )
122
+ payload = {
123
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
124
+ "generationConfig": {
125
+ "maxOutputTokens": max_tokens,
126
+ "temperature": 0.3,
127
+ },
128
+ "systemInstruction": {
129
+ "parts": [{
130
+ "text": (
131
+ "You are Sentinel, the God-layer AI of the Ultron system. "
132
+ "You are the non-technical CTO. You never answer user queries. "
133
+ "You only analyze, decide, and write structured reports. "
134
+ "Be concise, precise, and technical. Use markdown headers."
135
+ )
136
+ }]
137
+ },
138
+ }
139
+ try:
140
+ async with httpx.AsyncClient(timeout=timeout) as c:
141
+ r = await c.post(url, params={"key": self._key}, json=payload)
142
+ r.raise_for_status()
143
+ return r.json()["candidates"][0]["content"]["parts"][0]["text"]
144
+ except httpx.HTTPStatusError as e:
145
+ log.error(f"[Sentinel] Gemini API error: {e.response.status_code} {e.response.text[:200]}")
146
+ raise
147
+ except Exception as e:
148
+ log.error(f"[Sentinel] Gemini call failed: {e}")
149
+ raise
150
+
151
+ # ──────────────────────────────────────────────────────────────────────
152
+ # Cloudflare KV — routing table
153
+ # ──────────────────────────────────────────────────────────────────────
154
+
155
+ def _kv_base(self) -> str:
156
+ return (
157
+ f"https://api.cloudflare.com/client/v4/accounts/{self._cf_account_id}"
158
+ f"/storage/kv/namespaces/{self._cf_namespace_id}/values"
159
+ )
160
+
161
+ def _kv_headers(self) -> dict:
162
+ return {"Authorization": f"Bearer {self._cf_kv_token}"}
163
+
164
+ async def _kv_get(self, key: str) -> Optional[str]:
165
+ if not self._cf_kv_token:
166
+ return None
167
+ try:
168
+ async with httpx.AsyncClient(timeout=10) as c:
169
+ r = await c.get(f"{self._kv_base()}/{key}", headers=self._kv_headers())
170
+ if r.status_code == 404:
171
+ return None
172
+ r.raise_for_status()
173
+ return r.text
174
+ except Exception as e:
175
+ log.warning(f"[Sentinel] KV get failed key={key}: {e}")
176
+ return None
177
+
178
+ async def _kv_put(self, key: str, value: str) -> bool:
179
+ if not self._cf_kv_token:
180
+ return False
181
+ try:
182
+ async with httpx.AsyncClient(timeout=10) as c:
183
+ r = await c.put(
184
+ f"{self._kv_base()}/{key}",
185
+ headers=self._kv_headers(),
186
+ content=value,
187
+ )
188
+ r.raise_for_status()
189
+ return True
190
+ except Exception as e:
191
+ log.error(f"[Sentinel] KV put failed key={key}: {e}")
192
+ return False
193
+
194
+ # ──────────────────────────────────────────────────────────────────────
195
+ # Routing table
196
+ # ──────────────────────────────────────────────────────────────────────
197
+
198
+ async def get_routing_table(self) -> dict:
199
+ raw = await self._kv_get(ROUTING_TABLE_KEY)
200
+ if not raw:
201
+ return {
202
+ "primary": self._hf_primary_url,
203
+ "backup": self._hf_backup_url,
204
+ "updated_at": str(time.time()),
205
+ "version": "v4",
206
+ }
207
+ try:
208
+ return json.loads(raw)
209
+ except json.JSONDecodeError:
210
+ log.error("[Sentinel] Routing table corrupt JSON — using defaults")
211
+ return {"primary": self._hf_primary_url, "backup": self._hf_backup_url}
212
+
213
+ async def set_primary_space(self, url: str) -> bool:
214
+ table = await self.get_routing_table()
215
+ table["primary"] = url
216
+ table["updated_at"] = str(time.time())
217
+ ok = await self._kv_put(ROUTING_TABLE_KEY, json.dumps(table))
218
+ if ok:
219
+ log.info(f"[Sentinel] Routing updated → primary={url}")
220
+ return ok
221
+
222
+ async def check_space_health(self, url: str, timeout: float = 5.0) -> bool:
223
+ """Fast health check. Called on every request path — must be <50ms in p99."""
224
+ if not url:
225
+ return False
226
+ try:
227
+ async with httpx.AsyncClient(timeout=timeout) as c:
228
+ r = await c.get(f"{url}/health")
229
+ return r.status_code == 200
230
+ except Exception:
231
+ return False
232
+
233
+ # ──────────────────────────────────────────────────────────────────────
234
+ # Failure handling
235
+ # ──────────────────────────────────────────────────────────────────────
236
+
237
+ async def handle_space_failure(
238
+ self,
239
+ failed_url: str,
240
+ backup_url: str,
241
+ error: str,
242
+ ) -> None:
243
+ """
244
+ Called when primary Space fails health check.
245
+ Flow: promote backup → Gemini analysis → Notion incident page → Discord DM.
246
+ """
247
+ log.critical(f"[Sentinel] Space failure: failed={failed_url} promoting={backup_url}")
248
+
249
+ # 1. Promote backup to primary
250
+ await self.set_primary_space(backup_url)
251
+
252
+ # 2. Gemini incident analysis (best-effort)
253
+ analysis = ""
254
+ try:
255
+ analysis = await self._call_sentinel(
256
+ f"## Ultron Space Failure Incident\n\n"
257
+ f"Failed Space: {failed_url}\n"
258
+ f"Backup Promoted: {backup_url}\n"
259
+ f"Error: {error}\n\n"
260
+ f"Write structured incident report: Summary | Root Cause Hypothesis | "
261
+ f"Impact | Recovery Steps | Prevention. Max 300 words.",
262
+ max_tokens=512,
263
+ )
264
+ except Exception as e:
265
+ analysis = f"Gemini analysis unavailable: {e}"
266
+
267
+ # 3. Write Notion incident page
268
+ await self._write_notion_incident(
269
+ title=f"[INCIDENT] Space Failure — {time.strftime('%Y-%m-%d %H:%M UTC')}",
270
+ content=(
271
+ f"**Failed:** {failed_url}\n"
272
+ f"**Promoted:** {backup_url}\n"
273
+ f"**Error:** {error}\n\n"
274
+ f"## Sentinel Analysis\n\n{analysis}"
275
+ ),
276
+ )
277
+
278
+ # 4. Discord DM Ghost
279
+ msg = (
280
+ f"🚨 **SENTINEL INCIDENT**\n"
281
+ f"`{failed_url}` FAILED.\n"
282
+ f"Promoted `{backup_url}` to primary.\n\n"
283
+ f"**Analysis:**\n{analysis[:1200]}"
284
+ )
285
+ await self._discord_dm(msg)
286
+
287
+ # ──────────────────────────────────────────────────────────────────────
288
+ # Weekly audit
289
+ # ──────────────────────────────────────────────────────────────────────
290
+
291
+ async def weekly_audit(self) -> str:
292
+ """
293
+ Full week log read → Gemini 1M context analysis → Notion page → Discord DM.
294
+ Triggered by GH Actions cron (Sunday 23:59).
295
+ """
296
+ log.info("[Sentinel] Weekly audit starting...")
297
+ logs_text = "(Supabase not configured)"
298
+
299
+ if self._supabase_url and self._supabase_key:
300
+ try:
301
+ since = time.time() - 7 * 86_400
302
+ async with httpx.AsyncClient(timeout=30) as c:
303
+ r = await c.get(
304
+ f"{self._supabase_url}/rest/v1/logs",
305
+ params={
306
+ "ts": f"gte.{since}",
307
+ "select": "ts,component,level,msg,extra",
308
+ "order": "ts.asc",
309
+ "limit": "10000",
310
+ },
311
+ headers={
312
+ "apikey": self._supabase_key,
313
+ "Authorization": f"Bearer {self._supabase_key}",
314
+ },
315
+ )
316
+ logs_text = json.dumps(r.json(), separators=(",", ":"))[:800_000]
317
+ except Exception as e:
318
+ logs_text = f"Log fetch error: {e}"
319
+
320
+ try:
321
+ report = await self._call_sentinel(
322
+ f"## Weekly Audit — Ultron V4 System\n\n"
323
+ f"Date: {time.strftime('%Y-%m-%d %H:%M UTC')}\n\n"
324
+ f"LOGS (7 days):\n{logs_text}\n\n"
325
+ f"Write structured report:\n"
326
+ f"## Component Health\n## LLM Stats\n## Memory Hit Rates\n"
327
+ f"## Top 5 Errors + Root Cause Hypothesis\n"
328
+ f"## Key Rotation Events\n## Sentinel Recommendations for Next Version\n\n"
329
+ f"Max 1000 words.",
330
+ max_tokens=1800,
331
+ timeout=240.0, # S5: longer timeout for large log sets
332
+ )
333
+ except Exception as e:
334
+ report = f"Weekly audit Gemini call failed: {e}"
335
+
336
+ # Write to Notion
337
+ await self._write_notion_incident(
338
+ title=f"📊 Sentinel Weekly Audit — {time.strftime('%Y-%m-%d')}",
339
+ content=report,
340
+ )
341
+
342
+ # DM Ghost
343
+ dm_msg = f"📊 **SENTINEL WEEKLY AUDIT**\n\n{report[:1800]}"
344
+ await self._discord_dm(dm_msg)
345
+
346
+ log.info("[Sentinel] Weekly audit complete.")
347
+ return report
348
+
349
+ # ──────────────────────────────────────────────────────────────────────
350
+ # Event handler (called from main.py /sentinel/event)
351
+ # ──────────────────────────────────────────────────────────────────────
352
+
353
+ async def handle_event(self, event_type: str, payload: dict) -> dict:
354
+ """
355
+ Central event dispatcher. Called by main.py /sentinel/event endpoint.
356
+
357
+ event_type:
358
+ "space_failure" → handle_space_failure()
359
+ "routing_override" → set_primary_space(url)
360
+ "health_check" → check_space_health(url)
361
+ "weekly_audit" → weekly_audit()
362
+ "project_plan" → generate_project_plan(brief)
363
+ """
364
+ log.info(f"[Sentinel] handle_event type={event_type} payload_keys={list(payload.keys())}")
365
+
366
+ if event_type == "space_failure":
367
+ await self.handle_space_failure(
368
+ failed_url=payload.get("failed_url", ""),
369
+ backup_url=payload.get("backup_url", self._hf_backup_url),
370
+ error=payload.get("error", "unknown"),
371
+ )
372
+ return {"status": "failover_complete"}
373
+
374
+ elif event_type == "routing_override":
375
+ url = payload.get("url", "")
376
+ ok = await self.set_primary_space(url)
377
+ return {"status": "ok" if ok else "kv_write_failed", "primary": url}
378
+
379
+ elif event_type == "health_check":
380
+ url = payload.get("url", self._hf_primary_url)
381
+ result = await self.check_space_health(url)
382
+ return {"url": url, "healthy": result}
383
+
384
+ elif event_type == "weekly_audit":
385
+ report = await self.weekly_audit()
386
+ return {"status": "complete", "report_preview": report[:200]}
387
+
388
+ elif event_type == "project_plan":
389
+ brief = payload.get("brief", "")
390
+ plan = await self.generate_project_plan(brief)
391
+ return {"status": "complete", "plan": plan}
392
+
393
+ else:
394
+ log.warning(f"[Sentinel] Unknown event_type={event_type}")
395
+ return {"status": "unknown_event_type"}
396
+
397
+ # ──────────────────────────────────────────────────────────────────────
398
+ # Project plan generation
399
+ # ──────────────────────────────────────────────────────────────────────
400
+
401
+ async def generate_project_plan(self, brief: str) -> str:
402
+ return await self._call_sentinel(
403
+ f"## Project Operational Plan\n\n"
404
+ f"Brief: {brief}\n\n"
405
+ f"Generate: DevOps strategy | Memory architecture | MOA config | "
406
+ f"Tool assignments | Success criteria | Risk assessment. Max 600 words.",
407
+ max_tokens=1200,
408
+ )
409
+
410
+ # ──────────────────────────────────────────────────────────────────────
411
+ # Notion write
412
+ # ──────────────────────────────────────────────────────────────────────
413
+
414
+ async def _write_notion_incident(
415
+ self,
416
+ title: str,
417
+ content: str,
418
+ ) -> None:
419
+ """
420
+ Create a new child page under NOTION_SENTINEL_PAGE_ID.
421
+ S3 guard: if notion creds not set, warn and skip.
422
+ """
423
+ if not self._notion_token or not self._notion_incident_pid:
424
+ log.warning("[Sentinel] Notion creds unset — skipping incident write (S3)")
425
+ return
426
+
427
+ headers = {
428
+ "Authorization": f"Bearer {self._notion_token}",
429
+ "Notion-Version": NOTION_API_VERSION,
430
+ "Content-Type": "application/json",
431
+ }
432
+
433
+ body = {
434
+ "parent": {"page_id": self._notion_incident_pid},
435
+ "properties": {
436
+ "title": {
437
+ "title": [{"type": "text", "text": {"content": title}}]
438
+ }
439
+ },
440
+ "children": [
441
+ {
442
+ "object": "block",
443
+ "type": "paragraph",
444
+ "paragraph": {
445
+ "rich_text": [{
446
+ "type": "text",
447
+ "text": {"content": content[:2000]} # Notion block limit
448
+ }]
449
+ },
450
+ }
451
+ ],
452
+ }
453
+
454
+ # If content > 2000 chars, add a second paragraph block
455
+ if len(content) > 2000:
456
+ body["children"].append({
457
+ "object": "block",
458
+ "type": "paragraph",
459
+ "paragraph": {
460
+ "rich_text": [{
461
+ "type": "text",
462
+ "text": {"content": content[2000:4000]}
463
+ }]
464
+ },
465
+ })
466
+
467
+ try:
468
+ async with httpx.AsyncClient(timeout=15) as c:
469
+ r = await c.post(
470
+ f"{NOTION_API_BASE}/pages",
471
+ headers=headers,
472
+ json=body,
473
+ )
474
+ r.raise_for_status()
475
+ log.info(f"[Sentinel] Notion incident page created: {title}")
476
+ except httpx.HTTPStatusError as e:
477
+ log.error(
478
+ f"[Sentinel] Notion write failed: {e.response.status_code} "
479
+ f"{e.response.text[:200]}"
480
+ )
481
+ except Exception as e:
482
+ log.error(f"[Sentinel] Notion write exception: {e}")
483
+
484
+ # ──────────────────────────────────────────────────────────────────────
485
+ # Discord DM
486
+ # ──────────────────────────────────────────────────────────────────────
487
+
488
+ async def _discord_dm(self, message: str) -> None:
489
+ """DM Ghost via Discord bot. S4: silent fail if token revoked."""
490
+ if not self._discord_bot_token or not self._discord_ghost_uid:
491
+ log.warning("[Sentinel] Discord bot/UID not set — skipping DM (S4)")
492
+ return
493
+
494
+ headers = {
495
+ "Authorization": f"Bot {self._discord_bot_token}",
496
+ "Content-Type": "application/json",
497
+ }
498
+ try:
499
+ async with httpx.AsyncClient(timeout=15) as c:
500
+ dm = await c.post(
501
+ "https://discord.com/api/v10/users/@me/channels",
502
+ headers=headers,
503
+ json={"recipient_id": self._discord_ghost_uid},
504
+ )
505
+ if dm.status_code not in (200, 201):
506
+ log.error(f"[Sentinel] DM channel creation failed: {dm.status_code}")
507
+ return
508
+ channel_id = dm.json()["id"]
509
+ # Chunk into 1990-char Discord safe pieces
510
+ for chunk in [message[i:i + 1990] for i in range(0, len(message), 1990)]:
511
+ await c.post(
512
+ f"https://discord.com/api/v10/channels/{channel_id}/messages",
513
+ headers=headers,
514
+ json={"content": chunk},
515
+ )
516
+ except Exception as e:
517
+ log.error(f"[Sentinel] Discord DM failed: {e}")
518
+
519
+
520
+ # ──────────────────────────────────────────────────────────────────────────────
521
+ # Factory — called from main.py lifespan
522
+ # ──────────────────────────────────────────────────────────────────────────────
523
+
524
+ def build_sentinel(settings: Any) -> Optional["Sentinel"]:
525
+ """
526
+ Build Sentinel from settings. Returns None if GEMINI_SENTINEL_KEY not set.
527
+ Called during FastAPI lifespan — non-fatal if Sentinel unavailable.
528
+ """
529
+ import os
530
+ sentinel_key = (
531
+ getattr(settings, "gemini_sentinel_key", "")
532
+ or os.environ.get("GEMINI_SENTINEL_KEY", "")
533
+ )
534
+ if not sentinel_key:
535
+ log.warning("[Sentinel] GEMINI_SENTINEL_KEY not set — Sentinel INACTIVE")
536
+ return None
537
+
538
+ return Sentinel(
539
+ sentinel_key=sentinel_key,
540
+ cf_account_id=getattr(settings, "cf_account_id", "") or os.environ.get("CF_ACCOUNT_ID", ""),
541
+ cf_namespace_id=getattr(settings, "cf_kv_namespace_id", "") or os.environ.get("CF_KV_NAMESPACE_ID", ""),
542
+ cf_kv_token=getattr(settings, "cf_kv_api_token", "") or os.environ.get("CF_KV_API_TOKEN", ""),
543
+ hf_primary_url=os.environ.get("HF_PRIMARY_URL", "https://ghostdrive1-ultron1.hf.space"),
544
+ hf_backup_url=os.environ.get("HF_BACKUP_URL", ""),
545
+ discord_bot_token=getattr(settings, "discord_bot_token", "") or os.environ.get("DISCORD_BOT_TOKEN", ""),
546
+ discord_ghost_uid=os.environ.get("DISCORD_GHOST_UID", ""),
547
+ notion_token=os.environ.get("NOTION_TOKEN", ""),
548
+ notion_incident_page_id=os.environ.get("NOTION_SENTINEL_PAGE_ID", ""),
549
+ supabase_url=getattr(settings, "supabase_url", "") or os.environ.get("SUPABASE_URL", ""),
550
+ supabase_key=getattr(settings, "supabase_key", "") or os.environ.get("SUPABASE_KEY", ""),
551
+ )