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.
Files changed (66) hide show
  1. {codecortex-0.6.0/src/codecortex.egg-info → codecortex-0.7.0}/PKG-INFO +1 -1
  2. {codecortex-0.6.0 → codecortex-0.7.0/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/SOURCES.txt +1 -0
  4. codecortex-0.7.0/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/config.py +4 -1
  6. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/doctor.py +5 -0
  7. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/indexer.py +10 -2
  8. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/graph.py +3 -1
  9. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/lsp.py +3 -1
  10. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/semantic.py +4 -1
  11. codecortex-0.7.0/src/codeintel/searcher.py +311 -0
  12. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_config.py +11 -0
  13. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_doctor.py +12 -0
  14. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_integration.py +11 -5
  15. codecortex-0.7.0/tests/test_rerank.py +256 -0
  16. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_semantic_provider.py +48 -0
  17. codecortex-0.6.0/src/codeintel/__init__.py +0 -1
  18. codecortex-0.6.0/src/codeintel/searcher.py +0 -143
  19. {codecortex-0.6.0 → codecortex-0.7.0}/LICENSE +0 -0
  20. {codecortex-0.6.0 → codecortex-0.7.0}/README.md +0 -0
  21. {codecortex-0.6.0 → codecortex-0.7.0}/pyproject.toml +0 -0
  22. {codecortex-0.6.0 → codecortex-0.7.0}/setup.cfg +0 -0
  23. {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  24. {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  25. {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/requires.txt +0 -0
  26. {codecortex-0.6.0 → codecortex-0.7.0}/src/codecortex.egg-info/top_level.txt +0 -0
  27. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/__main__.py +0 -0
  28. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/auth.py +0 -0
  29. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/cache.py +0 -0
  30. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/gateway.py +0 -0
  31. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/http_server.py +0 -0
  32. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/injector.py +0 -0
  33. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/installer.py +0 -0
  34. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/logconfig.py +0 -0
  35. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/mapper.py +0 -0
  36. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/metrics.py +0 -0
  37. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/onboarding.py +0 -0
  38. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/policy.py +0 -0
  39. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/provider.py +0 -0
  40. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/__init__.py +0 -0
  41. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/providers/none.py +0 -0
  42. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/reindexer.py +0 -0
  43. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/reset.py +0 -0
  44. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/semantic_db.py +0 -0
  45. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/server.py +0 -0
  46. {codecortex-0.6.0 → codecortex-0.7.0}/src/codeintel/term.py +0 -0
  47. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_cache.py +0 -0
  48. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_chunking.py +0 -0
  49. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_e2e.py +0 -0
  50. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_enterprise.py +0 -0
  51. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_gateway.py +0 -0
  52. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_provider.py +0 -0
  53. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_real.py +0 -0
  54. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_graph_stdin.py +0 -0
  55. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_hardening.py +0 -0
  56. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_http_auth.py +0 -0
  57. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_http_server.py +0 -0
  58. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_lsp_provider.py +0 -0
  59. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_lsp_real.py +0 -0
  60. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_mapper.py +0 -0
  61. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_never_raise.py +0 -0
  62. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_onboarding.py +0 -0
  63. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_rbac.py +0 -0
  64. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_reindexer.py +0 -0
  65. {codecortex-0.6.0 → codecortex-0.7.0}/tests/test_reset.py +0 -0
  66. {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.6.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.6.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
@@ -56,6 +56,7 @@ tests/test_never_raise.py
56
56
  tests/test_onboarding.py
57
57
  tests/test_rbac.py
58
58
  tests/test_reindexer.py
59
+ tests/test_rerank.py
59
60
  tests/test_reset.py
60
61
  tests/test_semantic_provider.py
61
62
  tests/test_term.py
@@ -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
- "INSERT OR REPLACE INTO code_embeddings(chunk_id, embedding)"
385
- " VALUES (?, ?)",
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": "install codebase-memory-mcp (the graph backend)",
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) serena is fetched on first use",
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, cosine_floor=float(cfg.get("cosine_floor", 0.25))
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("cosine_floor = 0.99\n")
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["floor"] = cosine_floor
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
- assert captured.get("floor") == 0.99 # the config value reached the searcher
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