engraphis 0.1.0__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.
- engraphis/__init__.py +8 -0
- engraphis/analytics.py +243 -0
- engraphis/app.py +228 -0
- engraphis/backends/__init__.py +16 -0
- engraphis/backends/codegraph.py +345 -0
- engraphis/backends/embedder_api.py +188 -0
- engraphis/backends/embedder_deterministic.py +49 -0
- engraphis/backends/embedder_st.py +52 -0
- engraphis/backends/extractor.py +148 -0
- engraphis/backends/graph_extractor.py +201 -0
- engraphis/backends/reranker.py +46 -0
- engraphis/backends/vector_numpy.py +52 -0
- engraphis/backends/vector_sqlitevec.py +88 -0
- engraphis/config.py +137 -0
- engraphis/core/__init__.py +43 -0
- engraphis/core/consolidate.py +349 -0
- engraphis/core/engine.py +517 -0
- engraphis/core/graphrank.py +73 -0
- engraphis/core/grounded.py +207 -0
- engraphis/core/ids.py +54 -0
- engraphis/core/interfaces.py +186 -0
- engraphis/core/recall.py +232 -0
- engraphis/core/resolve.py +99 -0
- engraphis/core/schema.py +191 -0
- engraphis/core/scoring.py +108 -0
- engraphis/core/store.py +598 -0
- engraphis/core/textutil.py +51 -0
- engraphis/dashboard_app.py +115 -0
- engraphis/engines/__init__.py +1 -0
- engraphis/engines/embedder.py +104 -0
- engraphis/engines/ingest.py +216 -0
- engraphis/engines/intelligence.py +176 -0
- engraphis/engines/recall.py +155 -0
- engraphis/engines/reweight.py +90 -0
- engraphis/engines/thoughts.py +67 -0
- engraphis/graphdata.py +65 -0
- engraphis/inspector/__init__.py +9 -0
- engraphis/inspector/app.py +436 -0
- engraphis/inspector/auth.py +252 -0
- engraphis/inspector/index.html +1255 -0
- engraphis/licensing.py +432 -0
- engraphis/llm/__init__.py +1 -0
- engraphis/llm/client.py +237 -0
- engraphis/logging_setup.py +65 -0
- engraphis/mcp_server.py +703 -0
- engraphis/models.py +180 -0
- engraphis/routes/__init__.py +1 -0
- engraphis/routes/memory.py +828 -0
- engraphis/routes/v2_api.py +444 -0
- engraphis/routes/v2_team.py +152 -0
- engraphis/routes/vault.py +615 -0
- engraphis/service.py +881 -0
- engraphis/stores/__init__.py +194 -0
- engraphis/stores/graph.py +138 -0
- engraphis/stores/ledger.py +139 -0
- engraphis/stores/vaults.py +117 -0
- engraphis/stores/vectors.py +270 -0
- engraphis-0.1.0.dist-info/METADATA +333 -0
- engraphis-0.1.0.dist-info/RECORD +78 -0
- engraphis-0.1.0.dist-info/WHEEL +5 -0
- engraphis-0.1.0.dist-info/entry_points.txt +9 -0
- engraphis-0.1.0.dist-info/licenses/LICENSE +201 -0
- engraphis-0.1.0.dist-info/licenses/NOTICE +14 -0
- engraphis-0.1.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/cli.py +181 -0
- scripts/consolidate.py +208 -0
- scripts/init.py +164 -0
- scripts/inspector.py +78 -0
- scripts/install_shortcuts.py +220 -0
- scripts/license_admin.py +121 -0
- scripts/migrate_to_v2.py +201 -0
- scripts/sdk_compat.py +45 -0
- scripts/seed_from_obsidian.py +98 -0
- scripts/start_dashboard.py +79 -0
- scripts/start_server.py +29 -0
- scripts/test_routes.py +198 -0
- scripts/update.py +192 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Code-symbol graph extraction — the flagship coding-agent wedge.
|
|
2
|
+
|
|
3
|
+
Populates the ``symbols``/``code_edges`` tables (already in ``core/schema.py``, unused
|
|
4
|
+
until now) by parsing source files into definitions (functions/methods/classes) and
|
|
5
|
+
best-effort ``calls``/``imports`` edges. Two backends, same shape as every other
|
|
6
|
+
pluggable piece in this codebase:
|
|
7
|
+
|
|
8
|
+
* ``TreeSitterSymbolIndexer`` — real AST parsing via ``tree-sitter`` (when installed).
|
|
9
|
+
AST-derived structure is the source of truth for code relationships (more reliable
|
|
10
|
+
than LLM extraction for this — AGENTS.md §3.8).
|
|
11
|
+
* ``RegexSymbolIndexer`` — dependency-free offline fallback. Flatter (no qualified
|
|
12
|
+
names, no call edges) but always available, so a fresh clone with just ``numpy``
|
|
13
|
+
installed still gets *something* out of ``index_repo`` rather than nothing.
|
|
14
|
+
|
|
15
|
+
``get_code_indexer()`` picks the best available backend, exactly like
|
|
16
|
+
``get_embedder``/``get_vector_index``/``get_reranker``. Keep heavy imports
|
|
17
|
+
(``tree_sitter*``) inside the try block — never at module level — so importing this
|
|
18
|
+
module never requires the optional dependency (AGENTS.md §3.8).
|
|
19
|
+
|
|
20
|
+
Note on the tree-sitter Python binding: recent releases (0.22+) changed several
|
|
21
|
+
``Node``/``Tree`` accessors from properties to methods (e.g. ``node.kind`` vs the
|
|
22
|
+
older ``node.type``) and the exact set varies by installed version. ``_call_or_get``
|
|
23
|
+
below tries the call form and falls back to plain attribute access so this module
|
|
24
|
+
works across that churn instead of pinning to one binding generation.
|
|
25
|
+
|
|
26
|
+
Note on str vs bytes: ``Parser.parse()`` disagrees on its source type across
|
|
27
|
+
binding generations — some accept only ``bytes`` (the byte-offset contract),
|
|
28
|
+
others only ``str`` (raising ``TypeError`` when given bytes). ``_parse`` below
|
|
29
|
+
tries bytes first and falls back to ``str`` so this module works across that
|
|
30
|
+
churn instead of pinning to one form. Node byte offsets (``start_byte``/
|
|
31
|
+
``end_byte``) are offsets into the UTF-8 bytes regardless of which form the
|
|
32
|
+
binding consumed, so ``TreeSitterSymbolIndexer`` encodes file content once in
|
|
33
|
+
``index_file`` and threads the ``bytes`` buffer through ``_walk``/``_text`` as
|
|
34
|
+
``src``, decoding back to ``str`` only at ``_text()`` where a symbol's slice is
|
|
35
|
+
extracted. Do not reintroduce a bare ``str`` "src" threaded into the walker —
|
|
36
|
+
``_text`` slices by byte offset and must slice a ``bytes`` buffer. Feeding a
|
|
37
|
+
``str`` to a bytes-only binding silently fails to parse (caught by
|
|
38
|
+
``engine.py``'s per-file ``except Exception: continue``), so ``index_repo``/
|
|
39
|
+
``search_code`` quietly return zero results instead of raising; that
|
|
40
|
+
regression shipped undetected for a while. See ``tests/test_codegraph.py``'s
|
|
41
|
+
tree-sitter cases and ``tests/test_engine.py::test_index_repo_and_search_code``
|
|
42
|
+
for the coverage that now guards it.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import hashlib
|
|
47
|
+
import re
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any, Iterable, Optional
|
|
51
|
+
|
|
52
|
+
LANG_BY_EXT = {
|
|
53
|
+
".py": "python",
|
|
54
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
55
|
+
".ts": "typescript", ".tsx": "typescript",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Per-language AST node kinds (tree-sitter grammars are consistent on these names).
|
|
59
|
+
_DEF_KINDS = {
|
|
60
|
+
"python": {"function_definition": "function", "class_definition": "class"},
|
|
61
|
+
"javascript": {"function_declaration": "function", "class_declaration": "class",
|
|
62
|
+
"method_definition": "method"},
|
|
63
|
+
"typescript": {"function_declaration": "function", "class_declaration": "class",
|
|
64
|
+
"method_definition": "method"},
|
|
65
|
+
}
|
|
66
|
+
_CALL_KINDS = {"python": {"call"}, "javascript": {"call_expression"},
|
|
67
|
+
"typescript": {"call_expression"}}
|
|
68
|
+
_IMPORT_KINDS = {
|
|
69
|
+
"python": {"import_statement", "import_from_statement"},
|
|
70
|
+
"javascript": {"import_statement"}, "typescript": {"import_statement"},
|
|
71
|
+
}
|
|
72
|
+
_CLASS_KINDS = {"python": {"class_definition"},
|
|
73
|
+
"javascript": {"class_declaration"}, "typescript": {"class_declaration"}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Symbol:
|
|
78
|
+
kind: str
|
|
79
|
+
name: str
|
|
80
|
+
fqname: str
|
|
81
|
+
file: str
|
|
82
|
+
span: str
|
|
83
|
+
signature: str = ""
|
|
84
|
+
lang: str = ""
|
|
85
|
+
exported: bool = False
|
|
86
|
+
content_hash: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class CodeEdge:
|
|
91
|
+
src: str
|
|
92
|
+
dst: str
|
|
93
|
+
relation: str
|
|
94
|
+
file: str = ""
|
|
95
|
+
line: int = 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class FileIndex:
|
|
100
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
101
|
+
edges: list[CodeEdge] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def detect_lang(file_path: str) -> Optional[str]:
|
|
105
|
+
return LANG_BY_EXT.get(Path(file_path).suffix.lower())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _content_hash(content: str) -> str:
|
|
109
|
+
return hashlib.sha1(content.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── tree-sitter backend ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
class TreeSitterSymbolIndexer:
|
|
115
|
+
"""AST-based extraction via ``tree-sitter`` (optional dependency)."""
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
import tree_sitter_language_pack as _tslp # lazy: optional dependency
|
|
119
|
+
self._get_parser = _tslp.get_parser
|
|
120
|
+
|
|
121
|
+
def supports(self, lang: str) -> bool:
|
|
122
|
+
return lang in _DEF_KINDS
|
|
123
|
+
|
|
124
|
+
def index_file(self, file_path: str, content: str, lang: str) -> FileIndex:
|
|
125
|
+
parser = self._get_parser(lang)
|
|
126
|
+
# Node.start_byte/end_byte are byte offsets, so we keep the bytes
|
|
127
|
+
# buffer as `src` throughout and decode only at the point of text
|
|
128
|
+
# extraction (see _text()).
|
|
129
|
+
content_bytes = content.encode("utf-8", errors="replace")
|
|
130
|
+
tree = _parse(parser, content_bytes)
|
|
131
|
+
root = _cg(tree, "root_node")
|
|
132
|
+
out = FileIndex()
|
|
133
|
+
self._walk(root, content_bytes, file_path, lang, out, class_stack=[])
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
def _walk(self, node, src: bytes, file_path: str, lang: str, out: FileIndex,
|
|
137
|
+
*, class_stack: list[str]) -> None:
|
|
138
|
+
kind = _node_kind(node)
|
|
139
|
+
def_kinds = _DEF_KINDS.get(lang, {})
|
|
140
|
+
if kind in def_kinds:
|
|
141
|
+
name = self._def_name(node, src)
|
|
142
|
+
if name:
|
|
143
|
+
fqname = ".".join(class_stack + [name])
|
|
144
|
+
symbol_kind = "method" if class_stack else def_kinds[kind]
|
|
145
|
+
out.symbols.append(Symbol(
|
|
146
|
+
kind=symbol_kind, name=name, fqname=fqname, file=file_path,
|
|
147
|
+
span=f"{_start_line(node)}-{_end_line(node)}",
|
|
148
|
+
signature=_first_line(src, node), lang=lang,
|
|
149
|
+
exported=not name.startswith("_"),
|
|
150
|
+
content_hash=_content_hash(_text(src, node)),
|
|
151
|
+
))
|
|
152
|
+
if kind in _CLASS_KINDS.get(lang, set()) and name:
|
|
153
|
+
class_stack = class_stack + [name]
|
|
154
|
+
elif kind in _CALL_KINDS.get(lang, set()):
|
|
155
|
+
callee = self._call_target(node, src)
|
|
156
|
+
if callee:
|
|
157
|
+
caller = class_stack[-1] if class_stack else "<module>"
|
|
158
|
+
out.edges.append(CodeEdge(src=caller, dst=callee, relation="calls",
|
|
159
|
+
file=file_path, line=_start_line(node)))
|
|
160
|
+
elif kind in _IMPORT_KINDS.get(lang, set()):
|
|
161
|
+
for mod in self._import_targets(node, src):
|
|
162
|
+
out.edges.append(CodeEdge(src=file_path, dst=mod, relation="imports",
|
|
163
|
+
file=file_path, line=_start_line(node)))
|
|
164
|
+
|
|
165
|
+
cc = _cg(node, "child_count")
|
|
166
|
+
for i in range(cc):
|
|
167
|
+
self._walk(_cg(node, "child", i), src, file_path, lang, out,
|
|
168
|
+
class_stack=class_stack)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _def_name(node, src: bytes) -> str:
|
|
172
|
+
cc = _cg(node, "child_count")
|
|
173
|
+
for i in range(cc):
|
|
174
|
+
child = _cg(node, "child", i)
|
|
175
|
+
if _node_kind(child) in ("identifier", "type_identifier", "property_identifier"):
|
|
176
|
+
return _text(src, child)
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _call_target(node, src: bytes) -> str:
|
|
181
|
+
cc = _cg(node, "child_count")
|
|
182
|
+
if cc == 0:
|
|
183
|
+
return ""
|
|
184
|
+
first = _cg(node, "child", 0)
|
|
185
|
+
fkind = _node_kind(first)
|
|
186
|
+
if fkind == "identifier":
|
|
187
|
+
return _text(src, first)
|
|
188
|
+
if fkind in ("attribute", "member_expression"):
|
|
189
|
+
# best-effort: use the last identifier-ish segment (obj.method -> method)
|
|
190
|
+
fcc = _cg(first, "child_count")
|
|
191
|
+
for j in range(fcc - 1, -1, -1):
|
|
192
|
+
seg = _cg(first, "child", j)
|
|
193
|
+
if _node_kind(seg) in ("identifier", "property_identifier"):
|
|
194
|
+
return _text(src, seg)
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _import_targets(node, src: bytes) -> list[str]:
|
|
199
|
+
names = []
|
|
200
|
+
cc = _cg(node, "child_count")
|
|
201
|
+
for i in range(cc):
|
|
202
|
+
child = _cg(node, "child", i)
|
|
203
|
+
if _node_kind(child) in ("dotted_name", "identifier", "string"):
|
|
204
|
+
text = _text(src, child).strip("\"'")
|
|
205
|
+
if text:
|
|
206
|
+
names.append(text)
|
|
207
|
+
return names[:1] # the module path is conventionally the first dotted_name/string
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cg(obj: Any, name: str, *args: Any) -> Any:
|
|
211
|
+
"""Call-or-get: tree-sitter's Python binding has changed several Node/Tree
|
|
212
|
+
accessors between property and method across versions; try both so this module
|
|
213
|
+
isn't pinned to one generation (see module docstring)."""
|
|
214
|
+
val = getattr(obj, name)
|
|
215
|
+
return val(*args) if callable(val) else val
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _node_kind(node: Any) -> str:
|
|
219
|
+
"""Return a node's grammar symbol name (e.g. ``"function_definition"``).
|
|
220
|
+
|
|
221
|
+
Every released tree-sitter Python binding exposes this as the ``type``
|
|
222
|
+
attribute (never ``kind`` -- that name doesn't exist on ``Node`` in any
|
|
223
|
+
version we've checked, despite what the module docstring's version-churn
|
|
224
|
+
note might suggest). Prefer ``type`` and only fall back to ``_cg(node,
|
|
225
|
+
"kind")`` in case some future binding really does rename it.
|
|
226
|
+
"""
|
|
227
|
+
if hasattr(node, "type"):
|
|
228
|
+
val = node.type
|
|
229
|
+
return val() if callable(val) else val
|
|
230
|
+
return _cg(node, "kind")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _text(src: bytes, node: Any) -> str:
|
|
234
|
+
return src[_cg(node, "start_byte"):_cg(node, "end_byte")].decode("utf-8", errors="replace")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse(parser: Any, content_bytes: bytes) -> Any:
|
|
238
|
+
"""Parse ``content_bytes`` across tree-sitter binding generations.
|
|
239
|
+
|
|
240
|
+
Bindings disagree on ``Parser.parse()``'s source type: some accept only
|
|
241
|
+
``bytes`` (the byte-offset contract), others only ``str`` (raising
|
|
242
|
+
``TypeError: 'bytes' object is not an instance of 'str'`` when given bytes).
|
|
243
|
+
Try bytes first, fall back to the str form. Node byte offsets are valid
|
|
244
|
+
against either form (they index the UTF-8 bytes), so the bytes ``src``
|
|
245
|
+
buffer stays correct regardless of which form the binding consumed.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
return _cg(parser, "parse", content_bytes)
|
|
249
|
+
except TypeError:
|
|
250
|
+
return _cg(parser, "parse", content_bytes.decode("utf-8", errors="replace"))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _row(pos: Any) -> int:
|
|
254
|
+
if isinstance(pos, tuple):
|
|
255
|
+
return pos[0]
|
|
256
|
+
return getattr(pos, "row", 0)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _start_line(node: Any) -> int:
|
|
260
|
+
for attr in ("start_position", "start_point"):
|
|
261
|
+
if hasattr(node, attr):
|
|
262
|
+
return _row(_cg(node, attr)) + 1
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _end_line(node: Any) -> int:
|
|
267
|
+
for attr in ("end_position", "end_point"):
|
|
268
|
+
if hasattr(node, attr):
|
|
269
|
+
return _row(_cg(node, attr)) + 1
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _first_line(src: bytes, node: Any) -> str:
|
|
274
|
+
text = _text(src, node)
|
|
275
|
+
return text.splitlines()[0].strip()[:200] if text else ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── regex backend (offline, dependency-free fallback) ──────────────────────────
|
|
279
|
+
|
|
280
|
+
class RegexSymbolIndexer:
|
|
281
|
+
"""Dependency-free fallback: flat function/class detection, no qualified names
|
|
282
|
+
or call edges. Always available — keeps ``index_repo`` useful with just ``numpy``
|
|
283
|
+
installed (AGENTS.md §3.8: the core must work with no heavy dependencies)."""
|
|
284
|
+
|
|
285
|
+
_PATTERNS = {
|
|
286
|
+
"python": [
|
|
287
|
+
(re.compile(r"^\s*def\s+(\w+)\s*\("), "function"),
|
|
288
|
+
(re.compile(r"^\s*class\s+(\w+)"), "class"),
|
|
289
|
+
],
|
|
290
|
+
"javascript": [
|
|
291
|
+
(re.compile(r"^\s*(?:export\s+)?function\s+(\w+)\s*\("), "function"),
|
|
292
|
+
(re.compile(r"^\s*(?:export\s+)?class\s+(\w+)"), "class"),
|
|
293
|
+
(re.compile(r"^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>"),
|
|
294
|
+
"function"),
|
|
295
|
+
],
|
|
296
|
+
}
|
|
297
|
+
_PATTERNS["typescript"] = _PATTERNS["javascript"]
|
|
298
|
+
|
|
299
|
+
def supports(self, lang: str) -> bool:
|
|
300
|
+
return lang in self._PATTERNS
|
|
301
|
+
|
|
302
|
+
def index_file(self, file_path: str, content: str, lang: str) -> FileIndex:
|
|
303
|
+
out = FileIndex()
|
|
304
|
+
patterns = self._PATTERNS.get(lang, [])
|
|
305
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
306
|
+
for pattern, kind in patterns:
|
|
307
|
+
m = pattern.match(line)
|
|
308
|
+
if m:
|
|
309
|
+
name = m.group(1)
|
|
310
|
+
out.symbols.append(Symbol(
|
|
311
|
+
kind=kind, name=name, fqname=name, file=file_path,
|
|
312
|
+
span=f"{lineno}-{lineno}", signature=line.strip()[:200], lang=lang,
|
|
313
|
+
exported=not name.startswith("_"),
|
|
314
|
+
content_hash=_content_hash(line),
|
|
315
|
+
))
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_code_indexer(prefer: str = "auto"):
|
|
320
|
+
"""Return a tree-sitter indexer if available, else the regex fallback.
|
|
321
|
+
|
|
322
|
+
``prefer``: "auto" (try tree-sitter, fall back), "tree-sitter" (require it),
|
|
323
|
+
or "regex" (force the dependency-free fallback).
|
|
324
|
+
"""
|
|
325
|
+
if prefer == "regex":
|
|
326
|
+
return RegexSymbolIndexer()
|
|
327
|
+
try:
|
|
328
|
+
return TreeSitterSymbolIndexer()
|
|
329
|
+
except Exception:
|
|
330
|
+
if prefer == "tree-sitter":
|
|
331
|
+
raise
|
|
332
|
+
return RegexSymbolIndexer()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def iter_source_files(root: str, *, exclude_dirs: Optional[set] = None) -> Iterable[str]:
|
|
336
|
+
"""Yield source file paths under ``root`` whose extension we know how to index."""
|
|
337
|
+
exclude = exclude_dirs or {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
338
|
+
"dist", "build", ".tox", ".mypy_cache", ".pytest_cache"}
|
|
339
|
+
base = Path(root)
|
|
340
|
+
for path in base.rglob("*"):
|
|
341
|
+
if not path.is_file() or detect_lang(str(path)) is None:
|
|
342
|
+
continue
|
|
343
|
+
if any(part in exclude for part in path.parts):
|
|
344
|
+
continue
|
|
345
|
+
yield str(path)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""API-based embedder — calls an OpenAI-compatible endpoint (OpenRouter, etc.)
|
|
2
|
+
|
|
3
|
+
Uses the ``/v1/embeddings`` endpoint. Since many OpenRouter models are chat
|
|
4
|
+
models that may not expose a native embeddings endpoint, this module also
|
|
5
|
+
provides a fallback: a simple ``[CLS]``-style prompt wrapper that asks the
|
|
6
|
+
chat model to produce a text representation we then hash into a vector, or
|
|
7
|
+
for real embedding models simply passes the text to ``/v1/embeddings``.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
- Implements the ``Embedder`` protocol (``engraphis.core.interfaces.Embedder``).
|
|
11
|
+
- Dimension is detected from the first API response.
|
|
12
|
+
- Batch embedding sends multiple inputs in one API call.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Literal, Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("engraphis.embedder_api")
|
|
23
|
+
|
|
24
|
+
# Default OpenRouter endpoint
|
|
25
|
+
_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
26
|
+
_DEFAULT_API_KEY_ENV = "ENGRAPHIS_LLM_API_KEY"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiEmbedder:
|
|
30
|
+
"""Embedder that calls an OpenAI-compatible /v1/embeddings API.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
model : str
|
|
35
|
+
Model identifier, e.g. ``"nvidia/nemotron-3-ultra-550b-a55b:free"``.
|
|
36
|
+
base_url : str, optional
|
|
37
|
+
API base URL (default: OpenRouter).
|
|
38
|
+
api_key : str, optional
|
|
39
|
+
API key. Falls back to ``ENGRAPHIS_LLM_API_KEY`` env var.
|
|
40
|
+
dim : int, optional
|
|
41
|
+
Known embedding dimension. If not provided, detected from first response.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
model: str,
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
api_key: Optional[str] = None,
|
|
49
|
+
dim: Optional[int] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.model = model
|
|
52
|
+
self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
|
|
53
|
+
self._api_key = api_key or os.environ.get(_DEFAULT_API_KEY_ENV, "")
|
|
54
|
+
self._dim = dim
|
|
55
|
+
self._embeddings_url = f"{self._base_url}/v1/embeddings"
|
|
56
|
+
logger.info(
|
|
57
|
+
"ApiEmbedder(model=%s, base_url=%s, dim=%s)",
|
|
58
|
+
self.model, self._base_url, self._dim or "auto",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def dim(self) -> int:
|
|
63
|
+
if self._dim is None:
|
|
64
|
+
# Probe the API to get dimension
|
|
65
|
+
probe = self.embed(["hello"])
|
|
66
|
+
self._dim = probe.shape[1]
|
|
67
|
+
return self._dim # type: ignore[return-value]
|
|
68
|
+
|
|
69
|
+
def embed(
|
|
70
|
+
self, texts: list[str], *, kind: Literal["text", "code"] = "text"
|
|
71
|
+
) -> np.ndarray:
|
|
72
|
+
"""Embed a list of strings via the API.
|
|
73
|
+
|
|
74
|
+
Uses ``/v1/embeddings`` with batch input.
|
|
75
|
+
Falls back to per-item requests if the batch fails.
|
|
76
|
+
|
|
77
|
+
Notes
|
|
78
|
+
-----
|
|
79
|
+
The ``kind`` parameter is accepted for protocol compatibility
|
|
80
|
+
(``engraphis.core.interfaces.Embedder``) but is not used by the
|
|
81
|
+
API embedder — the same endpoint handles both text and code.
|
|
82
|
+
"""
|
|
83
|
+
if not texts:
|
|
84
|
+
return np.empty((0, self.dim), dtype=np.float32)
|
|
85
|
+
|
|
86
|
+
import httpx
|
|
87
|
+
|
|
88
|
+
if not self._api_key:
|
|
89
|
+
logger.error(
|
|
90
|
+
"No API key set — set %s env var or pass api_key",
|
|
91
|
+
_DEFAULT_API_KEY_ENV,
|
|
92
|
+
)
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"ApiEmbedder requires an API key via {_DEFAULT_API_KEY_ENV} "
|
|
95
|
+
"env var or the api_key parameter"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
headers = {
|
|
99
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
}
|
|
102
|
+
payload = {
|
|
103
|
+
"model": self.model,
|
|
104
|
+
"input": texts,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with httpx.Client(timeout=60.0) as client:
|
|
109
|
+
resp = client.post(
|
|
110
|
+
self._embeddings_url, headers=headers, json=payload
|
|
111
|
+
)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
data = resp.json()
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.warning("Batch embedding failed (%s), falling back per-item", exc)
|
|
116
|
+
# Fallback: embed one at a time
|
|
117
|
+
vecs = [self._embed_one(t) for t in texts]
|
|
118
|
+
return np.asarray(vecs, dtype=np.float32)
|
|
119
|
+
|
|
120
|
+
# Parse response — handle missing or malformed data gracefully
|
|
121
|
+
items = data.get("data", [])
|
|
122
|
+
if not items:
|
|
123
|
+
logger.warning("API returned empty data array — falling back per-item")
|
|
124
|
+
vecs = [self._embed_one(t) for t in texts]
|
|
125
|
+
return np.asarray(vecs, dtype=np.float32)
|
|
126
|
+
|
|
127
|
+
# Sort by index to preserve order
|
|
128
|
+
items.sort(key=lambda x: x.get("index", 0))
|
|
129
|
+
vecs = []
|
|
130
|
+
for item in items:
|
|
131
|
+
emb = item.get("embedding")
|
|
132
|
+
if emb is None:
|
|
133
|
+
logger.warning(
|
|
134
|
+
"Item index %s missing 'embedding' key, using zero vector",
|
|
135
|
+
item.get("index", "?"),
|
|
136
|
+
)
|
|
137
|
+
emb = [0.0] * (self._dim or 384)
|
|
138
|
+
vecs.append(emb)
|
|
139
|
+
|
|
140
|
+
result = np.asarray(vecs, dtype=np.float32)
|
|
141
|
+
# L2-normalize for cosine similarity
|
|
142
|
+
norms = np.linalg.norm(result, axis=1, keepdims=True)
|
|
143
|
+
norms = np.where(norms == 0, 1.0, norms)
|
|
144
|
+
result = result / norms
|
|
145
|
+
|
|
146
|
+
# Detect dimension from first response
|
|
147
|
+
if self._dim is None and len(vecs) > 0:
|
|
148
|
+
self._dim = len(vecs[0])
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
153
|
+
"""Embed a single string via the API."""
|
|
154
|
+
import httpx
|
|
155
|
+
|
|
156
|
+
headers = {
|
|
157
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
}
|
|
160
|
+
payload = {
|
|
161
|
+
"model": self.model,
|
|
162
|
+
"input": [text],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with httpx.Client(timeout=60.0) as client:
|
|
167
|
+
resp = client.post(
|
|
168
|
+
self._embeddings_url, headers=headers, json=payload
|
|
169
|
+
)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
data = resp.json()
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
logger.error("Single embedding request failed: %s", exc)
|
|
174
|
+
return [0.0] * (self._dim or 384)
|
|
175
|
+
|
|
176
|
+
items = data.get("data", [])
|
|
177
|
+
if items:
|
|
178
|
+
vec = items[0].get("embedding")
|
|
179
|
+
if vec is not None:
|
|
180
|
+
if self._dim is None:
|
|
181
|
+
self._dim = len(vec)
|
|
182
|
+
return vec
|
|
183
|
+
logger.warning(
|
|
184
|
+
"Item index 0 missing 'embedding' key, using zero vector"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
logger.warning("API returned empty data array for single item")
|
|
188
|
+
return [0.0] * (self._dim or 384)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Deterministic hashing embedder — offline, dependency-free, reproducible.
|
|
2
|
+
|
|
3
|
+
Maps text to a fixed-dim vector via feature hashing (the "hashing trick"): each
|
|
4
|
+
token is hashed to a dimension and a sign, counts are accumulated, then the
|
|
5
|
+
vector is L2-normalized. It captures lexical overlap, so cosine similarity is
|
|
6
|
+
meaningful enough to exercise the retrieval pipeline and write deterministic
|
|
7
|
+
tests — without downloading a model.
|
|
8
|
+
|
|
9
|
+
It is NOT a semantic model. Production uses a real embedder (BGE-M3 / Qwen3 /
|
|
10
|
+
Voyage / OpenAI) behind the same ``Embedder`` interface; swap via config.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeterministicEmbedder:
|
|
21
|
+
def __init__(self, dim: int = 256) -> None:
|
|
22
|
+
self._dim = dim
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def dim(self) -> int:
|
|
26
|
+
return self._dim
|
|
27
|
+
|
|
28
|
+
def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") -> np.ndarray:
|
|
29
|
+
out = np.zeros((len(texts), self._dim), dtype=np.float32)
|
|
30
|
+
for i, text in enumerate(texts):
|
|
31
|
+
for token in _tokenize(text, kind):
|
|
32
|
+
h = hashlib.sha1(token.encode("utf-8")).digest()
|
|
33
|
+
idx = int.from_bytes(h[:4], "big") % self._dim
|
|
34
|
+
sign = 1.0 if h[4] & 1 else -1.0
|
|
35
|
+
out[i, idx] += sign
|
|
36
|
+
norm = float(np.linalg.norm(out[i]))
|
|
37
|
+
if norm > 0:
|
|
38
|
+
out[i] /= norm
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tokenize(text: str, kind: str) -> list[str]:
|
|
43
|
+
text = (text or "").lower()
|
|
44
|
+
# For code, keep identifier-ish boundaries; for text, split on non-alphanumerics.
|
|
45
|
+
sep = "".join(c if c.isalnum() else " " for c in text)
|
|
46
|
+
tokens = [t for t in sep.split() if t]
|
|
47
|
+
# add character trigrams for short/OOV robustness
|
|
48
|
+
trigrams = [text[j:j + 3] for j in range(max(0, len(text) - 2))][:512]
|
|
49
|
+
return tokens + trigrams
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Real embedding model adapter + factory.
|
|
2
|
+
|
|
3
|
+
Wraps a sentence-transformers model (BGE-M3, Qwen3-Embedding, E5, MiniLM, …)
|
|
4
|
+
behind the ``Embedder`` interface. ``get_embedder`` returns a real model when one
|
|
5
|
+
is configured and importable, and otherwise falls back to the dependency-free
|
|
6
|
+
``DeterministicEmbedder`` so the system always runs (offline, CI).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from engraphis.backends.embedder_deterministic import DeterministicEmbedder
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SentenceTransformerEmbedder:
|
|
18
|
+
def __init__(self, model_name: str) -> None:
|
|
19
|
+
from sentence_transformers import SentenceTransformer # lazy: optional dependency
|
|
20
|
+
self.model = SentenceTransformer(model_name)
|
|
21
|
+
self._dim = int(self.model.get_sentence_embedding_dimension())
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def dim(self) -> int:
|
|
25
|
+
return self._dim
|
|
26
|
+
|
|
27
|
+
def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") -> np.ndarray:
|
|
28
|
+
vecs = self.model.encode(texts, normalize_embeddings=True, convert_to_numpy=True)
|
|
29
|
+
return np.asarray(vecs, dtype=np.float32)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: Why the real embedder last failed to load ("" when it loaded fine). The dashboard
|
|
33
|
+
#: surfaces this so a user can see and fix a broken semantic-search setup.
|
|
34
|
+
LAST_EMBEDDER_ERROR = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_embedder(model_name: Optional[str] = None, dim: int = 256):
|
|
38
|
+
"""A real model if available, else the deterministic offline embedder."""
|
|
39
|
+
global LAST_EMBEDDER_ERROR
|
|
40
|
+
if model_name:
|
|
41
|
+
try:
|
|
42
|
+
emb = SentenceTransformerEmbedder(model_name)
|
|
43
|
+
LAST_EMBEDDER_ERROR = ""
|
|
44
|
+
return emb
|
|
45
|
+
except Exception as exc: # noqa: BLE001 - optional dep; record why we fall back
|
|
46
|
+
LAST_EMBEDDER_ERROR = "%s: %s" % (type(exc).__name__, exc)
|
|
47
|
+
import logging
|
|
48
|
+
logging.getLogger("engraphis").warning(
|
|
49
|
+
"embedder '%s' unavailable (%s) - using the %d-dim deterministic "
|
|
50
|
+
"embedder; semantic recall/why/timeline will not match stored vectors.",
|
|
51
|
+
model_name, LAST_EMBEDDER_ERROR, dim)
|
|
52
|
+
return DeterministicEmbedder(dim)
|