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.
Files changed (78) hide show
  1. engraphis/__init__.py +8 -0
  2. engraphis/analytics.py +243 -0
  3. engraphis/app.py +228 -0
  4. engraphis/backends/__init__.py +16 -0
  5. engraphis/backends/codegraph.py +345 -0
  6. engraphis/backends/embedder_api.py +188 -0
  7. engraphis/backends/embedder_deterministic.py +49 -0
  8. engraphis/backends/embedder_st.py +52 -0
  9. engraphis/backends/extractor.py +148 -0
  10. engraphis/backends/graph_extractor.py +201 -0
  11. engraphis/backends/reranker.py +46 -0
  12. engraphis/backends/vector_numpy.py +52 -0
  13. engraphis/backends/vector_sqlitevec.py +88 -0
  14. engraphis/config.py +137 -0
  15. engraphis/core/__init__.py +43 -0
  16. engraphis/core/consolidate.py +349 -0
  17. engraphis/core/engine.py +517 -0
  18. engraphis/core/graphrank.py +73 -0
  19. engraphis/core/grounded.py +207 -0
  20. engraphis/core/ids.py +54 -0
  21. engraphis/core/interfaces.py +186 -0
  22. engraphis/core/recall.py +232 -0
  23. engraphis/core/resolve.py +99 -0
  24. engraphis/core/schema.py +191 -0
  25. engraphis/core/scoring.py +108 -0
  26. engraphis/core/store.py +598 -0
  27. engraphis/core/textutil.py +51 -0
  28. engraphis/dashboard_app.py +115 -0
  29. engraphis/engines/__init__.py +1 -0
  30. engraphis/engines/embedder.py +104 -0
  31. engraphis/engines/ingest.py +216 -0
  32. engraphis/engines/intelligence.py +176 -0
  33. engraphis/engines/recall.py +155 -0
  34. engraphis/engines/reweight.py +90 -0
  35. engraphis/engines/thoughts.py +67 -0
  36. engraphis/graphdata.py +65 -0
  37. engraphis/inspector/__init__.py +9 -0
  38. engraphis/inspector/app.py +436 -0
  39. engraphis/inspector/auth.py +252 -0
  40. engraphis/inspector/index.html +1255 -0
  41. engraphis/licensing.py +432 -0
  42. engraphis/llm/__init__.py +1 -0
  43. engraphis/llm/client.py +237 -0
  44. engraphis/logging_setup.py +65 -0
  45. engraphis/mcp_server.py +703 -0
  46. engraphis/models.py +180 -0
  47. engraphis/routes/__init__.py +1 -0
  48. engraphis/routes/memory.py +828 -0
  49. engraphis/routes/v2_api.py +444 -0
  50. engraphis/routes/v2_team.py +152 -0
  51. engraphis/routes/vault.py +615 -0
  52. engraphis/service.py +881 -0
  53. engraphis/stores/__init__.py +194 -0
  54. engraphis/stores/graph.py +138 -0
  55. engraphis/stores/ledger.py +139 -0
  56. engraphis/stores/vaults.py +117 -0
  57. engraphis/stores/vectors.py +270 -0
  58. engraphis-0.1.0.dist-info/METADATA +333 -0
  59. engraphis-0.1.0.dist-info/RECORD +78 -0
  60. engraphis-0.1.0.dist-info/WHEEL +5 -0
  61. engraphis-0.1.0.dist-info/entry_points.txt +9 -0
  62. engraphis-0.1.0.dist-info/licenses/LICENSE +201 -0
  63. engraphis-0.1.0.dist-info/licenses/NOTICE +14 -0
  64. engraphis-0.1.0.dist-info/top_level.txt +2 -0
  65. scripts/__init__.py +1 -0
  66. scripts/cli.py +181 -0
  67. scripts/consolidate.py +208 -0
  68. scripts/init.py +164 -0
  69. scripts/inspector.py +78 -0
  70. scripts/install_shortcuts.py +220 -0
  71. scripts/license_admin.py +121 -0
  72. scripts/migrate_to_v2.py +201 -0
  73. scripts/sdk_compat.py +45 -0
  74. scripts/seed_from_obsidian.py +98 -0
  75. scripts/start_dashboard.py +79 -0
  76. scripts/start_server.py +29 -0
  77. scripts/test_routes.py +198 -0
  78. 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)