oxe 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oxe/__init__.py +3 -0
- oxe/__main__.py +19 -0
- oxe/cache.py +245 -0
- oxe/exa_compat.py +131 -0
- oxe/mcp_server.py +84 -0
- oxe/search.py +33 -0
- oxe/server.py +206 -0
- oxe/static/app.js +190 -0
- oxe/static/ui.css +456 -0
- oxe/static/ui.js +11 -0
- oxe/ui.py +396 -0
- oxe-0.1.0.dist-info/METADATA +267 -0
- oxe-0.1.0.dist-info/RECORD +17 -0
- oxe-0.1.0.dist-info/WHEEL +5 -0
- oxe-0.1.0.dist-info/entry_points.txt +3 -0
- oxe-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxe-0.1.0.dist-info/top_level.txt +1 -0
oxe/__init__.py
ADDED
oxe/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from .server import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
uvicorn.run(
|
|
10
|
+
app,
|
|
11
|
+
host="127.0.0.1",
|
|
12
|
+
port=int(os.getenv("OXE_PORT", "4479")),
|
|
13
|
+
workers=1,
|
|
14
|
+
log_level=os.getenv("OXE_LOG_LEVEL", "info").lower(),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
oxe/cache.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
PRAGMA journal_mode = WAL;
|
|
11
|
+
PRAGMA synchronous = NORMAL;
|
|
12
|
+
PRAGMA cache_size = -32000;
|
|
13
|
+
PRAGMA temp_store = MEMORY;
|
|
14
|
+
PRAGMA busy_timeout = 5000;
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
17
|
+
query_hash TEXT PRIMARY KEY,
|
|
18
|
+
query_text TEXT NOT NULL,
|
|
19
|
+
response BLOB NOT NULL,
|
|
20
|
+
expires_at INTEGER NOT NULL,
|
|
21
|
+
hits INTEGER NOT NULL DEFAULT 0
|
|
22
|
+
) WITHOUT ROWID;
|
|
23
|
+
CREATE INDEX IF NOT EXISTS expires_idx ON cache(expires_at);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS clicks (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
query_hash TEXT NOT NULL,
|
|
28
|
+
result_id TEXT NOT NULL,
|
|
29
|
+
url TEXT NOT NULL,
|
|
30
|
+
title TEXT NOT NULL,
|
|
31
|
+
clicked_at INTEGER NOT NULL,
|
|
32
|
+
source TEXT NOT NULL DEFAULT 'web'
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS clicks_query_idx ON clicks(query_hash);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS clicks_recent_idx ON clicks(clicked_at);
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TTLCache:
|
|
40
|
+
def __init__(self, db_path: str | os.PathLike[str]):
|
|
41
|
+
self.db_path = str(db_path)
|
|
42
|
+
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
|
|
45
|
+
self._conn.executescript(SCHEMA)
|
|
46
|
+
self._conn.execute("PRAGMA optimize;")
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> dict | None:
|
|
49
|
+
now = int(time.time())
|
|
50
|
+
with self._lock:
|
|
51
|
+
row = self._conn.execute(
|
|
52
|
+
"SELECT response, expires_at FROM cache WHERE query_hash = ?", (key,)
|
|
53
|
+
).fetchone()
|
|
54
|
+
if row is None:
|
|
55
|
+
return None
|
|
56
|
+
blob, expires_at = row
|
|
57
|
+
if expires_at < now:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
payload = gzip.decompress(blob)
|
|
61
|
+
except (OSError, gzip.BadGzipFile):
|
|
62
|
+
return None
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._conn.execute(
|
|
65
|
+
"UPDATE cache SET hits = hits + 1 WHERE query_hash = ?", (key,)
|
|
66
|
+
)
|
|
67
|
+
return json.loads(payload)
|
|
68
|
+
|
|
69
|
+
def set(self, key: str, value: dict, ttl: int) -> None:
|
|
70
|
+
payload = gzip.compress(json.dumps(value, separators=(",", ":")).encode("utf-8"))
|
|
71
|
+
expires_at = int(time.time()) + ttl
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._conn.execute(
|
|
74
|
+
"INSERT OR REPLACE INTO cache (query_hash, query_text, response, expires_at, hits) "
|
|
75
|
+
"VALUES (?, ?, ?, ?, COALESCE((SELECT hits FROM cache WHERE query_hash = ?), 0))",
|
|
76
|
+
(key, value.get("_q", ""), payload, expires_at, key),
|
|
77
|
+
)
|
|
78
|
+
self._conn.execute("DELETE FROM cache WHERE expires_at < ?", (expires_at - ttl - 1,))
|
|
79
|
+
|
|
80
|
+
def invalidate(self) -> int:
|
|
81
|
+
with self._lock:
|
|
82
|
+
cur = self._conn.execute("DELETE FROM cache")
|
|
83
|
+
return cur.rowcount
|
|
84
|
+
|
|
85
|
+
def list_rows(
|
|
86
|
+
self,
|
|
87
|
+
q: str | None = None,
|
|
88
|
+
include_expired: bool = False,
|
|
89
|
+
limit: int = 100,
|
|
90
|
+
offset: int = 0,
|
|
91
|
+
) -> list[dict]:
|
|
92
|
+
now = int(time.time())
|
|
93
|
+
clauses: list[str] = []
|
|
94
|
+
params: list = []
|
|
95
|
+
if not include_expired:
|
|
96
|
+
clauses.append("expires_at >= ?")
|
|
97
|
+
params.append(now)
|
|
98
|
+
if q:
|
|
99
|
+
clauses.append("query_text LIKE ?")
|
|
100
|
+
params.append(f"%{q}%")
|
|
101
|
+
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
102
|
+
sql = (
|
|
103
|
+
"SELECT query_hash, query_text, expires_at, hits, length(response) "
|
|
104
|
+
f"FROM cache{where} ORDER BY expires_at DESC LIMIT ? OFFSET ?"
|
|
105
|
+
)
|
|
106
|
+
params.extend([limit, offset])
|
|
107
|
+
with self._lock:
|
|
108
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
"hash": h,
|
|
112
|
+
"query": qtext,
|
|
113
|
+
"expires_at": exp,
|
|
114
|
+
"hits": hits,
|
|
115
|
+
"size_bytes": size,
|
|
116
|
+
"expired": exp < now,
|
|
117
|
+
}
|
|
118
|
+
for h, qtext, exp, hits, size in rows
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
def peek(self, key: str) -> dict | None:
|
|
122
|
+
return self.get(key)
|
|
123
|
+
|
|
124
|
+
def delete(self, key: str) -> bool:
|
|
125
|
+
with self._lock:
|
|
126
|
+
cur = self._conn.execute("DELETE FROM cache WHERE query_hash = ?", (key,))
|
|
127
|
+
return cur.rowcount > 0
|
|
128
|
+
|
|
129
|
+
def stats(self) -> dict:
|
|
130
|
+
now = int(time.time())
|
|
131
|
+
with self._lock:
|
|
132
|
+
total = self._conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
|
133
|
+
unexpired = self._conn.execute(
|
|
134
|
+
"SELECT COUNT(*) FROM cache WHERE expires_at >= ?", (now,)
|
|
135
|
+
).fetchone()[0]
|
|
136
|
+
total_hits = self._conn.execute("SELECT COALESCE(SUM(hits), 0) FROM cache").fetchone()[0]
|
|
137
|
+
oldest = self._conn.execute(
|
|
138
|
+
"SELECT MIN(expires_at) FROM cache WHERE expires_at >= ?", (now,)
|
|
139
|
+
).fetchone()[0]
|
|
140
|
+
newest_row = self._conn.execute(
|
|
141
|
+
"SELECT MAX(expires_at) FROM cache"
|
|
142
|
+
).fetchone()[0]
|
|
143
|
+
db_size = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
|
|
144
|
+
return {
|
|
145
|
+
"rows": total,
|
|
146
|
+
"unexpired_rows": unexpired,
|
|
147
|
+
"db_size_bytes": db_size,
|
|
148
|
+
"total_hits": total_hits,
|
|
149
|
+
"oldest_unexpired": oldest,
|
|
150
|
+
"newest": newest_row,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._conn.close()
|
|
156
|
+
|
|
157
|
+
# -- click tracking -----------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def record_click(
|
|
160
|
+
self,
|
|
161
|
+
query_hash: str,
|
|
162
|
+
result_id: str,
|
|
163
|
+
url: str,
|
|
164
|
+
title: str,
|
|
165
|
+
source: str = "web",
|
|
166
|
+
) -> int:
|
|
167
|
+
now = int(time.time())
|
|
168
|
+
with self._lock:
|
|
169
|
+
cur = self._conn.execute(
|
|
170
|
+
"INSERT INTO clicks (query_hash, result_id, url, title, clicked_at, source) "
|
|
171
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
172
|
+
(query_hash, result_id, url, title, now, source),
|
|
173
|
+
)
|
|
174
|
+
return cur.lastrowid or 0
|
|
175
|
+
|
|
176
|
+
def get_clicks(
|
|
177
|
+
self,
|
|
178
|
+
query_hash: str | None = None,
|
|
179
|
+
query_text: str | None = None,
|
|
180
|
+
limit: int = 50,
|
|
181
|
+
since_hours: int | None = None,
|
|
182
|
+
) -> list[dict]:
|
|
183
|
+
clauses: list[str] = []
|
|
184
|
+
params: list = []
|
|
185
|
+
join = " LEFT JOIN cache k ON k.query_hash = c.query_hash "
|
|
186
|
+
if query_hash:
|
|
187
|
+
clauses.append("c.query_hash = ?")
|
|
188
|
+
params.append(query_hash)
|
|
189
|
+
if query_text:
|
|
190
|
+
clauses.append("k.query_text LIKE ?")
|
|
191
|
+
params.append(f"%{query_text}%")
|
|
192
|
+
if since_hours is not None:
|
|
193
|
+
clauses.append("c.clicked_at >= ?")
|
|
194
|
+
params.append(int(time.time()) - since_hours * 3600)
|
|
195
|
+
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
196
|
+
sql = (
|
|
197
|
+
"SELECT c.id, c.query_hash, COALESCE(k.query_text, ''), c.result_id, c.url, "
|
|
198
|
+
"c.title, c.clicked_at, c.source "
|
|
199
|
+
f"FROM clicks c{join}{where} ORDER BY c.clicked_at DESC LIMIT ?"
|
|
200
|
+
)
|
|
201
|
+
params.append(limit)
|
|
202
|
+
with self._lock:
|
|
203
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
"id": cid,
|
|
207
|
+
"query_hash": qh,
|
|
208
|
+
"query": qt,
|
|
209
|
+
"result_id": rid,
|
|
210
|
+
"url": url,
|
|
211
|
+
"title": title,
|
|
212
|
+
"clicked_at": ts,
|
|
213
|
+
"source": src,
|
|
214
|
+
}
|
|
215
|
+
for cid, qh, qt, rid, url, title, ts, src in rows
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def click_stats(self) -> dict:
|
|
219
|
+
now = int(time.time())
|
|
220
|
+
with self._lock:
|
|
221
|
+
total = self._conn.execute("SELECT COUNT(*) FROM clicks").fetchone()[0]
|
|
222
|
+
last_24h = self._conn.execute(
|
|
223
|
+
"SELECT COUNT(*) FROM clicks WHERE clicked_at >= ?", (now - 86400,)
|
|
224
|
+
).fetchone()[0]
|
|
225
|
+
oldest = self._conn.execute("SELECT MIN(clicked_at) FROM clicks").fetchone()[0]
|
|
226
|
+
return {"total": total, "last_24h": last_24h, "oldest": oldest}
|
|
227
|
+
|
|
228
|
+
def prune_clicks(self, retention_days: int) -> int:
|
|
229
|
+
cutoff = int(time.time()) - retention_days * 86400
|
|
230
|
+
with self._lock:
|
|
231
|
+
cur = self._conn.execute("DELETE FROM clicks WHERE clicked_at < ?", (cutoff,))
|
|
232
|
+
return cur.rowcount
|
|
233
|
+
|
|
234
|
+
def delete_clicks(self, scope: str) -> int:
|
|
235
|
+
"""scope: '24h' deletes last 24h, 'all' deletes everything."""
|
|
236
|
+
with self._lock:
|
|
237
|
+
if scope == "all":
|
|
238
|
+
cur = self._conn.execute("DELETE FROM clicks")
|
|
239
|
+
elif scope == "24h":
|
|
240
|
+
cur = self._conn.execute(
|
|
241
|
+
"DELETE FROM clicks WHERE clicked_at >= ?", (int(time.time()) - 86400,)
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
return 0
|
|
245
|
+
return cur.rowcount
|
oxe/exa_compat.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from ddgs import DDGS
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
|
13
|
+
_NUM_CLAMP = (1, 30)
|
|
14
|
+
_FAVICON = "https://www.google.com/s2/favicons?domain={netloc}&sz=32"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cache_key(req: dict) -> str:
|
|
18
|
+
contents = req.get("contents") or {}
|
|
19
|
+
norm = (
|
|
20
|
+
(req.get("query") or "").lower().strip(),
|
|
21
|
+
max(_NUM_CLAMP[0], min(_NUM_CLAMP[1], int(req.get("numResults") or 10))),
|
|
22
|
+
req.get("type") or "auto",
|
|
23
|
+
tuple(sorted(req.get("includeDomains") or [])),
|
|
24
|
+
tuple(sorted(req.get("excludeDomains") or [])),
|
|
25
|
+
bool(contents.get("highlights")),
|
|
26
|
+
bool(contents.get("text")),
|
|
27
|
+
)
|
|
28
|
+
return hashlib.sha256(repr(norm).encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_query(req: dict) -> str:
|
|
32
|
+
parts: list[str] = [(req.get("query") or "").strip()]
|
|
33
|
+
for d in req.get("includeDomains") or []:
|
|
34
|
+
if d:
|
|
35
|
+
parts.append(f"site:{d}")
|
|
36
|
+
for d in req.get("excludeDomains") or []:
|
|
37
|
+
if d:
|
|
38
|
+
parts.append(f"-site:{d}")
|
|
39
|
+
return " ".join(p for p in parts if p)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_highlights(body: str | None, max_n: int = 3) -> list[str]:
|
|
43
|
+
if not body:
|
|
44
|
+
return []
|
|
45
|
+
sentences = [s.strip() for s in _SENTENCE_SPLIT.split(body) if s.strip()]
|
|
46
|
+
return sentences[:max_n]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _favicon_for(url: str) -> str:
|
|
50
|
+
try:
|
|
51
|
+
netloc = urlparse(url).netloc
|
|
52
|
+
except ValueError:
|
|
53
|
+
netloc = ""
|
|
54
|
+
return _FAVICON.format(netloc=netloc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dgr_to_exa(r: dict, contents_highlights: bool, contents_text: bool) -> dict:
|
|
58
|
+
href = r.get("href") or ""
|
|
59
|
+
body = r.get("body") or ""
|
|
60
|
+
text = body if contents_text else ""
|
|
61
|
+
highlights = _extract_highlights(body) if contents_highlights else []
|
|
62
|
+
return {
|
|
63
|
+
"title": r.get("title") or "",
|
|
64
|
+
"url": href,
|
|
65
|
+
"id": href,
|
|
66
|
+
"text": text,
|
|
67
|
+
"highlights": highlights,
|
|
68
|
+
"highlightScores": [0.5] * len(highlights),
|
|
69
|
+
"publishedDate": None,
|
|
70
|
+
"author": None,
|
|
71
|
+
"image": None,
|
|
72
|
+
"favicon": _favicon_for(href),
|
|
73
|
+
"extras": {"links": []},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def search(req: dict) -> dict:
|
|
78
|
+
ignored = []
|
|
79
|
+
for field in ("startPublishedDate", "endPublishedDate", "additionalQueries",
|
|
80
|
+
"systemPrompt", "outputSchema", "stream"):
|
|
81
|
+
if req.get(field):
|
|
82
|
+
ignored.append(field)
|
|
83
|
+
if (req.get("contents") or {}).get("summary"):
|
|
84
|
+
ignored.append("contents.summary")
|
|
85
|
+
if ignored:
|
|
86
|
+
log.warning("exa_compat: ignoring unsupported fields: %s", ignored)
|
|
87
|
+
|
|
88
|
+
contents = req.get("contents") or {}
|
|
89
|
+
contents_highlights = bool(contents.get("highlights"))
|
|
90
|
+
contents_text = bool(contents.get("text"))
|
|
91
|
+
|
|
92
|
+
num_results = max(_NUM_CLAMP[0], min(_NUM_CLAMP[1], int(req.get("numResults") or 10)))
|
|
93
|
+
query = build_query(req)
|
|
94
|
+
search_type = req.get("type") or "auto"
|
|
95
|
+
|
|
96
|
+
region = "wt-wt" if search_type == "instant" else None
|
|
97
|
+
timelimit = "d" if req.get("category") == "news" else None
|
|
98
|
+
|
|
99
|
+
backends_to_try = ["duckduckgo", "auto"]
|
|
100
|
+
raw: list[dict[str, Any]] = []
|
|
101
|
+
last_err: Exception | None = None
|
|
102
|
+
for backend in backends_to_try:
|
|
103
|
+
try:
|
|
104
|
+
kwargs: dict[str, Any] = dict(
|
|
105
|
+
query=query,
|
|
106
|
+
max_results=num_results,
|
|
107
|
+
backend=backend,
|
|
108
|
+
safesearch="moderate",
|
|
109
|
+
)
|
|
110
|
+
if region:
|
|
111
|
+
kwargs["region"] = region
|
|
112
|
+
if timelimit:
|
|
113
|
+
kwargs["timelimit"] = timelimit
|
|
114
|
+
raw = list(DDGS().text(**kwargs))
|
|
115
|
+
if raw:
|
|
116
|
+
break
|
|
117
|
+
except Exception as e:
|
|
118
|
+
last_err = e
|
|
119
|
+
log.warning("exa_compat: backend %s failed: %s", backend, e)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if not raw and last_err is not None:
|
|
123
|
+
log.error("exa_compat: all backends failed, last error: %s", last_err)
|
|
124
|
+
|
|
125
|
+
results = [_dgr_to_exa(r, contents_highlights, contents_text) for r in raw]
|
|
126
|
+
return {
|
|
127
|
+
"requestId": str(uuid.uuid4()),
|
|
128
|
+
"searchType": search_type,
|
|
129
|
+
"results": results,
|
|
130
|
+
"costDollars": {"total": 0.0},
|
|
131
|
+
}
|
oxe/mcp_server.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp.server.mcpserver import MCPServer
|
|
4
|
+
|
|
5
|
+
from . import exa_compat
|
|
6
|
+
from .cache import TTLCache
|
|
7
|
+
|
|
8
|
+
_cache: TTLCache | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_cache(c: TTLCache) -> None:
|
|
12
|
+
global _cache
|
|
13
|
+
_cache = c
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
mcp = MCPServer(
|
|
17
|
+
name="oxe",
|
|
18
|
+
instructions=(
|
|
19
|
+
"Local Exa-compatible web search backed by DuckDuckGo with a TTL cache. "
|
|
20
|
+
"Query returns Exa-shaped JSON: {requestId, searchType, results, costDollars} "
|
|
21
|
+
"where each result has {title, url, id, text, highlights, highlightScores, "
|
|
22
|
+
"publishedDate, author, image, favicon, extras}. Unimplemented Exa fields "
|
|
23
|
+
"(deep search variants, contents.summary, additionalQueries, systemPrompt, "
|
|
24
|
+
"outputSchema, stream) are silently ignored."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool(name="exa_search", description=(
|
|
30
|
+
"Search the web via DuckDuckGo and return Exa-shaped JSON. "
|
|
31
|
+
"Args: query (required), num_results (1-30, default 10), type ('auto'|'instant'; "
|
|
32
|
+
"deep variants ignored), contents_highlights, contents_text, include_domains, "
|
|
33
|
+
"exclude_domains, category ('news' for last 24h, else ''). "
|
|
34
|
+
"Returns {requestId, searchType, results, costDollars, _source}."
|
|
35
|
+
))
|
|
36
|
+
def exa_search(
|
|
37
|
+
query: str,
|
|
38
|
+
num_results: int = 10,
|
|
39
|
+
type: str = "auto",
|
|
40
|
+
contents_highlights: bool = True,
|
|
41
|
+
contents_text: bool = True,
|
|
42
|
+
include_domains: list[str] | None = None,
|
|
43
|
+
exclude_domains: list[str] | None = None,
|
|
44
|
+
category: str = "",
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
req: dict[str, Any] = {
|
|
47
|
+
"query": query,
|
|
48
|
+
"numResults": num_results,
|
|
49
|
+
"type": type,
|
|
50
|
+
"contents": {"highlights": contents_highlights, "text": contents_text},
|
|
51
|
+
"category": category,
|
|
52
|
+
}
|
|
53
|
+
if include_domains:
|
|
54
|
+
req["includeDomains"] = include_domains
|
|
55
|
+
if exclude_domains:
|
|
56
|
+
req["excludeDomains"] = exclude_domains
|
|
57
|
+
if _cache is None:
|
|
58
|
+
return exa_compat.search(req)
|
|
59
|
+
from .search import do_search
|
|
60
|
+
out = do_search(_cache, req)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool(name="exa_user_history", description=(
|
|
65
|
+
"Recent URLs the user has clicked from the search UI for a given query. "
|
|
66
|
+
"Use this to avoid re-researching what the user has already explored. "
|
|
67
|
+
"Args: query (optional substring match against query text), query_hash "
|
|
68
|
+
"(optional exact match), limit (1-200, default 20), since_hours (default 168 = 1 week). "
|
|
69
|
+
"Returns {clicks: [{query_hash, query, result_id, url, title, clicked_at, source}], count}."
|
|
70
|
+
))
|
|
71
|
+
def exa_user_history(
|
|
72
|
+
query: str = "",
|
|
73
|
+
query_hash: str = "",
|
|
74
|
+
limit: int = 20,
|
|
75
|
+
since_hours: int = 168,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
if _cache is None:
|
|
78
|
+
return {"clicks": [], "count": 0, "error": "cache not initialized"}
|
|
79
|
+
qh = query_hash.strip() or None
|
|
80
|
+
qt = query.strip() or None
|
|
81
|
+
since = max(1, min(since_hours, 24 * 365))
|
|
82
|
+
lim = max(1, min(limit, 200))
|
|
83
|
+
rows = _cache.get_clicks(query_hash=qh, query_text=qt, limit=lim, since_hours=since)
|
|
84
|
+
return {"clicks": rows, "count": len(rows)}
|
oxe/search.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from . import exa_compat
|
|
6
|
+
from .cache import TTLCache
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
TTL_DEFAULT = int(os.getenv("OXE_TTL_DEFAULT", "3600"))
|
|
11
|
+
TTL_MAX = int(os.getenv("OXE_TTL_MAX", "86400"))
|
|
12
|
+
NEGATIVE_TTL = int(os.getenv("OXE_NEGATIVE_TTL", "300"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def do_search(cache: TTLCache, req_dict: dict, ttl: Optional[int] = None) -> dict:
|
|
16
|
+
key = exa_compat.cache_key(req_dict)
|
|
17
|
+
cached = cache.get(key)
|
|
18
|
+
if cached is not None:
|
|
19
|
+
out = dict(cached)
|
|
20
|
+
out["_source"] = "cache"
|
|
21
|
+
out["_q_hash"] = key
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
response = exa_compat.search(req_dict)
|
|
25
|
+
effective_ttl = ttl if ttl is not None else (NEGATIVE_TTL if not response["results"] else TTL_DEFAULT)
|
|
26
|
+
effective_ttl = min(effective_ttl, TTL_MAX)
|
|
27
|
+
to_store = dict(response)
|
|
28
|
+
to_store["_q"] = (req_dict.get("query") or "")[:200]
|
|
29
|
+
cache.set(key, to_store, effective_ttl)
|
|
30
|
+
out = dict(response)
|
|
31
|
+
out["_source"] = "network"
|
|
32
|
+
out["_q_hash"] = key
|
|
33
|
+
return out
|