cli-memory-os 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.
- cli/__init__.py +1 -0
- cli/commands/__init__.py +1 -0
- cli/commands/benchmark.py +151 -0
- cli/commands/config_cmd.py +57 -0
- cli/commands/doctor.py +61 -0
- cli/commands/export.py +106 -0
- cli/commands/import_cmd.py +143 -0
- cli/commands/init.py +205 -0
- cli/commands/logs.py +38 -0
- cli/commands/monitor.py +63 -0
- cli/commands/plugins.py +37 -0
- cli/commands/start.py +24 -0
- cli/commands/status.py +92 -0
- cli/commands/stop.py +19 -0
- cli/commands/version.py +25 -0
- cli/commands/workspace.py +154 -0
- cli/main.py +496 -0
- cli/parser.py +188 -0
- cli_memory_os-0.1.0.dist-info/METADATA +231 -0
- cli_memory_os-0.1.0.dist-info/RECORD +50 -0
- cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
- cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
- cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
- cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
- connectors/__init__.py +1 -0
- connectors/base.py +39 -0
- connectors/github.py +273 -0
- connectors/gmail.py +116 -0
- connectors/notion.py +156 -0
- connectors/registry.py +60 -0
- core/__init__.py +1 -0
- core/chunker.py +136 -0
- core/context_builder.py +407 -0
- core/embedder.py +42 -0
- core/llm.py +301 -0
- core/vector_store.py +629 -0
- infrastructure/__init__.py +1 -0
- infrastructure/compose.py +136 -0
- infrastructure/config.py +280 -0
- infrastructure/docker.py +61 -0
- infrastructure/health.py +338 -0
- infrastructure/observability.py +160 -0
- infrastructure/workspace.py +152 -0
- models/__init__.py +1 -0
- models/memory.py +28 -0
- storage/__init__.py +1 -0
- storage/db.py +528 -0
- storage/graph.py +324 -0
- storage/schema.sql +57 -0
- storage/tech_detector.py +117 -0
core/vector_store.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from qdrant_client import QdrantClient
|
|
3
|
+
from qdrant_client.http import models
|
|
4
|
+
from qdrant_client.http.exceptions import UnexpectedResponse
|
|
5
|
+
|
|
6
|
+
from core.embedder import Embedder
|
|
7
|
+
from core.chunker import generate_and_save_chunks
|
|
8
|
+
from storage.db import (
|
|
9
|
+
get_all_document_chunks,
|
|
10
|
+
get_repository_document_count
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
COLLECTION_NAME = "memory_os"
|
|
14
|
+
|
|
15
|
+
_qdrant_client = None
|
|
16
|
+
|
|
17
|
+
def get_qdrant_client() -> QdrantClient:
|
|
18
|
+
global _qdrant_client
|
|
19
|
+
if _qdrant_client is None:
|
|
20
|
+
url = os.getenv("QDRANT_URL")
|
|
21
|
+
if url:
|
|
22
|
+
_qdrant_client = QdrantClient(url=url)
|
|
23
|
+
else:
|
|
24
|
+
path = os.getenv("MEMORY_OS_QDRANT_PATH", "qdrant_storage")
|
|
25
|
+
_qdrant_client = QdrantClient(path=path)
|
|
26
|
+
return _qdrant_client
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def close_qdrant_client():
|
|
30
|
+
global _qdrant_client
|
|
31
|
+
if _qdrant_client is not None:
|
|
32
|
+
try:
|
|
33
|
+
_qdrant_client.close()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
_qdrant_client = None
|
|
37
|
+
|
|
38
|
+
def init_qdrant_collection(client: QdrantClient, force_recreate: bool = False):
|
|
39
|
+
exists = False
|
|
40
|
+
try:
|
|
41
|
+
collection_info = client.get_collection(COLLECTION_NAME)
|
|
42
|
+
exists = True
|
|
43
|
+
|
|
44
|
+
config = collection_info.config.params.vectors
|
|
45
|
+
if isinstance(config, dict):
|
|
46
|
+
dim = config.get("size")
|
|
47
|
+
else:
|
|
48
|
+
dim = getattr(config, "size", None)
|
|
49
|
+
|
|
50
|
+
if dim != 384 or force_recreate:
|
|
51
|
+
client.delete_collection(COLLECTION_NAME)
|
|
52
|
+
exists = False
|
|
53
|
+
except (UnexpectedResponse, ValueError, Exception):
|
|
54
|
+
exists = False
|
|
55
|
+
|
|
56
|
+
if not exists:
|
|
57
|
+
client.create_collection(
|
|
58
|
+
collection_name=COLLECTION_NAME,
|
|
59
|
+
vectors_config=models.VectorParams(
|
|
60
|
+
size=384,
|
|
61
|
+
distance=models.Distance.COSINE
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def upload_chunks_to_qdrant(client: QdrantClient, chunks: list, embeddings: list):
|
|
66
|
+
points = []
|
|
67
|
+
for idx, (chunk, vector) in enumerate(zip(chunks, embeddings)):
|
|
68
|
+
points.append(
|
|
69
|
+
models.PointStruct(
|
|
70
|
+
id=chunk["id"],
|
|
71
|
+
vector=vector,
|
|
72
|
+
payload={
|
|
73
|
+
"chunk_id": chunk["id"],
|
|
74
|
+
"repository_name": chunk["repository_name"],
|
|
75
|
+
"document_name": chunk["document_name"],
|
|
76
|
+
"source_type": chunk["source_type"],
|
|
77
|
+
"chunk_text": chunk["chunk_text"],
|
|
78
|
+
"chunk_index": chunk["chunk_index"]
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if points:
|
|
84
|
+
# Upload in batches of 100 for stability
|
|
85
|
+
batch_size = 100
|
|
86
|
+
for i in range(0, len(points), batch_size):
|
|
87
|
+
batch = points[i : i + batch_size]
|
|
88
|
+
client.upsert(
|
|
89
|
+
collection_name=COLLECTION_NAME,
|
|
90
|
+
points=batch
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def run_reindexing(repo_names=None):
|
|
94
|
+
import time
|
|
95
|
+
import logging
|
|
96
|
+
from storage.db import get_connection
|
|
97
|
+
logger = logging.getLogger("vector_store")
|
|
98
|
+
|
|
99
|
+
logger.info("Starting reindexing process...")
|
|
100
|
+
start_time = time.perf_counter()
|
|
101
|
+
|
|
102
|
+
# 1. Generate and save SQLite chunks
|
|
103
|
+
logger.info("Generating SQLite chunks...")
|
|
104
|
+
chunk_start = time.perf_counter()
|
|
105
|
+
generate_and_save_chunks(repo_names)
|
|
106
|
+
logger.info(f"Generated SQLite chunks in {time.perf_counter() - chunk_start:.2f}s")
|
|
107
|
+
|
|
108
|
+
# 2. Connect client
|
|
109
|
+
client = get_qdrant_client()
|
|
110
|
+
|
|
111
|
+
if repo_names is None:
|
|
112
|
+
# Full rebuild
|
|
113
|
+
chunks = get_all_document_chunks()
|
|
114
|
+
else:
|
|
115
|
+
# Incremental rebuild: clear specific points in Qdrant and query only those chunks
|
|
116
|
+
for r_name in repo_names:
|
|
117
|
+
if r_name == "__emails__":
|
|
118
|
+
client.delete(
|
|
119
|
+
collection_name=COLLECTION_NAME,
|
|
120
|
+
points_selector=models.Filter(
|
|
121
|
+
must=[models.FieldCondition(key="source_type", match=models.MatchValue(value="email"))]
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
client.delete(
|
|
126
|
+
collection_name=COLLECTION_NAME,
|
|
127
|
+
points_selector=models.Filter(
|
|
128
|
+
must=[models.FieldCondition(key="repository_name", match=models.MatchValue(value=r_name))]
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Load only the updated chunks from SQLite
|
|
133
|
+
conn = get_connection()
|
|
134
|
+
cursor = conn.cursor()
|
|
135
|
+
chunks = []
|
|
136
|
+
for r_name in repo_names:
|
|
137
|
+
if r_name == "__emails__":
|
|
138
|
+
cursor.execute(
|
|
139
|
+
"SELECT id, repository_name, document_name, source_type, chunk_text, chunk_index FROM document_chunks WHERE source_type = 'email'"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
cursor.execute(
|
|
143
|
+
"SELECT id, repository_name, document_name, source_type, chunk_text, chunk_index FROM document_chunks WHERE repository_name = ?",
|
|
144
|
+
(r_name,)
|
|
145
|
+
)
|
|
146
|
+
for row in cursor.fetchall():
|
|
147
|
+
chunks.append({
|
|
148
|
+
"id": row[0],
|
|
149
|
+
"repository_name": row[1],
|
|
150
|
+
"document_name": row[2],
|
|
151
|
+
"source_type": row[3],
|
|
152
|
+
"chunk_text": row[4],
|
|
153
|
+
"chunk_index": row[5]
|
|
154
|
+
})
|
|
155
|
+
conn.close()
|
|
156
|
+
|
|
157
|
+
if not chunks:
|
|
158
|
+
# Return empty summary if no content
|
|
159
|
+
duration = time.perf_counter() - start_time
|
|
160
|
+
logger.info(f"Reindexing process finished with 0 chunks in {duration:.2f}s")
|
|
161
|
+
return {
|
|
162
|
+
"documents": get_repository_document_count(),
|
|
163
|
+
"chunks": 0,
|
|
164
|
+
"collection": COLLECTION_NAME,
|
|
165
|
+
"dimension": 384,
|
|
166
|
+
"vectors_uploaded": 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# 3. Generate embeddings
|
|
170
|
+
logger.info(f"Generating embeddings for {len(chunks)} chunks...")
|
|
171
|
+
embed_start = time.perf_counter()
|
|
172
|
+
embedder = Embedder()
|
|
173
|
+
texts = [c["chunk_text"] for c in chunks]
|
|
174
|
+
embeddings = embedder.embed_documents(texts)
|
|
175
|
+
embed_duration = time.perf_counter() - embed_start
|
|
176
|
+
logger.info(f"Embedding generation complete for {len(chunks)} chunks in {embed_duration:.2f}s")
|
|
177
|
+
print(f"Embedding Generation Duration: {embed_duration:.2f}s")
|
|
178
|
+
|
|
179
|
+
# 4. Recreate collection or insert
|
|
180
|
+
logger.info("Uploading vectors to Qdrant...")
|
|
181
|
+
upload_start = time.perf_counter()
|
|
182
|
+
if repo_names is None:
|
|
183
|
+
init_qdrant_collection(client, force_recreate=True)
|
|
184
|
+
upload_chunks_to_qdrant(client, chunks, embeddings)
|
|
185
|
+
upload_duration = time.perf_counter() - upload_start
|
|
186
|
+
logger.info(f"Vector upload complete for {len(chunks)} vectors in {upload_duration:.2f}s")
|
|
187
|
+
print(f"Vector Upload Duration: {upload_duration:.2f}s")
|
|
188
|
+
|
|
189
|
+
total_duration = time.perf_counter() - start_time
|
|
190
|
+
logger.info(f"Reindexing process complete in {total_duration:.2f}s")
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"documents": get_repository_document_count(),
|
|
194
|
+
"chunks": len(chunks),
|
|
195
|
+
"collection": COLLECTION_NAME,
|
|
196
|
+
"dimension": 384,
|
|
197
|
+
"vectors_uploaded": len(chunks)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def get_vector_index_stats() -> dict:
|
|
201
|
+
"""Retrieve general metrics and configuration about the Qdrant vector index collection."""
|
|
202
|
+
client = get_qdrant_client()
|
|
203
|
+
try:
|
|
204
|
+
collection_info = client.get_collection(COLLECTION_NAME)
|
|
205
|
+
config = collection_info.config.params.vectors
|
|
206
|
+
if isinstance(config, dict):
|
|
207
|
+
dim = config.get("size")
|
|
208
|
+
else:
|
|
209
|
+
dim = getattr(config, "size", None)
|
|
210
|
+
|
|
211
|
+
vectors_count = getattr(collection_info, "points_count", 0)
|
|
212
|
+
return {
|
|
213
|
+
"collection": COLLECTION_NAME,
|
|
214
|
+
"dimension": dim,
|
|
215
|
+
"vectors": vectors_count,
|
|
216
|
+
"embedding_model": "all-MiniLM-L6-v2",
|
|
217
|
+
"exists": True
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
except Exception:
|
|
221
|
+
return {
|
|
222
|
+
"collection": COLLECTION_NAME,
|
|
223
|
+
"dimension": 384,
|
|
224
|
+
"vectors": 0,
|
|
225
|
+
"embedding_model": "all-MiniLM-L6-v2",
|
|
226
|
+
"exists": False
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def get_vector_chunks(repo_name: str, limit: int = 5) -> list:
|
|
230
|
+
"""Retrieve the first `limit` chunks for a given repository from Qdrant.
|
|
231
|
+
Returns a list of Record objects.
|
|
232
|
+
"""
|
|
233
|
+
client = get_qdrant_client()
|
|
234
|
+
try:
|
|
235
|
+
filter_expr = models.Filter(
|
|
236
|
+
must=[
|
|
237
|
+
models.FieldCondition(
|
|
238
|
+
key="repository_name",
|
|
239
|
+
match=models.MatchValue(value=repo_name)
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
)
|
|
243
|
+
points, _ = client.scroll(
|
|
244
|
+
collection_name=COLLECTION_NAME,
|
|
245
|
+
scroll_filter=filter_expr,
|
|
246
|
+
limit=limit,
|
|
247
|
+
with_payload=True,
|
|
248
|
+
with_vectors=False
|
|
249
|
+
)
|
|
250
|
+
return points
|
|
251
|
+
except Exception as e:
|
|
252
|
+
import logging
|
|
253
|
+
logging.getLogger("vector_store").error(f"Error fetching vector chunks: {e}")
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
def count_vector_chunks(repo_name: str) -> int:
|
|
257
|
+
"""Count the total number of vectors for a given repository in Qdrant."""
|
|
258
|
+
client = get_qdrant_client()
|
|
259
|
+
try:
|
|
260
|
+
filter_expr = models.Filter(
|
|
261
|
+
must=[
|
|
262
|
+
models.FieldCondition(
|
|
263
|
+
key="repository_name",
|
|
264
|
+
match=models.MatchValue(value=repo_name)
|
|
265
|
+
)
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
result = client.count(
|
|
269
|
+
collection_name=COLLECTION_NAME,
|
|
270
|
+
count_filter=filter_expr,
|
|
271
|
+
exact=True
|
|
272
|
+
)
|
|
273
|
+
return result.count
|
|
274
|
+
except Exception as e:
|
|
275
|
+
import logging
|
|
276
|
+
logging.getLogger("vector_store").error(f"Error counting vector chunks: {e}")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
def run_semantic_search(query: str, limit: int = 5, source_filter: str = None, raw_scores: bool = False, repo_filter: str = None) -> list:
|
|
280
|
+
"""Perform raw vector search on the Qdrant database, with optional source and repository filters."""
|
|
281
|
+
embedder = Embedder()
|
|
282
|
+
vector = embedder.embed_query(query)
|
|
283
|
+
|
|
284
|
+
must_conditions = []
|
|
285
|
+
if source_filter:
|
|
286
|
+
must_conditions.append(
|
|
287
|
+
models.FieldCondition(
|
|
288
|
+
key="source_type",
|
|
289
|
+
match=models.MatchValue(value=source_filter)
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
if repo_filter:
|
|
293
|
+
if isinstance(repo_filter, list):
|
|
294
|
+
must_conditions.append(
|
|
295
|
+
models.FieldCondition(
|
|
296
|
+
key="repository_name",
|
|
297
|
+
match=models.MatchAny(any=repo_filter)
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
must_conditions.append(
|
|
302
|
+
models.FieldCondition(
|
|
303
|
+
key="repository_name",
|
|
304
|
+
match=models.MatchValue(value=repo_filter)
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
query_filter = models.Filter(
|
|
309
|
+
must=must_conditions,
|
|
310
|
+
must_not=[
|
|
311
|
+
models.FieldCondition(
|
|
312
|
+
key="source_type",
|
|
313
|
+
match=models.MatchValue(value="repository_metadata")
|
|
314
|
+
)
|
|
315
|
+
]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
client = get_qdrant_client()
|
|
319
|
+
try:
|
|
320
|
+
# If we need raw scores, query with the exact limit.
|
|
321
|
+
# Otherwise, query a larger limit (e.g. limit * 4) to allow for keyword boost re-ranking.
|
|
322
|
+
qdrant_limit = limit if raw_scores else limit * 4
|
|
323
|
+
|
|
324
|
+
search_result = client.query_points(
|
|
325
|
+
collection_name=COLLECTION_NAME,
|
|
326
|
+
query=vector,
|
|
327
|
+
query_filter=query_filter,
|
|
328
|
+
limit=qdrant_limit
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
query_lower = query.lower()
|
|
332
|
+
tech_keywords = ["python", "javascript", "typescript", "react", "node", "express", "mongo", "fastapi", "postgres", "tailwind", "docker", "kafka", "redis", "plotly", "gemini", "groq", "next.js", "firebase", "sqlite", "repo", "repository", "code", "github", "project", "readme"]
|
|
333
|
+
is_repo_focused = any(k in query_lower for k in tech_keywords)
|
|
334
|
+
|
|
335
|
+
results = []
|
|
336
|
+
for point in search_result.points:
|
|
337
|
+
payload = point.payload
|
|
338
|
+
item = {
|
|
339
|
+
"repository_name": payload.get("repository_name"),
|
|
340
|
+
"document_name": payload.get("document_name"),
|
|
341
|
+
"source_type": payload.get("source_type"),
|
|
342
|
+
"chunk_text": payload.get("chunk_text"),
|
|
343
|
+
"chunk_index": payload.get("chunk_index")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if raw_scores:
|
|
347
|
+
item["score"] = point.score
|
|
348
|
+
else:
|
|
349
|
+
boost = compute_keyword_boost(item, query)
|
|
350
|
+
score = round((point.score * 0.8) + (boost * 0.2), 4)
|
|
351
|
+
# Down-weight emails for repository-focused queries
|
|
352
|
+
if is_repo_focused and item["source_type"] == "email":
|
|
353
|
+
score = round(score * 0.1, 4)
|
|
354
|
+
item["score"] = score
|
|
355
|
+
|
|
356
|
+
results.append(item)
|
|
357
|
+
|
|
358
|
+
if not raw_scores:
|
|
359
|
+
results.sort(key=lambda x: (-x["score"], x.get("repository_name") or ""))
|
|
360
|
+
|
|
361
|
+
return results[:limit]
|
|
362
|
+
except Exception:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
def compute_keyword_boost(item: dict, query: str) -> float:
|
|
366
|
+
repo_name = item.get("repo_name") or item.get("repository_name") or ""
|
|
367
|
+
file_name = item.get("file_name") or item.get("document_name") or ""
|
|
368
|
+
source_type = item.get("source_type") or item.get("type") or ""
|
|
369
|
+
description = item.get("description") or item.get("chunk_text") or item.get("content") or item.get("snippet") or ""
|
|
370
|
+
|
|
371
|
+
if not description and repo_name:
|
|
372
|
+
from storage.db import get_repository_details
|
|
373
|
+
details = get_repository_details(repo_name)
|
|
374
|
+
if details:
|
|
375
|
+
description = details.get("description") or ""
|
|
376
|
+
|
|
377
|
+
query_lower = query.lower().strip()
|
|
378
|
+
stop_words = {"a", "an", "the", "in", "of", "and", "or", "to", "for", "with", "is", "at", "on", "by"}
|
|
379
|
+
query_terms = [term for term in query_lower.split() if term not in stop_words and len(term) > 1]
|
|
380
|
+
if not query_terms:
|
|
381
|
+
query_terms = [term for term in query_lower.split() if len(term) > 0]
|
|
382
|
+
|
|
383
|
+
repo_lower = repo_name.lower()
|
|
384
|
+
desc_lower = description.lower()
|
|
385
|
+
file_lower = file_name.lower()
|
|
386
|
+
|
|
387
|
+
boost = 0.0
|
|
388
|
+
|
|
389
|
+
# 1. Repository name contains query terms
|
|
390
|
+
if repo_lower and any(term in repo_lower for term in query_terms):
|
|
391
|
+
boost += 0.4
|
|
392
|
+
# Exact match bonus
|
|
393
|
+
if query_lower == repo_lower:
|
|
394
|
+
boost += 0.2
|
|
395
|
+
|
|
396
|
+
# 2. README title priority boost
|
|
397
|
+
if "readme" in file_lower:
|
|
398
|
+
boost += 0.3
|
|
399
|
+
if any(term in repo_lower or term in file_lower for term in query_terms):
|
|
400
|
+
boost += 0.1
|
|
401
|
+
|
|
402
|
+
# 3. Repository description contains query terms
|
|
403
|
+
if desc_lower and any(term in desc_lower for term in query_terms):
|
|
404
|
+
boost += 0.1
|
|
405
|
+
|
|
406
|
+
# 4. Document source type boost
|
|
407
|
+
if source_type == "document":
|
|
408
|
+
boost += 0.1
|
|
409
|
+
|
|
410
|
+
return min(1.0, boost)
|
|
411
|
+
|
|
412
|
+
def detect_repo_in_query(query: str) -> str:
|
|
413
|
+
"""Check if any known repository name is mentioned in the query.
|
|
414
|
+
Returns the exact case-sensitive repository name if found, else None.
|
|
415
|
+
"""
|
|
416
|
+
import re
|
|
417
|
+
from storage.db import get_connection
|
|
418
|
+
conn = get_connection()
|
|
419
|
+
cursor = conn.cursor()
|
|
420
|
+
cursor.execute("SELECT DISTINCT repo_name FROM repository_documents WHERE repo_name IS NOT NULL")
|
|
421
|
+
names = [row[0] for row in cursor.fetchall()]
|
|
422
|
+
conn.close()
|
|
423
|
+
|
|
424
|
+
query_lower = query.lower()
|
|
425
|
+
# Sort by length descending to match longer names first (e.g. 'nextjs-ai-chatbot' before 'chatbot')
|
|
426
|
+
for name in sorted(names, key=len, reverse=True):
|
|
427
|
+
name_lower = name.lower()
|
|
428
|
+
# Match word boundaries or replaced hyphens
|
|
429
|
+
patterns = [
|
|
430
|
+
r'\b' + re.escape(name_lower) + r'\b',
|
|
431
|
+
r'\b' + re.escape(name_lower.replace('-', ' ')) + r'\b',
|
|
432
|
+
r'\b' + re.escape(name_lower.replace('-', '')) + r'\b',
|
|
433
|
+
]
|
|
434
|
+
if any(re.search(pat, query_lower) for pat in patterns):
|
|
435
|
+
return name
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
def hybrid_search(query: str, source_filter: str = None, repo_filter: str = None) -> list:
|
|
439
|
+
import time
|
|
440
|
+
import logging
|
|
441
|
+
logger = logging.getLogger("vector_store")
|
|
442
|
+
|
|
443
|
+
logger.info(f"Starting hybrid retrieval for query: '{query}'")
|
|
444
|
+
start_time = time.perf_counter()
|
|
445
|
+
|
|
446
|
+
from storage.db import search_local_knowledge_ranked
|
|
447
|
+
|
|
448
|
+
if not repo_filter:
|
|
449
|
+
repo_filter = detect_repo_in_query(query)
|
|
450
|
+
|
|
451
|
+
# 1. Run keyword search
|
|
452
|
+
keyword_results = search_local_knowledge_ranked(query, repo_filter=repo_filter)
|
|
453
|
+
|
|
454
|
+
# 2. Run semantic search
|
|
455
|
+
semantic_results = run_semantic_search(query, limit=20, source_filter=source_filter, raw_scores=True, repo_filter=repo_filter)
|
|
456
|
+
|
|
457
|
+
# 3. Run graph search to check for graph relevance
|
|
458
|
+
try:
|
|
459
|
+
from storage.graph import GraphStore
|
|
460
|
+
graph = GraphStore()
|
|
461
|
+
graph_results = graph.lookup_relationships(query)
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"Graph lookup failed during hybrid search: {e}")
|
|
464
|
+
graph_results = []
|
|
465
|
+
|
|
466
|
+
# Create candidate map to merge results
|
|
467
|
+
candidates = {}
|
|
468
|
+
|
|
469
|
+
# Parse keyword search results
|
|
470
|
+
for item in keyword_results:
|
|
471
|
+
t = item["type"]
|
|
472
|
+
if t == "repository":
|
|
473
|
+
key = f"repository:{item['repo_name'].lower()}"
|
|
474
|
+
candidates[key] = {
|
|
475
|
+
"type": "repository",
|
|
476
|
+
"repo_name": item["repo_name"],
|
|
477
|
+
"language": item.get("language"),
|
|
478
|
+
"description": item.get("description"),
|
|
479
|
+
"semantic_similarity": 0.0
|
|
480
|
+
}
|
|
481
|
+
elif t == "document":
|
|
482
|
+
key = f"document:{item['repo_name'].lower()}:{item['file_name'].lower()}"
|
|
483
|
+
candidates[key] = {
|
|
484
|
+
"type": "document",
|
|
485
|
+
"repo_name": item["repo_name"],
|
|
486
|
+
"file_name": item["file_name"],
|
|
487
|
+
"content": item["content"],
|
|
488
|
+
"semantic_similarity": 0.0
|
|
489
|
+
}
|
|
490
|
+
elif t == "email":
|
|
491
|
+
key = f"email:{item['subject'].lower()}"
|
|
492
|
+
candidates[key] = {
|
|
493
|
+
"type": "email",
|
|
494
|
+
"subject": item["subject"],
|
|
495
|
+
"sender": item["sender"],
|
|
496
|
+
"snippet": item["snippet"],
|
|
497
|
+
"semantic_similarity": 0.0
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
# Merge semantic search results (keep highest semantic score per unique result key)
|
|
501
|
+
for sem in semantic_results:
|
|
502
|
+
source = sem["source_type"]
|
|
503
|
+
|
|
504
|
+
if source == "repository":
|
|
505
|
+
key = f"repository:{sem['repository_name'].lower()}"
|
|
506
|
+
if key not in candidates:
|
|
507
|
+
candidates[key] = {
|
|
508
|
+
"type": "repository",
|
|
509
|
+
"repo_name": sem["repository_name"],
|
|
510
|
+
"description": sem["chunk_text"],
|
|
511
|
+
"semantic_similarity": sem["score"]
|
|
512
|
+
}
|
|
513
|
+
else:
|
|
514
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
515
|
+
|
|
516
|
+
elif source == "document":
|
|
517
|
+
key = f"document:{sem['repository_name'].lower()}:{sem['document_name'].lower()}"
|
|
518
|
+
if key not in candidates:
|
|
519
|
+
candidates[key] = {
|
|
520
|
+
"type": "document",
|
|
521
|
+
"repo_name": sem["repository_name"],
|
|
522
|
+
"file_name": sem["document_name"],
|
|
523
|
+
"content": sem["chunk_text"],
|
|
524
|
+
"semantic_similarity": sem["score"]
|
|
525
|
+
}
|
|
526
|
+
else:
|
|
527
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
528
|
+
|
|
529
|
+
elif source == "email":
|
|
530
|
+
key = f"email:{sem['document_name'].lower()}"
|
|
531
|
+
if key not in candidates:
|
|
532
|
+
candidates[key] = {
|
|
533
|
+
"type": "email",
|
|
534
|
+
"subject": sem["document_name"],
|
|
535
|
+
"sender": "Gmail Index",
|
|
536
|
+
"snippet": sem["chunk_text"],
|
|
537
|
+
"semantic_similarity": sem["score"]
|
|
538
|
+
}
|
|
539
|
+
else:
|
|
540
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
541
|
+
|
|
542
|
+
# Filter candidates by source_filter if specified
|
|
543
|
+
filtered_candidates = []
|
|
544
|
+
for cand in candidates.values():
|
|
545
|
+
t = cand["type"]
|
|
546
|
+
if source_filter:
|
|
547
|
+
# Match the source_filter exactly
|
|
548
|
+
if source_filter == "repository" and t != "repository":
|
|
549
|
+
continue
|
|
550
|
+
if source_filter == "document" and t != "document":
|
|
551
|
+
continue
|
|
552
|
+
if source_filter == "email" and t != "email":
|
|
553
|
+
continue
|
|
554
|
+
filtered_candidates.append(cand)
|
|
555
|
+
|
|
556
|
+
query_lower = query.lower()
|
|
557
|
+
stop_words = {"a", "an", "the", "in", "of", "and", "or", "to", "for", "with", "is", "at", "on", "by", "what", "which", "does", "use", "how", "tell", "me", "about"}
|
|
558
|
+
query_terms = [t for t in query_lower.split() if t not in stop_words and len(t) > 1]
|
|
559
|
+
if not query_terms:
|
|
560
|
+
query_terms = [t for t in query_lower.split() if len(t) > 0]
|
|
561
|
+
|
|
562
|
+
tech_keywords = ["python", "javascript", "typescript", "react", "node", "express", "mongo", "fastapi", "postgres", "tailwind", "docker", "kafka", "redis", "plotly", "gemini", "groq", "next.js", "firebase", "sqlite", "repo", "repository", "code", "github", "project", "readme"]
|
|
563
|
+
is_repo_focused = any(k in query_lower for k in tech_keywords)
|
|
564
|
+
|
|
565
|
+
# Calculate final scores using the new hybrid ranking formula
|
|
566
|
+
results = []
|
|
567
|
+
for cand in filtered_candidates:
|
|
568
|
+
sim = cand["semantic_similarity"]
|
|
569
|
+
|
|
570
|
+
# 1. repository_match (score = 1.0 if the query matches this candidate's repository name)
|
|
571
|
+
repo_name = cand.get("repo_name") or ""
|
|
572
|
+
repo_match_val = 0.0
|
|
573
|
+
if repo_filter and repo_name.lower() == repo_filter.lower():
|
|
574
|
+
repo_match_val = 1.0
|
|
575
|
+
|
|
576
|
+
# 2. readme_bonus (score = 1.0 if document name contains "readme")
|
|
577
|
+
file_name = cand.get("file_name") or ""
|
|
578
|
+
readme_bonus_val = 1.0 if "readme" in file_name.lower() else 0.0
|
|
579
|
+
|
|
580
|
+
# 3. graph_relevance (score = 1.0 if the candidate references any entity found in graph lookup)
|
|
581
|
+
graph_relevance_val = 0.0
|
|
582
|
+
if graph_results:
|
|
583
|
+
for rel_desc in graph_results:
|
|
584
|
+
if repo_name and repo_name.lower() in rel_desc.lower():
|
|
585
|
+
graph_relevance_val = 1.0
|
|
586
|
+
break
|
|
587
|
+
if file_name and file_name.lower() in rel_desc.lower():
|
|
588
|
+
graph_relevance_val = 1.0
|
|
589
|
+
break
|
|
590
|
+
if cand["type"] == "email" and cand.get("subject") and cand["subject"].lower() in rel_desc.lower():
|
|
591
|
+
graph_relevance_val = 1.0
|
|
592
|
+
break
|
|
593
|
+
|
|
594
|
+
# 4. keyword_match (score = 1.0 if any query term is found in content)
|
|
595
|
+
text_to_search = ""
|
|
596
|
+
if cand["type"] == "repository":
|
|
597
|
+
text_to_search = f"{cand.get('repo_name') or ''} {cand.get('description') or ''}"
|
|
598
|
+
elif cand["type"] == "document":
|
|
599
|
+
text_to_search = f"{cand.get('file_name') or ''} {cand.get('content') or ''}"
|
|
600
|
+
elif cand["type"] == "email":
|
|
601
|
+
text_to_search = f"{cand.get('subject') or ''} {cand.get('snippet') or ''}"
|
|
602
|
+
|
|
603
|
+
text_to_search_lower = text_to_search.lower()
|
|
604
|
+
keyword_match_val = 1.0 if any(term in text_to_search_lower for term in query_terms) else 0.0
|
|
605
|
+
|
|
606
|
+
# Calculate final hybrid score incorporating graph relevance (preferred hierarchy)
|
|
607
|
+
final_score = round(
|
|
608
|
+
(0.60 * sim) +
|
|
609
|
+
(0.15 * repo_match_val) +
|
|
610
|
+
(0.10 * readme_bonus_val) +
|
|
611
|
+
(0.10 * graph_relevance_val) +
|
|
612
|
+
(0.05 * keyword_match_val),
|
|
613
|
+
4
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Down-weight emails for repository-focused queries
|
|
617
|
+
if is_repo_focused and cand["type"] == "email":
|
|
618
|
+
final_score = round(final_score * 0.1, 4)
|
|
619
|
+
|
|
620
|
+
cand["score"] = final_score
|
|
621
|
+
results.append(cand)
|
|
622
|
+
|
|
623
|
+
# Re-sort ranked results
|
|
624
|
+
results.sort(key=lambda x: (-x["score"], x.get("repo_name") or x.get("subject") or ""))
|
|
625
|
+
|
|
626
|
+
duration = time.perf_counter() - start_time
|
|
627
|
+
logger.info(f"Hybrid retrieval finished for query: '{query}'. Retrieved {len(results)} items in {duration:.4f}s")
|
|
628
|
+
print(f"Retrieval Duration: {duration:.4f}s")
|
|
629
|
+
return results
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# infrastructure package
|