claude-self-reflect 3.2.4 → 3.3.1
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/claude-self-reflect-test.md +992 -510
- package/.claude/agents/reflection-specialist.md +59 -3
- package/README.md +14 -5
- package/installer/cli.js +16 -0
- package/installer/postinstall.js +14 -0
- package/installer/statusline-setup.js +289 -0
- package/mcp-server/run-mcp.sh +73 -5
- package/mcp-server/src/app_context.py +64 -0
- package/mcp-server/src/config.py +57 -0
- package/mcp-server/src/connection_pool.py +286 -0
- package/mcp-server/src/decay_manager.py +106 -0
- package/mcp-server/src/embedding_manager.py +64 -40
- package/mcp-server/src/embeddings_old.py +141 -0
- package/mcp-server/src/models.py +64 -0
- package/mcp-server/src/parallel_search.py +305 -0
- package/mcp-server/src/project_resolver.py +5 -0
- package/mcp-server/src/reflection_tools.py +211 -0
- package/mcp-server/src/rich_formatting.py +196 -0
- package/mcp-server/src/search_tools.py +874 -0
- package/mcp-server/src/server.py +127 -1720
- package/mcp-server/src/temporal_design.py +132 -0
- package/mcp-server/src/temporal_tools.py +604 -0
- package/mcp-server/src/temporal_utils.py +384 -0
- package/mcp-server/src/utils.py +150 -67
- package/package.json +15 -1
- package/scripts/add-timestamp-indexes.py +134 -0
- package/scripts/ast_grep_final_analyzer.py +325 -0
- package/scripts/ast_grep_unified_registry.py +556 -0
- package/scripts/check-collections.py +29 -0
- package/scripts/csr-status +366 -0
- package/scripts/debug-august-parsing.py +76 -0
- package/scripts/debug-import-single.py +91 -0
- package/scripts/debug-project-resolver.py +82 -0
- package/scripts/debug-temporal-tools.py +135 -0
- package/scripts/delta-metadata-update.py +547 -0
- package/scripts/import-conversations-unified.py +157 -25
- package/scripts/precompact-hook.sh +33 -0
- package/scripts/session_quality_tracker.py +481 -0
- package/scripts/streaming-watcher.py +1578 -0
- package/scripts/update_patterns.py +334 -0
- package/scripts/utils.py +39 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Embedding generation module for Claude Self-Reflect MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import voyageai
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
from fastembed import TextEmbedding
|
|
7
|
+
from config import (
|
|
8
|
+
VOYAGE_API_KEY,
|
|
9
|
+
VOYAGE_MODEL,
|
|
10
|
+
LOCAL_MODEL,
|
|
11
|
+
PREFER_LOCAL_EMBEDDINGS,
|
|
12
|
+
logger
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class EmbeddingManager:
|
|
16
|
+
"""Manages embedding generation for both local and Voyage AI models."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.local_model = None
|
|
20
|
+
self.voyage_client = None
|
|
21
|
+
self.embedding_cache = {}
|
|
22
|
+
|
|
23
|
+
# Initialize based on preference
|
|
24
|
+
if PREFER_LOCAL_EMBEDDINGS or not VOYAGE_API_KEY:
|
|
25
|
+
self._init_local_model()
|
|
26
|
+
|
|
27
|
+
if VOYAGE_API_KEY:
|
|
28
|
+
self._init_voyage_client()
|
|
29
|
+
|
|
30
|
+
def _init_local_model(self):
|
|
31
|
+
"""Initialize local FastEmbed model."""
|
|
32
|
+
try:
|
|
33
|
+
self.local_model = TextEmbedding(
|
|
34
|
+
model_name=LOCAL_MODEL,
|
|
35
|
+
cache_dir=str(os.path.expanduser("~/.cache/fastembed"))
|
|
36
|
+
)
|
|
37
|
+
logger.info(f"Initialized local embedding model: {LOCAL_MODEL}")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error(f"Failed to initialize local model: {e}")
|
|
40
|
+
|
|
41
|
+
def _init_voyage_client(self):
|
|
42
|
+
"""Initialize Voyage AI client."""
|
|
43
|
+
try:
|
|
44
|
+
self.voyage_client = voyageai.Client(api_key=VOYAGE_API_KEY)
|
|
45
|
+
logger.info("Initialized Voyage AI client")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Failed to initialize Voyage client: {e}")
|
|
48
|
+
|
|
49
|
+
async def generate_embedding(
|
|
50
|
+
self,
|
|
51
|
+
text: str,
|
|
52
|
+
embedding_type: Optional[str] = None
|
|
53
|
+
) -> Optional[List[float]]:
|
|
54
|
+
"""Generate embedding for text using specified or default model."""
|
|
55
|
+
|
|
56
|
+
# Use cache if available
|
|
57
|
+
cache_key = f"{embedding_type or 'default'}:{text[:100]}"
|
|
58
|
+
if cache_key in self.embedding_cache:
|
|
59
|
+
return self.embedding_cache[cache_key]
|
|
60
|
+
|
|
61
|
+
# Determine which model to use
|
|
62
|
+
use_local = True
|
|
63
|
+
if embedding_type:
|
|
64
|
+
use_local = 'local' in embedding_type
|
|
65
|
+
elif not PREFER_LOCAL_EMBEDDINGS and self.voyage_client:
|
|
66
|
+
use_local = False
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
if use_local and self.local_model:
|
|
70
|
+
# Generate local embedding
|
|
71
|
+
embeddings = list(self.local_model.embed([text]))
|
|
72
|
+
if embeddings:
|
|
73
|
+
embedding = list(embeddings[0])
|
|
74
|
+
self.embedding_cache[cache_key] = embedding
|
|
75
|
+
return embedding
|
|
76
|
+
|
|
77
|
+
elif self.voyage_client:
|
|
78
|
+
# Generate Voyage embedding
|
|
79
|
+
result = self.voyage_client.embed(
|
|
80
|
+
[text],
|
|
81
|
+
model=VOYAGE_MODEL,
|
|
82
|
+
input_type="document"
|
|
83
|
+
)
|
|
84
|
+
if result.embeddings:
|
|
85
|
+
embedding = result.embeddings[0]
|
|
86
|
+
self.embedding_cache[cache_key] = embedding
|
|
87
|
+
return embedding
|
|
88
|
+
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Failed to generate embedding: {e}")
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
async def generate_embeddings_batch(
|
|
95
|
+
self,
|
|
96
|
+
texts: List[str],
|
|
97
|
+
embedding_type: Optional[str] = None
|
|
98
|
+
) -> Dict[str, List[float]]:
|
|
99
|
+
"""Generate embeddings for multiple texts efficiently."""
|
|
100
|
+
results = {}
|
|
101
|
+
|
|
102
|
+
# Determine which model to use
|
|
103
|
+
use_local = True
|
|
104
|
+
if embedding_type:
|
|
105
|
+
use_local = 'local' in embedding_type
|
|
106
|
+
elif not PREFER_LOCAL_EMBEDDINGS and self.voyage_client:
|
|
107
|
+
use_local = False
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if use_local and self.local_model:
|
|
111
|
+
# Batch process with local model
|
|
112
|
+
embeddings = list(self.local_model.embed(texts))
|
|
113
|
+
for text, embedding in zip(texts, embeddings):
|
|
114
|
+
results[text] = list(embedding)
|
|
115
|
+
|
|
116
|
+
elif self.voyage_client:
|
|
117
|
+
# Batch process with Voyage
|
|
118
|
+
result = self.voyage_client.embed(
|
|
119
|
+
texts,
|
|
120
|
+
model=VOYAGE_MODEL,
|
|
121
|
+
input_type="document"
|
|
122
|
+
)
|
|
123
|
+
for text, embedding in zip(texts, result.embeddings):
|
|
124
|
+
results[text] = embedding
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"Failed to generate batch embeddings: {e}")
|
|
128
|
+
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
def get_embedding_dimension(self, embedding_type: str = "local") -> int:
|
|
132
|
+
"""Get the dimension of embeddings for a given type."""
|
|
133
|
+
if "local" in embedding_type:
|
|
134
|
+
return 384 # all-MiniLM-L6-v2 dimension
|
|
135
|
+
else:
|
|
136
|
+
return 1024 # voyage-3-lite dimension
|
|
137
|
+
|
|
138
|
+
def clear_cache(self):
|
|
139
|
+
"""Clear the embedding cache."""
|
|
140
|
+
self.embedding_cache.clear()
|
|
141
|
+
logger.info("Cleared embedding cache")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Pydantic models for Claude Self-Reflect MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List, Dict, Any, Set
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
class SearchResult(BaseModel):
|
|
8
|
+
"""Model for search results."""
|
|
9
|
+
id: str
|
|
10
|
+
score: float
|
|
11
|
+
timestamp: str
|
|
12
|
+
role: str
|
|
13
|
+
excerpt: str
|
|
14
|
+
project_name: str
|
|
15
|
+
conversation_id: Optional[str] = None
|
|
16
|
+
base_conversation_id: Optional[str] = None
|
|
17
|
+
collection_name: str
|
|
18
|
+
raw_payload: Optional[Dict[str, Any]] = None
|
|
19
|
+
code_patterns: Optional[Dict[str, List[str]]] = None
|
|
20
|
+
files_analyzed: Optional[List[str]] = None
|
|
21
|
+
files_edited: Optional[List[str]] = None
|
|
22
|
+
tools_used: Optional[List[str]] = None
|
|
23
|
+
concepts: Optional[List[str]] = None
|
|
24
|
+
|
|
25
|
+
class ConversationGroup(BaseModel):
|
|
26
|
+
"""Model for grouped conversations."""
|
|
27
|
+
conversation_id: str
|
|
28
|
+
base_conversation_id: str
|
|
29
|
+
timestamp: datetime
|
|
30
|
+
message_count: int
|
|
31
|
+
excerpts: List[str]
|
|
32
|
+
files: Set[str] = Field(default_factory=set)
|
|
33
|
+
tools: Set[str] = Field(default_factory=set)
|
|
34
|
+
concepts: Set[str] = Field(default_factory=set)
|
|
35
|
+
|
|
36
|
+
class WorkSession(BaseModel):
|
|
37
|
+
"""Model for work sessions."""
|
|
38
|
+
start_time: datetime
|
|
39
|
+
end_time: datetime
|
|
40
|
+
conversations: List[ConversationGroup]
|
|
41
|
+
total_messages: int
|
|
42
|
+
files_touched: Set[str] = Field(default_factory=set)
|
|
43
|
+
tools_used: Set[str] = Field(default_factory=set)
|
|
44
|
+
concepts: Set[str] = Field(default_factory=set)
|
|
45
|
+
|
|
46
|
+
class ActivityStats(BaseModel):
|
|
47
|
+
"""Model for activity statistics."""
|
|
48
|
+
total_conversations: int
|
|
49
|
+
total_messages: int
|
|
50
|
+
unique_files: int
|
|
51
|
+
unique_tools: int
|
|
52
|
+
peak_hour: Optional[str] = None
|
|
53
|
+
peak_day: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
class TimelineEntry(BaseModel):
|
|
56
|
+
"""Model for timeline entries."""
|
|
57
|
+
period: str
|
|
58
|
+
start_time: datetime
|
|
59
|
+
end_time: datetime
|
|
60
|
+
conversation_count: int
|
|
61
|
+
message_count: int
|
|
62
|
+
files: Set[str] = Field(default_factory=set)
|
|
63
|
+
tools: Set[str] = Field(default_factory=set)
|
|
64
|
+
concepts: Set[str] = Field(default_factory=set)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parallel search implementation for Qdrant collections.
|
|
3
|
+
This module implements asyncio.gather-based parallel searching to improve performance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import time
|
|
8
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
async def search_single_collection(
|
|
15
|
+
collection_name: str,
|
|
16
|
+
query: str,
|
|
17
|
+
query_embeddings: Dict[str, List[float]],
|
|
18
|
+
qdrant_client: Any,
|
|
19
|
+
ctx: Any,
|
|
20
|
+
limit: int,
|
|
21
|
+
min_score: float,
|
|
22
|
+
should_use_decay: bool,
|
|
23
|
+
target_project: str,
|
|
24
|
+
generate_embedding_func: Any,
|
|
25
|
+
constants: Dict[str, Any]
|
|
26
|
+
) -> Tuple[str, List[Any], Dict[str, Any]]:
|
|
27
|
+
"""
|
|
28
|
+
Search a single collection and return results.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (collection_name, results, timing_info)
|
|
32
|
+
"""
|
|
33
|
+
collection_timing = {'name': collection_name, 'start': time.time()}
|
|
34
|
+
results = []
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
# Determine embedding type for this collection
|
|
38
|
+
embedding_type_for_collection = 'voyage' if collection_name.endswith('_voyage') else 'local'
|
|
39
|
+
logger.debug(f"Collection {collection_name} needs {embedding_type_for_collection} embedding")
|
|
40
|
+
|
|
41
|
+
# Generate or retrieve cached embedding for this type
|
|
42
|
+
if embedding_type_for_collection not in query_embeddings:
|
|
43
|
+
try:
|
|
44
|
+
query_embeddings[embedding_type_for_collection] = await generate_embedding_func(
|
|
45
|
+
query, force_type=embedding_type_for_collection
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
await ctx.debug(f"Failed to generate {embedding_type_for_collection} embedding: {e}")
|
|
49
|
+
collection_timing['error'] = str(e)
|
|
50
|
+
collection_timing['end'] = time.time()
|
|
51
|
+
return collection_name, results, collection_timing
|
|
52
|
+
|
|
53
|
+
query_embedding = query_embeddings[embedding_type_for_collection]
|
|
54
|
+
|
|
55
|
+
# Check if this is a reflections collection
|
|
56
|
+
is_reflection_collection = collection_name.startswith('reflections_')
|
|
57
|
+
|
|
58
|
+
# Import necessary models
|
|
59
|
+
from qdrant_client import models
|
|
60
|
+
|
|
61
|
+
# Determine which decay method to use
|
|
62
|
+
USE_NATIVE_DECAY = constants.get('USE_NATIVE_DECAY', False)
|
|
63
|
+
NATIVE_DECAY_AVAILABLE = constants.get('NATIVE_DECAY_AVAILABLE', False)
|
|
64
|
+
DECAY_SCALE_DAYS = constants.get('DECAY_SCALE_DAYS', 90)
|
|
65
|
+
DECAY_WEIGHT = constants.get('DECAY_WEIGHT', 0.3)
|
|
66
|
+
|
|
67
|
+
# NOTE: Native decay API is not available in current Qdrant, fall back to client-side
|
|
68
|
+
# The Fusion/RankFusion API was experimental and removed, always use client-side decay
|
|
69
|
+
if should_use_decay and False: # Disabled until Qdrant provides stable decay API
|
|
70
|
+
# This code path is intentionally disabled
|
|
71
|
+
pass
|
|
72
|
+
else:
|
|
73
|
+
# Standard search without native decay or client-side decay
|
|
74
|
+
search_results = await qdrant_client.search(
|
|
75
|
+
collection_name=collection_name,
|
|
76
|
+
query_vector=query_embedding,
|
|
77
|
+
limit=limit * 3 if should_use_decay else limit, # Get more results for client-side decay
|
|
78
|
+
score_threshold=min_score if not should_use_decay else 0.0,
|
|
79
|
+
with_payload=True
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Debug: Log search results
|
|
83
|
+
logger.debug(f"Search of {collection_name} returned {len(search_results)} results")
|
|
84
|
+
|
|
85
|
+
if should_use_decay and not USE_NATIVE_DECAY:
|
|
86
|
+
# Apply client-side decay
|
|
87
|
+
await ctx.debug(f"Using CLIENT-SIDE decay for {collection_name}")
|
|
88
|
+
decay_results = []
|
|
89
|
+
|
|
90
|
+
for point in search_results:
|
|
91
|
+
try:
|
|
92
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
93
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
94
|
+
|
|
95
|
+
# Calculate age and decay
|
|
96
|
+
if 'timestamp' in point.payload:
|
|
97
|
+
try:
|
|
98
|
+
point_time = datetime.fromisoformat(clean_timestamp)
|
|
99
|
+
if point_time.tzinfo is None:
|
|
100
|
+
from zoneinfo import ZoneInfo
|
|
101
|
+
point_time = point_time.replace(tzinfo=ZoneInfo('UTC'))
|
|
102
|
+
|
|
103
|
+
now = datetime.now(ZoneInfo('UTC'))
|
|
104
|
+
age_ms = (now - point_time).total_seconds() * 1000
|
|
105
|
+
|
|
106
|
+
# Exponential decay with configurable half-life
|
|
107
|
+
half_life_ms = DECAY_SCALE_DAYS * 24 * 60 * 60 * 1000
|
|
108
|
+
decay_factor = 0.5 ** (age_ms / half_life_ms)
|
|
109
|
+
|
|
110
|
+
# Apply multiplicative decay
|
|
111
|
+
adjusted_score = point.score * ((1 - DECAY_WEIGHT) + DECAY_WEIGHT * decay_factor)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
await ctx.debug(f"Error calculating decay: {e}")
|
|
114
|
+
adjusted_score = point.score
|
|
115
|
+
else:
|
|
116
|
+
adjusted_score = point.score
|
|
117
|
+
|
|
118
|
+
# Only include if above min_score after decay
|
|
119
|
+
if adjusted_score >= min_score:
|
|
120
|
+
decay_results.append((adjusted_score, point))
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
await ctx.debug(f"Error applying decay to point: {e}")
|
|
124
|
+
decay_results.append((point.score, point))
|
|
125
|
+
|
|
126
|
+
# Sort by adjusted score and take top results
|
|
127
|
+
decay_results.sort(key=lambda x: x[0], reverse=True)
|
|
128
|
+
|
|
129
|
+
# Convert to SearchResult format
|
|
130
|
+
for adjusted_score, point in decay_results[:limit]:
|
|
131
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
132
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
133
|
+
|
|
134
|
+
point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
|
|
135
|
+
|
|
136
|
+
# Apply project filtering
|
|
137
|
+
if target_project != 'all' and not is_reflection_collection:
|
|
138
|
+
if point_project != target_project:
|
|
139
|
+
normalized_target = target_project.replace('-', '_')
|
|
140
|
+
normalized_point = point_project.replace('-', '_')
|
|
141
|
+
if not (normalized_point == normalized_target or
|
|
142
|
+
point_project.endswith(f"/{target_project}") or
|
|
143
|
+
point_project.endswith(f"-{target_project}") or
|
|
144
|
+
normalized_point.endswith(f"_{normalized_target}") or
|
|
145
|
+
normalized_point.endswith(f"/{normalized_target}")):
|
|
146
|
+
logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
|
|
147
|
+
continue
|
|
148
|
+
logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
|
|
149
|
+
|
|
150
|
+
# Create SearchResult with consistent structure
|
|
151
|
+
search_result = {
|
|
152
|
+
'id': str(point.id),
|
|
153
|
+
'score': adjusted_score,
|
|
154
|
+
'timestamp': clean_timestamp,
|
|
155
|
+
'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
|
|
156
|
+
'excerpt': (point.payload.get('text', '')[:350] + '...'
|
|
157
|
+
if len(point.payload.get('text', '')) > 350
|
|
158
|
+
else point.payload.get('text', '')),
|
|
159
|
+
'project_name': point_project,
|
|
160
|
+
'conversation_id': point.payload.get('conversation_id'),
|
|
161
|
+
'base_conversation_id': point.payload.get('base_conversation_id'),
|
|
162
|
+
'collection_name': collection_name,
|
|
163
|
+
'raw_payload': point.payload, # Renamed from 'payload' for consistency
|
|
164
|
+
'code_patterns': point.payload.get('code_patterns'),
|
|
165
|
+
'files_analyzed': point.payload.get('files_analyzed'),
|
|
166
|
+
'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
|
|
167
|
+
'concepts': point.payload.get('concepts')
|
|
168
|
+
}
|
|
169
|
+
results.append(search_result)
|
|
170
|
+
else:
|
|
171
|
+
# Process standard search results without decay
|
|
172
|
+
logger.debug(f"Processing {len(search_results)} results from {collection_name}")
|
|
173
|
+
for point in search_results:
|
|
174
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
175
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
176
|
+
|
|
177
|
+
point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
|
|
178
|
+
|
|
179
|
+
# Apply project filtering
|
|
180
|
+
if target_project != 'all' and not is_reflection_collection:
|
|
181
|
+
if point_project != target_project:
|
|
182
|
+
normalized_target = target_project.replace('-', '_')
|
|
183
|
+
normalized_point = point_project.replace('-', '_')
|
|
184
|
+
if not (normalized_point == normalized_target or
|
|
185
|
+
point_project.endswith(f"/{target_project}") or
|
|
186
|
+
point_project.endswith(f"-{target_project}") or
|
|
187
|
+
normalized_point.endswith(f"_{normalized_target}") or
|
|
188
|
+
normalized_point.endswith(f"/{normalized_target}")):
|
|
189
|
+
logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
|
|
190
|
+
continue
|
|
191
|
+
logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
|
|
192
|
+
|
|
193
|
+
# Create SearchResult as dictionary (consistent with other branches)
|
|
194
|
+
search_result = {
|
|
195
|
+
'id': str(point.id),
|
|
196
|
+
'score': point.score,
|
|
197
|
+
'timestamp': clean_timestamp,
|
|
198
|
+
'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
|
|
199
|
+
'excerpt': (point.payload.get('text', '')[:350] + '...'
|
|
200
|
+
if len(point.payload.get('text', '')) > 350
|
|
201
|
+
else point.payload.get('text', '')),
|
|
202
|
+
'project_name': point_project,
|
|
203
|
+
'conversation_id': point.payload.get('conversation_id'),
|
|
204
|
+
'base_conversation_id': point.payload.get('base_conversation_id'),
|
|
205
|
+
'collection_name': collection_name,
|
|
206
|
+
'raw_payload': point.payload,
|
|
207
|
+
'code_patterns': point.payload.get('code_patterns'),
|
|
208
|
+
'files_analyzed': point.payload.get('files_analyzed'),
|
|
209
|
+
'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
|
|
210
|
+
'concepts': point.payload.get('concepts')
|
|
211
|
+
}
|
|
212
|
+
results.append(search_result)
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
await ctx.debug(f"Error searching {collection_name}: {str(e)}")
|
|
216
|
+
collection_timing['error'] = str(e)
|
|
217
|
+
|
|
218
|
+
collection_timing['end'] = time.time()
|
|
219
|
+
logger.debug(f"Collection {collection_name} returning {len(results)} results after filtering")
|
|
220
|
+
return collection_name, results, collection_timing
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def parallel_search_collections(
|
|
224
|
+
collections_to_search: List[str],
|
|
225
|
+
query: str,
|
|
226
|
+
qdrant_client: Any,
|
|
227
|
+
ctx: Any,
|
|
228
|
+
limit: int,
|
|
229
|
+
min_score: float,
|
|
230
|
+
should_use_decay: bool,
|
|
231
|
+
target_project: str,
|
|
232
|
+
generate_embedding_func: Any,
|
|
233
|
+
constants: Dict[str, Any],
|
|
234
|
+
max_concurrent: int = 10
|
|
235
|
+
) -> Tuple[List[Any], List[Dict[str, Any]]]:
|
|
236
|
+
"""
|
|
237
|
+
Search multiple collections in parallel using asyncio.gather.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
collections_to_search: List of collection names to search
|
|
241
|
+
query: Search query
|
|
242
|
+
qdrant_client: Qdrant client instance
|
|
243
|
+
ctx: Context for debugging
|
|
244
|
+
limit: Maximum results per collection
|
|
245
|
+
min_score: Minimum similarity score
|
|
246
|
+
should_use_decay: Whether to apply time decay
|
|
247
|
+
target_project: Project filter ('all' or specific project)
|
|
248
|
+
generate_embedding_func: Function to generate embeddings
|
|
249
|
+
constants: Dictionary of constants (USE_NATIVE_DECAY, etc.)
|
|
250
|
+
max_concurrent: Maximum concurrent searches
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Tuple of (all_results, collection_timings)
|
|
254
|
+
"""
|
|
255
|
+
await ctx.debug(f"Starting parallel search across {len(collections_to_search)} collections")
|
|
256
|
+
|
|
257
|
+
# Shared cache for embeddings
|
|
258
|
+
query_embeddings = {}
|
|
259
|
+
|
|
260
|
+
# Create semaphore to limit concurrent operations
|
|
261
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
262
|
+
|
|
263
|
+
async def search_with_semaphore(collection_name: str) -> Tuple[str, List[Any], Dict[str, Any]]:
|
|
264
|
+
"""Search with concurrency limit"""
|
|
265
|
+
async with semaphore:
|
|
266
|
+
return await search_single_collection(
|
|
267
|
+
collection_name=collection_name,
|
|
268
|
+
query=query,
|
|
269
|
+
query_embeddings=query_embeddings,
|
|
270
|
+
qdrant_client=qdrant_client,
|
|
271
|
+
ctx=ctx,
|
|
272
|
+
limit=limit,
|
|
273
|
+
min_score=min_score,
|
|
274
|
+
should_use_decay=should_use_decay,
|
|
275
|
+
target_project=target_project,
|
|
276
|
+
generate_embedding_func=generate_embedding_func,
|
|
277
|
+
constants=constants
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Launch all searches in parallel
|
|
281
|
+
search_tasks = [
|
|
282
|
+
search_with_semaphore(collection_name)
|
|
283
|
+
for collection_name in collections_to_search
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
# Wait for all searches to complete
|
|
287
|
+
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
|
288
|
+
|
|
289
|
+
# Process results
|
|
290
|
+
all_results = []
|
|
291
|
+
collection_timings = []
|
|
292
|
+
|
|
293
|
+
for result in search_results:
|
|
294
|
+
if isinstance(result, Exception):
|
|
295
|
+
# Handle exceptions from gather
|
|
296
|
+
logger.error(f"Search task failed: {result}")
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
collection_name, results, timing = result
|
|
300
|
+
all_results.extend(results)
|
|
301
|
+
collection_timings.append(timing)
|
|
302
|
+
|
|
303
|
+
await ctx.debug(f"Parallel search complete: {len(all_results)} total results")
|
|
304
|
+
|
|
305
|
+
return all_results, collection_timings
|
|
@@ -69,6 +69,11 @@ class ProjectResolver:
|
|
|
69
69
|
Returns:
|
|
70
70
|
List of collection names that match the project
|
|
71
71
|
"""
|
|
72
|
+
# Special case: 'all' returns all conversation collections
|
|
73
|
+
if user_project_name == 'all':
|
|
74
|
+
collection_names = self._get_collection_names()
|
|
75
|
+
return collection_names # Return all conv_ collections
|
|
76
|
+
|
|
72
77
|
if user_project_name in self._cache:
|
|
73
78
|
# Check if cache entry is still valid
|
|
74
79
|
if time() - self._cache_ttl.get(user_project_name, 0) < self._cache_duration:
|