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.
@@ -0,0 +1,248 @@
1
+ """Agent-authored unit descriptions.
2
+
3
+ `annotate.py` says it in its own first line: it describes a unit *without an
4
+ LLM*. It humanises the identifier, lists parameter and callee names, and
5
+ appends the docstring verbatim -- so it introduces no vocabulary that was not
6
+ already in the source. That is precisely why retrieval cannot reach a concept
7
+ the author never wrote down: the embedder is a feature hash, so cosine measures
8
+ token overlap, and a query sharing no token with a unit scores exactly zero
9
+ against it.
10
+
11
+ The agent already consuming this index can write those words. This module
12
+ stores what it writes, keyed by unit id **and a digest of the unit's source**,
13
+ so a description can never be applied to code it does not describe. When the
14
+ source moves, the entry is retained but not used, and the unit reappears in the
15
+ pending queue; incremental indexing means only the units in changed files do.
16
+
17
+ Stored at the repository root as ``rag-your-code.descriptions.json``, beside
18
+ ``rag-your-code.toml`` and for the same two reasons: it is authored rather than
19
+ generated, and ``.rag-your-code/`` is both ignored by Git and the directory
20
+ people delete to clear the cache.
21
+
22
+ What this is, and is not: it moves the semantic work from query time to index
23
+ time. Matching stays lexical. A description saying `retry` still cannot answer
24
+ a query saying `resend` unless the description also says `resend`, which is why
25
+ the guidance handed to the agent asks for the words a reader would search by
26
+ rather than a restatement of the code.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ from .models import CodeUnit
37
+
38
+ STORE_FILENAME = "rag-your-code.descriptions.json"
39
+ SCHEMA = 1
40
+
41
+
42
+ def source_key(unit: CodeUnit) -> str:
43
+ """Digest of the exact text a description was written about."""
44
+ return hashlib.sha256(unit.source.encode("utf-8")).hexdigest()[:16]
45
+
46
+
47
+ def guidance(languages: tuple[str, ...] | list[str], max_chars: int) -> str:
48
+ """The instruction handed to the agent with every pending batch.
49
+
50
+ It is returned by the protocol rather than living only in SKILL.md so that
51
+ an agent reaching this index through any host receives the same brief, and
52
+ so the brief cannot drift away from the ``describe.*`` settings that shape
53
+ it.
54
+ """
55
+ names = {"en": "English", "zh": "Chinese"}
56
+ written = ", ".join(names.get(code, code) for code in languages)
57
+ return (
58
+ f"Write one description per unit, in {written}, at most {max_chars} characters total. "
59
+ "Retrieval over these descriptions is lexical: a query matches a unit only when they "
60
+ "share words. So write the words a person would search by -- what the unit is for in "
61
+ "domain terms, the operation it performs, the failure it handles, and the obvious "
62
+ "synonyms for each. Do not restate the signature, do not name the parameters, and do "
63
+ "not describe behaviour the source does not show; the source is included so you can "
64
+ "check. If a unit is trivial, say so briefly rather than padding it."
65
+ )
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class DescriptionStore:
70
+ """Authored descriptions, addressed by unit id."""
71
+
72
+ path: Path
73
+ entries: dict[str, dict]
74
+
75
+ @property
76
+ def fingerprint(self) -> str:
77
+ """Digest of the authored text, so an index can tell when it changed.
78
+
79
+ Importing descriptions touches no source file, so nothing the index
80
+ tracks moves and it would go on serving the previous text until someone
81
+ happened to rebuild. This is the same failure the configuration
82
+ fingerprint exists to prevent, in a second authored input.
83
+ """
84
+ # An empty store is the empty string rather than the digest of no
85
+ # bytes, so that it equals what `index_descriptions_fingerprint` reads
86
+ # out of an index written before this field existed. Otherwise every
87
+ # index predating 0.4.0 reports itself permanently stale over
88
+ # descriptions that neither it nor the repository has.
89
+ if not self.entries:
90
+ return ""
91
+ digest = hashlib.sha256()
92
+ for uid, entry in sorted(self.entries.items()):
93
+ digest.update(uid.encode("utf-8"))
94
+ digest.update(str(entry.get("hash", "")).encode("utf-8"))
95
+ digest.update(str(entry.get("text", "")).encode("utf-8"))
96
+ return digest.hexdigest()
97
+
98
+ def _relocations(self) -> dict[tuple[str, str], str | None]:
99
+ """Stored text addressed by file and code digest rather than by unit id.
100
+
101
+ A unit id embeds the line the declaration starts on, so inserting a
102
+ comment or an import near the top of a file changes the id of
103
+ everything below it while changing none of their code. Keyed only by
104
+ id, every description in that file would be orphaned by an edit that
105
+ did not touch a single one of the things they describe -- which is
106
+ what adding a seven-line comment to config.py did to nineteen of them.
107
+
108
+ The digest already answers "is this the same code?"; this uses it to
109
+ answer "where did that code go?" as well. Two units in one file with
110
+ byte-identical source and different stored text are ambiguous, and map
111
+ to nothing rather than to a guess.
112
+ """
113
+ table: dict[tuple[str, str], str | None] = {}
114
+ for entry in self.entries.values():
115
+ path, digest = entry.get("path"), entry.get("hash")
116
+ if not isinstance(path, str) or not isinstance(digest, str):
117
+ continue
118
+ text = entry.get("text")
119
+ key = (path, digest)
120
+ if key in table and table[key] != text:
121
+ table[key] = None
122
+ else:
123
+ table.setdefault(key, text)
124
+ return table
125
+
126
+ def applicable(self, units: list[CodeUnit]) -> dict[str, str]:
127
+ """Descriptions whose digest still matches the unit they describe."""
128
+ relocated = self._relocations()
129
+ usable: dict[str, str] = {}
130
+ for unit in units:
131
+ digest = source_key(unit)
132
+ entry = self.entries.get(unit.id)
133
+ text = entry.get("text") if entry and entry.get("hash") == digest else None
134
+ if text is None:
135
+ text = relocated.get((unit.path, digest))
136
+ if isinstance(text, str) and text.strip():
137
+ usable[unit.id] = text
138
+ return usable
139
+
140
+ def classify(self, units: list[CodeUnit]) -> dict[str, list[CodeUnit]]:
141
+ """Split units into described / superseded / missing.
142
+
143
+ ``superseded`` is the interesting one: something was written about this
144
+ declaration and the code has since changed, so it is deliberately not
145
+ applied. It is distinguished from ``missing`` by name rather than by
146
+ id, for the same reason applicability is: an id changes when the lines
147
+ above it do.
148
+ """
149
+ usable = self.applicable(units)
150
+ written = {(entry.get("path"), entry.get("name")) for entry in self.entries.values()}
151
+ described: list[CodeUnit] = []
152
+ superseded: list[CodeUnit] = []
153
+ missing: list[CodeUnit] = []
154
+ for unit in units:
155
+ if unit.id in usable:
156
+ described.append(unit)
157
+ elif self.entries.get(unit.id) or (unit.path, unit.qualified_name) in written:
158
+ superseded.append(unit)
159
+ else:
160
+ missing.append(unit)
161
+ return {"described": described, "superseded": superseded, "missing": missing}
162
+
163
+ def pending(self, units: list[CodeUnit], limit: int) -> list[CodeUnit]:
164
+ """Units still needing a description, missing ones before stale ones.
165
+
166
+ A unit with no description at all is worth more than a refresh of one
167
+ that exists, so a budget-limited agent spends its first batches where
168
+ retrieval is currently blind.
169
+ """
170
+ groups = self.classify(units)
171
+ return (groups["missing"] + groups["superseded"])[: max(0, limit)]
172
+
173
+ def put(self, unit: CodeUnit, text: str) -> None:
174
+ self.entries[unit.id] = {
175
+ "hash": source_key(unit),
176
+ "path": unit.path,
177
+ "name": unit.qualified_name,
178
+ "text": text,
179
+ }
180
+
181
+ def save(self, units: list[CodeUnit] | None = None) -> None:
182
+ """Write the store, dropping entries for units that no longer exist.
183
+
184
+ Pruning needs the full unit list to be safe, so it only happens when a
185
+ caller supplies one; a partial list would silently discard the
186
+ descriptions of everything it omitted.
187
+
188
+ An entry is kept when its id is still live *or* its code is, since a
189
+ declaration that merely moved down the file has a new id and the same
190
+ digest. Pruning on ids alone would have deleted exactly the entries
191
+ the relocation lookup exists to rescue.
192
+ """
193
+ if units is not None:
194
+ live_ids = {unit.id for unit in units}
195
+ live_code = {(unit.path, source_key(unit)) for unit in units}
196
+ self.entries = {
197
+ uid: entry
198
+ for uid, entry in self.entries.items()
199
+ if uid in live_ids or (entry.get("path"), entry.get("hash")) in live_code
200
+ }
201
+ payload = {
202
+ "schema": SCHEMA,
203
+ "descriptions": dict(sorted(self.entries.items())),
204
+ }
205
+ self.path.parent.mkdir(parents=True, exist_ok=True)
206
+ temp = self.path.with_name(f"{self.path.name}.tmp")
207
+ with temp.open("w", encoding="utf-8", newline="\n") as stream:
208
+ json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=False)
209
+ stream.write("\n")
210
+ temp.replace(self.path)
211
+
212
+
213
+ def index_descriptions_fingerprint(payload: dict) -> str:
214
+ """The descriptions digest an index was published with.
215
+
216
+ An index written before 0.4.0 has no such key, which means the same thing
217
+ as an empty store: no authored description was applied.
218
+ """
219
+ stored = payload.get("descriptions_fingerprint")
220
+ return stored if isinstance(stored, str) else ""
221
+
222
+
223
+ def store_path(root: Path) -> Path:
224
+ return root / STORE_FILENAME
225
+
226
+
227
+ def load(root: Path) -> DescriptionStore:
228
+ """Read the store, treating an unusable file as empty rather than fatal.
229
+
230
+ This file is committed, so it will meet merge conflicts and hand-editing. A
231
+ malformed one costs retrieval quality, which is recoverable by describing
232
+ again; refusing to search until it is repaired would not be.
233
+ """
234
+ path = store_path(root)
235
+ entries: dict[str, dict] = {}
236
+ if path.is_file():
237
+ try:
238
+ payload = json.loads(path.read_text(encoding="utf-8"))
239
+ stored = payload.get("descriptions") if isinstance(payload, dict) else None
240
+ if isinstance(stored, dict):
241
+ entries = {
242
+ str(uid): entry
243
+ for uid, entry in stored.items()
244
+ if isinstance(entry, dict) and isinstance(entry.get("text"), str)
245
+ }
246
+ except (OSError, ValueError):
247
+ entries = {}
248
+ return DescriptionStore(path, entries)
@@ -0,0 +1,60 @@
1
+ """Dependency-free deterministic embeddings.
2
+
3
+ The default hasher is deliberately local and reproducible. It is useful for
4
+ small/medium repositories and can later be replaced by an API or sentence
5
+ transformer without changing the index schema.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import math
12
+ import re
13
+ from collections.abc import Sequence
14
+
15
+ TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[\u4e00-\u9fff]+|\d+")
16
+ CJK_RE = re.compile(r"^[\u4e00-\u9fff]+$")
17
+ DEFAULT_DIMENSIONS = 384
18
+ EMBEDDING_PROVIDER = "signed-feature-hash"
19
+ EMBEDDING_VERSION = 1
20
+
21
+
22
+ def embedding_metadata(dimensions: int = DEFAULT_DIMENSIONS) -> dict[str, object]:
23
+ return {"provider": EMBEDDING_PROVIDER, "version": EMBEDDING_VERSION, "dimensions": dimensions}
24
+
25
+
26
+ def tokenize(text: str) -> list[str]:
27
+ tokens: list[str] = []
28
+ for raw in TOKEN_RE.findall(text):
29
+ token = raw.lower()
30
+ tokens.append(token)
31
+ if CJK_RE.fullmatch(token) and len(token) > 1:
32
+ tokens.extend(token[index : index + 2] for index in range(len(token) - 1))
33
+ return tokens
34
+
35
+
36
+ def _bucket(token: str, dimensions: int) -> int:
37
+ digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest()
38
+ return int.from_bytes(digest, "big") % dimensions
39
+
40
+
41
+ def embed(text: str, dimensions: int = DEFAULT_DIMENSIONS) -> list[float]:
42
+ """Return a normalized signed feature-hash vector."""
43
+ if dimensions < 32:
44
+ raise ValueError("dimensions must be at least 32")
45
+ tokens = tokenize(text)
46
+ vector = [0.0] * dimensions
47
+ for token in tokens:
48
+ index = _bucket(token, dimensions)
49
+ sign = 1.0 if _bucket("sign:" + token, dimensions) % 2 else -1.0
50
+ vector[index] += sign
51
+ norm = math.sqrt(sum(value * value for value in vector))
52
+ if norm:
53
+ vector = [value / norm for value in vector]
54
+ return vector
55
+
56
+
57
+ def cosine(left: Sequence[float], right: Sequence[float]) -> float:
58
+ if not left or not right or len(left) != len(right):
59
+ return 0.0
60
+ return sum(a * b for a, b in zip(left, right))
ragyourcode/graph.py ADDED
@@ -0,0 +1,198 @@
1
+ """Explainable symbol graph used by graph-aware retrieval.
2
+
3
+ The graph is intentionally derived from the parser's stable ``CodeUnit`` IDs.
4
+ Edges are conservative: unresolved calls are omitted rather than guessed. This
5
+ keeps graph expansion useful for navigation without fabricating relationships.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict, deque
11
+ from dataclasses import dataclass
12
+ from typing import Iterable
13
+
14
+ from .models import CodeUnit, SearchResult
15
+ from .search import DEFAULT_VECTOR_WEIGHT, SearchIndex, search
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class CodeEdge:
20
+ source: str
21
+ target: str
22
+ kind: str
23
+ label: str = ""
24
+
25
+ def to_dict(self) -> dict[str, str]:
26
+ return {"source": self.source, "target": self.target, "kind": self.kind, "label": self.label}
27
+
28
+
29
+ class CodeGraph:
30
+ """Directed code relationship graph with bounded neighborhood traversal."""
31
+
32
+ def __init__(self, units: Iterable[CodeUnit], edges: Iterable[CodeEdge] = ()):
33
+ self.units = {unit.id: unit for unit in units}
34
+ self.edges = sorted(set(edges), key=lambda edge: (edge.source, edge.kind, edge.target))
35
+ self._out: dict[str, list[CodeEdge]] = defaultdict(list)
36
+ self._in: dict[str, list[CodeEdge]] = defaultdict(list)
37
+ for edge in self.edges:
38
+ if edge.source in self.units and edge.target in self.units:
39
+ self._out[edge.source].append(edge)
40
+ self._in[edge.target].append(edge)
41
+
42
+ def neighbors(self, unit_id: str, hops: int = 1, direction: str = "both") -> list[tuple[CodeUnit, list[str]]]:
43
+ if unit_id not in self.units or hops <= 0:
44
+ return []
45
+ directions = {"out"} if direction == "out" else {"in"} if direction == "in" else {"out", "in"}
46
+ queue: deque[tuple[str, int, list[str]]] = deque([(unit_id, 0, [unit_id])])
47
+ seen = {unit_id}
48
+ found: list[tuple[CodeUnit, list[str]]] = []
49
+ while queue:
50
+ current, distance, path = queue.popleft()
51
+ if distance >= hops:
52
+ continue
53
+ edges: list[CodeEdge] = []
54
+ if "out" in directions:
55
+ edges.extend(self._out.get(current, []))
56
+ if "in" in directions:
57
+ edges.extend(self._in.get(current, []))
58
+ for edge in edges:
59
+ target = edge.target if edge.source == current else edge.source
60
+ if target in seen:
61
+ continue
62
+ seen.add(target)
63
+ edge_text = f"{edge.kind}:{edge.source}->{edge.target}"
64
+ next_path = path + [edge_text, target]
65
+ found.append((self.units[target], next_path))
66
+ queue.append((target, distance + 1, next_path))
67
+ return found
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ return {"edges": [edge.to_dict() for edge in self.edges]}
71
+
72
+
73
+ def _name_indexes(units: Iterable[CodeUnit]):
74
+ by_name: dict[str, list[str]] = defaultdict(list)
75
+ by_module: dict[str, list[str]] = defaultdict(list)
76
+ for unit in units:
77
+ by_name[unit.name].append(unit.id)
78
+ if unit.qualified_name != unit.name:
79
+ by_name[unit.qualified_name].append(unit.id)
80
+ module = unit.path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
81
+ by_module[module].append(unit.id)
82
+ return by_name, by_module
83
+
84
+
85
+ def _call_targets(call: str, by_name: dict[str, list[str]], local_modules: set[str], unit: CodeUnit, unit_by_id: dict[str, CodeUnit]) -> list[str]:
86
+ """Resolve a recorded call to local unit ids, guessing only where allowed.
87
+
88
+ An exact match on the text the parser recorded always wins. Falling back to
89
+ the last dotted segment is a guess, and an unrestricted guess is how
90
+ `os.path.join` acquired a `calls` edge to an unrelated local `join` -- while
91
+ this module's own docstring promises that unresolved calls are omitted
92
+ rather than guessed. The fallback now requires the head of the dotted path
93
+ to be attributable to this repository: `self`/`cls`, or a module some file
94
+ here actually defines. `os`, `json`, `requests` and every other foreign
95
+ prefix resolve to nothing, as documented.
96
+ """
97
+ exact = by_name.get(call)
98
+ if exact:
99
+ return list(exact)
100
+ if "." not in call:
101
+ return []
102
+ head = call.split(".", 1)[0]
103
+ leaf = call.rsplit(".", 1)[-1]
104
+ if head in {"self", "cls"}:
105
+ # A receiver call names a sibling defined alongside this unit.
106
+ return [target for target in by_name.get(leaf, []) if unit_by_id[target].path == unit.path]
107
+ if head in local_modules:
108
+ return list(by_name.get(leaf, []))
109
+ return []
110
+
111
+
112
+ def build_graph(units: Iterable[CodeUnit], max_edges_per_unit: int = 64) -> CodeGraph:
113
+ units = list(units)
114
+ unit_by_id = {unit.id: unit for unit in units}
115
+ by_name, by_module = _name_indexes(units)
116
+ local_modules = set(by_module)
117
+ edges: set[CodeEdge] = set()
118
+ for unit in units:
119
+ budget = max(1, max_edges_per_unit)
120
+ if unit.parent:
121
+ parent_ids = by_name.get(unit.parent, [])
122
+ same_file_parents = [target for target in parent_ids if unit_by_id[target].path == unit.path]
123
+ resolved_parents = same_file_parents if len(same_file_parents) == 1 else parent_ids if len(parent_ids) == 1 else []
124
+ for parent_id in resolved_parents:
125
+ edges.add(CodeEdge(parent_id, unit.id, "contains", unit.qualified_name))
126
+ budget -= 1
127
+ for call in unit.calls:
128
+ if budget <= 0:
129
+ break
130
+ targets = _call_targets(call, by_name, local_modules, unit, unit_by_id)
131
+ same_file = [target for target in targets if target != unit.id and unit_by_id[target].path == unit.path]
132
+ resolved = same_file if len(same_file) == 1 else targets if len(targets) == 1 else []
133
+ for target in resolved:
134
+ if target != unit.id:
135
+ edges.add(CodeEdge(unit.id, target, "calls", call))
136
+ budget -= 1
137
+ for imported in unit.imports:
138
+ if budget <= 0:
139
+ break
140
+ module = imported.rsplit(".", 1)[-1]
141
+ module_targets = by_module.get(module, [])
142
+ # A module import is a coarse relationship. Only materialize it
143
+ # when the target module has a small, unambiguous surface.
144
+ module_paths = {unit_by_id[target].path for target in module_targets}
145
+ resolved_module = module_targets if len(module_targets) <= 3 and len(module_paths) == 1 else []
146
+ for target in resolved_module:
147
+ if target != unit.id:
148
+ edges.add(CodeEdge(unit.id, target, "imports", imported))
149
+ budget -= 1
150
+ if budget <= 0:
151
+ break
152
+ return CodeGraph(units, edges)
153
+
154
+
155
+ def graph_from_dict(units: Iterable[CodeUnit], data: dict[str, object] | None) -> CodeGraph:
156
+ raw_edges = (data or {}).get("edges", [])
157
+ edges: list[CodeEdge] = []
158
+ for edge in raw_edges if isinstance(raw_edges, list) else []:
159
+ if not isinstance(edge, dict):
160
+ continue
161
+ try:
162
+ edges.append(CodeEdge(**edge))
163
+ except (TypeError, ValueError):
164
+ continue
165
+ return CodeGraph(units, edges)
166
+
167
+
168
+ def graph_search(
169
+ units: list[CodeUnit],
170
+ query: str,
171
+ limit: int = 8,
172
+ hops: int = 1,
173
+ graph: CodeGraph | None = None,
174
+ search_index: SearchIndex | None = None,
175
+ vector_weight: float = DEFAULT_VECTOR_WEIGHT,
176
+ ) -> list[SearchResult]:
177
+ """Search seeds and add bounded graph neighbors with explicit evidence."""
178
+ if limit <= 0:
179
+ return []
180
+ hops = min(3, max(0, hops))
181
+ graph = graph or build_graph(units)
182
+ seeds = search(units, query, max(limit * 2, 8), search_index=search_index, vector_weight=vector_weight)
183
+ ranked: dict[str, SearchResult] = {seed.unit.id: seed for seed in seeds}
184
+ if hops <= 0:
185
+ return seeds[:limit]
186
+ for seed in seeds:
187
+ for neighbor, path in graph.neighbors(seed.unit.id, hops=hops, direction="both"):
188
+ weights = {"calls": 0.7, "contains": 0.5, "imports": 0.3}
189
+ propagated = seed.score
190
+ for edge_text in path[1::2]:
191
+ propagated *= weights.get(edge_text.split(":", 1)[0], 0.4)
192
+ current = ranked.get(neighbor.id)
193
+ evidence = ["graph:" + " -> ".join(path)]
194
+ if current is None or propagated > current.score:
195
+ ranked[neighbor.id] = SearchResult(neighbor, propagated, [], evidence)
196
+ elif evidence[0] not in current.evidence:
197
+ current.evidence.extend(evidence)
198
+ return sorted(ranked.values(), key=lambda result: (-result.score, result.unit.id))[:limit]