contextl 1.2.6 → 1.2.8
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/README.md +0 -37
- package/package.json +1 -1
- package/python/query_engine.py +155 -58
package/README.md
CHANGED
|
@@ -84,44 +84,7 @@ Finds unused files and dead code by analyzing the dependency graph for files wit
|
|
|
84
84
|
|
|
85
85
|
Takes the exact dependency graph built by the intelligence engine and physically writes it to disk as a directory of interconnected Markdown files. Automatically injects file metadata, JSDoc/Docstring explanations, and uses standard `[[wikilinks]]` to map out dependencies. Open the generated folder as an Obsidian vault for a stunning 3D interactive graph of your architecture!
|
|
86
86
|
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## AI System Prompt (Query Optimizer)
|
|
90
|
-
|
|
91
|
-
ContextL is a deterministic keyword and graph engine, not a semantic AI. For maximum accuracy, inject this system prompt into your AI agent's rules (e.g., `.cursorrules` or custom instructions). It forces the AI to translate your natural language questions into highly optimized, lexical keyword strings before hitting the `query_repo` tool.
|
|
92
|
-
|
|
93
|
-
```text
|
|
94
|
-
System Instruction: You are a translation layer designed to convert human requests into highly optimized queries for the contextl codebase search engine. The contextl engine is NOT a semantic AI; it is an advanced, dependency-aware fuzzy keyword searcher. It relies on exact token matching and import-graph ranking.
|
|
95
|
-
|
|
96
|
-
Your goal is to extract the core technical keywords from the user's request and discard all conversational filler or vague concepts.
|
|
97
|
-
|
|
98
|
-
Rules for generating the query string:
|
|
99
|
-
|
|
100
|
-
1. Remove Conversational Filler: Drop words like "where is", "how does", "find the", "show me".
|
|
101
|
-
2. Translate Concepts to Code: If the user asks for a concept, translate it into the exact keywords, classes, or library syntax a developer would type.
|
|
102
|
-
Example: "routing" ➡️ createRouter Route routeTree
|
|
103
|
-
Example: "entry point" ➡️ main SpringBootApplication bootstrap web.xml init
|
|
104
|
-
Example: "database schema" ➡️ Entity Column Table model schema
|
|
105
|
-
3. Include Known Framework Terms: If you know the language or framework of the codebase, append its standard terminology.
|
|
106
|
-
4. Target File Types/Structures: If looking for configurations, use terms like config properties yaml xml json.
|
|
107
|
-
5. Keep it space-separated: Output a single string of space-separated keywords without punctuation.
|
|
108
87
|
|
|
109
|
-
Examples:
|
|
110
|
-
|
|
111
|
-
User: "Where are the main react components and routing defined?"
|
|
112
|
-
Optimized Query: createRouter Route routeTree components layout
|
|
113
|
-
|
|
114
|
-
User: "Find the application entry point and main method."
|
|
115
|
-
Optimized Query: public static void main bootstrap init ApplicationContext configuration
|
|
116
|
-
|
|
117
|
-
User: "How is the user authentication handled?"
|
|
118
|
-
Optimized Query: login auth authenticate jwt token session passport
|
|
119
|
-
|
|
120
|
-
Input: [Insert human request here]
|
|
121
|
-
Output: [Return ONLY the optimized keyword string]
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
---
|
|
125
88
|
|
|
126
89
|
## How the ranking works
|
|
127
90
|
|
package/package.json
CHANGED
package/python/query_engine.py
CHANGED
|
@@ -32,7 +32,9 @@ STOP_WORDS = {
|
|
|
32
32
|
"where", "when", "which", "who", "make", "change", "update", "fix",
|
|
33
33
|
"add", "remove", "edit", "modify", "the", "my", "our", "i", "we",
|
|
34
34
|
"string", "int", "boolean", "class", "function", "return", "import",
|
|
35
|
-
"public", "private", "logic", "handler", "data", "value"
|
|
35
|
+
"public", "private", "logic", "handler", "data", "value", "yo", "bro",
|
|
36
|
+
"bruh", "pls", "please", "bug", "issue", "error", "help", "stuff",
|
|
37
|
+
"handling", "failing", "why", "need", "can", "could", "would", "should", "fr"
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
|
|
@@ -60,37 +62,6 @@ class RankedFile:
|
|
|
60
62
|
)
|
|
61
63
|
|
|
62
64
|
|
|
63
|
-
@dataclass
|
|
64
|
-
class FileFeatures:
|
|
65
|
-
tokens: set[str]
|
|
66
|
-
annotations: set[str]
|
|
67
|
-
declarations: set[str]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _extract_file_features(content: str, extension: str) -> FileFeatures:
|
|
71
|
-
"""Extract structural heuristics and lowercase tokens from a file."""
|
|
72
|
-
# 1. Declarations: class X, interface Y, enum Z
|
|
73
|
-
decl_pattern = r"\b(?:class|interface|enum)\s+([A-Za-z0-9_]+)"
|
|
74
|
-
declarations = {m.group(1).lower() for m in re.finditer(decl_pattern, content)}
|
|
75
|
-
|
|
76
|
-
# 2. Annotations: @Controller, @Injectable
|
|
77
|
-
annotations = {m.group(1).lower() for m in re.finditer(r"@([A-Za-z0-9_]+)", content)}
|
|
78
|
-
|
|
79
|
-
# 3. Base tokens
|
|
80
|
-
tokens = set(re.findall(r"[a-z0-9]+", content.lower()))
|
|
81
|
-
|
|
82
|
-
# 4. CamelCase / PascalCase Splitting for structural languages
|
|
83
|
-
if extension in {".java", ".ts", ".tsx"}:
|
|
84
|
-
for word in re.findall(r"[A-Za-z]+", content):
|
|
85
|
-
if not word.islower() and not word.isupper() and len(word) > 2:
|
|
86
|
-
# Split CamelCase
|
|
87
|
-
sub = re.sub(r"([a-z])([A-Z])", r"\1 \2", word).lower()
|
|
88
|
-
for part in re.findall(r"[a-z0-9]+", sub):
|
|
89
|
-
if len(part) > 1:
|
|
90
|
-
tokens.add(part)
|
|
91
|
-
|
|
92
|
-
return FileFeatures(tokens=tokens, annotations=annotations, declarations=declarations)
|
|
93
|
-
|
|
94
65
|
|
|
95
66
|
def _tokenize(text: str) -> list[str]:
|
|
96
67
|
"""Lowercase, split on non-alphanumeric, remove stop words."""
|
|
@@ -122,55 +93,154 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
|
|
|
122
93
|
score = 0.0
|
|
123
94
|
|
|
124
95
|
for term in query_terms:
|
|
96
|
+
# Exact match
|
|
125
97
|
if term in filename_toks:
|
|
126
|
-
score += idf_weights.get(term, 1.0) #
|
|
98
|
+
score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
|
|
127
99
|
matched.append(term)
|
|
100
|
+
# Substring match on filename
|
|
101
|
+
elif len(term) > 3 and any(term in ft for ft in filename_toks):
|
|
102
|
+
score += 1.5 * idf_weights.get(term, 1.0)
|
|
103
|
+
matched.append(term)
|
|
104
|
+
# Exact match on path
|
|
128
105
|
elif term in path_toks:
|
|
129
|
-
score += 0.
|
|
106
|
+
score += 0.8 * idf_weights.get(term, 1.0) # Strong signal
|
|
107
|
+
if term not in matched:
|
|
108
|
+
matched.append(term)
|
|
109
|
+
# Substring match on path
|
|
110
|
+
elif len(term) > 3 and any(term in pt for pt in path_toks):
|
|
111
|
+
score += 0.4 * idf_weights.get(term, 1.0)
|
|
130
112
|
if term not in matched:
|
|
131
113
|
matched.append(term)
|
|
132
114
|
|
|
133
115
|
# Normalize by max possible score (sum of all IDFs)
|
|
134
116
|
max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
|
|
135
117
|
if max_score > 0:
|
|
136
|
-
score = score / max_score
|
|
118
|
+
score = min(1.0, score / max_score) # Cap at 1.0 since we have massive multipliers
|
|
137
119
|
|
|
138
120
|
return score, matched
|
|
139
121
|
|
|
140
122
|
|
|
141
|
-
def _content_score(query_terms: list[str],
|
|
123
|
+
def _content_score(query_terms: list[str], file_path: str, file_content: str, idf_weights: dict[str, float], dl: int, avgdl: float) -> tuple[float, list[str]]:
|
|
142
124
|
"""
|
|
143
125
|
Score how often query terms appear in the file's source code.
|
|
144
|
-
|
|
126
|
+
Uses BM25 Term Frequency weighting to neutralize document length bias.
|
|
127
|
+
Includes language-specific heuristics for Java and other OOP languages.
|
|
145
128
|
"""
|
|
146
|
-
|
|
129
|
+
is_java = file_path.endswith(".java")
|
|
130
|
+
is_ts = file_path.endswith(".ts") or file_path.endswith(".tsx")
|
|
131
|
+
is_oop = is_java or is_ts
|
|
132
|
+
|
|
133
|
+
content_tokens_list = re.findall(r"[a-z0-9]+", file_content.lower())
|
|
134
|
+
|
|
135
|
+
# 1. CamelCase Splitting (Java only)
|
|
136
|
+
if is_java:
|
|
137
|
+
camel_tokens = []
|
|
138
|
+
for word in re.findall(r"[a-zA-Z]+", file_content):
|
|
139
|
+
if any(c.isupper() for c in word):
|
|
140
|
+
sub = re.sub(r"([A-Z])", r" \1", word).lower()
|
|
141
|
+
camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
|
|
142
|
+
content_tokens_list.extend([t for t in camel_tokens if len(t) > 1])
|
|
143
|
+
|
|
144
|
+
# 2. Extract Class Declarations, Annotations & Method Names
|
|
145
|
+
declared_classes = set()
|
|
146
|
+
ts_exports = set()
|
|
147
|
+
java_methods = set()
|
|
148
|
+
if is_oop or file_path.endswith(".py"):
|
|
149
|
+
decls = re.findall(r"\b(?:class|interface|enum)\s+([a-zA-Z0-9_]+)", file_content)
|
|
150
|
+
for d in decls:
|
|
151
|
+
declared_classes.add(d.lower())
|
|
152
|
+
|
|
153
|
+
if is_java:
|
|
154
|
+
# Extract Java method names: public/private/protected ... methodName(
|
|
155
|
+
methods = re.findall(r"\b(?:public|private|protected)\s+(?:static\s+)?(?:[a-zA-Z0-9_<>\[\]]+)\s+([a-zA-Z0-9_]+)\s*\(", file_content)
|
|
156
|
+
for m in methods:
|
|
157
|
+
if m.lower() not in {"main", "get", "set", "is", "has"}:
|
|
158
|
+
java_methods.add(m.lower())
|
|
159
|
+
# Also add CamelCase parts of method names
|
|
160
|
+
sub = re.sub(r"([A-Z])", r" \1", m).lower()
|
|
161
|
+
for part in re.findall(r"[a-z0-9]+", sub):
|
|
162
|
+
if len(part) > 3:
|
|
163
|
+
java_methods.add(part)
|
|
164
|
+
|
|
165
|
+
if is_ts:
|
|
166
|
+
exports = re.findall(r"\bexport\s+(?:const|let|var|class|interface|function|default)\s+([a-zA-Z0-9_]+)", file_content)
|
|
167
|
+
for e in exports:
|
|
168
|
+
ts_exports.add(e.lower())
|
|
169
|
+
|
|
170
|
+
annotations = set()
|
|
171
|
+
if is_oop or file_path.endswith(".py"):
|
|
172
|
+
anns = re.findall(r"@([a-zA-Z0-9_]+)", file_content)
|
|
173
|
+
for a in anns:
|
|
174
|
+
annotations.add(a.lower())
|
|
175
|
+
|
|
176
|
+
term_counts = {}
|
|
177
|
+
for t in query_terms:
|
|
178
|
+
count = 0
|
|
179
|
+
for ct in content_tokens_list:
|
|
180
|
+
if t == ct:
|
|
181
|
+
count += 1
|
|
182
|
+
elif len(t) > 3 and t in ct:
|
|
183
|
+
count += 1
|
|
184
|
+
if count > 0:
|
|
185
|
+
term_counts[t] = count
|
|
186
|
+
|
|
187
|
+
matched = list(term_counts.keys())
|
|
188
|
+
|
|
189
|
+
if not query_terms:
|
|
147
190
|
return 0.0, []
|
|
148
191
|
|
|
149
|
-
|
|
192
|
+
# Multiplier strength scales inversely with query length.
|
|
193
|
+
# Short queries (1-2 terms, likely vibe) need a loud structural signal.
|
|
194
|
+
# Long queries (4+ terms, likely standard LLM) rely more on BM25 math.
|
|
195
|
+
n_terms = len(query_terms)
|
|
196
|
+
if n_terms <= 2:
|
|
197
|
+
class_mult = 10.0
|
|
198
|
+
ann_mult = 5.0
|
|
199
|
+
elif n_terms <= 4:
|
|
200
|
+
class_mult = 6.0
|
|
201
|
+
ann_mult = 3.0
|
|
202
|
+
else:
|
|
203
|
+
class_mult = 3.0
|
|
204
|
+
ann_mult = 2.0
|
|
150
205
|
|
|
151
206
|
score = 0.0
|
|
152
207
|
for t in matched:
|
|
153
208
|
base_weight = idf_weights.get(t, 1.0)
|
|
154
209
|
multiplier = 1.0
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
multiplier =
|
|
159
|
-
|
|
210
|
+
|
|
211
|
+
# 3. Apply Multipliers — TS exports always get 8x regardless of query length
|
|
212
|
+
if t in ts_exports:
|
|
213
|
+
multiplier = 8.0
|
|
214
|
+
elif t in declared_classes:
|
|
215
|
+
multiplier = class_mult
|
|
216
|
+
elif t in java_methods:
|
|
217
|
+
multiplier = 4.0
|
|
218
|
+
elif t in annotations:
|
|
219
|
+
multiplier = ann_mult
|
|
220
|
+
|
|
221
|
+
# 4. BM25 Term Frequency
|
|
222
|
+
count = term_counts[t]
|
|
223
|
+
k1 = 1.5
|
|
224
|
+
b = 0.75
|
|
225
|
+
tf_bm25 = (count * (k1 + 1)) / (count + k1 * (1 - b + b * (dl / avgdl)))
|
|
226
|
+
|
|
227
|
+
score += (tf_bm25 * base_weight * multiplier)
|
|
160
228
|
|
|
161
229
|
max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
|
|
162
230
|
if max_score > 0:
|
|
163
|
-
|
|
231
|
+
base_normalized = min(1.0, sum(idf_weights.get(t, 1.0) for t in matched) / max_score)
|
|
232
|
+
bonus = score - sum(idf_weights.get(t, 1.0) for t in matched)
|
|
233
|
+
final_score = base_normalized + bonus
|
|
164
234
|
else:
|
|
165
|
-
|
|
235
|
+
final_score = 0.0
|
|
166
236
|
|
|
167
|
-
return
|
|
237
|
+
return final_score, matched
|
|
168
238
|
|
|
169
239
|
|
|
170
240
|
def _apply_neighbor_bonus(
|
|
171
241
|
scores: dict[str, float],
|
|
172
242
|
repo_graph: RepoGraph,
|
|
173
|
-
boost: float = 0.
|
|
243
|
+
boost: float = 0.35,
|
|
174
244
|
depth: int = 1,
|
|
175
245
|
) -> dict[str, float]:
|
|
176
246
|
"""
|
|
@@ -218,33 +288,59 @@ def query(
|
|
|
218
288
|
if not query_terms:
|
|
219
289
|
return []
|
|
220
290
|
|
|
221
|
-
# --- Pre-computation: Global TF-IDF ---
|
|
291
|
+
# --- Pre-computation: Global TF-IDF & BM25 ---
|
|
222
292
|
import pathlib
|
|
223
293
|
total_files = len(repo_graph.nodes)
|
|
224
294
|
document_frequency = {term: 0 for term in query_terms}
|
|
225
295
|
|
|
226
|
-
|
|
296
|
+
file_contents = {}
|
|
297
|
+
doc_lengths = {}
|
|
298
|
+
total_tokens_count = 0
|
|
299
|
+
|
|
227
300
|
for path, node in repo_graph.nodes.items():
|
|
228
301
|
abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
|
|
229
|
-
extension = pathlib.Path(path).suffix.lower()
|
|
230
|
-
|
|
231
302
|
try:
|
|
232
303
|
content = open(abs_path, encoding="utf-8").read()
|
|
233
|
-
|
|
234
|
-
file_features_cache[path] = features
|
|
304
|
+
file_contents[path] = content
|
|
235
305
|
|
|
306
|
+
raw_tokens = re.findall(r"[a-z0-9]+", content.lower())
|
|
307
|
+
dl = len(raw_tokens)
|
|
308
|
+
doc_lengths[path] = dl
|
|
309
|
+
total_tokens_count += dl
|
|
310
|
+
|
|
311
|
+
content_tokens = set(raw_tokens)
|
|
312
|
+
|
|
313
|
+
if path.endswith(".java"):
|
|
314
|
+
camel_tokens = []
|
|
315
|
+
for word in re.findall(r"[a-zA-Z]+", content):
|
|
316
|
+
if any(c.isupper() for c in word):
|
|
317
|
+
sub = re.sub(r"([A-Z])", r" \1", word).lower()
|
|
318
|
+
camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
|
|
319
|
+
content_tokens.update([t for t in camel_tokens if len(t) > 1])
|
|
320
|
+
|
|
236
321
|
path_tokens = set(_path_tokens(path))
|
|
237
|
-
combined_tokens =
|
|
322
|
+
combined_tokens = content_tokens.union(path_tokens)
|
|
238
323
|
for term in query_terms:
|
|
239
324
|
if term in combined_tokens:
|
|
240
325
|
document_frequency[term] += 1
|
|
326
|
+
elif len(term) > 3:
|
|
327
|
+
for ct in combined_tokens:
|
|
328
|
+
if term in ct:
|
|
329
|
+
document_frequency[term] += 1
|
|
330
|
+
break
|
|
241
331
|
except Exception:
|
|
242
|
-
|
|
332
|
+
file_contents[path] = ""
|
|
333
|
+
doc_lengths[path] = 0
|
|
334
|
+
|
|
335
|
+
avgdl = total_tokens_count / total_files if total_files > 0 else 1.0
|
|
243
336
|
|
|
244
337
|
idf_weights = {}
|
|
245
338
|
for term in query_terms:
|
|
246
339
|
df = document_frequency[term]
|
|
247
|
-
|
|
340
|
+
if df == 0:
|
|
341
|
+
idf_weights[term] = 0.0
|
|
342
|
+
else:
|
|
343
|
+
idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
|
|
248
344
|
|
|
249
345
|
# --- Pass 1: keyword + content scores per file ---
|
|
250
346
|
keyword_scores: dict[str, float] = {}
|
|
@@ -253,7 +349,8 @@ def query(
|
|
|
253
349
|
|
|
254
350
|
for path, node in repo_graph.nodes.items():
|
|
255
351
|
kscore, kterms = _keyword_score(query_terms, path, idf_weights)
|
|
256
|
-
|
|
352
|
+
dl = doc_lengths.get(path, 0)
|
|
353
|
+
cscore, cterms = _content_score(query_terms, path, file_contents.get(path, ""), idf_weights, dl, avgdl)
|
|
257
354
|
|
|
258
355
|
keyword_scores[path] = kscore
|
|
259
356
|
content_scores[path] = cscore
|