agentcache-core 0.9.9__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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
agentcache/search.py
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
import array
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
9
|
+
|
|
10
|
+
# =====================================================================
|
|
11
|
+
# Custom Porter-like Stemmer (Ported from stemmer.ts)
|
|
12
|
+
# =====================================================================
|
|
13
|
+
|
|
14
|
+
step2map = {
|
|
15
|
+
"ational": "ate",
|
|
16
|
+
"tional": "tion",
|
|
17
|
+
"enci": "ence",
|
|
18
|
+
"anci": "ance",
|
|
19
|
+
"izer": "ize",
|
|
20
|
+
"iser": "ise",
|
|
21
|
+
"abli": "able",
|
|
22
|
+
"alli": "al",
|
|
23
|
+
"entli": "ent",
|
|
24
|
+
"eli": "e",
|
|
25
|
+
"ousli": "ous",
|
|
26
|
+
"ization": "ize",
|
|
27
|
+
"isation": "ise",
|
|
28
|
+
"ation": "ate",
|
|
29
|
+
"ator": "ate",
|
|
30
|
+
"alism": "al",
|
|
31
|
+
"iveness": "ive",
|
|
32
|
+
"fulness": "ful",
|
|
33
|
+
"ousness": "ous",
|
|
34
|
+
"aliti": "al",
|
|
35
|
+
"iviti": "ive",
|
|
36
|
+
"biliti": "ble",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
step3map = {
|
|
40
|
+
"icate": "ic",
|
|
41
|
+
"ative": "",
|
|
42
|
+
"alize": "al",
|
|
43
|
+
"alise": "al",
|
|
44
|
+
"iciti": "ic",
|
|
45
|
+
"ical": "ic",
|
|
46
|
+
"ful": "",
|
|
47
|
+
"ness": "",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_vowel(s: str) -> bool:
|
|
52
|
+
return any(c in "aeiou" for c in s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _measure(s: str) -> int:
|
|
56
|
+
# Reduce non-vowels (excluding y) to C, vowels (+y) to V
|
|
57
|
+
reduced = ""
|
|
58
|
+
for c in s:
|
|
59
|
+
if c in "aeiouy":
|
|
60
|
+
if not reduced or reduced[-1] != "V":
|
|
61
|
+
reduced += "V"
|
|
62
|
+
else:
|
|
63
|
+
if not reduced or reduced[-1] != "C":
|
|
64
|
+
reduced += "C"
|
|
65
|
+
# count "VC" patterns
|
|
66
|
+
return len(re.findall(r"VC", reduced))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ends_double_consonant(s: str) -> bool:
|
|
70
|
+
return len(s) >= 2 and s[-1] == s[-2] and s[-1] not in "aeiou"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ends_cvc(s: str) -> bool:
|
|
74
|
+
if len(s) < 3:
|
|
75
|
+
return False
|
|
76
|
+
c1, v, c2 = s[-3], s[-2], s[-1]
|
|
77
|
+
return c1 not in "aeiou" and v in "aeiou" and c2 not in "aeiouwxy"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stem(word: str) -> str:
|
|
81
|
+
if len(word) <= 2:
|
|
82
|
+
return word
|
|
83
|
+
|
|
84
|
+
w = word
|
|
85
|
+
|
|
86
|
+
# Step 1a
|
|
87
|
+
if w.endswith("sses"):
|
|
88
|
+
w = w[:-2]
|
|
89
|
+
elif w.endswith("ies"):
|
|
90
|
+
w = w[:-2]
|
|
91
|
+
elif not w.endswith("ss") and w.endswith("s"):
|
|
92
|
+
w = w[:-1]
|
|
93
|
+
|
|
94
|
+
# Step 1b
|
|
95
|
+
if w.endswith("eed"):
|
|
96
|
+
if _measure(w[:-3]) > 0:
|
|
97
|
+
w = w[:-1]
|
|
98
|
+
elif w.endswith("ed") and _has_vowel(w[:-2]):
|
|
99
|
+
w = w[:-2]
|
|
100
|
+
if w.endswith("at") or w.endswith("bl") or w.endswith("iz"):
|
|
101
|
+
w += "e"
|
|
102
|
+
elif _ends_double_consonant(w) and not w.endswith(("l", "s", "z")):
|
|
103
|
+
w = w[:-1]
|
|
104
|
+
elif _measure(w) == 1 and _ends_cvc(w):
|
|
105
|
+
w += "e"
|
|
106
|
+
elif w.endswith("ing") and _has_vowel(w[:-3]):
|
|
107
|
+
w = w[:-3]
|
|
108
|
+
if w.endswith("at") or w.endswith("bl") or w.endswith("iz"):
|
|
109
|
+
w += "e"
|
|
110
|
+
elif _ends_double_consonant(w) and not w.endswith(("l", "s", "z")):
|
|
111
|
+
w = w[:-1]
|
|
112
|
+
elif _measure(w) == 1 and _ends_cvc(w):
|
|
113
|
+
w += "e"
|
|
114
|
+
|
|
115
|
+
# Step 1c
|
|
116
|
+
if w.endswith("y") and _has_vowel(w[:-1]):
|
|
117
|
+
w = w[:-1] + "i"
|
|
118
|
+
|
|
119
|
+
# Step 2
|
|
120
|
+
for suffix, replacement in step2map.items():
|
|
121
|
+
if w.endswith(suffix):
|
|
122
|
+
base = w[: -len(suffix)]
|
|
123
|
+
if _measure(base) > 0:
|
|
124
|
+
w = base + replacement
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# Step 3
|
|
128
|
+
for suffix, replacement in step3map.items():
|
|
129
|
+
if w.endswith(suffix):
|
|
130
|
+
base = w[: -len(suffix)]
|
|
131
|
+
if _measure(base) > 0:
|
|
132
|
+
w = base + replacement
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
# Step 4
|
|
136
|
+
suffixes_step4 = (
|
|
137
|
+
"al",
|
|
138
|
+
"ance",
|
|
139
|
+
"ence",
|
|
140
|
+
"er",
|
|
141
|
+
"ic",
|
|
142
|
+
"able",
|
|
143
|
+
"ible",
|
|
144
|
+
"ant",
|
|
145
|
+
"ement",
|
|
146
|
+
"ment",
|
|
147
|
+
"ent",
|
|
148
|
+
"tion",
|
|
149
|
+
"sion",
|
|
150
|
+
"ou",
|
|
151
|
+
"ism",
|
|
152
|
+
"ate",
|
|
153
|
+
"iti",
|
|
154
|
+
"ous",
|
|
155
|
+
"ive",
|
|
156
|
+
"ize",
|
|
157
|
+
"ise",
|
|
158
|
+
)
|
|
159
|
+
if w.endswith(suffixes_step4):
|
|
160
|
+
# find matching suffix length
|
|
161
|
+
match = re.search(
|
|
162
|
+
r"(ement|ment|tion|sion|ance|ence|able|ible|ism|ate|iti|ous|ive|ize|ise|ant|ent|al|er|ic|ou)$",
|
|
163
|
+
w,
|
|
164
|
+
)
|
|
165
|
+
if match:
|
|
166
|
+
suffix_len = len(match.group(1))
|
|
167
|
+
base = w[:-suffix_len]
|
|
168
|
+
if _measure(base) > 1:
|
|
169
|
+
w = base
|
|
170
|
+
|
|
171
|
+
# Step 5a
|
|
172
|
+
if w.endswith("e"):
|
|
173
|
+
base = w[:-1]
|
|
174
|
+
if _measure(base) > 1 or (_measure(base) == 1 and not _ends_cvc(base)):
|
|
175
|
+
w = base
|
|
176
|
+
|
|
177
|
+
# Step 5b
|
|
178
|
+
if _ends_double_consonant(w) and w.endswith("l") and _measure(w[:-1]) > 1:
|
|
179
|
+
w = w[:-1]
|
|
180
|
+
|
|
181
|
+
return w
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# =====================================================================
|
|
185
|
+
# Synonym Map (Ported from synonyms.ts)
|
|
186
|
+
# =====================================================================
|
|
187
|
+
|
|
188
|
+
SYNONYM_GROUPS = [
|
|
189
|
+
["auth", "authentication", "authn", "authenticating"],
|
|
190
|
+
["authz", "authorization", "authorizing"],
|
|
191
|
+
["db", "database", "datastore"],
|
|
192
|
+
["perf", "performance", "latency", "throughput", "slow", "bottleneck"],
|
|
193
|
+
["optim", "optimization", "optimizing", "optimise", "query-optimization"],
|
|
194
|
+
["k8s", "kubernetes", "kube"],
|
|
195
|
+
["config", "configuration", "configuring", "setup"],
|
|
196
|
+
["deps", "dependencies", "dependency"],
|
|
197
|
+
["env", "environment"],
|
|
198
|
+
["fn", "function"],
|
|
199
|
+
["impl", "implementation", "implementing"],
|
|
200
|
+
["msg", "message", "messaging"],
|
|
201
|
+
["repo", "repository"],
|
|
202
|
+
["req", "request"],
|
|
203
|
+
["res", "response"],
|
|
204
|
+
["ts", "typescript"],
|
|
205
|
+
["js", "javascript"],
|
|
206
|
+
["pg", "postgres", "postgresql"],
|
|
207
|
+
["err", "error", "errors"],
|
|
208
|
+
["api", "endpoint", "endpoints"],
|
|
209
|
+
["ci", "continuous-integration"],
|
|
210
|
+
["cd", "continuous-deployment"],
|
|
211
|
+
["test", "testing", "tests"],
|
|
212
|
+
["doc", "documentation", "docs"],
|
|
213
|
+
["infra", "infrastructure"],
|
|
214
|
+
["deploy", "deployment", "deploying"],
|
|
215
|
+
["cache", "caching", "cached"],
|
|
216
|
+
["log", "logging", "logs"],
|
|
217
|
+
["monitor", "monitoring"],
|
|
218
|
+
["observe", "observability"],
|
|
219
|
+
["sec", "security", "secure"],
|
|
220
|
+
["validate", "validation", "validating"],
|
|
221
|
+
["migrate", "migration", "migrations"],
|
|
222
|
+
["debug", "debugging"],
|
|
223
|
+
["container", "containerization", "docker"],
|
|
224
|
+
["crash", "crashloop", "crashloopbackoff"],
|
|
225
|
+
["webhook", "webhooks", "callback"],
|
|
226
|
+
["middleware", "mw"],
|
|
227
|
+
["paginate", "pagination"],
|
|
228
|
+
["serialize", "serialization"],
|
|
229
|
+
["encrypt", "encryption"],
|
|
230
|
+
["hash", "hashing"],
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
synonymMap: Dict[str, Set[str]] = {}
|
|
234
|
+
for group in SYNONYM_GROUPS:
|
|
235
|
+
stemmed = [stem(t.lower()) for t in group]
|
|
236
|
+
for s in stemmed:
|
|
237
|
+
if s not in synonymMap:
|
|
238
|
+
synonymMap[s] = set()
|
|
239
|
+
for other in stemmed:
|
|
240
|
+
if other != s:
|
|
241
|
+
synonymMap[s].add(other)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_synonyms(stemmed_term: str) -> List[str]:
|
|
245
|
+
return list(synonymMap.get(stemmed_term, []))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# =====================================================================
|
|
249
|
+
# CJK Segmenter (Ported from cjk-segmenter.ts)
|
|
250
|
+
# =====================================================================
|
|
251
|
+
|
|
252
|
+
CJK_RE = re.compile(
|
|
253
|
+
r"[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\uac00-\ud7a3]"
|
|
254
|
+
)
|
|
255
|
+
CJK_RUN_RE = re.compile(
|
|
256
|
+
r"[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\uac00-\ud7a3]+"
|
|
257
|
+
)
|
|
258
|
+
HANGUL_RE = re.compile(r"[\uac00-\ud7a3]")
|
|
259
|
+
KANA_RE = re.compile(r"[\u3040-\u309f\u30a0-\u30ff]")
|
|
260
|
+
HANGUL_BLOCK_RE = re.compile(r"[가-]+")
|
|
261
|
+
|
|
262
|
+
jieba_loaded = False
|
|
263
|
+
jieba_instance = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_jieba():
|
|
267
|
+
global jieba_loaded, jieba_instance
|
|
268
|
+
if jieba_loaded:
|
|
269
|
+
return jieba_instance
|
|
270
|
+
jieba_loaded = True
|
|
271
|
+
try:
|
|
272
|
+
import jieba
|
|
273
|
+
|
|
274
|
+
jieba_instance = jieba
|
|
275
|
+
except ImportError:
|
|
276
|
+
print(
|
|
277
|
+
"[search] Install jieba to improve Chinese word segmentation (pip install jieba)"
|
|
278
|
+
)
|
|
279
|
+
return jieba_instance
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def has_cjk(text: str) -> bool:
|
|
283
|
+
return bool(CJK_RE.search(text))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def segment_cjk(text: str) -> List[str]:
|
|
287
|
+
if not has_cjk(text):
|
|
288
|
+
return [text]
|
|
289
|
+
|
|
290
|
+
out: List[str] = []
|
|
291
|
+
cursor = 0
|
|
292
|
+
|
|
293
|
+
for match in CJK_RUN_RE.finditer(text):
|
|
294
|
+
start = match.start()
|
|
295
|
+
run = match.group(0)
|
|
296
|
+
end = match.end()
|
|
297
|
+
|
|
298
|
+
if start > cursor:
|
|
299
|
+
piece = text[cursor:start].strip()
|
|
300
|
+
if piece:
|
|
301
|
+
out.append(piece)
|
|
302
|
+
|
|
303
|
+
if HANGUL_RE.search(run):
|
|
304
|
+
# Hangul: split by blocks
|
|
305
|
+
out.extend(HANGUL_BLOCK_RE.findall(run))
|
|
306
|
+
elif KANA_RE.search(run):
|
|
307
|
+
# Japanese Kana fallback: split every character
|
|
308
|
+
out.extend(list(run))
|
|
309
|
+
else:
|
|
310
|
+
# Chinese Han: use jieba if available
|
|
311
|
+
jb = get_jieba()
|
|
312
|
+
if jb:
|
|
313
|
+
out.extend([t.strip() for t in jb.cut(run, cut_all=False) if t.strip()])
|
|
314
|
+
else:
|
|
315
|
+
out.extend(list(run))
|
|
316
|
+
|
|
317
|
+
cursor = end
|
|
318
|
+
|
|
319
|
+
if cursor < len(text):
|
|
320
|
+
trailing = text[cursor:].strip()
|
|
321
|
+
if trailing:
|
|
322
|
+
out.append(trailing)
|
|
323
|
+
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# =====================================================================
|
|
328
|
+
# SearchIndex (BM25 - Ported from search-index.ts)
|
|
329
|
+
# =====================================================================
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SearchIndex:
|
|
333
|
+
def __init__(self):
|
|
334
|
+
self.entries: Dict[str, Dict[str, Any]] = {}
|
|
335
|
+
self.inverted_index: Dict[str, Set[str]] = {}
|
|
336
|
+
self.doc_term_counts: Dict[str, Dict[str, int]] = {}
|
|
337
|
+
self.total_doc_length = 0
|
|
338
|
+
self.sorted_terms: Optional[List[str]] = None
|
|
339
|
+
self._dirty: bool = False # A4.2
|
|
340
|
+
|
|
341
|
+
self.k1 = 1.2
|
|
342
|
+
self.b = 0.75
|
|
343
|
+
|
|
344
|
+
def add(self, obs: Dict[str, Any]) -> None:
|
|
345
|
+
obs_id = obs.get("id")
|
|
346
|
+
if not obs_id:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
terms = self.extract_terms(obs)
|
|
350
|
+
term_freq: Dict[str, int] = {}
|
|
351
|
+
term_count = 0
|
|
352
|
+
|
|
353
|
+
for term in terms:
|
|
354
|
+
term_freq[term] = term_freq.get(term, 0) + 1
|
|
355
|
+
term_count += 1
|
|
356
|
+
|
|
357
|
+
self.entries[obs_id] = {
|
|
358
|
+
"obsId": obs_id,
|
|
359
|
+
"sessionId": obs.get("sessionId", ""),
|
|
360
|
+
"termCount": term_count,
|
|
361
|
+
}
|
|
362
|
+
self.doc_term_counts[obs_id] = term_freq
|
|
363
|
+
self.total_doc_length += term_count
|
|
364
|
+
|
|
365
|
+
for term in term_freq.keys():
|
|
366
|
+
if term not in self.inverted_index:
|
|
367
|
+
self.inverted_index[term] = set()
|
|
368
|
+
self.inverted_index[term].add(obs_id)
|
|
369
|
+
|
|
370
|
+
self.sorted_terms = None
|
|
371
|
+
self._dirty = True # A4.2
|
|
372
|
+
|
|
373
|
+
def has(self, id: str) -> bool:
|
|
374
|
+
return id in self.entries
|
|
375
|
+
|
|
376
|
+
def remove(self, id: str) -> None:
|
|
377
|
+
entry = self.entries.get(id)
|
|
378
|
+
if not entry:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
term_freq = self.doc_term_counts.get(id)
|
|
382
|
+
if term_freq:
|
|
383
|
+
for term in term_freq.keys():
|
|
384
|
+
posting_list = self.inverted_index.get(term)
|
|
385
|
+
if posting_list:
|
|
386
|
+
posting_list.discard(id)
|
|
387
|
+
if not posting_list:
|
|
388
|
+
self.inverted_index.pop(term, None)
|
|
389
|
+
self.doc_term_counts.pop(id, None)
|
|
390
|
+
|
|
391
|
+
self.total_doc_length = max(0, self.total_doc_length - entry["termCount"])
|
|
392
|
+
self.entries.pop(id, None)
|
|
393
|
+
self.sorted_terms = None
|
|
394
|
+
self._dirty = True # A4.2
|
|
395
|
+
|
|
396
|
+
def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
397
|
+
raw_terms = self.tokenize(query.lower())
|
|
398
|
+
if not raw_terms:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
N = len(self.entries)
|
|
402
|
+
if N == 0:
|
|
403
|
+
return []
|
|
404
|
+
avg_doc_len = self.total_doc_length / N
|
|
405
|
+
|
|
406
|
+
query_terms: List[Dict[str, Any]] = []
|
|
407
|
+
seen = set()
|
|
408
|
+
for term in raw_terms:
|
|
409
|
+
if term not in seen:
|
|
410
|
+
seen.add(term)
|
|
411
|
+
query_terms.append({"term": term, "weight": 1.0})
|
|
412
|
+
for syn in get_synonyms(term):
|
|
413
|
+
if syn not in seen:
|
|
414
|
+
seen.add(syn)
|
|
415
|
+
query_terms.append({"term": syn, "weight": 0.7})
|
|
416
|
+
|
|
417
|
+
scores: Dict[str, float] = {}
|
|
418
|
+
sorted_terms = self.get_sorted_terms()
|
|
419
|
+
|
|
420
|
+
for q_item in query_terms:
|
|
421
|
+
term = q_item["term"]
|
|
422
|
+
weight = q_item["weight"]
|
|
423
|
+
|
|
424
|
+
matching_docs = self.inverted_index.get(term)
|
|
425
|
+
if matching_docs:
|
|
426
|
+
df = len(matching_docs)
|
|
427
|
+
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
|
|
428
|
+
|
|
429
|
+
for obs_id in matching_docs:
|
|
430
|
+
entry = self.entries[obs_id]
|
|
431
|
+
doc_terms = self.doc_term_counts.get(obs_id, {})
|
|
432
|
+
tf = doc_terms.get(term, 0)
|
|
433
|
+
doc_len = entry["termCount"]
|
|
434
|
+
|
|
435
|
+
numerator = tf * (self.k1 + 1)
|
|
436
|
+
denominator = tf + self.k1 * (
|
|
437
|
+
1 - self.b + self.b * (doc_len / avg_doc_len)
|
|
438
|
+
)
|
|
439
|
+
bm25_score = idf * (numerator / denominator) * weight
|
|
440
|
+
|
|
441
|
+
scores[obs_id] = scores.get(obs_id, 0.0) + bm25_score
|
|
442
|
+
|
|
443
|
+
# Prefix matching (binary search)
|
|
444
|
+
start_idx = self.lower_bound(sorted_terms, term)
|
|
445
|
+
for si in range(start_idx, len(sorted_terms)):
|
|
446
|
+
index_term = sorted_terms[si]
|
|
447
|
+
if not index_term.startswith(term):
|
|
448
|
+
break
|
|
449
|
+
if index_term == term:
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
obs_ids = self.inverted_index.get(index_term, set())
|
|
453
|
+
prefix_df = len(obs_ids)
|
|
454
|
+
prefix_idf = (
|
|
455
|
+
math.log((N - prefix_df + 0.5) / (prefix_df + 0.5) + 1) * 0.5
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
for obs_id in obs_ids:
|
|
459
|
+
entry = self.entries[obs_id]
|
|
460
|
+
doc_terms = self.doc_term_counts.get(obs_id, {})
|
|
461
|
+
tf = doc_terms.get(index_term, 0)
|
|
462
|
+
doc_len = entry["termCount"]
|
|
463
|
+
numerator = tf * (self.k1 + 1)
|
|
464
|
+
denominator = tf + self.k1 * (
|
|
465
|
+
1 - self.b + self.b * (doc_len / avg_doc_len)
|
|
466
|
+
)
|
|
467
|
+
scores[obs_id] = (
|
|
468
|
+
scores.get(obs_id, 0.0)
|
|
469
|
+
+ prefix_idf * (numerator / denominator) * weight
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
results = []
|
|
473
|
+
for obs_id, score in scores.items():
|
|
474
|
+
entry = self.entries[obs_id]
|
|
475
|
+
results.append(
|
|
476
|
+
{"obsId": obs_id, "sessionId": entry["sessionId"], "score": score}
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
480
|
+
return results[:limit]
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def size(self) -> int:
|
|
484
|
+
return len(self.entries)
|
|
485
|
+
|
|
486
|
+
def clear(self) -> None:
|
|
487
|
+
self.entries.clear()
|
|
488
|
+
self.inverted_index.clear()
|
|
489
|
+
self.doc_term_counts.clear()
|
|
490
|
+
self.total_doc_length = 0
|
|
491
|
+
self.sorted_terms = None
|
|
492
|
+
|
|
493
|
+
def restore_from_data(self, data: Dict[str, Any]) -> None:
|
|
494
|
+
self.clear()
|
|
495
|
+
if not data:
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
for k, v in data.get("entries", []):
|
|
499
|
+
self.entries[k] = v
|
|
500
|
+
for term, ids in data.get("inverted", []):
|
|
501
|
+
self.inverted_index[term] = set(ids)
|
|
502
|
+
for id_, counts in data.get("docTerms", []):
|
|
503
|
+
self.doc_term_counts[id_] = dict(counts)
|
|
504
|
+
self.total_doc_length = int(data.get("totalDocLength", 0))
|
|
505
|
+
self._dirty = False # A4.2 — freshly loaded, not dirty
|
|
506
|
+
|
|
507
|
+
def serialize_data(self) -> Dict[str, Any]:
|
|
508
|
+
entries = list(self.entries.items())
|
|
509
|
+
inverted = [(term, list(ids)) for term, ids in self.inverted_index.items()]
|
|
510
|
+
doc_terms = [
|
|
511
|
+
(id_, list(counts.items())) for id_, counts in self.doc_term_counts.items()
|
|
512
|
+
]
|
|
513
|
+
return {
|
|
514
|
+
"v": 2,
|
|
515
|
+
"entries": entries,
|
|
516
|
+
"inverted": inverted,
|
|
517
|
+
"docTerms": doc_terms,
|
|
518
|
+
"totalDocLength": self.total_doc_length,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def extract_terms(self, obs: Dict[str, Any]) -> List[str]:
|
|
522
|
+
parts = [
|
|
523
|
+
obs.get("title", ""),
|
|
524
|
+
obs.get("subtitle", "") or "",
|
|
525
|
+
obs.get("text", "") or "",
|
|
526
|
+
obs.get("narrative", "") or "",
|
|
527
|
+
" ".join(obs.get("facts", []) or []),
|
|
528
|
+
" ".join(obs.get("concepts", []) or []),
|
|
529
|
+
" ".join(obs.get("files", []) or []),
|
|
530
|
+
obs.get("type", ""),
|
|
531
|
+
]
|
|
532
|
+
return self.tokenize(" ".join(parts).lower())
|
|
533
|
+
|
|
534
|
+
def tokenize(self, text: str) -> List[str]:
|
|
535
|
+
# Strip special characters except valid separators
|
|
536
|
+
cleaned = re.sub(r"[^\w\s/.\\-_]", " ", text)
|
|
537
|
+
out = []
|
|
538
|
+
for raw in cleaned.split():
|
|
539
|
+
if len(raw) < 2:
|
|
540
|
+
continue
|
|
541
|
+
if has_cjk(raw):
|
|
542
|
+
for seg in segment_cjk(raw):
|
|
543
|
+
if len(seg) >= 1:
|
|
544
|
+
out.append(seg)
|
|
545
|
+
else:
|
|
546
|
+
out.append(stem(raw))
|
|
547
|
+
return out
|
|
548
|
+
|
|
549
|
+
def get_sorted_terms(self) -> List[str]:
|
|
550
|
+
if not self.sorted_terms:
|
|
551
|
+
self.sorted_terms = sorted(self.inverted_index.keys())
|
|
552
|
+
return self.sorted_terms
|
|
553
|
+
|
|
554
|
+
def lower_bound(self, arr: List[str], target: str) -> int:
|
|
555
|
+
lo = 0
|
|
556
|
+
hi = len(arr)
|
|
557
|
+
while lo < hi:
|
|
558
|
+
mid = (lo + hi) // 2
|
|
559
|
+
if arr[mid] < target:
|
|
560
|
+
lo = mid + 1
|
|
561
|
+
else:
|
|
562
|
+
hi = mid
|
|
563
|
+
return lo
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# =====================================================================
|
|
567
|
+
# VectorIndex (Cosine Similarity - Ported from vector-index.ts)
|
|
568
|
+
# =====================================================================
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def float32_to_base64(floats: List[float]) -> str:
|
|
572
|
+
arr = array.array("f", floats)
|
|
573
|
+
return base64.b64encode(arr.tobytes()).decode("utf-8")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def base64_to_float32(b64: str) -> List[float]:
|
|
577
|
+
arr = array.array("f")
|
|
578
|
+
arr.frombytes(base64.b64decode(b64))
|
|
579
|
+
return list(arr)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def cosine_similarity(a: List[float], b: List[float]) -> float:
|
|
583
|
+
if len(a) != len(b) or len(a) == 0:
|
|
584
|
+
return 0.0
|
|
585
|
+
dot = 0.0
|
|
586
|
+
norm_a = 0.0
|
|
587
|
+
norm_b = 0.0
|
|
588
|
+
for x, y in zip(a, b):
|
|
589
|
+
dot += x * y
|
|
590
|
+
norm_a += x * x
|
|
591
|
+
norm_b += y * y
|
|
592
|
+
denom = math.sqrt(norm_a) * math.sqrt(norm_b)
|
|
593
|
+
return dot / denom if denom != 0.0 else 0.0
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class VectorIndex:
|
|
597
|
+
def __init__(self):
|
|
598
|
+
self.vectors: Dict[str, Dict[str, Any]] = {}
|
|
599
|
+
self._dirty: bool = False # A4.2
|
|
600
|
+
|
|
601
|
+
def add(self, obs_id: str, session_id: str, embedding: List[float]) -> None:
|
|
602
|
+
self.vectors[obs_id] = {"embedding": embedding, "sessionId": session_id}
|
|
603
|
+
self._dirty = True # A4.2
|
|
604
|
+
|
|
605
|
+
def remove(self, obs_id: str) -> None:
|
|
606
|
+
if obs_id in self.vectors:
|
|
607
|
+
self.vectors.pop(obs_id, None)
|
|
608
|
+
self._dirty = True # A4.2
|
|
609
|
+
|
|
610
|
+
def search(self, query: List[float], limit: int = 20) -> List[Dict[str, Any]]:
|
|
611
|
+
results = []
|
|
612
|
+
for obs_id, entry in self.vectors.items():
|
|
613
|
+
score = cosine_similarity(query, entry["embedding"])
|
|
614
|
+
results.append(
|
|
615
|
+
{"obsId": obs_id, "sessionId": entry["sessionId"], "score": score}
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
619
|
+
return results[:limit]
|
|
620
|
+
|
|
621
|
+
@property
|
|
622
|
+
def size(self) -> int:
|
|
623
|
+
return len(self.vectors)
|
|
624
|
+
|
|
625
|
+
def validate_dimensions(
|
|
626
|
+
self, expected: int
|
|
627
|
+
) -> Tuple[List[Dict[str, Any]], Set[int]]:
|
|
628
|
+
mismatches = []
|
|
629
|
+
seen_dimensions = set()
|
|
630
|
+
for obs_id, entry in self.vectors.items():
|
|
631
|
+
dim = len(entry["embedding"])
|
|
632
|
+
seen_dimensions.add(dim)
|
|
633
|
+
if dim != expected:
|
|
634
|
+
mismatches.append({"obsId": obs_id, "dim": dim})
|
|
635
|
+
return mismatches, seen_dimensions
|
|
636
|
+
|
|
637
|
+
def clear(self) -> None:
|
|
638
|
+
self.vectors.clear()
|
|
639
|
+
|
|
640
|
+
def serialize_data(self) -> List[Any]:
|
|
641
|
+
data = []
|
|
642
|
+
for obs_id, entry in self.vectors.items():
|
|
643
|
+
data.append(
|
|
644
|
+
[
|
|
645
|
+
obs_id,
|
|
646
|
+
{
|
|
647
|
+
"embedding": float32_to_base64(entry["embedding"]),
|
|
648
|
+
"sessionId": entry["sessionId"],
|
|
649
|
+
},
|
|
650
|
+
]
|
|
651
|
+
)
|
|
652
|
+
return data
|
|
653
|
+
|
|
654
|
+
def restore_from_data(self, data: List[Any]) -> None:
|
|
655
|
+
self.clear()
|
|
656
|
+
if not isinstance(data, list):
|
|
657
|
+
return
|
|
658
|
+
for row in data:
|
|
659
|
+
try:
|
|
660
|
+
if not isinstance(row, list) or len(row) < 2:
|
|
661
|
+
continue
|
|
662
|
+
obs_id, entry = row
|
|
663
|
+
if not isinstance(obs_id, str) or not isinstance(entry, dict):
|
|
664
|
+
continue
|
|
665
|
+
emb_b64 = entry.get("embedding")
|
|
666
|
+
sess_id = entry.get("sessionId")
|
|
667
|
+
if not isinstance(emb_b64, str) or not isinstance(sess_id, str):
|
|
668
|
+
continue
|
|
669
|
+
self.vectors[obs_id] = {
|
|
670
|
+
"embedding": base64_to_float32(emb_b64),
|
|
671
|
+
"sessionId": sess_id,
|
|
672
|
+
}
|
|
673
|
+
except Exception:
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# =====================================================================
|
|
678
|
+
# Gemini Embedding Client (Urllib POST completion)
|
|
679
|
+
# =====================================================================
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class GeminiEmbeddingProvider:
|
|
683
|
+
def __init__(self, api_key: str):
|
|
684
|
+
self.name = "gemini"
|
|
685
|
+
self.dimensions = 768
|
|
686
|
+
self.api_key = api_key
|
|
687
|
+
self.model = "models/gemini-embedding-001"
|
|
688
|
+
self.api_url = f"https://generativelanguage.googleapis.com/v1beta/{self.model}:batchEmbedContents"
|
|
689
|
+
|
|
690
|
+
def embed(self, text: str) -> List[float]:
|
|
691
|
+
results = self.embed_batch([text])
|
|
692
|
+
return results[0]
|
|
693
|
+
|
|
694
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
695
|
+
results: List[List[float]] = []
|
|
696
|
+
batch_limit = 100
|
|
697
|
+
|
|
698
|
+
for i in range(0, len(texts), batch_limit):
|
|
699
|
+
chunk = texts[i : i + batch_limit]
|
|
700
|
+
|
|
701
|
+
payload = {
|
|
702
|
+
"requests": [
|
|
703
|
+
{
|
|
704
|
+
"model": self.model,
|
|
705
|
+
"content": {"parts": [{"text": t}]},
|
|
706
|
+
"outputDimensionality": self.dimensions,
|
|
707
|
+
}
|
|
708
|
+
for t in chunk
|
|
709
|
+
]
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
713
|
+
url = f"{self.api_url}?key={self.api_key}"
|
|
714
|
+
|
|
715
|
+
req = urllib.request.Request(
|
|
716
|
+
url,
|
|
717
|
+
data=req_data,
|
|
718
|
+
headers={"Content-Type": "application/json"},
|
|
719
|
+
method="POST",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
with urllib.request.urlopen(req, timeout=30.0) as response: # nosec B310
|
|
724
|
+
resp_data = json.loads(response.read().decode("utf-8"))
|
|
725
|
+
|
|
726
|
+
for emb in resp_data.get("embeddings", []):
|
|
727
|
+
values = emb.get("values", [])
|
|
728
|
+
results.append(self._l2_normalize(values))
|
|
729
|
+
except Exception as e:
|
|
730
|
+
raise RuntimeError(f"Gemini embedding batch call failed: {e}")
|
|
731
|
+
|
|
732
|
+
return results
|
|
733
|
+
|
|
734
|
+
def _l2_normalize(self, vec: List[float]) -> List[float]:
|
|
735
|
+
sum_sq = sum(x * x for x in vec)
|
|
736
|
+
norm = math.sqrt(sum_sq)
|
|
737
|
+
if norm == 0:
|
|
738
|
+
return vec
|
|
739
|
+
return [x / norm for x in vec]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# =====================================================================
|
|
743
|
+
# HybridSearch (Triple Stream - Ported from hybrid-search.ts)
|
|
744
|
+
# =====================================================================
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class HybridSearch:
|
|
748
|
+
def __init__(
|
|
749
|
+
self,
|
|
750
|
+
bm25: SearchIndex,
|
|
751
|
+
vector: Optional[VectorIndex],
|
|
752
|
+
embedding_provider: Optional[GeminiEmbeddingProvider],
|
|
753
|
+
kv: Any,
|
|
754
|
+
bm25_weight: float = 0.4,
|
|
755
|
+
vector_weight: float = 0.6,
|
|
756
|
+
graph_weight: float = 0.3,
|
|
757
|
+
):
|
|
758
|
+
self.bm25 = bm25
|
|
759
|
+
self.vector = vector
|
|
760
|
+
self.embedding_provider = embedding_provider
|
|
761
|
+
self.kv = kv
|
|
762
|
+
self.bm25_weight = bm25_weight
|
|
763
|
+
self.vector_weight = vector_weight
|
|
764
|
+
self.graph_weight = graph_weight
|
|
765
|
+
|
|
766
|
+
def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
767
|
+
# Triple-stream search combining BM25, vectors, and graph weights
|
|
768
|
+
bm25_results = self.bm25.search(query, limit * 2)
|
|
769
|
+
|
|
770
|
+
vector_results: List[Dict[str, Any]] = []
|
|
771
|
+
if self.vector and self.embedding_provider and self.vector.size > 0:
|
|
772
|
+
try:
|
|
773
|
+
query_embedding = self.embedding_provider.embed(query)
|
|
774
|
+
vector_results = self.vector.search(query_embedding, limit * 2)
|
|
775
|
+
except Exception:
|
|
776
|
+
pass # Fallback to BM25
|
|
777
|
+
|
|
778
|
+
# Build scores mapping
|
|
779
|
+
scores: Dict[str, Dict[str, Any]] = {}
|
|
780
|
+
RRF_K = 60
|
|
781
|
+
|
|
782
|
+
for idx, r in enumerate(bm25_results):
|
|
783
|
+
obs_id = r["obsId"]
|
|
784
|
+
scores[obs_id] = {
|
|
785
|
+
"bm25Rank": idx + 1,
|
|
786
|
+
"vectorRank": float("inf"),
|
|
787
|
+
"sessionId": r["sessionId"],
|
|
788
|
+
"bm25Score": r["score"],
|
|
789
|
+
"vectorScore": 0.0,
|
|
790
|
+
"graphScore": 0.0,
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for idx, r in enumerate(vector_results):
|
|
794
|
+
obs_id = r["obsId"]
|
|
795
|
+
if obs_id in scores:
|
|
796
|
+
scores[obs_id]["vectorRank"] = idx + 1
|
|
797
|
+
scores[obs_id]["vectorScore"] = r["score"]
|
|
798
|
+
else:
|
|
799
|
+
scores[obs_id] = {
|
|
800
|
+
"bm25Rank": float("inf"),
|
|
801
|
+
"vectorRank": idx + 1,
|
|
802
|
+
"sessionId": r["sessionId"],
|
|
803
|
+
"bm25Score": 0.0,
|
|
804
|
+
"vectorScore": r["score"],
|
|
805
|
+
"graphScore": 0.0,
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
has_vector = len(vector_results) > 0
|
|
809
|
+
|
|
810
|
+
effective_bm25_w = self.bm25_weight
|
|
811
|
+
effective_vector_w = self.vector_weight if has_vector else 0.0
|
|
812
|
+
|
|
813
|
+
total_w = effective_bm25_w + effective_vector_w
|
|
814
|
+
if total_w > 0:
|
|
815
|
+
effective_bm25_w /= total_w
|
|
816
|
+
effective_vector_w /= total_w
|
|
817
|
+
|
|
818
|
+
combined = []
|
|
819
|
+
for obs_id, s in scores.items():
|
|
820
|
+
combined.append(
|
|
821
|
+
{
|
|
822
|
+
"obsId": obs_id,
|
|
823
|
+
"sessionId": s["sessionId"],
|
|
824
|
+
"bm25Score": s["bm25Score"],
|
|
825
|
+
"vectorScore": s["vectorScore"],
|
|
826
|
+
"graphScore": s["graphScore"],
|
|
827
|
+
"combinedScore": (
|
|
828
|
+
effective_bm25_w * (1.0 / (RRF_K + s["bm25Rank"]))
|
|
829
|
+
+ effective_vector_w * (1.0 / (RRF_K + s["vectorRank"]))
|
|
830
|
+
),
|
|
831
|
+
}
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
combined.sort(key=lambda x: x["combinedScore"], reverse=True)
|
|
835
|
+
return combined[:limit]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
# =====================================================================
|
|
839
|
+
# OpenAI Embedding Client (D5.1)
|
|
840
|
+
# =====================================================================
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
class OpenAIEmbeddingProvider:
|
|
844
|
+
"""OpenAI text-embedding-3-small provider (1536 dims).
|
|
845
|
+
|
|
846
|
+
Uses urllib.request only — no new dependencies.
|
|
847
|
+
Reads API key from OPENAI_API_KEY env var.
|
|
848
|
+
"""
|
|
849
|
+
|
|
850
|
+
def __init__(self, api_key: str):
|
|
851
|
+
self.name = "openai"
|
|
852
|
+
self.dimensions = 1536
|
|
853
|
+
self.api_key = api_key
|
|
854
|
+
self.model = "text-embedding-3-small"
|
|
855
|
+
self.api_url = "https://api.openai.com/v1/embeddings"
|
|
856
|
+
|
|
857
|
+
def embed(self, text: str) -> List[float]:
|
|
858
|
+
return self.embed_batch([text])[0]
|
|
859
|
+
|
|
860
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
861
|
+
results: List[List[float]] = []
|
|
862
|
+
# OpenAI supports up to 2048 inputs per request; batch in chunks of 100
|
|
863
|
+
batch_limit = 100
|
|
864
|
+
for i in range(0, len(texts), batch_limit):
|
|
865
|
+
chunk = texts[i : i + batch_limit]
|
|
866
|
+
payload = json.dumps({"model": self.model, "input": chunk}).encode("utf-8")
|
|
867
|
+
req = urllib.request.Request(
|
|
868
|
+
self.api_url,
|
|
869
|
+
data=payload,
|
|
870
|
+
headers={
|
|
871
|
+
"Content-Type": "application/json",
|
|
872
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
873
|
+
},
|
|
874
|
+
method="POST",
|
|
875
|
+
)
|
|
876
|
+
try:
|
|
877
|
+
with urllib.request.urlopen(req, timeout=30.0) as response: # nosec B310
|
|
878
|
+
resp_data = json.loads(response.read().decode("utf-8"))
|
|
879
|
+
# Sort by index to preserve order
|
|
880
|
+
embeddings_sorted = sorted(
|
|
881
|
+
resp_data.get("data", []), key=lambda e: e["index"]
|
|
882
|
+
)
|
|
883
|
+
for emb in embeddings_sorted:
|
|
884
|
+
results.append(self._l2_normalize(emb["embedding"]))
|
|
885
|
+
except Exception as e:
|
|
886
|
+
raise RuntimeError(f"OpenAI embedding batch call failed: {e}")
|
|
887
|
+
return results
|
|
888
|
+
|
|
889
|
+
def _l2_normalize(self, vec: List[float]) -> List[float]:
|
|
890
|
+
sum_sq = sum(x * x for x in vec)
|
|
891
|
+
norm = math.sqrt(sum_sq)
|
|
892
|
+
if norm == 0:
|
|
893
|
+
return vec
|
|
894
|
+
return [x / norm for x in vec]
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# =====================================================================
|
|
898
|
+
# SentenceTransformer Local Provider (D5.2)
|
|
899
|
+
# =====================================================================
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class SentenceTransformerProvider:
|
|
903
|
+
"""Local sentence-transformers provider (optional install).
|
|
904
|
+
|
|
905
|
+
Default model: all-MiniLM-L6-v2 (384 dims).
|
|
906
|
+
Override via AGENTMEMORY_LOCAL_EMBEDDING_MODEL env var.
|
|
907
|
+
|
|
908
|
+
Install: pip install sentence-transformers
|
|
909
|
+
"""
|
|
910
|
+
|
|
911
|
+
def __init__(self, model_name: Optional[str] = None):
|
|
912
|
+
model_name = model_name or "all-MiniLM-L6-v2"
|
|
913
|
+
self.name = "sentence-transformers"
|
|
914
|
+
try:
|
|
915
|
+
from sentence_transformers import SentenceTransformer # type: ignore
|
|
916
|
+
|
|
917
|
+
self._model = SentenceTransformer(model_name)
|
|
918
|
+
self.dimensions = self._model.get_sentence_embedding_dimension()
|
|
919
|
+
print(
|
|
920
|
+
f"[search] SentenceTransformerProvider loaded: {model_name} ({self.dimensions} dims)"
|
|
921
|
+
)
|
|
922
|
+
except ImportError:
|
|
923
|
+
raise ImportError(
|
|
924
|
+
"sentence-transformers is not installed. "
|
|
925
|
+
"Run: pip install sentence-transformers or pip install agentmemory[local-embeddings]"
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def embed(self, text: str) -> List[float]:
|
|
929
|
+
return self.embed_batch([text])[0]
|
|
930
|
+
|
|
931
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
932
|
+
embeddings = self._model.encode(
|
|
933
|
+
texts, show_progress_bar=False, normalize_embeddings=True
|
|
934
|
+
)
|
|
935
|
+
return [emb.tolist() for emb in embeddings]
|