rag-your-code 0.4.1__py3-none-any.whl
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.
- rag_your_code-0.4.1.dist-info/METADATA +237 -0
- rag_your_code-0.4.1.dist-info/RECORD +19 -0
- rag_your_code-0.4.1.dist-info/WHEEL +5 -0
- rag_your_code-0.4.1.dist-info/entry_points.txt +2 -0
- rag_your_code-0.4.1.dist-info/licenses/LICENSE +21 -0
- rag_your_code-0.4.1.dist-info/top_level.txt +1 -0
- ragyourcode/__init__.py +6 -0
- ragyourcode/agentic.py +55 -0
- ragyourcode/annotate.py +35 -0
- ragyourcode/cli.py +575 -0
- ragyourcode/config.py +572 -0
- ragyourcode/descriptions.py +248 -0
- ragyourcode/embeddings.py +60 -0
- ragyourcode/graph.py +198 -0
- ragyourcode/indexer.py +411 -0
- ragyourcode/models.py +86 -0
- ragyourcode/parser.py +485 -0
- ragyourcode/py.typed +0 -0
- ragyourcode/search.py +131 -0
ragyourcode/indexer.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Repository walking and index construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import struct
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from array import array
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import config as config_module
|
|
16
|
+
from .annotate import comment_for
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .descriptions import DescriptionStore, index_descriptions_fingerprint
|
|
19
|
+
from .embeddings import embed, embedding_metadata
|
|
20
|
+
from .models import CodeUnit
|
|
21
|
+
from .parser import parse_file
|
|
22
|
+
|
|
23
|
+
# These names predate the configuration layer and several tests import them.
|
|
24
|
+
# They are derived from the settings table rather than restated, so there is
|
|
25
|
+
# still exactly one place a default is written down.
|
|
26
|
+
DEFAULT_IGNORES = set(config_module.BY_PATH["index.ignore"].default)
|
|
27
|
+
SOURCE_SUFFIXES = set(config_module.BY_PATH["index.suffixes"].default)
|
|
28
|
+
MAX_SOURCE_BYTES = config_module.BY_PATH["index.max_file_bytes"].default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve(root: Path, cfg: Config | None) -> Config:
|
|
32
|
+
"""Fall back to the repository's own configuration file.
|
|
33
|
+
|
|
34
|
+
Callers that do not pass one get the same settings the CLI would use, so a
|
|
35
|
+
library caller and a command line invocation cannot disagree about which
|
|
36
|
+
files are source.
|
|
37
|
+
"""
|
|
38
|
+
return cfg if cfg is not None else config_module.load(root)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StaleMonitor:
|
|
42
|
+
"""Rate-limit repository stat walks while allowing forced checks."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
root: Path,
|
|
47
|
+
payload: dict,
|
|
48
|
+
interval_seconds: float = 1.0,
|
|
49
|
+
assume_checked: bool = False,
|
|
50
|
+
cfg: Config | None = None,
|
|
51
|
+
descriptions_fingerprint: str | None = None,
|
|
52
|
+
):
|
|
53
|
+
self.root = root
|
|
54
|
+
self.payload = payload
|
|
55
|
+
self.cfg = _resolve(root, cfg)
|
|
56
|
+
self.interval_seconds = max(0.0, interval_seconds)
|
|
57
|
+
self.last_checked = time.monotonic() if assume_checked else 0.0
|
|
58
|
+
# Neither authored input is an indexed source, so nothing in the
|
|
59
|
+
# file-stat comparison below can see either one change. An index built
|
|
60
|
+
# under different suffixes, ignores or vector width describes a
|
|
61
|
+
# different corpus; an index built from different descriptions serves
|
|
62
|
+
# text nobody wrote any more. Both are stale regardless of the walk.
|
|
63
|
+
self.config_changed = index_config_fingerprint(payload) != self.cfg.build_fingerprint
|
|
64
|
+
self.inputs_changed = self.config_changed or (
|
|
65
|
+
descriptions_fingerprint is not None and index_descriptions_fingerprint(payload) != descriptions_fingerprint
|
|
66
|
+
)
|
|
67
|
+
self.value = self.inputs_changed or bool(payload.get("stale", True))
|
|
68
|
+
|
|
69
|
+
def check(self, force: bool = False) -> bool:
|
|
70
|
+
if self.inputs_changed:
|
|
71
|
+
self.payload["stale"] = True
|
|
72
|
+
return True
|
|
73
|
+
now = time.monotonic()
|
|
74
|
+
if not force and self.last_checked and now - self.last_checked < self.interval_seconds:
|
|
75
|
+
return self.value
|
|
76
|
+
try:
|
|
77
|
+
stored_stats = self.payload.get("file_stats")
|
|
78
|
+
if isinstance(stored_stats, dict):
|
|
79
|
+
self.value = stored_stats != file_stats(self.root, self.cfg)
|
|
80
|
+
else:
|
|
81
|
+
self.value = self.payload.get("fingerprint") != fingerprint(self.root, self.cfg)
|
|
82
|
+
except OSError:
|
|
83
|
+
self.value = True
|
|
84
|
+
self.last_checked = now
|
|
85
|
+
self.payload["stale"] = self.value
|
|
86
|
+
return self.value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def index_config_fingerprint(payload: dict) -> str:
|
|
90
|
+
"""The build fingerprint an index was published with.
|
|
91
|
+
|
|
92
|
+
An index written before 0.4.0 carries no such key, and by construction it
|
|
93
|
+
was built with the built-in defaults, so that is what a missing key means.
|
|
94
|
+
Treating it as unknown instead would force one pointless full rebuild on
|
|
95
|
+
every upgrade.
|
|
96
|
+
"""
|
|
97
|
+
stored = payload.get("config_fingerprint")
|
|
98
|
+
return stored if isinstance(stored, str) else config_module.defaults().build_fingerprint
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def iter_source_files(root: Path, cfg: Config | None = None):
|
|
102
|
+
cfg = _resolve(root, cfg)
|
|
103
|
+
ignored = set(cfg["index.ignore"])
|
|
104
|
+
suffixes = {suffix.lower() for suffix in cfg["index.suffixes"]}
|
|
105
|
+
max_bytes = cfg["index.max_file_bytes"]
|
|
106
|
+
for directory, dirs, files in os.walk(root):
|
|
107
|
+
dirs[:] = sorted(name for name in dirs if name not in ignored and not name.startswith("."))
|
|
108
|
+
for filename in sorted(files):
|
|
109
|
+
path = Path(directory) / filename
|
|
110
|
+
if path.suffix.lower() not in suffixes or path.is_symlink():
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
if path.stat().st_size > max_bytes:
|
|
114
|
+
continue
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
yield path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class RepositorySnapshot:
|
|
122
|
+
"""One walk of the repository, shared by parsing and by publication.
|
|
123
|
+
|
|
124
|
+
Parsing from one walk while publishing hashes from a second is what let an
|
|
125
|
+
index record a file's NEW hash beside units parsed from its OLD content: a
|
|
126
|
+
save landing between the two walks was invisible, `fingerprint` then
|
|
127
|
+
reported the index fresh, and every later incremental run reused the stale
|
|
128
|
+
units forever. Taking the snapshot once removes the window rather than
|
|
129
|
+
narrowing it, and drops a run from four tree walks to two.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
paths: tuple[Path, ...]
|
|
133
|
+
fingerprints: dict[str, str]
|
|
134
|
+
stats: dict[str, list[int]]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def fingerprint(self) -> str:
|
|
138
|
+
return _fingerprint_files(self.fingerprints)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def snapshot_repository(root: Path, cfg: Config | None = None) -> RepositorySnapshot:
|
|
142
|
+
"""Hash, stat and collect every source file in a single pass."""
|
|
143
|
+
paths: list[Path] = []
|
|
144
|
+
fingerprints: dict[str, str] = {}
|
|
145
|
+
stats: dict[str, list[int]] = {}
|
|
146
|
+
for path in iter_source_files(root, cfg):
|
|
147
|
+
try:
|
|
148
|
+
stat = path.stat()
|
|
149
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
150
|
+
except OSError:
|
|
151
|
+
# A file that vanished or became unreadable between the walk and the
|
|
152
|
+
# read simply is not in this snapshot. Recording a half-read entry
|
|
153
|
+
# would reintroduce exactly the parse/publish mismatch this type exists
|
|
154
|
+
# to prevent.
|
|
155
|
+
continue
|
|
156
|
+
relative = path.relative_to(root).as_posix()
|
|
157
|
+
paths.append(path)
|
|
158
|
+
fingerprints[relative] = digest
|
|
159
|
+
stats[relative] = [stat.st_size, stat.st_mtime_ns]
|
|
160
|
+
return RepositorySnapshot(tuple(paths), fingerprints, stats)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def file_fingerprints(root: Path, cfg: Config | None = None) -> dict[str, str]:
|
|
164
|
+
return snapshot_repository(root, cfg).fingerprints
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def file_stats(root: Path, cfg: Config | None = None) -> dict[str, list[int]]:
|
|
168
|
+
"""Size and mtime only -- deliberately NOT routed through the snapshot.
|
|
169
|
+
|
|
170
|
+
StaleMonitor asks "has anything changed?" many times per session and needs
|
|
171
|
+
no content consistency with a parse. Routing it through snapshot_repository
|
|
172
|
+
made every stale check SHA-256 the whole repository, which measured a 2.5x
|
|
173
|
+
regression (69 ms -> 172 ms at 10k units).
|
|
174
|
+
"""
|
|
175
|
+
result: dict[str, list[int]] = {}
|
|
176
|
+
for path in iter_source_files(root, cfg):
|
|
177
|
+
try:
|
|
178
|
+
stat = path.stat()
|
|
179
|
+
except OSError:
|
|
180
|
+
# A file that disappeared mid-walk is simply absent from this
|
|
181
|
+
# reading; the next check will see the directory as it then is.
|
|
182
|
+
continue
|
|
183
|
+
result[path.relative_to(root).as_posix()] = [stat.st_size, stat.st_mtime_ns]
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _assign_global_serials(units: list[CodeUnit], previous: dict[str, int] | None = None) -> None:
|
|
188
|
+
previous = previous or {}
|
|
189
|
+
used: set[int] = set()
|
|
190
|
+
assigned: set[str] = set()
|
|
191
|
+
next_serial = max(previous.values(), default=0) + 1
|
|
192
|
+
for unit in sorted(units, key=lambda item: (item.path, item.start_line, item.qualified_name, item.id)):
|
|
193
|
+
old = previous.get(unit.id)
|
|
194
|
+
if old is not None and old > 0 and old not in used:
|
|
195
|
+
unit.serial = old
|
|
196
|
+
used.add(old)
|
|
197
|
+
assigned.add(unit.id)
|
|
198
|
+
for unit in sorted(units, key=lambda item: (item.path, item.start_line, item.qualified_name, item.id)):
|
|
199
|
+
if unit.id in assigned:
|
|
200
|
+
continue
|
|
201
|
+
while next_serial in used:
|
|
202
|
+
next_serial += 1
|
|
203
|
+
unit.serial = next_serial
|
|
204
|
+
used.add(next_serial)
|
|
205
|
+
next_serial += 1
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_units(
|
|
209
|
+
root: Path,
|
|
210
|
+
previous_units: list[CodeUnit] | None = None,
|
|
211
|
+
previous_files: dict[str, str] | None = None,
|
|
212
|
+
diagnostics: list[dict] | None = None,
|
|
213
|
+
snapshot: RepositorySnapshot | None = None,
|
|
214
|
+
cfg: Config | None = None,
|
|
215
|
+
previous_config: str | None = None,
|
|
216
|
+
descriptions: "DescriptionStore | None" = None,
|
|
217
|
+
) -> list[CodeUnit]:
|
|
218
|
+
"""Build units, reusing unchanged files and stable serials when possible.
|
|
219
|
+
|
|
220
|
+
Pass the same ``snapshot`` to ``write_index`` so the hashes published
|
|
221
|
+
describe exactly the bytes these units were parsed from.
|
|
222
|
+
|
|
223
|
+
``previous_config`` is the build fingerprint the previous index was
|
|
224
|
+
published with. When it disagrees with the current one the previous units
|
|
225
|
+
describe a different corpus -- different suffixes, ignores, size cap or
|
|
226
|
+
vector width -- so they are discarded rather than reused. Reuse is keyed on
|
|
227
|
+
file content, which cannot notice that the rules changed.
|
|
228
|
+
"""
|
|
229
|
+
cfg = _resolve(root, cfg)
|
|
230
|
+
if previous_config is not None and previous_config != cfg.build_fingerprint:
|
|
231
|
+
previous_units, previous_files = None, None
|
|
232
|
+
dimensions = cfg["embedding.dimensions"]
|
|
233
|
+
snapshot = snapshot or snapshot_repository(root, cfg)
|
|
234
|
+
current_files = snapshot.fingerprints
|
|
235
|
+
old_by_path: dict[str, list[CodeUnit]] = {}
|
|
236
|
+
for unit in previous_units or []:
|
|
237
|
+
old_by_path.setdefault(unit.path, []).append(unit)
|
|
238
|
+
units: list[CodeUnit] = []
|
|
239
|
+
for path in snapshot.paths:
|
|
240
|
+
relative = path.relative_to(root).as_posix()
|
|
241
|
+
if previous_files and previous_files.get(relative) == current_files.get(relative) and relative in old_by_path:
|
|
242
|
+
units.extend(replace(unit) for unit in old_by_path[relative])
|
|
243
|
+
else:
|
|
244
|
+
units.extend(parse_file(path, root, diagnostics))
|
|
245
|
+
previous_serials = {unit.id: unit.serial for unit in previous_units or []}
|
|
246
|
+
_assign_global_serials(units, previous_serials)
|
|
247
|
+
# Authored descriptions are checked against the units they describe, which
|
|
248
|
+
# is why the store is filtered here rather than by the caller: the digest
|
|
249
|
+
# comparison needs the parsed units, and the parsed units do not exist
|
|
250
|
+
# until this point.
|
|
251
|
+
authored = descriptions.applicable(units) if descriptions is not None else {}
|
|
252
|
+
for unit in units:
|
|
253
|
+
# The generated description is replaced before the vector is computed.
|
|
254
|
+
# `description` is part of `searchable_text`, so embedding first would
|
|
255
|
+
# produce a vector for the sentence the agent replaced.
|
|
256
|
+
text = authored.get(unit.id)
|
|
257
|
+
if text and unit.description != text:
|
|
258
|
+
unit.description = text
|
|
259
|
+
unit.vector = []
|
|
260
|
+
if previous_serials.get(unit.id, unit.serial) != unit.serial or len(unit.vector) != dimensions:
|
|
261
|
+
unit.vector = []
|
|
262
|
+
if not unit.vector:
|
|
263
|
+
# Embed the numbered sidecar comment together with source/context so
|
|
264
|
+
# retrieval is grounded in the same records users can review.
|
|
265
|
+
unit.vector = embed(comment_for(unit.description, unit.serial, unit.id) + "\n" + unit.searchable_text, dimensions)
|
|
266
|
+
return sorted(units, key=lambda item: (item.serial, item.id))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def fingerprint(root: Path, cfg: Config | None = None) -> str:
|
|
270
|
+
return _fingerprint_files(file_fingerprints(root, cfg))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _fingerprint_files(files: dict[str, str]) -> str:
|
|
274
|
+
digest = hashlib.sha256()
|
|
275
|
+
for relative, file_hash in sorted(files.items()):
|
|
276
|
+
digest.update(relative.encode())
|
|
277
|
+
digest.update(file_hash.encode())
|
|
278
|
+
return digest.hexdigest()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def write_index(
|
|
282
|
+
path: Path,
|
|
283
|
+
root: Path,
|
|
284
|
+
units: list[CodeUnit],
|
|
285
|
+
graph: dict | None = None,
|
|
286
|
+
compact: bool = False,
|
|
287
|
+
diagnostics: list[dict] | None = None,
|
|
288
|
+
snapshot: RepositorySnapshot | None = None,
|
|
289
|
+
cfg: Config | None = None,
|
|
290
|
+
descriptions_fingerprint: str | None = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
cfg = _resolve(root, cfg)
|
|
293
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
snapshot = snapshot or snapshot_repository(root, cfg)
|
|
295
|
+
files = snapshot.fingerprints
|
|
296
|
+
repository_fingerprint = snapshot.fingerprint
|
|
297
|
+
dimensions = len(units[0].vector) if units and units[0].vector else cfg["embedding.dimensions"]
|
|
298
|
+
serialized_units = [unit.to_dict(include_vector=not compact) for unit in units]
|
|
299
|
+
vector_store = None
|
|
300
|
+
if compact and units:
|
|
301
|
+
vector_temp = path.with_name(f"{path.stem}.vectors.{os.getpid()}.tmp")
|
|
302
|
+
vector_digest = hashlib.sha256()
|
|
303
|
+
with vector_temp.open("wb") as stream:
|
|
304
|
+
for unit in units:
|
|
305
|
+
vector = unit.vector or [0.0] * dimensions
|
|
306
|
+
if len(vector) != dimensions:
|
|
307
|
+
raise ValueError("all vectors must have the same dimensions")
|
|
308
|
+
packed = struct.pack(f"<{dimensions}f", *vector)
|
|
309
|
+
vector_digest.update(packed)
|
|
310
|
+
stream.write(packed)
|
|
311
|
+
vector_path = path.with_name(
|
|
312
|
+
f"{path.stem}.{repository_fingerprint[:8]}.{vector_digest.hexdigest()[:16]}.vectors.bin"
|
|
313
|
+
)
|
|
314
|
+
vector_temp.replace(vector_path)
|
|
315
|
+
vector_store = {
|
|
316
|
+
"path": vector_path.name,
|
|
317
|
+
"dimensions": dimensions,
|
|
318
|
+
"dtype": "float32-le",
|
|
319
|
+
"count": len(units),
|
|
320
|
+
}
|
|
321
|
+
payload = {
|
|
322
|
+
"schema": 2,
|
|
323
|
+
"root": str(root.resolve()),
|
|
324
|
+
"fingerprint": repository_fingerprint,
|
|
325
|
+
"config_fingerprint": cfg.build_fingerprint,
|
|
326
|
+
"descriptions_fingerprint": descriptions_fingerprint,
|
|
327
|
+
"files": files,
|
|
328
|
+
"file_stats": snapshot.stats,
|
|
329
|
+
"dimensions": dimensions,
|
|
330
|
+
"embedding": embedding_metadata(dimensions),
|
|
331
|
+
"units": serialized_units,
|
|
332
|
+
"graph": graph or {"edges": []},
|
|
333
|
+
"diagnostics": diagnostics or [],
|
|
334
|
+
}
|
|
335
|
+
if vector_store:
|
|
336
|
+
payload["vector_store"] = vector_store
|
|
337
|
+
index_temp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
338
|
+
with index_temp.open("w", encoding="utf-8", newline="\n") as stream:
|
|
339
|
+
json.dump(payload, stream, ensure_ascii=False, indent=2)
|
|
340
|
+
index_temp.replace(path)
|
|
341
|
+
_remove_superseded_vectors(path, vector_store["path"] if vector_store else None)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _remove_superseded_vectors(path: Path, active_name: str | None) -> None:
|
|
345
|
+
"""Delete this index's own outdated vector sidecars.
|
|
346
|
+
|
|
347
|
+
The set of files to remove is derived from the naming scheme ``write_index``
|
|
348
|
+
itself uses (``<stem>.<fingerprint>.<digest>.vectors.bin``) and never from a
|
|
349
|
+
path read back out of the index being replaced. That index lives inside the
|
|
350
|
+
repository being scanned, so a repository can ship one; trusting its
|
|
351
|
+
``vector_store.path`` turned publication into an arbitrary in-tree delete.
|
|
352
|
+
Enumerating instead of trusting also reclaims sidecars orphaned by an
|
|
353
|
+
earlier run whose index.json was unreadable or absent.
|
|
354
|
+
"""
|
|
355
|
+
prefix, suffix = f"{path.stem}.", ".vectors.bin"
|
|
356
|
+
try:
|
|
357
|
+
candidates = list(path.parent.iterdir())
|
|
358
|
+
except OSError:
|
|
359
|
+
# The directory was just written to successfully, so an unreadable
|
|
360
|
+
# parent here means a concurrent removal. Cleanup is best-effort by
|
|
361
|
+
# design; the freshly published index is already valid without it.
|
|
362
|
+
return
|
|
363
|
+
for candidate in candidates:
|
|
364
|
+
if candidate.name == active_name or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
candidate.unlink()
|
|
368
|
+
except OSError:
|
|
369
|
+
# Sidecars are content-addressed, so one that cannot be unlinked
|
|
370
|
+
# (a concurrent reader holding it open on Windows) is inert: no
|
|
371
|
+
# index points at it. Leaking a file beats failing the publish.
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def read_index(path: Path) -> tuple[dict, list[CodeUnit]]:
|
|
376
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
377
|
+
if not isinstance(payload, dict):
|
|
378
|
+
raise ValueError("index root must be a JSON object")
|
|
379
|
+
stored_units = payload.get("units", [])
|
|
380
|
+
if not isinstance(stored_units, list) or any(not isinstance(item, dict) for item in stored_units):
|
|
381
|
+
raise ValueError("index units must be a list of objects")
|
|
382
|
+
raw_units = [dict(item) for item in stored_units]
|
|
383
|
+
vector_store = payload.get("vector_store")
|
|
384
|
+
payload["degraded"] = None
|
|
385
|
+
if isinstance(vector_store, dict):
|
|
386
|
+
vector_path = (path.parent / str(vector_store.get("path", ""))).resolve()
|
|
387
|
+
dimensions = int(vector_store.get("dimensions", payload.get("dimensions", 384)))
|
|
388
|
+
count = len(raw_units)
|
|
389
|
+
try:
|
|
390
|
+
vector_path.relative_to(path.parent.resolve())
|
|
391
|
+
if not 32 <= dimensions <= 4096:
|
|
392
|
+
raise ValueError("invalid vector dimensions")
|
|
393
|
+
raw_vectors = vector_path.read_bytes()
|
|
394
|
+
expected = count * dimensions * 4
|
|
395
|
+
if len(raw_vectors) != expected:
|
|
396
|
+
raise ValueError("vector sidecar length does not match index")
|
|
397
|
+
if sys.byteorder == "little":
|
|
398
|
+
values = memoryview(raw_vectors).cast("f")
|
|
399
|
+
else: # The sidecar contract is explicitly little-endian.
|
|
400
|
+
native_values = array("f")
|
|
401
|
+
native_values.frombytes(raw_vectors)
|
|
402
|
+
native_values.byteswap()
|
|
403
|
+
values = memoryview(native_values)
|
|
404
|
+
for index, item in enumerate(raw_units):
|
|
405
|
+
start = index * dimensions
|
|
406
|
+
item["vector"] = values[start : start + dimensions]
|
|
407
|
+
except (OSError, ValueError, struct.error):
|
|
408
|
+
payload["degraded"] = "vector_store_unavailable"
|
|
409
|
+
for item in raw_units:
|
|
410
|
+
item["vector"] = []
|
|
411
|
+
return payload, [CodeUnit.from_dict(item) for item in raw_units]
|
ragyourcode/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Data contracts used by indexing, storage, and agent integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class CodeUnit:
|
|
12
|
+
"""One searchable code unit and its generated, explainable description."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
path: str
|
|
16
|
+
language: str
|
|
17
|
+
kind: str
|
|
18
|
+
name: str
|
|
19
|
+
qualified_name: str
|
|
20
|
+
signature: str
|
|
21
|
+
start_line: int
|
|
22
|
+
end_line: int
|
|
23
|
+
source: str
|
|
24
|
+
description: str
|
|
25
|
+
serial: int
|
|
26
|
+
parent: str | None = None
|
|
27
|
+
calls: list[str] = field(default_factory=list)
|
|
28
|
+
imports: list[str] = field(default_factory=list)
|
|
29
|
+
vector: Sequence[float] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def searchable_text(self) -> str:
|
|
33
|
+
return "\n".join(
|
|
34
|
+
(
|
|
35
|
+
f"{self.qualified_name} {self.kind} {self.signature}",
|
|
36
|
+
self.description,
|
|
37
|
+
"calls: " + " ".join(self.calls),
|
|
38
|
+
"imports: " + " ".join(self.imports),
|
|
39
|
+
self.source,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def to_dict(self, include_vector: bool = True) -> dict[str, Any]:
|
|
44
|
+
data = {
|
|
45
|
+
"id": self.id,
|
|
46
|
+
"path": self.path,
|
|
47
|
+
"language": self.language,
|
|
48
|
+
"kind": self.kind,
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"qualified_name": self.qualified_name,
|
|
51
|
+
"signature": self.signature,
|
|
52
|
+
"start_line": self.start_line,
|
|
53
|
+
"end_line": self.end_line,
|
|
54
|
+
"source": self.source,
|
|
55
|
+
"description": self.description,
|
|
56
|
+
"serial": self.serial,
|
|
57
|
+
"parent": self.parent,
|
|
58
|
+
"calls": self.calls,
|
|
59
|
+
"imports": self.imports,
|
|
60
|
+
}
|
|
61
|
+
if include_vector:
|
|
62
|
+
data["vector"] = list(self.vector)
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> "CodeUnit":
|
|
67
|
+
return cls(**data)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class SearchResult:
|
|
72
|
+
unit: CodeUnit
|
|
73
|
+
score: float
|
|
74
|
+
matched_terms: list[str] = field(default_factory=list)
|
|
75
|
+
evidence: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
# Vectors are persisted for ranking but are an internal detail of the
|
|
79
|
+
# index; returning them would needlessly consume an agent's context.
|
|
80
|
+
unit = self.unit.to_dict(include_vector=False)
|
|
81
|
+
return {
|
|
82
|
+
"score": round(self.score, 6),
|
|
83
|
+
"matched_terms": self.matched_terms,
|
|
84
|
+
"evidence": self.evidence,
|
|
85
|
+
"unit": unit,
|
|
86
|
+
}
|