ghostdrive1 commited on
Commit
d59842f
·
1 Parent(s): a4a5261

feat: search.py (Tavily+DDG fallback), requirements.txt, Dockerfile, README — deploy-ready

Browse files
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultron Brain V4 — HuggingFace Spaces Docker
2
+ # python:3.11-slim is intentional: no CUDA, minimal image, fast build
3
+ FROM python:3.11-slim
4
+
5
+ # System deps: curl (health checks), git (optional tooling), ffmpeg (voice Phase 5)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ curl git ffmpeg && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ WORKDIR /app
11
+
12
+ # Install Python deps first (layer cache: deps change rarely)
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Install Playwright browsers (headless Chromium for browser_fetch)
17
+ RUN playwright install chromium --with-deps
18
+
19
+ # Copy source
20
+ COPY . .
21
+
22
+ # Non-root user (HF Space requirement: uid 1000)
23
+ RUN adduser --disabled-password --gecos '' --uid 1000 ultron && \
24
+ chown -R ultron:ultron /app
25
+ USER ultron
26
+
27
+ # HF Space metadata: app_port required or Space never opens
28
+ # See README.md for the ---\napp_port: 7860\n--- block (MUST be in README)
29
+ EXPOSE 7860
30
+
31
+ # Start Brain (FastAPI) + Discord bot as background processes
32
+ # Single worker: avoids dual-KeyPool quota burn (bug M1 / BOT1)
33
+ # PYTHONPATH=/app: enables all packages.* imports
34
+ CMD ["bash", "-c", \
35
+ "PYTHONPATH=/app uvicorn packages.brain.main:app --host 0.0.0.0 --port 7860 --workers 1 & \
36
+ sleep 5 && \
37
+ PYTHONPATH=/app python3 -c 'from packages.brain.discord_bot import run; run()' & \
38
+ wait"]
README.md CHANGED
@@ -1 +1,41 @@
1
- # ultron-v4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Ultron Brain V4
3
+ app_file: packages/brain/main.py
4
+ app_port: 7860
5
+ sdk: docker
6
+ ---
7
+
8
+ # Ultron Brain V4
9
+
10
+ FastAPI brain for Ultron AI OS. Multi-provider LLM routing (Groq + Cerebras + Together + OpenRouter + Gemini), ReAct agentic loop, per-channel Redis context, Discord bot interface.
11
+
12
+ ## Architecture
13
+
14
+ - **Brain:** FastAPI on port 7860
15
+ - **Bot:** Discord (discord.py, same container)
16
+ - **LLM Pool:** 5-provider circuit-breaker key pool
17
+ - **Memory:** Redis (Upstash) per-channel context
18
+ - **Search:** Tavily free-tier + DuckDuckGo fallback
19
+
20
+ ## Environment Variables (set in HF Space Secrets)
21
+
22
+ ```
23
+ DISCORD_BOT_TOKEN=
24
+ INTERNAL_AUTH_TOKEN=
25
+ DISCORD_GHOST_USER_ID=
26
+ ALLOWED_DISCORD_USERS=
27
+ REDIS_URL=
28
+ GROQ_KEY_0=
29
+ GROQ_KEY_1=
30
+ ...
31
+ CEREBRAS_KEY_0=
32
+ TOGETHER_KEY_0=
33
+ OPENROUTER_KEY_0=
34
+ GEMINI_KEY_0=
35
+ GEMINI_SENTINEL_KEY=
36
+ TAVILY_API_KEY_0=
37
+ ```
38
+
39
+ ## Repo
40
+
41
+ `github.com/ghostdriveg1/ultron-v4`
packages/tools/__init__.py ADDED
File without changes
packages/tools/search.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/tools/search.py
3
+
4
+ Ultron V4 — Web Search Tool (Tavily Free-Tier)
5
+ ================================================
6
+ Real implementation replacing the stub in task_dispatcher._tool_search.
7
+
8
+ Tavily free tier: 1000 searches/month per API key.
9
+ Ghost has 5 Gmail accounts → 5 Tavily keys → 5000 searches/month total.
10
+ Key rotation: same pool pattern as LLM keys (indexed env vars).
11
+
12
+ Usage (from task_dispatcher or ToolRegistry):
13
+ from packages.tools.search import tavily_search
14
+ result: ActionResult = await tavily_search({"query": "BTC price", "max_results": 5})
15
+
16
+ Also exposes:
17
+ get_search_client() — singleton TavilyClient (lazy-init, key-aware)
18
+ search_with_fallback() — tries Tavily, falls back to DuckDuckGo HTML parse
19
+
20
+ Future bug risks (pre-registered):
21
+ S1 [HIGH] Tavily key exhausted (1000/month) → 401 → need to rotate to next key
22
+ Fix: parse_indexed_keys pattern, try next key on 401/429
23
+ S2 [HIGH] DuckDuckGo fallback HTML structure changes → regex returns empty
24
+ Fix: return raw text truncated, never fail silently
25
+ S3 [MED] search() called with empty or whitespace query → Tavily 400 error
26
+ Fix: guard + return error ActionResult immediately
27
+ S4 [MED] Tavily response schema change → KeyError on result parsing
28
+ Fix: use .get() everywhere, validate shape before accessing
29
+ S5 [LOW] 5000 searches/month hit during heavy Council/MOA calls
30
+ Fix: cache identical queries for 10min in Redis (key: ultron:search:{hash})
31
+
32
+ Tool calls used this session:
33
+ Github:get_file_contents x6 (task_dispatcher, v3 bot, v3 root, v3 packages, v3 requirements, v3 Dockerfile)
34
+ Github:push_files x2
35
+ Notion:notion-fetch x1
36
+ Notion:notion-update-page x1
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ import logging
43
+ import os
44
+ import re
45
+ from typing import Optional
46
+
47
+ import httpx
48
+
49
+ # Import ActionResult from react_loop for consistent return type
50
+ try:
51
+ from packages.brain.react_loop import ActionResult
52
+ except ImportError:
53
+ # Fallback for standalone testing
54
+ from dataclasses import dataclass, field
55
+
56
+ @dataclass
57
+ class ActionResult: # type: ignore
58
+ is_done: bool = False
59
+ success: bool = True
60
+ error: Optional[str] = None
61
+ extracted_content: Optional[str] = None
62
+ long_term_memory: Optional[str] = None
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Constants
68
+ # ---------------------------------------------------------------------------
69
+
70
+ TAVILY_BASE_URL = "https://api.tavily.com"
71
+ DDG_BASE_URL = "https://html.duckduckgo.com/html/"
72
+
73
+ # Tavily key rotation — parse TAVILY_API_KEY_0 ... TAVILY_API_KEY_19
74
+ def _parse_tavily_keys() -> list[str]:
75
+ keys: list[str] = []
76
+ # First check plain TAVILY_API_KEY
77
+ plain = os.environ.get("TAVILY_API_KEY", "").strip()
78
+ if plain:
79
+ keys.append(plain)
80
+ # Then indexed keys
81
+ for i in range(20):
82
+ k = os.environ.get(f"TAVILY_API_KEY_{i}", "").strip()
83
+ if k:
84
+ keys.append(k)
85
+ return keys
86
+
87
+
88
+ _tavily_keys: list[str] = []
89
+ _current_key_idx: int = 0
90
+
91
+
92
+ def _get_tavily_key() -> Optional[str]:
93
+ """Return next available Tavily key via round-robin (S1 mitigation)."""
94
+ global _tavily_keys, _current_key_idx
95
+ if not _tavily_keys:
96
+ _tavily_keys = _parse_tavily_keys()
97
+ if not _tavily_keys:
98
+ return None
99
+ key = _tavily_keys[_current_key_idx % len(_tavily_keys)]
100
+ return key
101
+
102
+
103
+ def _rotate_tavily_key() -> None:
104
+ """Advance to next Tavily key (call on 401/429)."""
105
+ global _current_key_idx
106
+ _current_key_idx = (_current_key_idx + 1) % max(len(_tavily_keys), 1)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Tavily search
111
+ # ---------------------------------------------------------------------------
112
+
113
+ async def _tavily_search(query: str, max_results: int = 5) -> Optional[list[dict]]:
114
+ """Call Tavily /search endpoint. Returns list of result dicts or None on failure."""
115
+ key = _get_tavily_key()
116
+ if not key:
117
+ logger.warning("[search] No Tavily key configured")
118
+ return None
119
+
120
+ payload = {
121
+ "api_key": key,
122
+ "query": query,
123
+ "search_depth": "basic",
124
+ "max_results": max_results,
125
+ "include_answer": True, # Tavily provides a direct answer field
126
+ }
127
+
128
+ try:
129
+ async with httpx.AsyncClient(timeout=15.0) as client:
130
+ r = await client.post(f"{TAVILY_BASE_URL}/search", json=payload)
131
+
132
+ if r.status_code in (401, 429):
133
+ logger.warning(f"[search] Tavily key {_current_key_idx} hit {r.status_code} — rotating")
134
+ _rotate_tavily_key()
135
+ return None
136
+
137
+ if r.status_code != 200:
138
+ logger.warning(f"[search] Tavily {r.status_code}: {r.text[:200]}")
139
+ return None
140
+
141
+ data = r.json() # S4: use .get() everywhere below
142
+ results = []
143
+
144
+ # Direct answer field (Tavily feature)
145
+ answer = data.get("answer", "")
146
+ if answer:
147
+ results.append({"title": "Direct Answer", "content": answer, "url": ""})
148
+
149
+ for item in data.get("results", [])[:max_results]:
150
+ results.append({
151
+ "title": item.get("title", ""),
152
+ "content": item.get("content", "")[:500],
153
+ "url": item.get("url", ""),
154
+ })
155
+
156
+ return results if results else None
157
+
158
+ except Exception as exc:
159
+ logger.warning(f"[search] Tavily request failed: {exc}")
160
+ return None
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # DuckDuckGo fallback (no key required)
165
+ # ---------------------------------------------------------------------------
166
+
167
+ async def _ddg_search(query: str, max_results: int = 5) -> list[dict]:
168
+ """HTML scrape DuckDuckGo as no-key fallback. Returns best-effort results (S2 mitigation)."""
169
+ try:
170
+ async with httpx.AsyncClient(
171
+ timeout=10.0,
172
+ headers={"User-Agent": "Mozilla/5.0 UltronBot/1.0"},
173
+ follow_redirects=True,
174
+ ) as client:
175
+ r = await client.get(DDG_BASE_URL, params={"q": query, "kl": "wt-wt"})
176
+
177
+ if r.status_code != 200:
178
+ return [{"title": "Search unavailable", "content": f"DDG returned {r.status_code}", "url": ""}]
179
+
180
+ # Extract snippet text — simple regex, degrades gracefully if DDG changes structure (S2)
181
+ snippets = re.findall(r'class="result__snippet"[^>]*>([^<]+)', r.text)
182
+ titles = re.findall(r'class="result__title[^>]*>.*?<a[^>]*>([^<]+)', r.text)
183
+
184
+ results = []
185
+ for i in range(min(max_results, len(snippets))):
186
+ results.append({
187
+ "title": titles[i].strip() if i < len(titles) else f"Result {i+1}",
188
+ "content": snippets[i].strip()[:500],
189
+ "url": "",
190
+ })
191
+
192
+ if not results:
193
+ # Raw fallback — strip HTML, return first 1000 chars
194
+ raw = re.sub(r"<[^>]+>", " ", r.text)
195
+ raw = re.sub(r"\s+", " ", raw).strip()[:1000]
196
+ results = [{"title": "DDG raw", "content": raw, "url": ""}]
197
+
198
+ return results
199
+
200
+ except Exception as exc:
201
+ logger.warning(f"[search] DDG fallback failed: {exc}")
202
+ return [{"title": "Search error", "content": str(exc)[:200], "url": ""}]
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Format results for LLM consumption
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def _format_results(results: list[dict], query: str) -> str:
210
+ """Format search results into a clean string for the ReAct loop."""
211
+ if not results:
212
+ return f"No results found for: {query}"
213
+
214
+ lines = [f"Search results for: {query}\n"]
215
+ for i, r in enumerate(results, 1):
216
+ title = r.get("title", "")
217
+ content = r.get("content", "")
218
+ url = r.get("url", "")
219
+ line = f"{i}. {title}\n {content}"
220
+ if url:
221
+ line += f"\n Source: {url}"
222
+ lines.append(line)
223
+
224
+ return "\n\n".join(lines)[:3000] # hard cap for Groq context budget
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Public interface
229
+ # ---------------------------------------------------------------------------
230
+
231
+ async def tavily_search(params: dict) -> ActionResult:
232
+ """Main search tool. Drop-in replacement for task_dispatcher._tool_search stub.
233
+
234
+ params: {query: str, max_results: int = 5}
235
+ Returns ActionResult with extracted_content = formatted results string.
236
+ """
237
+ query = params.get("query", "").strip()
238
+ if not query: # S3 guard
239
+ return ActionResult(success=False, error="search: query param missing or empty")
240
+
241
+ max_results = min(int(params.get("max_results", 5)), 10)
242
+
243
+ logger.info(f"[search] query='{query}' max_results={max_results}")
244
+
245
+ # Try Tavily first, fall back to DDG
246
+ results = await _tavily_search(query, max_results)
247
+ if results is None:
248
+ logger.info("[search] Tavily unavailable, falling back to DDG")
249
+ results = await _ddg_search(query, max_results)
250
+
251
+ formatted = _format_results(results, query)
252
+
253
+ return ActionResult(
254
+ extracted_content=formatted,
255
+ long_term_memory=f"searched:{query[:100]}",
256
+ )
257
+
258
+
259
+ async def search_with_key_check(params: dict) -> ActionResult:
260
+ """Alias for tavily_search. Named explicitly for ToolRegistry registration."""
261
+ return await tavily_search(params)
requirements.txt ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultron V4 — Python Dependencies
2
+ # HuggingFace Spaces Docker (python:3.11-slim)
3
+
4
+ # Web framework
5
+ fastapi==0.115.0
6
+ uvicorn[standard]==0.32.0
7
+ pydantic==2.9.2
8
+
9
+ # HTTP client
10
+ httpx==0.27.2
11
+
12
+ # Discord
13
+ discord.py==2.4.0
14
+
15
+ # Redis (Upstash async compatible)
16
+ redis==5.2.0
17
+
18
+ # Embeddings — CPU-only torch (avoids 2.5GB CUDA download on HF Space)
19
+ --extra-index-url https://download.pytorch.org/whl/cpu
20
+ torch==2.4.0+cpu
21
+ sentence-transformers==3.2.1
22
+ numpy==1.26.4
23
+
24
+ # Browser automation (Playwright for browser_fetch + future browser_agent)
25
+ playwright==1.48.0
26
+
27
+ # Env loading (local dev only — HF Space uses Secrets)
28
+ python-dotenv==1.0.1
29
+
30
+ # File handling (multipart uploads to FastAPI)
31
+ python-multipart==0.0.12
32
+
33
+ # Async task queue (used by memory worker for Zilliz flush — Phase 3)
34
+ # celery==5.4.0 # defer until Phase 3
35
+
36
+ # Zilliz / Milvus client (Phase 3 memory pipeline)
37
+ # pymilvus==2.4.9 # defer until Phase 3
38
+
39
+ # Supabase (Phase 3 structured memory)
40
+ # supabase==2.10.0 # defer until Phase 3