codecortex 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {codecortex-0.6.0/src/codecortex.egg-info → codecortex-0.7.0}/PKG-INFO +1 -1
- {codecortex-0.6.0 → codecortex-0.7.0/src/codecortex.egg-info}/PKG-INFO +1 -1
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/SOURCES.txt +1 -0
- codecortex-0.7.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/config.py +4 -1
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/doctor.py +5 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/indexer.py +10 -2
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/graph.py +3 -1
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/lsp.py +3 -1
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/semantic.py +4 -1
- codecortex-0.7.0/src/codeintel/searcher.py +311 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_config.py +11 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_doctor.py +12 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_integration.py +11 -5
- codecortex-0.7.0/tests/test_rerank.py +256 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_semantic_provider.py +48 -0
- codecortex-0.6.0/src/codeintel/__init__.py +0 -1
- codecortex-0.6.0/src/codeintel/searcher.py +0 -143
- {codecortex-0.6.0 → codecortex-0.7.0}/LICENSE +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/README.md +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/pyproject.toml +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/setup.cfg +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/gateway.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/term.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_cache.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_chunking.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_e2e.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_gateway.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_real.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_hardening.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_http_server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_mapper.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_rbac.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_reset.py +0 -0
- {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_term.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
|
@@ -25,6 +25,8 @@ _DEFAULTS: dict = {
|
|
|
25
25
|
"cosine_floor": 0.25,
|
|
26
26
|
"model": "BAAI/bge-small-en-v1.5",
|
|
27
27
|
"chunk_strategy": "syntax", # syntax-aware (def/class boundaries) vs fixed line windows
|
|
28
|
+
"rerank": "on", # hybrid lexical+semantic rerank of search results
|
|
29
|
+
"rerank_candidates": 30, # cosine candidates fused/re-ranked before returning top-k
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
# Values restricted to a fixed set — anything else falls back to the default.
|
|
@@ -33,8 +35,9 @@ _ENUMS: dict = {
|
|
|
33
35
|
"semantic": {"on", "off"},
|
|
34
36
|
"reindex": {"on-demand", "never"},
|
|
35
37
|
"chunk_strategy": {"syntax", "lines"},
|
|
38
|
+
"rerank": {"on", "off"},
|
|
36
39
|
}
|
|
37
|
-
_POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks")
|
|
40
|
+
_POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks", "rerank_candidates")
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
def _read_toml(path: pathlib.Path) -> dict:
|
|
@@ -158,4 +158,9 @@ def render_doctor_text(report: dict) -> str:
|
|
|
158
158
|
tail = "" if report.get("deep") else c.dim(" (run with --deep to boot-check serena)")
|
|
159
159
|
out.append("")
|
|
160
160
|
out.append(f" {count} engines ready for this repo.{tail}")
|
|
161
|
+
if healthy is False:
|
|
162
|
+
out.append(" " + c.dim(
|
|
163
|
+
"tip: `codeintel setup --install-uv --install-deps --index` bootstraps the "
|
|
164
|
+
"pip-installable backends; each fix: line above has the per-engine command."
|
|
165
|
+
))
|
|
161
166
|
return "\n".join(out)
|
|
@@ -380,9 +380,17 @@ class Indexer:
|
|
|
380
380
|
try:
|
|
381
381
|
vec = embeddings[j]
|
|
382
382
|
vec_bytes = struct.pack(f"{len(vec)}f", *vec)
|
|
383
|
+
# sqlite-vec's vec0 virtual table does NOT honor INSERT OR REPLACE — it raises
|
|
384
|
+
# UNIQUE on an existing chunk_id instead of replacing. So re-embedding a chunk
|
|
385
|
+
# whose content changed but whose chunk_id (start line) is stable — the common
|
|
386
|
+
# case under syntax chunking, where a def's chunk_id is its def line — would
|
|
387
|
+
# silently fail and keep the STALE vector. DELETE-then-INSERT is the supported
|
|
388
|
+
# upsert for vec0. (chunk_hashes below is a normal table, where REPLACE works.)
|
|
383
389
|
conn.execute(
|
|
384
|
-
"
|
|
385
|
-
|
|
390
|
+
"DELETE FROM code_embeddings WHERE chunk_id = ?", (chunk_id,)
|
|
391
|
+
)
|
|
392
|
+
conn.execute(
|
|
393
|
+
"INSERT INTO code_embeddings(chunk_id, embedding) VALUES (?, ?)",
|
|
386
394
|
(chunk_id, vec_bytes),
|
|
387
395
|
)
|
|
388
396
|
conn.execute(
|
|
@@ -157,7 +157,9 @@ class GraphProvider:
|
|
|
157
157
|
return {
|
|
158
158
|
"installed": False, "runnable": False, "repo_indexed": False, "project": None,
|
|
159
159
|
"detail": "codebase-memory-mcp not found on PATH",
|
|
160
|
-
"remediation": "
|
|
160
|
+
"remediation": "put the codebase-memory-mcp binary on PATH — it's an external "
|
|
161
|
+
"native backend (see docs/graph.md); once present it self-updates "
|
|
162
|
+
"via `codebase-memory-mcp update`",
|
|
161
163
|
}
|
|
162
164
|
raw = self._run("list_projects", {}, timeout_ms)
|
|
163
165
|
if raw is None:
|
|
@@ -144,7 +144,9 @@ class LspProvider:
|
|
|
144
144
|
return {
|
|
145
145
|
"installed": False, "runnable": False, "repo_indexed": None,
|
|
146
146
|
"detail": "neither `serena` nor `uvx` found on PATH",
|
|
147
|
-
"remediation": "install uv (provides uvx)
|
|
147
|
+
"remediation": "install uv (provides uvx): `codeintel setup --install-uv` "
|
|
148
|
+
"(or `brew install uv` / `pip install uv`) — serena is then "
|
|
149
|
+
"fetched on first use",
|
|
148
150
|
}
|
|
149
151
|
cmd = self._cmd
|
|
150
152
|
if not deep:
|
|
@@ -130,7 +130,10 @@ class SemanticProvider:
|
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
matches = searcher.search(
|
|
133
|
-
target, project_root,
|
|
133
|
+
target, project_root,
|
|
134
|
+
cosine_floor=float(cfg.get("cosine_floor", 0.25)),
|
|
135
|
+
rerank=str(cfg.get("rerank", "on")),
|
|
136
|
+
rerank_candidates=int(cfg.get("rerank_candidates", 30)),
|
|
134
137
|
)
|
|
135
138
|
if not matches:
|
|
136
139
|
return safe_null_result(op, target, engine="semantic", reason="below-floor")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from codeintel.semantic_db import SemanticDb
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_SNIPPET_LINES = 5
|
|
17
|
+
# Hybrid rerank (0.7.0): re-read up to this many lines per candidate for lexical scoring — a hard
|
|
18
|
+
# per-candidate cap so one huge file can't blow up a query. The read is further bounded at the next
|
|
19
|
+
# stored chunk start (see _rerank); the returned snippet still uses only the first _SNIPPET_LINES.
|
|
20
|
+
_RERANK_READ_LINES = 40
|
|
21
|
+
# Hard ceiling on the candidate set regardless of rerank_candidates, so a misconfigured value can't
|
|
22
|
+
# turn one query into thousands of file reads on the interactive hot path.
|
|
23
|
+
_RERANK_CANDIDATES_CAP = 200
|
|
24
|
+
_RRF_K = 60 # Reciprocal Rank Fusion constant (standard ≈ 60; damps rank differences)
|
|
25
|
+
_SYMBOL_BOOST = 0.1 # additive fusion bonus for an exact def/class-name match (≫ one RRF term)
|
|
26
|
+
|
|
27
|
+
# `\w` is Unicode-aware in Python 3, so a non-ASCII identifier is kept whole; the sub-splitter is
|
|
28
|
+
# Latin-cased on purpose (camelCase only exists there) and just adds bonus pieces for ASCII names.
|
|
29
|
+
_IDENT_RE = re.compile(r"\w+")
|
|
30
|
+
_SUBTOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+")
|
|
31
|
+
_SYMBOL_RE = re.compile(r"[^\W\d]\w*") # a query that is a single identifier (letter/_ then word)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pos_int(val: object, default: int) -> int:
|
|
35
|
+
"""A usable positive int or the default — guards the public Searcher.search against a caller
|
|
36
|
+
passing a non-int/zero/negative k or rerank_candidates (mirrors indexer._pos_int)."""
|
|
37
|
+
try:
|
|
38
|
+
n = int(val) # type: ignore[arg-type]
|
|
39
|
+
except (TypeError, ValueError, OverflowError):
|
|
40
|
+
return default
|
|
41
|
+
return n if n > 0 else default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tokenize(text: str) -> set[str]:
|
|
45
|
+
"""Lexical tokens for overlap scoring: each whole identifier plus its camel/snake sub-pieces,
|
|
46
|
+
lowercased. Deliberately set-based (presence, not frequency) — cheap and enough for ranking."""
|
|
47
|
+
toks: set[str] = set()
|
|
48
|
+
for ident in _IDENT_RE.findall(text):
|
|
49
|
+
toks.add(ident.lower())
|
|
50
|
+
for sub in _SUBTOKEN_RE.findall(ident):
|
|
51
|
+
toks.add(sub.lower())
|
|
52
|
+
return toks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Searcher:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
db: SemanticDb,
|
|
59
|
+
model_name: str = "BAAI/bge-small-en-v1.5",
|
|
60
|
+
) -> None:
|
|
61
|
+
self.db = db
|
|
62
|
+
self.model_name = model_name
|
|
63
|
+
self._embedder = None
|
|
64
|
+
|
|
65
|
+
def _get_embedder(self):
|
|
66
|
+
if self._embedder is None:
|
|
67
|
+
from fastembed import TextEmbedding
|
|
68
|
+
self._embedder = TextEmbedding(model_name=self.model_name)
|
|
69
|
+
return self._embedder
|
|
70
|
+
|
|
71
|
+
def _embed_query(self, query: str) -> bytes | None:
|
|
72
|
+
try:
|
|
73
|
+
embedder = self._get_embedder()
|
|
74
|
+
vecs = list(embedder.embed([query]))
|
|
75
|
+
if not vecs:
|
|
76
|
+
return None
|
|
77
|
+
vec = vecs[0]
|
|
78
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
logger.warning("query embedding failed: %s", exc)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _row_count(self, project_root_real: str) -> int:
|
|
84
|
+
try:
|
|
85
|
+
conn = self.db.conn()
|
|
86
|
+
row = conn.execute(
|
|
87
|
+
"SELECT COUNT(*) FROM chunk_hashes WHERE project_root = ?",
|
|
88
|
+
(project_root_real,),
|
|
89
|
+
).fetchone()
|
|
90
|
+
return row[0] if row else 0
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.warning("rowcount check failed: %s", exc)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
def has_index(self, project_root: str) -> bool:
|
|
96
|
+
"""True when this project has at least one indexed chunk — lets the provider
|
|
97
|
+
distinguish 'nothing indexed yet' (no-index) from 'matches below floor'."""
|
|
98
|
+
return self._row_count(os.path.realpath(project_root)) > 0
|
|
99
|
+
|
|
100
|
+
def _read_snippet(self, file_path: Path, chunk_start: int) -> str:
|
|
101
|
+
try:
|
|
102
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
103
|
+
lines = f.readlines()
|
|
104
|
+
snippet_lines = lines[chunk_start: chunk_start + _SNIPPET_LINES]
|
|
105
|
+
return "".join(snippet_lines).rstrip()
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return "[file not found]"
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.debug("snippet read failed for %s:%d: %s", file_path, chunk_start, exc)
|
|
110
|
+
return "[file not found]"
|
|
111
|
+
|
|
112
|
+
def _read_chunk(self, file_path: Path, chunk_start: int) -> list[str] | None:
|
|
113
|
+
"""Bounded re-read from ``chunk_start`` for rerank lexical scoring (and the snippet in one
|
|
114
|
+
read). Uses ``islice`` so a huge multi-line file is not fully materialised just to take a
|
|
115
|
+
40-line window. ``None`` on any failure — the caller scores that candidate 0 and shows a
|
|
116
|
+
not-found snippet, never crashing (a missing/edited file must degrade, per never-raise)."""
|
|
117
|
+
try:
|
|
118
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
119
|
+
return list(itertools.islice(f, chunk_start, chunk_start + _RERANK_READ_LINES))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
logger.debug("chunk re-read failed for %s:%d: %s", file_path, chunk_start, exc)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _chunk_starts(self, project_root_real: str, file_path: str) -> list[int]:
|
|
125
|
+
"""All stored chunk start lines for one file, sorted. Used to bound each candidate's rerank
|
|
126
|
+
read at the *next* chunk — the chunk's true end for tiling syntax chunks, its owned region
|
|
127
|
+
for overlapping line windows — so lexical/boost text never bleeds into an unrelated def.
|
|
128
|
+
Uses the (project_root, file_path) composite index. ``[]`` on any error → no bound applied."""
|
|
129
|
+
try:
|
|
130
|
+
rows = self.db.conn().execute(
|
|
131
|
+
"SELECT chunk_start FROM chunk_hashes"
|
|
132
|
+
" WHERE project_root = ? AND file_path = ? ORDER BY chunk_start",
|
|
133
|
+
(project_root_real, file_path),
|
|
134
|
+
).fetchall()
|
|
135
|
+
return [int(r[0]) for r in rows]
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.debug("chunk-start lookup failed for %s: %s", file_path, exc)
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _lexical_score(query_tokens: set[str], chunk_text: str) -> float:
|
|
142
|
+
"""Token overlap in [0, 1]: the fraction of query (sub)tokens present in the chunk."""
|
|
143
|
+
if not query_tokens:
|
|
144
|
+
return 0.0
|
|
145
|
+
return len(query_tokens & _tokenize(chunk_text)) / len(query_tokens)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _symbol_boost(query: str, chunk_text: str) -> float:
|
|
149
|
+
"""Additive fusion bonus for the 'exact symbol' case: when the query is a single identifier
|
|
150
|
+
that appears in the chunk as a ``def``/``class`` name (full boost) or as a standalone word
|
|
151
|
+
(half). Case-insensitive, to match the lexical score. Cosine alone under-ranks these literal
|
|
152
|
+
matches; this is what pulls them to the top. The ``_SYMBOL_RE`` guard runs before any
|
|
153
|
+
``re.escape`` interpolation, so no query character can ever reach the built pattern."""
|
|
154
|
+
q = (query or "").strip()
|
|
155
|
+
if not _SYMBOL_RE.fullmatch(q):
|
|
156
|
+
return 0.0 # multi-word / non-identifier query → no structural signal
|
|
157
|
+
if re.search(rf"\b(?:def|class)\s+{re.escape(q)}\b", chunk_text, re.IGNORECASE):
|
|
158
|
+
return _SYMBOL_BOOST
|
|
159
|
+
if re.search(rf"\b{re.escape(q)}\b", chunk_text, re.IGNORECASE):
|
|
160
|
+
return _SYMBOL_BOOST * 0.5
|
|
161
|
+
return 0.0
|
|
162
|
+
|
|
163
|
+
def _rerank(
|
|
164
|
+
self, query: str, root: Path, project_root_real: str, candidates: list[dict]
|
|
165
|
+
) -> list[dict]:
|
|
166
|
+
"""Reorder floor-gated candidates (given in cosine order) by Reciprocal Rank Fusion over
|
|
167
|
+
the semantic rank and a lexical rank, plus a symbol boost. Reads each candidate's chunk
|
|
168
|
+
once (bounded) — caching its 5-line snippet — so the whole rerank costs ≤ len(candidates)
|
|
169
|
+
reads. When no candidate has any lexical overlap the lexical rank mirrors the semantic
|
|
170
|
+
rank, so the cosine order is returned unchanged (rerank only *reorders* on real signal)."""
|
|
171
|
+
query_tokens = _tokenize(query)
|
|
172
|
+
n = len(candidates)
|
|
173
|
+
# Bound each candidate's lexical/boost text at the NEXT stored chunk start in its file — the
|
|
174
|
+
# chunk's true end for tiling syntax chunks, its owned region for overlapping line windows.
|
|
175
|
+
# A chunk's end line isn't stored, so a fixed 40-line read would otherwise (a) bleed into an
|
|
176
|
+
# unrelated later def, handing this chunk a symbol boost that isn't its own, and/or (b) — if
|
|
177
|
+
# we capped at neighbouring *candidates* — truncate a chunk below its own overlapping span.
|
|
178
|
+
# Bounding at the next real chunk gives each source line to exactly one chunk: the one whose
|
|
179
|
+
# 5-line snippet will actually show it.
|
|
180
|
+
starts_by_file: dict[str, list[int]] = {}
|
|
181
|
+
for path in {c["path"] for c in candidates}:
|
|
182
|
+
starts_by_file[path] = self._chunk_starts(project_root_real, path)
|
|
183
|
+
|
|
184
|
+
lex = [0.0] * n
|
|
185
|
+
boost = [0.0] * n
|
|
186
|
+
for i, c in enumerate(candidates):
|
|
187
|
+
lines = self._read_chunk(root / c["path"], c["line"])
|
|
188
|
+
if lines is None:
|
|
189
|
+
c["snippet"] = "[file not found]"
|
|
190
|
+
text = ""
|
|
191
|
+
else:
|
|
192
|
+
# snippet keeps the existing 5-line preview (bleed-tolerant, unchanged); the
|
|
193
|
+
# lexical/boost text is capped at the next stored chunk start in this file.
|
|
194
|
+
c["snippet"] = "".join(lines[:_SNIPPET_LINES]).rstrip()
|
|
195
|
+
nxt = next((s for s in starts_by_file.get(c["path"], ()) if s > c["line"]), None)
|
|
196
|
+
text = "".join(lines if nxt is None else lines[: max(1, nxt - c["line"])])
|
|
197
|
+
lex[i] = self._lexical_score(query_tokens, text)
|
|
198
|
+
boost[i] = self._symbol_boost(query, text)
|
|
199
|
+
|
|
200
|
+
# lexical rank: highest lexical score first, ties broken by the semantic rank (index i)
|
|
201
|
+
order_by_lex = sorted(range(n), key=lambda i: (-lex[i], i))
|
|
202
|
+
rank_lex = [0] * n
|
|
203
|
+
for pos, i in enumerate(order_by_lex):
|
|
204
|
+
rank_lex[i] = pos
|
|
205
|
+
|
|
206
|
+
# sem rank is the candidate's position i (they arrive in cosine order)
|
|
207
|
+
fused = [1.0 / (_RRF_K + i) + 1.0 / (_RRF_K + rank_lex[i]) + boost[i] for i in range(n)]
|
|
208
|
+
order = sorted(range(n), key=lambda i: (-fused[i], i)) # fused desc, tie → better cosine
|
|
209
|
+
return [candidates[i] for i in order]
|
|
210
|
+
|
|
211
|
+
def search(
|
|
212
|
+
self,
|
|
213
|
+
query: str,
|
|
214
|
+
project_root: str,
|
|
215
|
+
k: int = 10,
|
|
216
|
+
cosine_floor: float = 0.25,
|
|
217
|
+
rerank: str = "on",
|
|
218
|
+
rerank_candidates: int = 30,
|
|
219
|
+
) -> list[dict]:
|
|
220
|
+
if not query or not query.strip():
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
# Guard the public API: a direct caller passing a non-int/zero/negative k or
|
|
224
|
+
# rerank_candidates must degrade, not raise (never-raise). Config callers already pre-cast.
|
|
225
|
+
k = _pos_int(k, 10)
|
|
226
|
+
rerank_candidates = _pos_int(rerank_candidates, 30)
|
|
227
|
+
# Accept the documented "off" plus the obvious falsy spellings (incl. the Python bool
|
|
228
|
+
# ``False`` → ``"false"``), so a direct caller isn't silently left with rerank on.
|
|
229
|
+
do_rerank = str(rerank).strip().lower() not in ("off", "false", "0", "no", "none")
|
|
230
|
+
# With rerank on, retrieve a wider candidate set by cosine, then re-order it; still return
|
|
231
|
+
# the top-k. Cap only the *extra* rerank breadth (rerank_candidates) — the DoS guard — while
|
|
232
|
+
# always honoring k, so enabling rerank never returns fewer results than the pure-cosine
|
|
233
|
+
# path would for the same k (even a large k > cap).
|
|
234
|
+
candidate_limit = max(k, min(rerank_candidates, _RERANK_CANDIDATES_CAP)) if do_rerank else k
|
|
235
|
+
|
|
236
|
+
project_root_real = os.path.realpath(project_root)
|
|
237
|
+
|
|
238
|
+
# Scope the KNN to THIS project — a search in repo B must never surface repo A's
|
|
239
|
+
# chunks (wrong-file, wrong-content hits) from the shared cache.
|
|
240
|
+
if self._row_count(project_root_real) == 0:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
query_vec = self._embed_query(query)
|
|
244
|
+
if query_vec is None:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
conn = self.db.conn()
|
|
249
|
+
rows = conn.execute(
|
|
250
|
+
"""
|
|
251
|
+
SELECT
|
|
252
|
+
ce.chunk_id,
|
|
253
|
+
ch.chunk_start,
|
|
254
|
+
ch.file_path,
|
|
255
|
+
vec_distance_cosine(ce.embedding, ?) AS dist
|
|
256
|
+
FROM code_embeddings ce
|
|
257
|
+
JOIN chunk_hashes ch ON ce.chunk_id = ch.chunk_id
|
|
258
|
+
WHERE ch.project_root = ?
|
|
259
|
+
ORDER BY dist
|
|
260
|
+
LIMIT ?
|
|
261
|
+
""",
|
|
262
|
+
(query_vec, project_root_real, candidate_limit),
|
|
263
|
+
).fetchall()
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
logger.warning("KNN query failed: %s", exc)
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
root = Path(project_root)
|
|
269
|
+
|
|
270
|
+
# Floor-gated candidate set, in cosine order. The cosine_floor stays on the *semantic*
|
|
271
|
+
# candidates (not the fused score), so rerank can only re-order what pure cosine already
|
|
272
|
+
# judged good enough — quality can't regress below the pre-0.7 path.
|
|
273
|
+
candidates: list[dict] = []
|
|
274
|
+
for row in rows:
|
|
275
|
+
try:
|
|
276
|
+
score = 1.0 - float(row["dist"])
|
|
277
|
+
if score < cosine_floor:
|
|
278
|
+
continue
|
|
279
|
+
candidates.append({
|
|
280
|
+
"path": str(row["file_path"]),
|
|
281
|
+
"line": int(row["chunk_start"]),
|
|
282
|
+
"score": round(score, 6),
|
|
283
|
+
})
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.debug("candidate row processing failed: %s", exc)
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if not candidates:
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
# Rerank re-reads chunk text; if anything goes wrong, fall back to the cosine order so a
|
|
292
|
+
# rerank fault can never do worse than today (and never raises).
|
|
293
|
+
if do_rerank and len(candidates) > 1:
|
|
294
|
+
try:
|
|
295
|
+
candidates = self._rerank(query, root, project_root_real, candidates)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
logger.warning("rerank failed, using cosine order: %s", exc)
|
|
298
|
+
|
|
299
|
+
results: list[dict] = []
|
|
300
|
+
for c in candidates[:k]:
|
|
301
|
+
# _rerank caches the snippet on each candidate it read; fill it in otherwise.
|
|
302
|
+
snippet = c.get("snippet")
|
|
303
|
+
if snippet is None:
|
|
304
|
+
snippet = self._read_snippet(root / c["path"], c["line"])
|
|
305
|
+
results.append({
|
|
306
|
+
"path": c["path"],
|
|
307
|
+
"line": c["line"],
|
|
308
|
+
"snippet": snippet,
|
|
309
|
+
"score": c["score"],
|
|
310
|
+
})
|
|
311
|
+
return results
|
|
@@ -36,6 +36,17 @@ def test_chunk_strategy_enum_validation():
|
|
|
36
36
|
assert _coerce({"chunk_strategy": "ast"})["chunk_strategy"] == "syntax" # unknown → default
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_rerank_config_validation():
|
|
40
|
+
assert _coerce({})["rerank"] == "on" # default
|
|
41
|
+
assert _coerce({})["rerank_candidates"] == 30 # default
|
|
42
|
+
assert _coerce({"rerank": "off"})["rerank"] == "off" # escape hatch kept
|
|
43
|
+
assert _coerce({"rerank": "ON"})["rerank"] == "on" # case-normalized
|
|
44
|
+
assert _coerce({"rerank": "maybe"})["rerank"] == "on" # unknown → default
|
|
45
|
+
assert _coerce({"rerank_candidates": 50})["rerank_candidates"] == 50 # positive kept
|
|
46
|
+
assert _coerce({"rerank_candidates": 0})["rerank_candidates"] == 30 # non-positive → default
|
|
47
|
+
assert _coerce({"rerank_candidates": "x"})["rerank_candidates"] == 30 # non-int → default
|
|
48
|
+
|
|
49
|
+
|
|
39
50
|
def test_valid_values_are_preserved():
|
|
40
51
|
c = _coerce({"backend": "graph", "cosine_floor": 0.5, "window": 40, "reindex": "never"})
|
|
41
52
|
assert c["backend"] == "graph"
|
|
@@ -50,6 +50,18 @@ def test_render_shows_marks_and_remediation():
|
|
|
50
50
|
assert "n/a" in text # lsp repo-indexed
|
|
51
51
|
assert "codeintel index /repo" in text # remediation surfaced (two-line fix:)
|
|
52
52
|
assert "1 / 3 engines ready" in text
|
|
53
|
+
assert "codeintel setup" in text # actionable tip footer surfaces when not healthy
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_setup_tip_footer_only_when_unhealthy():
|
|
57
|
+
base = {"project_root": "/repo", "deep": False,
|
|
58
|
+
"engines": {"semantic": {"engine": "semantic", "status": "ok", "installed": True,
|
|
59
|
+
"runnable": True, "repo_indexed": True, "detail": "ok",
|
|
60
|
+
"remediation": None}}}
|
|
61
|
+
healthy = doctor.render_doctor_text({**base, "summary": {"ready": 1, "total": 1, "healthy": True}})
|
|
62
|
+
unhealthy = doctor.render_doctor_text({**base, "summary": {"ready": 0, "total": 1, "healthy": False}})
|
|
63
|
+
assert "codeintel setup" not in healthy # no noise when everything is ready
|
|
64
|
+
assert "codeintel setup" in unhealthy # actionable guidance only when something is missing
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
# --------------------------------------------------------------------------- #
|
|
@@ -146,7 +146,9 @@ def test_empty_project_reports_no_index(tmp_path, monkeypatch):
|
|
|
146
146
|
|
|
147
147
|
def test_config_cosine_floor_reaches_searcher(tmp_path, monkeypatch):
|
|
148
148
|
import codeintel.providers.semantic as sem
|
|
149
|
-
(tmp_path / ".codeintel.toml").write_text(
|
|
149
|
+
(tmp_path / ".codeintel.toml").write_text(
|
|
150
|
+
'cosine_floor = 0.99\nrerank = "off"\nrerank_candidates = 7\n'
|
|
151
|
+
)
|
|
150
152
|
(tmp_path / "code.py").write_text("def f():\n return 1\n")
|
|
151
153
|
monkeypatch.setattr(sem, "_DB_PATH", tmp_path / "s.db")
|
|
152
154
|
monkeypatch.setattr(sem, "_DEPS_OK", True)
|
|
@@ -154,14 +156,18 @@ def test_config_cosine_floor_reaches_searcher(tmp_path, monkeypatch):
|
|
|
154
156
|
captured = {}
|
|
155
157
|
real_search = Searcher.search
|
|
156
158
|
|
|
157
|
-
def spy(self, query, project_root, k=10, cosine_floor=0.25):
|
|
158
|
-
captured
|
|
159
|
-
return real_search(self, query, project_root, k=k, cosine_floor=cosine_floor
|
|
159
|
+
def spy(self, query, project_root, k=10, cosine_floor=0.25, rerank="on", rerank_candidates=30):
|
|
160
|
+
captured.update(floor=cosine_floor, rerank=rerank, rerank_candidates=rerank_candidates)
|
|
161
|
+
return real_search(self, query, project_root, k=k, cosine_floor=cosine_floor,
|
|
162
|
+
rerank=rerank, rerank_candidates=rerank_candidates)
|
|
160
163
|
|
|
161
164
|
monkeypatch.setattr(Searcher, "search", spy)
|
|
162
165
|
with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
|
|
163
166
|
SemanticProvider().build_result("search", "f", [], 0, str(tmp_path))
|
|
164
|
-
|
|
167
|
+
# every semantic knob from .codeintel.toml must reach the searcher, not just cosine_floor
|
|
168
|
+
assert captured.get("floor") == 0.99
|
|
169
|
+
assert captured.get("rerank") == "off"
|
|
170
|
+
assert captured.get("rerank_candidates") == 7
|
|
165
171
|
|
|
166
172
|
|
|
167
173
|
# --- Bug #8: HTTP transport 400s a malformed Content-Length (never crashes) --
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Hybrid reranking (0.7.0, Phase 2 of docs/roadmap-semantic.md).
|
|
2
|
+
|
|
3
|
+
Cosine alone under-ranks exact lexical/symbol matches; a lexical + RRF fusion rerank fixes the
|
|
4
|
+
ordering with no model dependency. These tests use a content-addressed fake embedder that places
|
|
5
|
+
each fixture chunk at a known angle, so the *cosine* order is controlled and we can prove the
|
|
6
|
+
rerank flips a literal-symbol match above a closer-but-lexically-distant one — and that
|
|
7
|
+
``rerank="off"`` restores the pure-cosine order.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from codeintel.indexer import Indexer
|
|
17
|
+
from codeintel.searcher import Searcher, _SYMBOL_BOOST, _tokenize
|
|
18
|
+
from codeintel.semantic_db import SemanticDb
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _angle_vec(theta_deg: float) -> np.ndarray:
|
|
22
|
+
v = np.zeros(384, dtype=np.float32)
|
|
23
|
+
r = math.radians(theta_deg)
|
|
24
|
+
v[0], v[1] = math.cos(r), math.sin(r)
|
|
25
|
+
return v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _AngleEmbedding:
|
|
29
|
+
"""Deterministic, content-addressed: each fixture chunk / the query is placed at a fixed angle,
|
|
30
|
+
so cosine similarity between any two is a known cos(Δθ). Lets a test dictate the cosine order."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, model_name=None):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def embed(self, texts):
|
|
36
|
+
out = []
|
|
37
|
+
for t in texts:
|
|
38
|
+
if "def parse_config" in t:
|
|
39
|
+
out.append(_angle_vec(60)) # literal-symbol chunk — FARTHER from the query
|
|
40
|
+
elif "def load_settings" in t:
|
|
41
|
+
out.append(_angle_vec(20)) # semantic chunk — NEARER the query
|
|
42
|
+
else:
|
|
43
|
+
out.append(_angle_vec(0)) # the query (and anything else) at the origin
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mem_db() -> SemanticDb:
|
|
48
|
+
db = SemanticDb(":memory:")
|
|
49
|
+
db.init()
|
|
50
|
+
return db
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_FIXTURE = (
|
|
54
|
+
"def parse_config(path):\n" # line 0 (literal symbol)
|
|
55
|
+
" return open(path).read()\n" # line 1
|
|
56
|
+
"\n" # line 2
|
|
57
|
+
"def load_settings():\n" # line 3 (semantic match)
|
|
58
|
+
' """Read and parse configuration values from disk."""\n' # line 4
|
|
59
|
+
" return {}\n" # line 5
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------------------- core acceptance
|
|
64
|
+
|
|
65
|
+
def test_exact_symbol_ranks_literal_match_above_semantic(tmp_path):
|
|
66
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
67
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
68
|
+
db = _mem_db()
|
|
69
|
+
Indexer(db).index(str(tmp_path))
|
|
70
|
+
s = Searcher(db)
|
|
71
|
+
on = s.search("parse_config", str(tmp_path), rerank="on")
|
|
72
|
+
off = s.search("parse_config", str(tmp_path), rerank="off")
|
|
73
|
+
|
|
74
|
+
# pure cosine puts the nearer (semantic) chunk first; rerank pulls the literal symbol up
|
|
75
|
+
assert [r["line"] for r in off][0] == 3, "cosine order should lead with the semantic chunk"
|
|
76
|
+
assert [r["line"] for r in on][0] == 0, "rerank should lead with the literal parse_config"
|
|
77
|
+
assert {r["line"] for r in on} == {0, 3}, "rerank reorders, it does not drop candidates"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_rerank_off_matches_pure_cosine(tmp_path):
|
|
81
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
82
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
83
|
+
db = _mem_db()
|
|
84
|
+
Indexer(db).index(str(tmp_path))
|
|
85
|
+
s = Searcher(db)
|
|
86
|
+
off = s.search("parse_config", str(tmp_path), rerank="off")
|
|
87
|
+
# cosine order: load_settings (20°, score .94) then parse_config (60°, score .5), scores intact
|
|
88
|
+
assert [(r["line"], r["score"]) for r in off] == [
|
|
89
|
+
(3, round(1 - (1 - math.cos(math.radians(20))), 6)),
|
|
90
|
+
(0, round(1 - (1 - math.cos(math.radians(60))), 6)),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_no_lexical_signal_preserves_cosine_order(tmp_path):
|
|
95
|
+
# a query with zero token overlap must leave the cosine order untouched (rerank reorders only
|
|
96
|
+
# on real lexical signal — otherwise the lexical rank mirrors the semantic rank)
|
|
97
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
98
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
99
|
+
db = _mem_db()
|
|
100
|
+
Indexer(db).index(str(tmp_path))
|
|
101
|
+
s = Searcher(db)
|
|
102
|
+
on = s.search("zzz_totally_unrelated_qqq", str(tmp_path), rerank="on")
|
|
103
|
+
off = s.search("zzz_totally_unrelated_qqq", str(tmp_path), rerank="off")
|
|
104
|
+
assert [r["line"] for r in on] == [r["line"] for r in off]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- never-raise / bounds
|
|
108
|
+
|
|
109
|
+
def test_rerank_never_raises_on_missing_file(tmp_path):
|
|
110
|
+
(tmp_path / "gone.py").write_text("def target():\n return 1\n")
|
|
111
|
+
(tmp_path / "keep.py").write_text("def other():\n return 2\n")
|
|
112
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
113
|
+
db = _mem_db()
|
|
114
|
+
Indexer(db).index(str(tmp_path))
|
|
115
|
+
(tmp_path / "gone.py").unlink() # deleted AFTER indexing → DB row survives, file is gone
|
|
116
|
+
s = Searcher(db)
|
|
117
|
+
res = s.search("target", str(tmp_path), rerank="on", cosine_floor=-1.0)
|
|
118
|
+
|
|
119
|
+
assert isinstance(res, list) and res # no crash; still returns
|
|
120
|
+
gone = [r for r in res if r["path"] == "gone.py"]
|
|
121
|
+
assert gone and gone[0]["snippet"] == "[file not found]" # missing file → sentinel, scored 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_rerank_does_not_bleed_boost_into_neighbouring_def(tmp_path):
|
|
125
|
+
# load_settings (nearer cosine) sits right above parse_config; a naive 40-line read from
|
|
126
|
+
# load_settings' start bleeds into `def parse_config` and would steal its symbol boost. The
|
|
127
|
+
# next-stored-chunk bound must confine each candidate to its own span, so the chunk that
|
|
128
|
+
# actually DEFINES the queried symbol wins despite its worse cosine.
|
|
129
|
+
(tmp_path / "mod.py").write_text(
|
|
130
|
+
"def load_settings():\n" # 0 near cosine, does NOT define the query symbol
|
|
131
|
+
" return 1\n" # 1
|
|
132
|
+
"\n" # 2
|
|
133
|
+
"def parse_config(path):\n" # 3 far cosine, but DEFINES parse_config
|
|
134
|
+
" return open(path).read()\n" # 4
|
|
135
|
+
)
|
|
136
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
137
|
+
db = _mem_db()
|
|
138
|
+
Indexer(db).index(str(tmp_path))
|
|
139
|
+
res = Searcher(db).search("parse_config", str(tmp_path), rerank="on")
|
|
140
|
+
assert [r["line"] for r in res][0] == 3, "the chunk that actually defines the symbol must win"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_search_survives_bad_param_types(tmp_path):
|
|
144
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
145
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
146
|
+
db = _mem_db()
|
|
147
|
+
Indexer(db).index(str(tmp_path))
|
|
148
|
+
# non-int k / rerank_candidates must degrade to defaults, never raise (public API)
|
|
149
|
+
res = Searcher(db).search("parse_config", str(tmp_path), k="oops", rerank_candidates="lots")
|
|
150
|
+
assert isinstance(res, list) and {r["line"] for r in res} == {0, 3}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_rerank_falsey_values_treated_as_off(tmp_path):
|
|
154
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
155
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
156
|
+
db = _mem_db()
|
|
157
|
+
Indexer(db).index(str(tmp_path))
|
|
158
|
+
s = Searcher(db)
|
|
159
|
+
off = s.search("parse_config", str(tmp_path), rerank="off")
|
|
160
|
+
false_bool = s.search("parse_config", str(tmp_path), rerank=False)
|
|
161
|
+
assert [r["line"] for r in false_bool] == [r["line"] for r in off] == [3, 0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_candidate_limit_is_capped(tmp_path, monkeypatch):
|
|
165
|
+
import codeintel.searcher as sm
|
|
166
|
+
for i in range(6):
|
|
167
|
+
(tmp_path / f"f{i}.py").write_text(f"def fn{i}():\n return {i}\n")
|
|
168
|
+
monkeypatch.setattr(sm, "_RERANK_CANDIDATES_CAP", 2) # tiny cap for the test
|
|
169
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
170
|
+
db = _mem_db()
|
|
171
|
+
Indexer(db).index(str(tmp_path))
|
|
172
|
+
s = Searcher(db)
|
|
173
|
+
calls = {"n": 0}
|
|
174
|
+
orig = s._read_chunk
|
|
175
|
+
|
|
176
|
+
def counting(fp, cs):
|
|
177
|
+
calls["n"] += 1
|
|
178
|
+
return orig(fp, cs)
|
|
179
|
+
|
|
180
|
+
monkeypatch.setattr(s, "_read_chunk", counting)
|
|
181
|
+
s.search("fn1", str(tmp_path), k=1, rerank="on", rerank_candidates=1000)
|
|
182
|
+
assert calls["n"] <= 2, "candidate set must be clamped to the cap regardless of config"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_large_k_is_not_shrunk_by_cap(tmp_path, monkeypatch):
|
|
186
|
+
# the cap bounds only the extra rerank breadth (rerank_candidates); a large k must still be
|
|
187
|
+
# honored, so rerank-on never returns fewer results than pure cosine would for that k
|
|
188
|
+
import codeintel.searcher as sm
|
|
189
|
+
for i in range(6):
|
|
190
|
+
(tmp_path / f"f{i}.py").write_text(f"def fn{i}():\n return {i}\n")
|
|
191
|
+
monkeypatch.setattr(sm, "_RERANK_CANDIDATES_CAP", 2) # tiny cap, but k below must still win
|
|
192
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
193
|
+
db = _mem_db()
|
|
194
|
+
Indexer(db).index(str(tmp_path))
|
|
195
|
+
res = Searcher(db).search(
|
|
196
|
+
"fn1", str(tmp_path), k=5, rerank="on", rerank_candidates=1, cosine_floor=-1.0
|
|
197
|
+
)
|
|
198
|
+
assert len(res) == 5, "k must be honored despite a smaller cap/rerank_candidates"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_rerank_reads_are_bounded(tmp_path, monkeypatch):
|
|
202
|
+
for i in range(40):
|
|
203
|
+
(tmp_path / f"f{i}.py").write_text(f"def fn{i}():\n return {i}\n")
|
|
204
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
205
|
+
db = _mem_db()
|
|
206
|
+
Indexer(db).index(str(tmp_path))
|
|
207
|
+
s = Searcher(db)
|
|
208
|
+
calls = {"n": 0}
|
|
209
|
+
orig = s._read_chunk
|
|
210
|
+
|
|
211
|
+
def counting(fp, cs):
|
|
212
|
+
calls["n"] += 1
|
|
213
|
+
return orig(fp, cs)
|
|
214
|
+
|
|
215
|
+
monkeypatch.setattr(s, "_read_chunk", counting)
|
|
216
|
+
s.search("fn1", str(tmp_path), k=3, rerank="on", rerank_candidates=5)
|
|
217
|
+
|
|
218
|
+
assert calls["n"] <= 5, "rerank must read at most rerank_candidates chunks, not the whole repo"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --------------------------------------------------------------------------- scoring units
|
|
222
|
+
|
|
223
|
+
def test_tokenize_splits_camel_and_snake():
|
|
224
|
+
t = _tokenize("parseConfig load_settings HTTPServer")
|
|
225
|
+
assert {"parseconfig", "parse", "config",
|
|
226
|
+
"load_settings", "load", "settings",
|
|
227
|
+
"httpserver", "http", "server"} <= t
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_lexical_score_is_query_term_fraction():
|
|
231
|
+
q = _tokenize("parse_config") # {parse_config, parse, config}
|
|
232
|
+
assert Searcher._lexical_score(q, "def parse_config(): pass") == 1.0
|
|
233
|
+
assert Searcher._lexical_score(q, "def unrelated(): pass") == 0.0
|
|
234
|
+
assert Searcher._lexical_score(q, "please parse the text") == 1 / 3 # only 'parse' overlaps
|
|
235
|
+
assert Searcher._lexical_score(set(), "anything") == 0.0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_symbol_boost_prefers_def_name():
|
|
239
|
+
assert Searcher._symbol_boost("parse_config", "def parse_config(x): ...") == _SYMBOL_BOOST
|
|
240
|
+
assert Searcher._symbol_boost("parse_config", "y = parse_config()") == _SYMBOL_BOOST * 0.5
|
|
241
|
+
assert Searcher._symbol_boost("parse_config", "unrelated text") == 0.0
|
|
242
|
+
assert Searcher._symbol_boost("two words", "def two(): ...") == 0.0 # multi-word → no boost
|
|
243
|
+
assert Searcher._symbol_boost("", "def f(): ...") == 0.0
|
|
244
|
+
# case-insensitive, to match the lexical score's lowercasing
|
|
245
|
+
assert Searcher._symbol_boost("Parse_Config", "def parse_config(x): ...") == _SYMBOL_BOOST
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_rerank_default_is_on(tmp_path):
|
|
249
|
+
# the method default must match the config default so a direct caller gets reranking too
|
|
250
|
+
(tmp_path / "mod.py").write_text(_FIXTURE)
|
|
251
|
+
with patch("fastembed.TextEmbedding", _AngleEmbedding):
|
|
252
|
+
db = _mem_db()
|
|
253
|
+
Indexer(db).index(str(tmp_path))
|
|
254
|
+
default = Searcher(db).search("parse_config", str(tmp_path)) # no rerank kwarg
|
|
255
|
+
explicit = Searcher(db).search("parse_config", str(tmp_path), rerank="on")
|
|
256
|
+
assert [r["line"] for r in default] == [r["line"] for r in explicit] == [0, 3]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for SemanticProvider — covers all 4 USER_STORIES (7 test cases)."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib
|
|
4
5
|
import struct
|
|
5
6
|
from unittest.mock import MagicMock, patch
|
|
6
7
|
|
|
@@ -33,6 +34,53 @@ def _mem_db() -> SemanticDb:
|
|
|
33
34
|
return db
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
class _ContentEmbedding:
|
|
38
|
+
"""Content-addressed stub: the vector depends on the text, so a chunk whose content changed
|
|
39
|
+
yields a different embedding — lets a test detect whether a re-embed actually persisted
|
|
40
|
+
(the constant-vector _FakeTextEmbedding above cannot)."""
|
|
41
|
+
def __init__(self, model_name=None):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def embed(self, texts):
|
|
45
|
+
out = []
|
|
46
|
+
for t in list(texts):
|
|
47
|
+
h = int(hashlib.sha256(t.encode()).hexdigest()[:8], 16)
|
|
48
|
+
v = np.zeros(384, dtype=np.float32)
|
|
49
|
+
v[0] = (h % 100000) / 100000.0
|
|
50
|
+
v[1] = 1.0
|
|
51
|
+
out.append(v)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_changed_chunk_reembeds_at_stable_chunk_id(tmp_path):
|
|
56
|
+
# Regression: sqlite-vec's vec0 ignores INSERT OR REPLACE and raises UNIQUE, so re-embedding a
|
|
57
|
+
# chunk whose content changed but whose chunk_id (def start line) is stable must go
|
|
58
|
+
# DELETE-then-INSERT — else the stale vector is kept forever. Syntax chunking exposes this on
|
|
59
|
+
# every function-body edit (the def line, hence the chunk_id, doesn't move).
|
|
60
|
+
mod = tmp_path / "mod.py"
|
|
61
|
+
mod.write_text("def f():\n return 1\n")
|
|
62
|
+
|
|
63
|
+
def _vec(db):
|
|
64
|
+
row = db.conn().execute(
|
|
65
|
+
"SELECT embedding FROM code_embeddings WHERE chunk_id LIKE ?", ("%:mod.py:0",)
|
|
66
|
+
).fetchone()
|
|
67
|
+
return row[0] if row else None
|
|
68
|
+
|
|
69
|
+
with patch("fastembed.TextEmbedding", _ContentEmbedding):
|
|
70
|
+
db = _mem_db()
|
|
71
|
+
idx = Indexer(db)
|
|
72
|
+
assert idx.index(str(tmp_path)) > 0
|
|
73
|
+
before = _vec(db)
|
|
74
|
+
assert before is not None
|
|
75
|
+
|
|
76
|
+
mod.write_text("def f():\n return 999999\n") # body changed, def line (chunk_id) stable
|
|
77
|
+
assert idx.index(str(tmp_path)) > 0, "the changed chunk must re-embed, not silently fail"
|
|
78
|
+
after = _vec(db)
|
|
79
|
+
|
|
80
|
+
assert after is not None
|
|
81
|
+
assert before != after, "the stored embedding must UPDATE for changed content on a vec0 table"
|
|
82
|
+
|
|
83
|
+
|
|
36
84
|
# ---------------------------------------------------------------------------
|
|
37
85
|
# Story 4: availability check
|
|
38
86
|
# ---------------------------------------------------------------------------
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.6.0"
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
import struct
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from codeintel.semantic_db import SemanticDb
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
_SNIPPET_LINES = 5
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Searcher:
|
|
18
|
-
def __init__(
|
|
19
|
-
self,
|
|
20
|
-
db: SemanticDb,
|
|
21
|
-
model_name: str = "BAAI/bge-small-en-v1.5",
|
|
22
|
-
) -> None:
|
|
23
|
-
self.db = db
|
|
24
|
-
self.model_name = model_name
|
|
25
|
-
self._embedder = None
|
|
26
|
-
|
|
27
|
-
def _get_embedder(self):
|
|
28
|
-
if self._embedder is None:
|
|
29
|
-
from fastembed import TextEmbedding
|
|
30
|
-
self._embedder = TextEmbedding(model_name=self.model_name)
|
|
31
|
-
return self._embedder
|
|
32
|
-
|
|
33
|
-
def _embed_query(self, query: str) -> bytes | None:
|
|
34
|
-
try:
|
|
35
|
-
embedder = self._get_embedder()
|
|
36
|
-
vecs = list(embedder.embed([query]))
|
|
37
|
-
if not vecs:
|
|
38
|
-
return None
|
|
39
|
-
vec = vecs[0]
|
|
40
|
-
return struct.pack(f"{len(vec)}f", *vec)
|
|
41
|
-
except Exception as exc:
|
|
42
|
-
logger.warning("query embedding failed: %s", exc)
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
def _row_count(self, project_root_real: str) -> int:
|
|
46
|
-
try:
|
|
47
|
-
conn = self.db.conn()
|
|
48
|
-
row = conn.execute(
|
|
49
|
-
"SELECT COUNT(*) FROM chunk_hashes WHERE project_root = ?",
|
|
50
|
-
(project_root_real,),
|
|
51
|
-
).fetchone()
|
|
52
|
-
return row[0] if row else 0
|
|
53
|
-
except Exception as exc:
|
|
54
|
-
logger.warning("rowcount check failed: %s", exc)
|
|
55
|
-
return 0
|
|
56
|
-
|
|
57
|
-
def has_index(self, project_root: str) -> bool:
|
|
58
|
-
"""True when this project has at least one indexed chunk — lets the provider
|
|
59
|
-
distinguish 'nothing indexed yet' (no-index) from 'matches below floor'."""
|
|
60
|
-
return self._row_count(os.path.realpath(project_root)) > 0
|
|
61
|
-
|
|
62
|
-
def _read_snippet(self, file_path: Path, chunk_start: int) -> str:
|
|
63
|
-
try:
|
|
64
|
-
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
65
|
-
lines = f.readlines()
|
|
66
|
-
snippet_lines = lines[chunk_start: chunk_start + _SNIPPET_LINES]
|
|
67
|
-
return "".join(snippet_lines).rstrip()
|
|
68
|
-
except FileNotFoundError:
|
|
69
|
-
return "[file not found]"
|
|
70
|
-
except Exception as exc:
|
|
71
|
-
logger.debug("snippet read failed for %s:%d: %s", file_path, chunk_start, exc)
|
|
72
|
-
return "[file not found]"
|
|
73
|
-
|
|
74
|
-
def search(
|
|
75
|
-
self,
|
|
76
|
-
query: str,
|
|
77
|
-
project_root: str,
|
|
78
|
-
k: int = 10,
|
|
79
|
-
cosine_floor: float = 0.25,
|
|
80
|
-
) -> list[dict]:
|
|
81
|
-
if not query or not query.strip():
|
|
82
|
-
return []
|
|
83
|
-
|
|
84
|
-
k = max(1, k)
|
|
85
|
-
project_root_real = os.path.realpath(project_root)
|
|
86
|
-
|
|
87
|
-
# Scope the KNN to THIS project — a search in repo B must never surface repo A's
|
|
88
|
-
# chunks (wrong-file, wrong-content hits) from the shared cache.
|
|
89
|
-
if self._row_count(project_root_real) == 0:
|
|
90
|
-
return []
|
|
91
|
-
|
|
92
|
-
query_vec = self._embed_query(query)
|
|
93
|
-
if query_vec is None:
|
|
94
|
-
return []
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
conn = self.db.conn()
|
|
98
|
-
rows = conn.execute(
|
|
99
|
-
"""
|
|
100
|
-
SELECT
|
|
101
|
-
ce.chunk_id,
|
|
102
|
-
ch.chunk_start,
|
|
103
|
-
ch.file_path,
|
|
104
|
-
vec_distance_cosine(ce.embedding, ?) AS dist
|
|
105
|
-
FROM code_embeddings ce
|
|
106
|
-
JOIN chunk_hashes ch ON ce.chunk_id = ch.chunk_id
|
|
107
|
-
WHERE ch.project_root = ?
|
|
108
|
-
ORDER BY dist
|
|
109
|
-
LIMIT ?
|
|
110
|
-
""",
|
|
111
|
-
(query_vec, project_root_real, k),
|
|
112
|
-
).fetchall()
|
|
113
|
-
except Exception as exc:
|
|
114
|
-
logger.warning("KNN query failed: %s", exc)
|
|
115
|
-
return []
|
|
116
|
-
|
|
117
|
-
root = Path(project_root)
|
|
118
|
-
results: list[dict] = []
|
|
119
|
-
|
|
120
|
-
for row in rows:
|
|
121
|
-
try:
|
|
122
|
-
dist = float(row["dist"])
|
|
123
|
-
score = 1.0 - dist
|
|
124
|
-
if score < cosine_floor:
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
chunk_start = int(row["chunk_start"])
|
|
128
|
-
rel_path = str(row["file_path"])
|
|
129
|
-
abs_path = root / rel_path
|
|
130
|
-
|
|
131
|
-
snippet = self._read_snippet(abs_path, chunk_start)
|
|
132
|
-
|
|
133
|
-
results.append({
|
|
134
|
-
"path": rel_path,
|
|
135
|
-
"line": chunk_start,
|
|
136
|
-
"snippet": snippet,
|
|
137
|
-
"score": round(score, 6),
|
|
138
|
-
})
|
|
139
|
-
except Exception as exc:
|
|
140
|
-
logger.debug("result row processing failed: %s", exc)
|
|
141
|
-
continue
|
|
142
|
-
|
|
143
|
-
return results
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|