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.
Files changed (50) hide show
  1. cli/__init__.py +1 -0
  2. cli/commands/__init__.py +1 -0
  3. cli/commands/benchmark.py +151 -0
  4. cli/commands/config_cmd.py +57 -0
  5. cli/commands/doctor.py +61 -0
  6. cli/commands/export.py +106 -0
  7. cli/commands/import_cmd.py +143 -0
  8. cli/commands/init.py +205 -0
  9. cli/commands/logs.py +38 -0
  10. cli/commands/monitor.py +63 -0
  11. cli/commands/plugins.py +37 -0
  12. cli/commands/start.py +24 -0
  13. cli/commands/status.py +92 -0
  14. cli/commands/stop.py +19 -0
  15. cli/commands/version.py +25 -0
  16. cli/commands/workspace.py +154 -0
  17. cli/main.py +496 -0
  18. cli/parser.py +188 -0
  19. cli_memory_os-0.1.0.dist-info/METADATA +231 -0
  20. cli_memory_os-0.1.0.dist-info/RECORD +50 -0
  21. cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
  22. cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
  23. cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
  24. cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
  25. connectors/__init__.py +1 -0
  26. connectors/base.py +39 -0
  27. connectors/github.py +273 -0
  28. connectors/gmail.py +116 -0
  29. connectors/notion.py +156 -0
  30. connectors/registry.py +60 -0
  31. core/__init__.py +1 -0
  32. core/chunker.py +136 -0
  33. core/context_builder.py +407 -0
  34. core/embedder.py +42 -0
  35. core/llm.py +301 -0
  36. core/vector_store.py +629 -0
  37. infrastructure/__init__.py +1 -0
  38. infrastructure/compose.py +136 -0
  39. infrastructure/config.py +280 -0
  40. infrastructure/docker.py +61 -0
  41. infrastructure/health.py +338 -0
  42. infrastructure/observability.py +160 -0
  43. infrastructure/workspace.py +152 -0
  44. models/__init__.py +1 -0
  45. models/memory.py +28 -0
  46. storage/__init__.py +1 -0
  47. storage/db.py +528 -0
  48. storage/graph.py +324 -0
  49. storage/schema.sql +57 -0
  50. 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