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/context_builder.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger("context_builder")
|
|
5
|
+
|
|
6
|
+
class ContextBuilder:
|
|
7
|
+
"""Handles query classification, merging, deduplicating, scoring, and enforcing size constraints on RAG contexts."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, max_context_chars: int = 12000, max_doc_chars: int = 1000, max_graph_relationships: int = 10, max_prompt_tokens: int = 6000, max_chunks: int = 8):
|
|
10
|
+
self.max_context_chars = max_context_chars
|
|
11
|
+
self.max_doc_chars = max_doc_chars
|
|
12
|
+
self.max_graph_relationships = max_graph_relationships
|
|
13
|
+
self.max_prompt_tokens = max_prompt_tokens
|
|
14
|
+
self.max_chunks = max_chunks
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def classify_query(query: str, repo_filter = None) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Classify the user query to optimize retrieval precision.
|
|
20
|
+
Rule-based, no LLM required.
|
|
21
|
+
"""
|
|
22
|
+
query_lower = query.lower()
|
|
23
|
+
|
|
24
|
+
# 1. Email Question
|
|
25
|
+
email_keywords = ["email", "emails", "inbox", "message", "messages", "sent from", "received from", "mail", "mails", "gmail"]
|
|
26
|
+
if any(k in query_lower for k in email_keywords):
|
|
27
|
+
return "Email Question"
|
|
28
|
+
|
|
29
|
+
# 2. Technology Question
|
|
30
|
+
tech_triggers = [
|
|
31
|
+
"use", "uses", "using", "built with", "written in", "framework", "technology", "technologies", "tech stack",
|
|
32
|
+
"database", "databases", "language", "languages", "library", "libraries", "techs"
|
|
33
|
+
]
|
|
34
|
+
known_techs = ["python", "javascript", "typescript", "react", "node", "express", "mongodb", "fastapi", "postgresql", "tailwind", "docker", "kafka", "redis", "plotly", "gemini", "groq", "next.js", "firebase", "sqlite", "neo4j", "qdrant", "composio", "langchain", "sentence-transformer", "sentence-transformers"]
|
|
35
|
+
|
|
36
|
+
has_tech_trigger = any(t in query_lower for t in tech_triggers)
|
|
37
|
+
has_known_tech = any(re.search(r'\b' + re.escape(tech) + r'\b', query_lower) for tech in known_techs)
|
|
38
|
+
|
|
39
|
+
if (has_tech_trigger or "which" in query_lower) and has_known_tech:
|
|
40
|
+
if "repositories" in query_lower or "projects" in query_lower or "which" in query_lower or "what" in query_lower:
|
|
41
|
+
return "Technology Question"
|
|
42
|
+
|
|
43
|
+
# 3. Repository Question
|
|
44
|
+
if repo_filter:
|
|
45
|
+
return "Repository Question"
|
|
46
|
+
|
|
47
|
+
# 4. Documentation Question
|
|
48
|
+
doc_keywords = ["readme", "documentation", "document", "documents", "architecture", "design", "pyproject.toml", "package.json", "requirements.txt", "file", "files"]
|
|
49
|
+
if any(dk in query_lower for dk in doc_keywords):
|
|
50
|
+
return "Documentation Question"
|
|
51
|
+
|
|
52
|
+
# 5. Cross Repository Question
|
|
53
|
+
if "cross repository" in query_lower or "multiple repositories" in query_lower or "across all projects" in query_lower:
|
|
54
|
+
return "Cross Repository Question"
|
|
55
|
+
|
|
56
|
+
# Default
|
|
57
|
+
return "General Knowledge Question"
|
|
58
|
+
|
|
59
|
+
def estimate_tokens(self, text: str) -> int:
|
|
60
|
+
"""Estimate token count of a string (heuristic: ~4 characters per token)."""
|
|
61
|
+
return len(text) // 4
|
|
62
|
+
|
|
63
|
+
def is_nearly_identical(self, text1: str, text2: str) -> bool:
|
|
64
|
+
"""Check if two chunks have high word overlap (>75% identical words) to filter out duplicates."""
|
|
65
|
+
words1 = set(re.findall(r'\w+', text1.lower()))
|
|
66
|
+
words2 = set(re.findall(r'\w+', text2.lower()))
|
|
67
|
+
if not words1 or not words2:
|
|
68
|
+
return False
|
|
69
|
+
intersection = words1.intersection(words2)
|
|
70
|
+
overlap1 = len(intersection) / len(words1)
|
|
71
|
+
overlap2 = len(intersection) / len(words2)
|
|
72
|
+
return overlap1 > 0.75 or overlap2 > 0.75
|
|
73
|
+
|
|
74
|
+
def build_context(self, question: str, vector_results: list, keyword_results: list, graph_results: list, repo_filter = None, query_class: str = "General Knowledge Question") -> tuple:
|
|
75
|
+
"""
|
|
76
|
+
Merge, deduplicate, score, and format context from vector, keyword, and graph sources.
|
|
77
|
+
Returns (formatted_chunks, sources_list, repos_list, num_vector, num_keyword, num_graph, after_dedup_count)
|
|
78
|
+
"""
|
|
79
|
+
query_lower = question.lower()
|
|
80
|
+
stop_words = {"a", "an", "the", "in", "of", "and", "or", "to", "for", "with", "is", "at", "on", "by", "what", "which", "does", "use", "how", "tell", "me", "about"}
|
|
81
|
+
query_terms = [t for t in query_lower.split() if t not in stop_words and len(t) > 1]
|
|
82
|
+
if not query_terms:
|
|
83
|
+
query_terms = [t for t in query_lower.split() if len(t) > 0]
|
|
84
|
+
|
|
85
|
+
# Candidate Map to merge vector and keyword results
|
|
86
|
+
candidates = {}
|
|
87
|
+
|
|
88
|
+
# 1. Parse keyword results
|
|
89
|
+
num_keyword_raw = len(keyword_results)
|
|
90
|
+
for item in keyword_results:
|
|
91
|
+
t = item["type"]
|
|
92
|
+
if t == "repository":
|
|
93
|
+
key = f"repository:{item['repo_name'].lower()}"
|
|
94
|
+
candidates[key] = {
|
|
95
|
+
"type": "repository",
|
|
96
|
+
"repo_name": item["repo_name"],
|
|
97
|
+
"language": item.get("language") or "",
|
|
98
|
+
"description": item.get("description") or "",
|
|
99
|
+
"semantic_similarity": 0.0,
|
|
100
|
+
"keyword_match_val": 1.0
|
|
101
|
+
}
|
|
102
|
+
elif t == "document":
|
|
103
|
+
key = f"document:{item['repo_name'].lower()}:{item['file_name'].lower()}"
|
|
104
|
+
candidates[key] = {
|
|
105
|
+
"type": "document",
|
|
106
|
+
"repo_name": item["repo_name"],
|
|
107
|
+
"file_name": item["file_name"],
|
|
108
|
+
"content": item["content"] or "",
|
|
109
|
+
"semantic_similarity": 0.0,
|
|
110
|
+
"keyword_match_val": 1.0
|
|
111
|
+
}
|
|
112
|
+
elif t == "email":
|
|
113
|
+
key = f"email:{item['subject'].lower()}"
|
|
114
|
+
candidates[key] = {
|
|
115
|
+
"type": "email",
|
|
116
|
+
"subject": item["subject"],
|
|
117
|
+
"sender": item["sender"],
|
|
118
|
+
"snippet": item["snippet"] or "",
|
|
119
|
+
"semantic_similarity": 0.0,
|
|
120
|
+
"keyword_match_val": 1.0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# 2. Parse vector results
|
|
124
|
+
num_vector_raw = len(vector_results)
|
|
125
|
+
for sem in vector_results:
|
|
126
|
+
source = sem["source_type"]
|
|
127
|
+
if source == "repository":
|
|
128
|
+
key = f"repository:{sem['repository_name'].lower()}"
|
|
129
|
+
if key not in candidates:
|
|
130
|
+
candidates[key] = {
|
|
131
|
+
"type": "repository",
|
|
132
|
+
"repo_name": sem["repository_name"],
|
|
133
|
+
"description": sem["chunk_text"] or "",
|
|
134
|
+
"semantic_similarity": sem["score"],
|
|
135
|
+
"keyword_match_val": 0.0
|
|
136
|
+
}
|
|
137
|
+
else:
|
|
138
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
139
|
+
elif source == "document":
|
|
140
|
+
key = f"document:{sem['repository_name'].lower()}:{sem['document_name'].lower()}"
|
|
141
|
+
if key not in candidates:
|
|
142
|
+
candidates[key] = {
|
|
143
|
+
"type": "document",
|
|
144
|
+
"repo_name": sem["repository_name"],
|
|
145
|
+
"file_name": sem["document_name"],
|
|
146
|
+
"content": sem["chunk_text"] or "",
|
|
147
|
+
"semantic_similarity": sem["score"],
|
|
148
|
+
"keyword_match_val": 0.0
|
|
149
|
+
}
|
|
150
|
+
else:
|
|
151
|
+
if sem["score"] > candidates[key]["semantic_similarity"]:
|
|
152
|
+
candidates[key]["content"] = sem["chunk_text"]
|
|
153
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
154
|
+
elif source == "email":
|
|
155
|
+
key = f"email:{sem['document_name'].lower()}"
|
|
156
|
+
if key not in candidates:
|
|
157
|
+
candidates[key] = {
|
|
158
|
+
"type": "email",
|
|
159
|
+
"subject": sem["document_name"],
|
|
160
|
+
"sender": "Gmail Index",
|
|
161
|
+
"snippet": sem["chunk_text"] or "",
|
|
162
|
+
"semantic_similarity": sem["score"],
|
|
163
|
+
"keyword_match_val": 0.0
|
|
164
|
+
}
|
|
165
|
+
else:
|
|
166
|
+
if sem["score"] > candidates[key]["semantic_similarity"]:
|
|
167
|
+
candidates[key]["snippet"] = sem["chunk_text"]
|
|
168
|
+
candidates[key]["semantic_similarity"] = max(candidates[key]["semantic_similarity"], sem["score"])
|
|
169
|
+
|
|
170
|
+
# 3. Source Priority Checks (TASK 3)
|
|
171
|
+
# Check if any documentation exists (either document or repository)
|
|
172
|
+
has_documentation = any(cand["type"] in ["document", "repository"] for cand in candidates.values())
|
|
173
|
+
|
|
174
|
+
# Discard emails if the query does NOT explicitly ask about emails AND documentation exists
|
|
175
|
+
is_email_query = (query_class == "Email Question")
|
|
176
|
+
if not is_email_query and has_documentation:
|
|
177
|
+
# Remove all emails
|
|
178
|
+
candidates = {k: v for k, v in candidates.items() if v["type"] != "email"}
|
|
179
|
+
|
|
180
|
+
# 4. Calculate Hybrid Score for each candidate (TASK 4)
|
|
181
|
+
scored_candidates = []
|
|
182
|
+
for key, cand in candidates.items():
|
|
183
|
+
sim = cand["semantic_similarity"]
|
|
184
|
+
repo_name = cand.get("repo_name") or ""
|
|
185
|
+
file_name = cand.get("file_name") or ""
|
|
186
|
+
|
|
187
|
+
# Repository match boost (0.20)
|
|
188
|
+
repo_match_val = 0.0
|
|
189
|
+
if repo_filter:
|
|
190
|
+
if isinstance(repo_filter, list):
|
|
191
|
+
if repo_name in repo_filter:
|
|
192
|
+
repo_match_val = 1.0
|
|
193
|
+
elif repo_name.lower() == repo_filter.lower():
|
|
194
|
+
repo_match_val = 1.0
|
|
195
|
+
|
|
196
|
+
# README Bonus (0.10)
|
|
197
|
+
readme_bonus_val = 1.0 if "readme" in file_name.lower() else 0.0
|
|
198
|
+
|
|
199
|
+
# Graph relevance boost (0.10)
|
|
200
|
+
graph_relevance_val = 0.0
|
|
201
|
+
if graph_results:
|
|
202
|
+
for rel_desc in graph_results:
|
|
203
|
+
if repo_name and repo_name.lower() in rel_desc.lower():
|
|
204
|
+
graph_relevance_val = 1.0
|
|
205
|
+
break
|
|
206
|
+
if file_name and file_name.lower() in rel_desc.lower():
|
|
207
|
+
graph_relevance_val = 1.0
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
# Keyword Match (0.05)
|
|
211
|
+
keyword_match_val = cand.get("keyword_match_val", 0.0)
|
|
212
|
+
if keyword_match_val == 0.0:
|
|
213
|
+
text_to_search = ""
|
|
214
|
+
if cand["type"] == "repository":
|
|
215
|
+
text_to_search = f"{cand.get('repo_name') or ''} {cand.get('description') or ''}"
|
|
216
|
+
elif cand["type"] == "document":
|
|
217
|
+
text_to_search = f"{cand.get('file_name') or ''} {cand.get('content') or ''}"
|
|
218
|
+
elif cand["type"] == "email":
|
|
219
|
+
text_to_search = f"{cand.get('subject') or ''} {cand.get('snippet') or ''}"
|
|
220
|
+
text_to_search_lower = text_to_search.lower()
|
|
221
|
+
if any(term in text_to_search_lower for term in query_terms):
|
|
222
|
+
keyword_match_val = 1.0
|
|
223
|
+
|
|
224
|
+
# Documentation Bonus (0.05)
|
|
225
|
+
doc_bonus_val = 0.0
|
|
226
|
+
if cand["type"] == "document":
|
|
227
|
+
file_lower = file_name.lower()
|
|
228
|
+
if "readme" in file_lower:
|
|
229
|
+
doc_bonus_val = 1.0
|
|
230
|
+
elif "architecture" in file_lower or "design" in file_lower or "structure" in file_lower:
|
|
231
|
+
doc_bonus_val = 1.0
|
|
232
|
+
elif file_lower.endswith(".md"):
|
|
233
|
+
doc_bonus_val = 1.0
|
|
234
|
+
elif file_name in ["pyproject.toml", "package.json", "requirements.txt"]:
|
|
235
|
+
doc_bonus_val = 0.6
|
|
236
|
+
elif file_lower.endswith((".py", ".js", ".ts", ".go", ".rs", ".java", ".c", ".cpp")):
|
|
237
|
+
doc_bonus_val = 0.2
|
|
238
|
+
elif cand["type"] == "repository":
|
|
239
|
+
doc_bonus_val = 0.5
|
|
240
|
+
|
|
241
|
+
# Calculate final score
|
|
242
|
+
# Semantic Similarity 0.50
|
|
243
|
+
# Repository Match 0.20
|
|
244
|
+
# README Bonus 0.10
|
|
245
|
+
# Graph Relevance 0.10
|
|
246
|
+
# Keyword Match 0.05
|
|
247
|
+
# Documentation Bonus 0.05
|
|
248
|
+
final_score = round(
|
|
249
|
+
(0.50 * sim) +
|
|
250
|
+
(0.20 * repo_match_val) +
|
|
251
|
+
(0.10 * readme_bonus_val) +
|
|
252
|
+
(0.10 * graph_relevance_val) +
|
|
253
|
+
(0.05 * keyword_match_val) +
|
|
254
|
+
(0.05 * doc_bonus_val),
|
|
255
|
+
4
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
cand["score"] = final_score
|
|
259
|
+
scored_candidates.append(cand)
|
|
260
|
+
|
|
261
|
+
# Sort candidates by score descending
|
|
262
|
+
scored_candidates.sort(key=lambda x: (-x["score"], x.get("repo_name") or x.get("subject") or ""))
|
|
263
|
+
|
|
264
|
+
# 5. Deduplication and Context Filtering (TASK 5)
|
|
265
|
+
deduplicated_candidates = []
|
|
266
|
+
for cand in scored_candidates:
|
|
267
|
+
cand_text = ""
|
|
268
|
+
if cand["type"] == "repository":
|
|
269
|
+
cand_text = cand.get("description") or ""
|
|
270
|
+
elif cand["type"] == "document":
|
|
271
|
+
cand_text = cand.get("content") or ""
|
|
272
|
+
elif cand["type"] == "email":
|
|
273
|
+
cand_text = cand.get("snippet") or ""
|
|
274
|
+
|
|
275
|
+
# Check if nearly identical to any already included chunk
|
|
276
|
+
is_dup = False
|
|
277
|
+
for doc in deduplicated_candidates:
|
|
278
|
+
doc_text = ""
|
|
279
|
+
if doc["type"] == "repository":
|
|
280
|
+
doc_text = doc.get("description") or ""
|
|
281
|
+
elif doc["type"] == "document":
|
|
282
|
+
doc_text = doc.get("content") or ""
|
|
283
|
+
elif doc["type"] == "email":
|
|
284
|
+
doc_text = doc.get("snippet") or ""
|
|
285
|
+
|
|
286
|
+
if self.is_nearly_identical(cand_text, doc_text):
|
|
287
|
+
is_dup = True
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if not is_dup:
|
|
291
|
+
deduplicated_candidates.append(cand)
|
|
292
|
+
|
|
293
|
+
after_dedup_count = len(deduplicated_candidates)
|
|
294
|
+
|
|
295
|
+
# Enforce context limits (max 8-10 chunks or max 12000 chars)
|
|
296
|
+
limited_graph_results = graph_results[:self.max_graph_relationships]
|
|
297
|
+
num_graph = len(limited_graph_results)
|
|
298
|
+
|
|
299
|
+
formatted_chunks = []
|
|
300
|
+
sources = []
|
|
301
|
+
repos = []
|
|
302
|
+
char_count = 0
|
|
303
|
+
chunk_count = 0
|
|
304
|
+
|
|
305
|
+
# Build final candidates checking both chunk limit and char limit
|
|
306
|
+
# Always keep highest ranked chunks
|
|
307
|
+
for cand in deduplicated_candidates:
|
|
308
|
+
if chunk_count >= self.max_chunks:
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
# Format and truncate single chunk
|
|
312
|
+
if cand["type"] == "repository":
|
|
313
|
+
repo_name = cand["repo_name"]
|
|
314
|
+
desc = cand.get("description") or ""
|
|
315
|
+
if len(desc) > self.max_doc_chars:
|
|
316
|
+
desc = desc[:self.max_doc_chars] + "... [TRUNCATED]"
|
|
317
|
+
chunk_str = f"Repository: {repo_name}\nDescription: {desc}\nLanguage: {cand.get('language') or ''}"
|
|
318
|
+
|
|
319
|
+
repos.append(repo_name)
|
|
320
|
+
sources.append(f"Repository metadata for '{repo_name}'")
|
|
321
|
+
elif cand["type"] == "document":
|
|
322
|
+
repo_name = cand["repo_name"]
|
|
323
|
+
file_name = cand["file_name"]
|
|
324
|
+
content = cand.get("content") or ""
|
|
325
|
+
if len(content) > self.max_doc_chars:
|
|
326
|
+
content = content[:self.max_doc_chars] + "... [TRUNCATED]"
|
|
327
|
+
chunk_str = f"Repository: {repo_name}\nDocument: {file_name}\nContent: {content}"
|
|
328
|
+
|
|
329
|
+
repos.append(repo_name)
|
|
330
|
+
sources.append(f"{repo_name}/{file_name}")
|
|
331
|
+
elif cand["type"] == "email":
|
|
332
|
+
subject = cand["subject"]
|
|
333
|
+
sender = cand["sender"]
|
|
334
|
+
snippet = cand.get("snippet") or ""
|
|
335
|
+
if len(snippet) > self.max_doc_chars:
|
|
336
|
+
snippet = snippet[:self.max_doc_chars] + "... [TRUNCATED]"
|
|
337
|
+
chunk_str = f"Email Subject: {subject}\nFrom: {sender}\nContent Snippet: {snippet}"
|
|
338
|
+
|
|
339
|
+
sources.append(f"Email: {subject}")
|
|
340
|
+
|
|
341
|
+
# Check if adding this chunk exceeds character limit
|
|
342
|
+
projected_chars = char_count + len(chunk_str) + 4
|
|
343
|
+
if projected_chars > self.max_context_chars and chunk_count > 0:
|
|
344
|
+
# Character limit exceeded
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
formatted_chunks.append({
|
|
348
|
+
"text": chunk_str,
|
|
349
|
+
"score": cand["score"],
|
|
350
|
+
"type": cand["type"]
|
|
351
|
+
})
|
|
352
|
+
char_count += len(chunk_str) + 4
|
|
353
|
+
chunk_count += 1
|
|
354
|
+
|
|
355
|
+
# Append limited graph results (max graph relationships)
|
|
356
|
+
for rel_desc in limited_graph_results:
|
|
357
|
+
chunk_str = f"Knowledge Graph Relationship: {rel_desc}"
|
|
358
|
+
|
|
359
|
+
projected_chars = char_count + len(chunk_str) + 4
|
|
360
|
+
if projected_chars > self.max_context_chars:
|
|
361
|
+
break
|
|
362
|
+
|
|
363
|
+
if "Repository '" in rel_desc:
|
|
364
|
+
parts = rel_desc.split("Repository '")
|
|
365
|
+
if len(parts) > 1:
|
|
366
|
+
r_name = parts[1].split("'")[0]
|
|
367
|
+
repos.append(r_name)
|
|
368
|
+
sources.append("Knowledge Graph")
|
|
369
|
+
formatted_chunks.append({
|
|
370
|
+
"text": chunk_str,
|
|
371
|
+
"score": 0.05,
|
|
372
|
+
"type": "graph"
|
|
373
|
+
})
|
|
374
|
+
char_count += len(chunk_str) + 4
|
|
375
|
+
|
|
376
|
+
sources = sorted(list(set(sources)))
|
|
377
|
+
repos = sorted(list(set(repos)))
|
|
378
|
+
|
|
379
|
+
return formatted_chunks, sources, repos, num_vector_raw, num_keyword_raw, num_graph, after_dedup_count
|
|
380
|
+
|
|
381
|
+
def trim_context_to_limit(self, system_prompt: str, user_prompt_template: str, formatted_chunks: list) -> tuple:
|
|
382
|
+
"""Trims lowest-ranked context chunks iteratively if total estimated tokens exceed self.max_prompt_tokens."""
|
|
383
|
+
active_chunks = list(formatted_chunks)
|
|
384
|
+
|
|
385
|
+
while len(active_chunks) > 0:
|
|
386
|
+
merged_context = "\n\n".join([c["text"] for c in active_chunks])
|
|
387
|
+
full_prompt_text = system_prompt + user_prompt_template.replace("{context}", merged_context)
|
|
388
|
+
est_tokens = self.estimate_tokens(full_prompt_text)
|
|
389
|
+
|
|
390
|
+
if est_tokens <= self.max_prompt_tokens:
|
|
391
|
+
return merged_context, len(active_chunks)
|
|
392
|
+
|
|
393
|
+
# Over limit, remove lowest-ranked chunk
|
|
394
|
+
min_idx = -1
|
|
395
|
+
min_score = float('inf')
|
|
396
|
+
for i, c in enumerate(active_chunks):
|
|
397
|
+
if c["score"] < min_score:
|
|
398
|
+
min_score = c["score"]
|
|
399
|
+
min_idx = i
|
|
400
|
+
|
|
401
|
+
if min_idx != -1:
|
|
402
|
+
removed = active_chunks.pop(min_idx)
|
|
403
|
+
logger.info(f"Context builder trimmed chunk of type '{removed['type']}' with score {removed['score']} due to token limit.")
|
|
404
|
+
else:
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
return "", 0
|
core/embedder.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
class Embedder:
|
|
4
|
+
_model = None
|
|
5
|
+
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.model_name = "all-MiniLM-L6-v2"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def model(self):
|
|
11
|
+
if Embedder._model is None:
|
|
12
|
+
# Load SentenceTransformer model
|
|
13
|
+
from sentence_transformers import SentenceTransformer
|
|
14
|
+
try:
|
|
15
|
+
Embedder._model = SentenceTransformer(self.model_name)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
# If network fails or times out, try loading from local cache only
|
|
18
|
+
try:
|
|
19
|
+
Embedder._model = SentenceTransformer(self.model_name, local_files_only=True)
|
|
20
|
+
except Exception:
|
|
21
|
+
raise e
|
|
22
|
+
return Embedder._model
|
|
23
|
+
|
|
24
|
+
def fit(self, documents=None):
|
|
25
|
+
# SentenceTransformer all-MiniLM-L6-v2 is pre-trained, so this is a no-op
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def embed_documents(self, texts: list) -> list:
|
|
29
|
+
if not texts:
|
|
30
|
+
return []
|
|
31
|
+
embeddings = self.model.encode(texts, convert_to_numpy=True)
|
|
32
|
+
return [e.tolist() for e in embeddings]
|
|
33
|
+
|
|
34
|
+
def embed_query(self, text: str) -> list:
|
|
35
|
+
embedding = self.model.encode(text, convert_to_numpy=True)
|
|
36
|
+
return embedding.tolist()
|
|
37
|
+
|
|
38
|
+
def dimension(self) -> int:
|
|
39
|
+
return 384
|
|
40
|
+
|
|
41
|
+
def version(self) -> str:
|
|
42
|
+
return "all-MiniLM-L6-v2-384"
|