claude-self-reflect 5.0.2 → 5.0.5
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.
- package/.claude/agents/csr-validator.md +43 -0
- package/.claude/agents/open-source-maintainer.md +77 -0
- package/docker-compose.yaml +3 -1
- package/installer/setup-wizard-docker.js +64 -9
- package/package.json +6 -1
- package/scripts/ast_grep_final_analyzer.py +16 -6
- package/scripts/csr-status +120 -17
- package/scripts/debug-august-parsing.py +5 -1
- package/scripts/debug-project-resolver.py +3 -3
- package/scripts/doctor.py +342 -0
- package/scripts/embedding_service.py +241 -0
- package/scripts/import-conversations-unified.py +292 -821
- package/scripts/import_strategies.py +344 -0
- package/scripts/message_processors.py +248 -0
- package/scripts/metadata_extractor.py +262 -0
- package/scripts/session_quality_tracker.py +10 -0
- package/scripts/unified_state_manager.py +7 -4
- package/mcp-server/src/test_quality.py +0 -153
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Diagnostic script to check Claude Self-Reflect installation and identify issues.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Tuple
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
class Colors:
|
|
17
|
+
"""Terminal colors for output"""
|
|
18
|
+
GREEN = '\033[92m'
|
|
19
|
+
YELLOW = '\033[93m'
|
|
20
|
+
RED = '\033[91m'
|
|
21
|
+
BLUE = '\033[94m'
|
|
22
|
+
ENDC = '\033[0m'
|
|
23
|
+
BOLD = '\033[1m'
|
|
24
|
+
|
|
25
|
+
def print_header(text: str):
|
|
26
|
+
"""Print a section header"""
|
|
27
|
+
print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.ENDC}")
|
|
28
|
+
print(f"{Colors.BOLD}{Colors.BLUE}{text}{Colors.ENDC}")
|
|
29
|
+
print(f"{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.ENDC}")
|
|
30
|
+
|
|
31
|
+
def print_status(name: str, status: bool, message: str = ""):
|
|
32
|
+
"""Print a status line with colored indicator"""
|
|
33
|
+
icon = f"{Colors.GREEN}✅{Colors.ENDC}" if status else f"{Colors.RED}❌{Colors.ENDC}"
|
|
34
|
+
status_text = f"{Colors.GREEN}OK{Colors.ENDC}" if status else f"{Colors.RED}FAILED{Colors.ENDC}"
|
|
35
|
+
print(f"{icon} {name}: {status_text}")
|
|
36
|
+
if message:
|
|
37
|
+
print(f" {Colors.YELLOW}{message}{Colors.ENDC}")
|
|
38
|
+
|
|
39
|
+
def check_docker() -> Tuple[bool, str]:
|
|
40
|
+
"""Check if Docker is installed and running"""
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(['docker', 'info'], capture_output=True, text=True)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
# Check docker compose v2
|
|
45
|
+
compose_result = subprocess.run(['docker', 'compose', 'version'], capture_output=True, text=True)
|
|
46
|
+
if compose_result.returncode == 0:
|
|
47
|
+
return True, "Docker and Docker Compose v2 are running"
|
|
48
|
+
else:
|
|
49
|
+
return False, "Docker Compose v2 not found. Please update Docker Desktop"
|
|
50
|
+
else:
|
|
51
|
+
return False, "Docker is not running"
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return False, "Docker is not installed"
|
|
54
|
+
|
|
55
|
+
def check_qdrant() -> Tuple[bool, str]:
|
|
56
|
+
"""Check if Qdrant is running and accessible"""
|
|
57
|
+
try:
|
|
58
|
+
req = urllib.request.Request('http://localhost:6333')
|
|
59
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
60
|
+
if response.status == 200:
|
|
61
|
+
data = json.loads(response.read().decode())
|
|
62
|
+
version = data.get('version', 'unknown')
|
|
63
|
+
return True, f"Qdrant {version} is running on port 6333"
|
|
64
|
+
except:
|
|
65
|
+
pass
|
|
66
|
+
return False, "Qdrant is not accessible on localhost:6333"
|
|
67
|
+
|
|
68
|
+
def check_collections() -> Tuple[bool, str, List[str]]:
|
|
69
|
+
"""Check if Qdrant has any collections"""
|
|
70
|
+
try:
|
|
71
|
+
req = urllib.request.Request('http://localhost:6333/collections')
|
|
72
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
73
|
+
if response.status == 200:
|
|
74
|
+
data = json.loads(response.read().decode())
|
|
75
|
+
collections = data.get('result', {}).get('collections', [])
|
|
76
|
+
if collections:
|
|
77
|
+
collection_names = [c['name'] for c in collections]
|
|
78
|
+
return True, f"Found {len(collections)} collections", collection_names
|
|
79
|
+
else:
|
|
80
|
+
return False, "No collections found - import may not have run", []
|
|
81
|
+
except:
|
|
82
|
+
pass
|
|
83
|
+
return False, "Could not query Qdrant collections", []
|
|
84
|
+
|
|
85
|
+
def check_claude_projects() -> Tuple[bool, str, Dict]:
|
|
86
|
+
"""Check Claude projects directory for JSONL files"""
|
|
87
|
+
claude_dir = Path.home() / '.claude' / 'projects'
|
|
88
|
+
stats = {
|
|
89
|
+
'total_projects': 0,
|
|
90
|
+
'total_files': 0,
|
|
91
|
+
'total_size': 0,
|
|
92
|
+
'sample_projects': []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if not claude_dir.exists():
|
|
96
|
+
return False, f"Claude projects directory not found: {claude_dir}", stats
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
projects = list(claude_dir.iterdir())
|
|
100
|
+
for project in projects:
|
|
101
|
+
if project.is_dir():
|
|
102
|
+
jsonl_files = list(project.glob('*.jsonl'))
|
|
103
|
+
if jsonl_files:
|
|
104
|
+
stats['total_projects'] += 1
|
|
105
|
+
stats['total_files'] += len(jsonl_files)
|
|
106
|
+
for f in jsonl_files:
|
|
107
|
+
stats['total_size'] += f.stat().st_size
|
|
108
|
+
if len(stats['sample_projects']) < 3:
|
|
109
|
+
stats['sample_projects'].append(project.name)
|
|
110
|
+
|
|
111
|
+
if stats['total_files'] == 0:
|
|
112
|
+
return False, "No JSONL files found in Claude projects", stats
|
|
113
|
+
|
|
114
|
+
size_mb = stats['total_size'] / (1024 * 1024)
|
|
115
|
+
return True, f"Found {stats['total_files']} files across {stats['total_projects']} projects ({size_mb:.1f} MB)", stats
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return False, f"Error scanning Claude projects: {e}", stats
|
|
118
|
+
|
|
119
|
+
def check_import_state() -> Tuple[bool, str, Dict]:
|
|
120
|
+
"""Check the import state file"""
|
|
121
|
+
config_dir = Path.home() / '.claude-self-reflect' / 'config'
|
|
122
|
+
state_file = config_dir / 'imported-files.json'
|
|
123
|
+
|
|
124
|
+
stats = {
|
|
125
|
+
'imported_count': 0,
|
|
126
|
+
'last_import': None,
|
|
127
|
+
'has_metadata': False
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if not state_file.exists():
|
|
131
|
+
return False, "No import state file found - imports haven't run yet", stats
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with open(state_file) as f:
|
|
135
|
+
state = json.load(f)
|
|
136
|
+
|
|
137
|
+
imported = state.get('imported_files', {})
|
|
138
|
+
stats['imported_count'] = len(imported)
|
|
139
|
+
|
|
140
|
+
# Check for metadata (new format)
|
|
141
|
+
for file_path, data in imported.items():
|
|
142
|
+
if isinstance(data, dict):
|
|
143
|
+
stats['has_metadata'] = True
|
|
144
|
+
if data.get('imported_at'):
|
|
145
|
+
import_time = data['imported_at']
|
|
146
|
+
if not stats['last_import'] or import_time > stats['last_import']:
|
|
147
|
+
stats['last_import'] = import_time
|
|
148
|
+
elif isinstance(data, str):
|
|
149
|
+
# Old format
|
|
150
|
+
if not stats['last_import'] or data > stats['last_import']:
|
|
151
|
+
stats['last_import'] = data
|
|
152
|
+
|
|
153
|
+
if stats['imported_count'] == 0:
|
|
154
|
+
return False, "Import state exists but no files imported", stats
|
|
155
|
+
|
|
156
|
+
msg = f"Imported {stats['imported_count']} files"
|
|
157
|
+
if stats['last_import']:
|
|
158
|
+
msg += f" (last: {stats['last_import'][:19]})"
|
|
159
|
+
if not stats['has_metadata']:
|
|
160
|
+
msg += " - OLD FORMAT (consider re-importing for metadata)"
|
|
161
|
+
|
|
162
|
+
return True, msg, stats
|
|
163
|
+
except Exception as e:
|
|
164
|
+
return False, f"Error reading import state: {e}", stats
|
|
165
|
+
|
|
166
|
+
def check_env_file() -> Tuple[bool, str, Dict]:
|
|
167
|
+
"""Check .env file configuration"""
|
|
168
|
+
env_file = Path('.env')
|
|
169
|
+
config = {
|
|
170
|
+
'has_voyage_key': False,
|
|
171
|
+
'prefer_local': True,
|
|
172
|
+
'claude_logs_path': None,
|
|
173
|
+
'config_path': None
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if not env_file.exists():
|
|
177
|
+
return False, ".env file not found", config
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with open(env_file) as f:
|
|
181
|
+
content = f.read()
|
|
182
|
+
|
|
183
|
+
for line in content.split('\n'):
|
|
184
|
+
if '=' in line and not line.startswith('#'):
|
|
185
|
+
key, value = line.split('=', 1)
|
|
186
|
+
key = key.strip()
|
|
187
|
+
value = value.strip()
|
|
188
|
+
|
|
189
|
+
if key == 'VOYAGE_KEY' and value and not value.startswith('your-'):
|
|
190
|
+
config['has_voyage_key'] = True
|
|
191
|
+
elif key == 'PREFER_LOCAL_EMBEDDINGS':
|
|
192
|
+
config['prefer_local'] = value.lower() == 'true'
|
|
193
|
+
elif key == 'CLAUDE_LOGS_PATH':
|
|
194
|
+
config['claude_logs_path'] = value
|
|
195
|
+
elif key == 'CONFIG_PATH':
|
|
196
|
+
config['config_path'] = value
|
|
197
|
+
|
|
198
|
+
# Check critical paths
|
|
199
|
+
issues = []
|
|
200
|
+
if config['claude_logs_path'] and '~' in config['claude_logs_path']:
|
|
201
|
+
issues.append("CLAUDE_LOGS_PATH contains ~ which Docker won't expand")
|
|
202
|
+
if config['config_path'] and '~' in config['config_path']:
|
|
203
|
+
issues.append("CONFIG_PATH contains ~ which Docker won't expand")
|
|
204
|
+
|
|
205
|
+
if issues:
|
|
206
|
+
return False, "; ".join(issues), config
|
|
207
|
+
|
|
208
|
+
mode = "Local embeddings" if config['prefer_local'] else "Voyage AI embeddings"
|
|
209
|
+
return True, f"Configured for {mode}", config
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return False, f"Error reading .env: {e}", config
|
|
212
|
+
|
|
213
|
+
def check_docker_containers() -> Tuple[bool, str, List[str]]:
|
|
214
|
+
"""Check which Docker containers are running"""
|
|
215
|
+
try:
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
['docker', 'compose', 'ps', '--format', 'json'],
|
|
218
|
+
capture_output=True, text=True, cwd='.'
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if result.returncode != 0:
|
|
222
|
+
return False, "Could not query Docker containers", []
|
|
223
|
+
|
|
224
|
+
running = []
|
|
225
|
+
lines = result.stdout.strip().split('\n')
|
|
226
|
+
for line in lines:
|
|
227
|
+
if line:
|
|
228
|
+
try:
|
|
229
|
+
container = json.loads(line)
|
|
230
|
+
if container.get('State') == 'running':
|
|
231
|
+
running.append(container.get('Service', 'unknown'))
|
|
232
|
+
except:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
if not running:
|
|
236
|
+
return False, "No containers running", []
|
|
237
|
+
|
|
238
|
+
essential = ['qdrant']
|
|
239
|
+
missing = [s for s in essential if s not in running]
|
|
240
|
+
|
|
241
|
+
if missing:
|
|
242
|
+
return False, f"Essential services not running: {', '.join(missing)}", running
|
|
243
|
+
|
|
244
|
+
return True, f"Running: {', '.join(running)}", running
|
|
245
|
+
except Exception as e:
|
|
246
|
+
return False, f"Error checking containers: {e}", []
|
|
247
|
+
|
|
248
|
+
def main():
|
|
249
|
+
"""Run all diagnostic checks"""
|
|
250
|
+
print(f"{Colors.BOLD}{Colors.BLUE}")
|
|
251
|
+
print("╔════════════════════════════════════════════════════════╗")
|
|
252
|
+
print("║ Claude Self-Reflect Diagnostic Tool v1.0 ║")
|
|
253
|
+
print("╚════════════════════════════════════════════════════════╝")
|
|
254
|
+
print(f"{Colors.ENDC}")
|
|
255
|
+
|
|
256
|
+
# Basic checks
|
|
257
|
+
print_header("1. Environment Checks")
|
|
258
|
+
|
|
259
|
+
docker_ok, docker_msg = check_docker()
|
|
260
|
+
print_status("Docker", docker_ok, docker_msg)
|
|
261
|
+
|
|
262
|
+
env_ok, env_msg, env_config = check_env_file()
|
|
263
|
+
print_status("Environment (.env)", env_ok, env_msg)
|
|
264
|
+
|
|
265
|
+
# Service checks
|
|
266
|
+
print_header("2. Service Status")
|
|
267
|
+
|
|
268
|
+
containers_ok, containers_msg, running_containers = check_docker_containers()
|
|
269
|
+
print_status("Docker Containers", containers_ok, containers_msg)
|
|
270
|
+
|
|
271
|
+
qdrant_ok, qdrant_msg = check_qdrant()
|
|
272
|
+
print_status("Qdrant Database", qdrant_ok, qdrant_msg)
|
|
273
|
+
|
|
274
|
+
# Data checks
|
|
275
|
+
print_header("3. Data & Import Status")
|
|
276
|
+
|
|
277
|
+
claude_ok, claude_msg, claude_stats = check_claude_projects()
|
|
278
|
+
print_status("Claude Projects", claude_ok, claude_msg)
|
|
279
|
+
if claude_stats['sample_projects']:
|
|
280
|
+
print(f" Sample projects: {', '.join(claude_stats['sample_projects'][:3])}")
|
|
281
|
+
|
|
282
|
+
import_ok, import_msg, import_stats = check_import_state()
|
|
283
|
+
print_status("Import State", import_ok, import_msg)
|
|
284
|
+
|
|
285
|
+
collections_ok, collections_msg, collection_list = check_collections()
|
|
286
|
+
print_status("Qdrant Collections", collections_ok, collections_msg)
|
|
287
|
+
if collection_list:
|
|
288
|
+
print(f" Collections: {', '.join(collection_list[:5])}")
|
|
289
|
+
|
|
290
|
+
# Summary and recommendations
|
|
291
|
+
print_header("4. Summary & Recommendations")
|
|
292
|
+
|
|
293
|
+
all_ok = all([docker_ok, env_ok, qdrant_ok, claude_ok])
|
|
294
|
+
|
|
295
|
+
if all_ok and collections_ok:
|
|
296
|
+
print(f"{Colors.GREEN}✅ System appears to be working correctly!{Colors.ENDC}")
|
|
297
|
+
else:
|
|
298
|
+
print(f"{Colors.YELLOW}⚠️ Issues detected:{Colors.ENDC}")
|
|
299
|
+
|
|
300
|
+
if not docker_ok:
|
|
301
|
+
print(f"\n{Colors.RED}Critical:{Colors.ENDC} Docker is required")
|
|
302
|
+
print(" → Install Docker Desktop from https://docker.com")
|
|
303
|
+
|
|
304
|
+
if env_ok and '~' in str(env_config.get('claude_logs_path', '')):
|
|
305
|
+
print(f"\n{Colors.RED}Critical:{Colors.ENDC} Path expansion issue in .env")
|
|
306
|
+
print(" → Run: claude-self-reflect setup")
|
|
307
|
+
print(" → Or manually fix paths in .env to use full paths")
|
|
308
|
+
|
|
309
|
+
if not qdrant_ok and docker_ok:
|
|
310
|
+
print(f"\n{Colors.YELLOW}Issue:{Colors.ENDC} Qdrant not running")
|
|
311
|
+
print(" → Run: docker compose --profile mcp up -d")
|
|
312
|
+
|
|
313
|
+
if claude_ok and not collections_ok:
|
|
314
|
+
print(f"\n{Colors.YELLOW}Issue:{Colors.ENDC} No collections found but JSONL files exist")
|
|
315
|
+
print(" → Run: docker compose run --rm importer")
|
|
316
|
+
print(" → This will import your conversation history")
|
|
317
|
+
|
|
318
|
+
if not claude_ok:
|
|
319
|
+
print(f"\n{Colors.YELLOW}Note:{Colors.ENDC} No Claude conversations found")
|
|
320
|
+
print(" → This is normal if you haven't used Claude Desktop yet")
|
|
321
|
+
print(" → The watcher will import new conversations automatically")
|
|
322
|
+
|
|
323
|
+
# Quick commands
|
|
324
|
+
print_header("5. Quick Commands")
|
|
325
|
+
print("• Start services: docker compose --profile mcp --profile watch up -d")
|
|
326
|
+
print("• Import conversations: docker compose run --rm importer")
|
|
327
|
+
print("• View logs: docker compose logs -f")
|
|
328
|
+
print("• Check status: claude-self-reflect status")
|
|
329
|
+
print("• Restart everything: docker compose down && docker compose --profile mcp --profile watch up -d")
|
|
330
|
+
|
|
331
|
+
print(f"\n{Colors.BLUE}Documentation: https://github.com/ramakay/claude-self-reflect{Colors.ENDC}")
|
|
332
|
+
|
|
333
|
+
# Return exit code based on critical issues
|
|
334
|
+
if not docker_ok:
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
if all_ok:
|
|
337
|
+
sys.exit(0)
|
|
338
|
+
else:
|
|
339
|
+
sys.exit(2) # Non-critical issues
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Embedding service abstraction to handle both local and cloud embeddings.
|
|
3
|
+
Reduces complexity by separating embedding concerns from import logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EmbeddingProvider(ABC):
|
|
16
|
+
"""Abstract base class for embedding providers."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def generate_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
20
|
+
"""Generate embeddings for a list of texts."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_dimension(self) -> int:
|
|
25
|
+
"""Get the dimension of embeddings produced by this provider."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_collection_suffix(self) -> str:
|
|
30
|
+
"""Get the suffix for collection naming."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalEmbeddingProvider(EmbeddingProvider):
|
|
35
|
+
"""Local embedding provider using FastEmbed."""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.model = None
|
|
39
|
+
self.dimension = 384
|
|
40
|
+
self._initialize_model()
|
|
41
|
+
|
|
42
|
+
def _initialize_model(self):
|
|
43
|
+
"""Initialize the FastEmbed model."""
|
|
44
|
+
try:
|
|
45
|
+
from fastembed import TextEmbedding
|
|
46
|
+
self.model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
|
|
47
|
+
logger.info("Initialized local FastEmbed model (384 dimensions)")
|
|
48
|
+
except ImportError as e:
|
|
49
|
+
logger.error("FastEmbed not installed. Install with: pip install fastembed")
|
|
50
|
+
raise
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.exception(f"Failed to initialize FastEmbed: {e}")
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
def generate_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
56
|
+
"""Generate embeddings using FastEmbed."""
|
|
57
|
+
if not self.model:
|
|
58
|
+
raise RuntimeError("FastEmbed model not initialized")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
embeddings = list(self.model.embed(texts))
|
|
62
|
+
return [list(emb) for emb in embeddings]
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Failed to generate local embeddings: {e}")
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
def get_dimension(self) -> int:
|
|
68
|
+
"""Get embedding dimension (384 for FastEmbed)."""
|
|
69
|
+
return self.dimension
|
|
70
|
+
|
|
71
|
+
def get_collection_suffix(self) -> str:
|
|
72
|
+
"""Get collection suffix for local embeddings."""
|
|
73
|
+
return "local_384d"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CloudEmbeddingProvider(EmbeddingProvider):
|
|
77
|
+
"""Cloud embedding provider using Voyage AI."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, api_key: str):
|
|
80
|
+
# Don't store API key directly, use it only for client initialization
|
|
81
|
+
self.client = None
|
|
82
|
+
self.dimension = 1024
|
|
83
|
+
self._initialize_client(api_key)
|
|
84
|
+
|
|
85
|
+
def _initialize_client(self, api_key: str):
|
|
86
|
+
"""Initialize the Voyage AI client."""
|
|
87
|
+
try:
|
|
88
|
+
import voyageai
|
|
89
|
+
self.client = voyageai.Client(api_key=api_key)
|
|
90
|
+
logger.info("Initialized Voyage AI client (1024 dimensions)")
|
|
91
|
+
except ImportError as e:
|
|
92
|
+
logger.error("voyageai not installed. Install with: pip install voyageai")
|
|
93
|
+
raise
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.exception(f"Failed to initialize Voyage AI: {e}")
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
def generate_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
99
|
+
"""Generate embeddings using Voyage AI."""
|
|
100
|
+
if not self.client:
|
|
101
|
+
raise RuntimeError("Voyage AI client not initialized")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = self.client.embed(texts, model="voyage-2")
|
|
105
|
+
return result.embeddings
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Failed to generate cloud embeddings: {e}")
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
def get_dimension(self) -> int:
|
|
111
|
+
"""Get embedding dimension (1024 for Voyage)."""
|
|
112
|
+
return self.dimension
|
|
113
|
+
|
|
114
|
+
def get_collection_suffix(self) -> str:
|
|
115
|
+
"""Get collection suffix for cloud embeddings."""
|
|
116
|
+
return "cloud_1024d"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EmbeddingService:
|
|
120
|
+
"""
|
|
121
|
+
Service to manage embedding generation with automatic provider selection.
|
|
122
|
+
Reduces complexity by encapsulating embedding logic.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, prefer_local: bool = True, voyage_api_key: Optional[str] = None):
|
|
126
|
+
"""
|
|
127
|
+
Initialize embedding service.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
prefer_local: Whether to prefer local embeddings when available
|
|
131
|
+
voyage_api_key: API key for Voyage AI (if using cloud embeddings)
|
|
132
|
+
"""
|
|
133
|
+
self.prefer_local = prefer_local
|
|
134
|
+
self.voyage_api_key = voyage_api_key
|
|
135
|
+
self.provider = None
|
|
136
|
+
self._initialize_provider()
|
|
137
|
+
|
|
138
|
+
def _initialize_provider(self):
|
|
139
|
+
"""Initialize the appropriate embedding provider."""
|
|
140
|
+
if self.prefer_local or not self.voyage_api_key:
|
|
141
|
+
try:
|
|
142
|
+
self.provider = LocalEmbeddingProvider()
|
|
143
|
+
logger.info("Using local embedding provider (FastEmbed)")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning(f"Failed to initialize local provider: {e}")
|
|
146
|
+
if self.voyage_api_key:
|
|
147
|
+
self._fallback_to_cloud()
|
|
148
|
+
else:
|
|
149
|
+
raise RuntimeError("No embedding provider available")
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
self.provider = CloudEmbeddingProvider(self.voyage_api_key)
|
|
153
|
+
logger.info("Using cloud embedding provider (Voyage AI)")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(f"Failed to initialize cloud provider: {e}")
|
|
156
|
+
self._fallback_to_local()
|
|
157
|
+
|
|
158
|
+
def _fallback_to_cloud(self):
|
|
159
|
+
"""Fallback to cloud provider."""
|
|
160
|
+
if not self.voyage_api_key:
|
|
161
|
+
raise RuntimeError("No Voyage API key available for cloud fallback")
|
|
162
|
+
try:
|
|
163
|
+
self.provider = CloudEmbeddingProvider(self.voyage_api_key)
|
|
164
|
+
logger.info("Fallback to cloud embedding provider")
|
|
165
|
+
# Clear the key after use
|
|
166
|
+
self.voyage_api_key = None
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise RuntimeError(f"Failed to initialize any embedding provider: {e}")
|
|
169
|
+
|
|
170
|
+
def _fallback_to_local(self):
|
|
171
|
+
"""Fallback to local provider."""
|
|
172
|
+
try:
|
|
173
|
+
self.provider = LocalEmbeddingProvider()
|
|
174
|
+
logger.info("Fallback to local embedding provider")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
raise RuntimeError(f"Failed to initialize any embedding provider: {e}")
|
|
177
|
+
|
|
178
|
+
def generate_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
179
|
+
"""
|
|
180
|
+
Generate embeddings for texts using the configured provider.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
texts: List of texts to embed
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of embedding vectors
|
|
187
|
+
"""
|
|
188
|
+
if not self.provider:
|
|
189
|
+
raise RuntimeError("No embedding provider initialized")
|
|
190
|
+
|
|
191
|
+
# Filter out empty texts
|
|
192
|
+
non_empty_texts = [t for t in texts if t and t.strip()]
|
|
193
|
+
if not non_empty_texts:
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
return self.provider.generate_embeddings(non_empty_texts)
|
|
197
|
+
|
|
198
|
+
def get_dimension(self) -> int:
|
|
199
|
+
"""Get the dimension of embeddings."""
|
|
200
|
+
if not self.provider:
|
|
201
|
+
raise RuntimeError("No embedding provider initialized")
|
|
202
|
+
return self.provider.get_dimension()
|
|
203
|
+
|
|
204
|
+
def get_collection_suffix(self) -> str:
|
|
205
|
+
"""Get the collection suffix for current provider."""
|
|
206
|
+
if not self.provider:
|
|
207
|
+
raise RuntimeError("No embedding provider initialized")
|
|
208
|
+
return self.provider.get_collection_suffix()
|
|
209
|
+
|
|
210
|
+
def get_provider_name(self) -> str:
|
|
211
|
+
"""Get the name of the current provider."""
|
|
212
|
+
if isinstance(self.provider, LocalEmbeddingProvider):
|
|
213
|
+
return "FastEmbed (Local)"
|
|
214
|
+
elif isinstance(self.provider, CloudEmbeddingProvider):
|
|
215
|
+
return "Voyage AI (Cloud)"
|
|
216
|
+
else:
|
|
217
|
+
return "Unknown"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Factory function for convenience
|
|
221
|
+
def create_embedding_service(
|
|
222
|
+
prefer_local: Optional[bool] = None,
|
|
223
|
+
voyage_api_key: Optional[str] = None
|
|
224
|
+
) -> EmbeddingService:
|
|
225
|
+
"""
|
|
226
|
+
Create an embedding service with environment variable defaults.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
prefer_local: Override for PREFER_LOCAL_EMBEDDINGS env var
|
|
230
|
+
voyage_api_key: Override for VOYAGE_KEY env var
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Configured EmbeddingService instance
|
|
234
|
+
"""
|
|
235
|
+
if prefer_local is None:
|
|
236
|
+
prefer_local = os.getenv("PREFER_LOCAL_EMBEDDINGS", "true").lower() == "true"
|
|
237
|
+
|
|
238
|
+
if voyage_api_key is None:
|
|
239
|
+
voyage_api_key = os.getenv("VOYAGE_KEY")
|
|
240
|
+
|
|
241
|
+
return EmbeddingService(prefer_local, voyage_api_key)
|