contextl 1.2.4 → 1.2.6
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 +37 -0
- package/package.json +1 -1
- package/python/import_parser.py +26 -21
- package/python/query_engine.py +91 -19
package/README.md
CHANGED
|
@@ -86,6 +86,43 @@ Takes the exact dependency graph built by the intelligence engine and physically
|
|
|
86
86
|
|
|
87
87
|
---
|
|
88
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
|
+
|
|
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
|
+
|
|
89
126
|
## How the ranking works
|
|
90
127
|
|
|
91
128
|
1. **Keyword match** — does the filename contain query terms?
|
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -92,19 +92,25 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
|
92
92
|
return list(set(paths))
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
def _is_external(import_path: str, extension: str) -> bool:
|
|
95
|
+
def _is_external(import_path: str, extension: str, alias_map: dict[str, str] = None) -> bool:
|
|
96
96
|
"""Return True if this is an external package rather than a local file."""
|
|
97
97
|
if extension in {".py", ".java"}:
|
|
98
98
|
# For Python and Java, it's hard to tell without resolving,
|
|
99
99
|
# so assume it's local and let the resolver fail if it's external.
|
|
100
100
|
return False
|
|
101
101
|
|
|
102
|
-
# Node.js: Local files start with . (relative) or
|
|
103
|
-
# but NOT @scope/package style from npm — those don't contain our alias prefix
|
|
102
|
+
# Node.js: Local files start with . (relative) or match an alias
|
|
104
103
|
if import_path.startswith("./") or import_path.startswith("../"):
|
|
105
104
|
return False
|
|
105
|
+
|
|
106
|
+
if alias_map:
|
|
107
|
+
for alias in alias_map:
|
|
108
|
+
if import_path.startswith(alias):
|
|
109
|
+
return False
|
|
110
|
+
|
|
106
111
|
if import_path.startswith("@/"):
|
|
107
112
|
return False
|
|
113
|
+
|
|
108
114
|
return True # Everything else is an external package
|
|
109
115
|
|
|
110
116
|
|
|
@@ -212,16 +218,18 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
212
218
|
Returns a map like {"@/": "frontend/"} or {"@/": ""}
|
|
213
219
|
"""
|
|
214
220
|
root = Path(scan_result.root)
|
|
221
|
+
alias_map = {}
|
|
215
222
|
|
|
216
223
|
# Look for tsconfig.json to find paths config
|
|
217
224
|
for tsconfig in root.rglob("tsconfig.json"):
|
|
218
225
|
try:
|
|
219
226
|
import json
|
|
220
|
-
|
|
227
|
+
# naive regex to strip comments from tsconfig before parsing
|
|
228
|
+
text = tsconfig.read_text()
|
|
229
|
+
text = re.sub(r'//.*?\n|/\*.*?\*/', '', text, flags=re.S)
|
|
230
|
+
data = json.loads(text)
|
|
221
231
|
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
222
|
-
base_url = data.get("compilerOptions", {}).get("baseUrl", ".")
|
|
223
232
|
|
|
224
|
-
alias_map = {}
|
|
225
233
|
for alias, targets in paths.items():
|
|
226
234
|
if not targets:
|
|
227
235
|
continue
|
|
@@ -232,19 +240,19 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
232
240
|
tsconfig_dir = tsconfig.parent.relative_to(root)
|
|
233
241
|
prefix = str(tsconfig_dir / clean_target).lstrip("./")
|
|
234
242
|
alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
|
|
235
|
-
|
|
236
|
-
if alias_map:
|
|
237
|
-
return alias_map
|
|
238
243
|
except Exception:
|
|
239
244
|
continue
|
|
240
245
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
if not alias_map:
|
|
247
|
+
# Heuristic fallback: if all files share a common top-level dir, use that
|
|
248
|
+
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
249
|
+
if len(top_dirs) == 1:
|
|
250
|
+
top = list(top_dirs)[0]
|
|
251
|
+
alias_map["@/"] = top + "/"
|
|
252
|
+
else:
|
|
253
|
+
alias_map["@/"] = ""
|
|
246
254
|
|
|
247
|
-
return
|
|
255
|
+
return alias_map
|
|
248
256
|
|
|
249
257
|
|
|
250
258
|
def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
@@ -275,7 +283,7 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
275
283
|
raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
|
|
276
284
|
|
|
277
285
|
for raw in raw_imports:
|
|
278
|
-
if _is_external(raw, scanned_file.extension):
|
|
286
|
+
if _is_external(raw, scanned_file.extension, alias_map):
|
|
279
287
|
result.unresolved.append((scanned_file.path, raw))
|
|
280
288
|
continue
|
|
281
289
|
|
|
@@ -298,11 +306,8 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
298
306
|
candidate = raw.replace(".", "/")
|
|
299
307
|
else:
|
|
300
308
|
# Node.js
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if resolved_alias is None:
|
|
304
|
-
result.unresolved.append((scanned_file.path, raw))
|
|
305
|
-
continue
|
|
309
|
+
resolved_alias = _resolve_alias(raw, alias_map)
|
|
310
|
+
if resolved_alias is not None:
|
|
306
311
|
candidate = resolved_alias
|
|
307
312
|
else:
|
|
308
313
|
candidate = _resolve_relative(raw, scanned_file.path)
|
package/python/query_engine.py
CHANGED
|
@@ -14,6 +14,7 @@ Accepts a natural-language query and ranks files by relevance using:
|
|
|
14
14
|
No LLM. No embeddings. Pure text + graph.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
+
import math
|
|
17
18
|
import re
|
|
18
19
|
from dataclasses import dataclass, field
|
|
19
20
|
|
|
@@ -30,6 +31,8 @@ STOP_WORDS = {
|
|
|
30
31
|
"or", "is", "it", "this", "that", "with", "from", "how", "what",
|
|
31
32
|
"where", "when", "which", "who", "make", "change", "update", "fix",
|
|
32
33
|
"add", "remove", "edit", "modify", "the", "my", "our", "i", "we",
|
|
34
|
+
"string", "int", "boolean", "class", "function", "return", "import",
|
|
35
|
+
"public", "private", "logic", "handler", "data", "value"
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
|
|
@@ -57,6 +60,38 @@ class RankedFile:
|
|
|
57
60
|
)
|
|
58
61
|
|
|
59
62
|
|
|
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
|
+
|
|
60
95
|
def _tokenize(text: str) -> list[str]:
|
|
61
96
|
"""Lowercase, split on non-alphanumeric, remove stop words."""
|
|
62
97
|
tokens = re.findall(r"[a-z0-9]+", text.lower())
|
|
@@ -75,7 +110,7 @@ def _path_tokens(path: str) -> list[str]:
|
|
|
75
110
|
return [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
|
|
76
111
|
|
|
77
112
|
|
|
78
|
-
def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
|
|
113
|
+
def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
|
|
79
114
|
"""
|
|
80
115
|
Score how well query terms match the file path / name.
|
|
81
116
|
Filename matches score higher than directory matches.
|
|
@@ -88,37 +123,47 @@ def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[
|
|
|
88
123
|
|
|
89
124
|
for term in query_terms:
|
|
90
125
|
if term in filename_toks:
|
|
91
|
-
score += 1.0 # Strong signal: term in filename
|
|
126
|
+
score += idf_weights.get(term, 1.0) # Strong signal: term in filename
|
|
92
127
|
matched.append(term)
|
|
93
128
|
elif term in path_toks:
|
|
94
|
-
score += 0.4 # Weaker signal: term in directory path
|
|
129
|
+
score += 0.4 * idf_weights.get(term, 1.0) # Weaker signal: term in directory path
|
|
95
130
|
if term not in matched:
|
|
96
131
|
matched.append(term)
|
|
97
132
|
|
|
98
|
-
# Normalize by
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
# Normalize by max possible score (sum of all IDFs)
|
|
134
|
+
max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
|
|
135
|
+
if max_score > 0:
|
|
136
|
+
score = score / max_score
|
|
101
137
|
|
|
102
138
|
return score, matched
|
|
103
139
|
|
|
104
140
|
|
|
105
|
-
def _content_score(query_terms: list[str],
|
|
141
|
+
def _content_score(query_terms: list[str], features: "FileFeatures", idf_weights: dict[str, float]) -> tuple[float, list[str]]:
|
|
106
142
|
"""
|
|
107
143
|
Score how often query terms appear in the file's source code.
|
|
108
|
-
|
|
144
|
+
Applies massive multipliers for architectural declarations and annotations.
|
|
109
145
|
"""
|
|
110
|
-
|
|
111
|
-
content = open(file_path, encoding="utf-8").read().lower()
|
|
112
|
-
except Exception:
|
|
146
|
+
if not features or not query_terms:
|
|
113
147
|
return 0.0, []
|
|
114
148
|
|
|
115
|
-
|
|
116
|
-
matched = [t for t in query_terms if t in content_tokens]
|
|
149
|
+
matched = [t for t in query_terms if t in features.tokens]
|
|
117
150
|
|
|
118
|
-
|
|
119
|
-
|
|
151
|
+
score = 0.0
|
|
152
|
+
for t in matched:
|
|
153
|
+
base_weight = idf_weights.get(t, 1.0)
|
|
154
|
+
multiplier = 1.0
|
|
155
|
+
if t in features.declarations:
|
|
156
|
+
multiplier = 10.0
|
|
157
|
+
elif t in features.annotations:
|
|
158
|
+
multiplier = 5.0
|
|
159
|
+
score += base_weight * multiplier
|
|
160
|
+
|
|
161
|
+
max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
|
|
162
|
+
if max_score > 0:
|
|
163
|
+
score = score / max_score
|
|
164
|
+
else:
|
|
165
|
+
score = 0.0
|
|
120
166
|
|
|
121
|
-
score = len(matched) / len(query_terms)
|
|
122
167
|
return score, matched
|
|
123
168
|
|
|
124
169
|
|
|
@@ -173,15 +218,42 @@ def query(
|
|
|
173
218
|
if not query_terms:
|
|
174
219
|
return []
|
|
175
220
|
|
|
221
|
+
# --- Pre-computation: Global TF-IDF ---
|
|
222
|
+
import pathlib
|
|
223
|
+
total_files = len(repo_graph.nodes)
|
|
224
|
+
document_frequency = {term: 0 for term in query_terms}
|
|
225
|
+
|
|
226
|
+
file_features_cache = {}
|
|
227
|
+
for path, node in repo_graph.nodes.items():
|
|
228
|
+
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
|
+
try:
|
|
232
|
+
content = open(abs_path, encoding="utf-8").read()
|
|
233
|
+
features = _extract_file_features(content, extension)
|
|
234
|
+
file_features_cache[path] = features
|
|
235
|
+
|
|
236
|
+
path_tokens = set(_path_tokens(path))
|
|
237
|
+
combined_tokens = features.tokens.union(path_tokens)
|
|
238
|
+
for term in query_terms:
|
|
239
|
+
if term in combined_tokens:
|
|
240
|
+
document_frequency[term] += 1
|
|
241
|
+
except Exception:
|
|
242
|
+
file_features_cache[path] = None
|
|
243
|
+
|
|
244
|
+
idf_weights = {}
|
|
245
|
+
for term in query_terms:
|
|
246
|
+
df = document_frequency[term]
|
|
247
|
+
idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
|
|
248
|
+
|
|
176
249
|
# --- Pass 1: keyword + content scores per file ---
|
|
177
250
|
keyword_scores: dict[str, float] = {}
|
|
178
251
|
content_scores: dict[str, float] = {}
|
|
179
252
|
matched_terms: dict[str, list[str]] = {}
|
|
180
253
|
|
|
181
254
|
for path, node in repo_graph.nodes.items():
|
|
182
|
-
kscore, kterms = _keyword_score(query_terms, path)
|
|
183
|
-
cscore, cterms = _content_score(query_terms,
|
|
184
|
-
str(__import__("pathlib").Path(repo_graph.root) / path))
|
|
255
|
+
kscore, kterms = _keyword_score(query_terms, path, idf_weights)
|
|
256
|
+
cscore, cterms = _content_score(query_terms, file_features_cache.get(path), idf_weights)
|
|
185
257
|
|
|
186
258
|
keyword_scores[path] = kscore
|
|
187
259
|
content_scores[path] = cscore
|