aethergraph 0.1.0a3__py3-none-any.whl → 0.1.0a4__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 (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,387 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ import json
6
+ from pathlib import Path
7
+ import sqlite3
8
+ import time
9
+ from typing import Any
10
+
11
+ from aethergraph.contracts.storage.search_backend import ScoredItem, SearchBackend
12
+
13
+ LEXICAL_SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS docs (
15
+ corpus_id TEXT,
16
+ item_id TEXT,
17
+ text TEXT,
18
+ meta_json TEXT,
19
+ created_at_ts REAL,
20
+ org_id TEXT,
21
+ user_id TEXT,
22
+ scope_id TEXT,
23
+ client_id TEXT,
24
+ app_id TEXT,
25
+ session_id TEXT,
26
+ run_id TEXT,
27
+ graph_id TEXT,
28
+ node_id TEXT,
29
+ kind TEXT,
30
+ source TEXT,
31
+ PRIMARY KEY (corpus_id, item_id)
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_docs_corpus_scope_time
35
+ ON docs(corpus_id, scope_id, created_at_ts DESC);
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_docs_corpus_user_time
38
+ ON docs(corpus_id, user_id, created_at_ts DESC);
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_docs_corpus_org_time
41
+ ON docs(corpus_id, org_id, created_at_ts DESC);
42
+ """
43
+
44
+
45
+ def _ensure_db(path: str) -> None:
46
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
47
+ conn = sqlite3.connect(path, check_same_thread=False)
48
+ try:
49
+ cur = conn.cursor()
50
+ for stmt in LEXICAL_SCHEMA.strip().split(";\n\n"):
51
+ s = stmt.strip()
52
+ if s:
53
+ cur.execute(s)
54
+ conn.commit()
55
+ finally:
56
+ conn.close()
57
+
58
+
59
+ @dataclass
60
+ class SQLiteLexicalSearchBackend(SearchBackend):
61
+ """
62
+ Cheap non-LLM search backend.
63
+
64
+ - Upsert: store raw text + metadata in a SQLite table.
65
+ - Search: use simple keyword LIKE search + identity/time filters.
66
+
67
+
68
+ Right now the lexical backend is a simple bag-of-words search over a SQLite table:
69
+ - Every upsert stores: corpus_id, item_id, raw text, full meta_json, and promoted fields (org_id, user_id, scope_id, run_id, kind, source, created_at_ts, etc.) into docs.
70
+ - At query time, we:
71
+ - Use SQL to filter by corpus, org/user/scope, and optional time window (created_at_min/max), and sort by created_at_ts DESC LIMIT N (recency bias).
72
+ - Pull that candidate row set into Python.
73
+ - Tokenize the query ("sample text JSON artifact" → ["sample", "text", "json", "artifact"]).
74
+ - For each candidate text, count how many tokens appear (and how often), and derive a simple score:
75
+ - “more distinct query words present + a tiny bump for repeats = higher score.”
76
+ - Discard docs that match none of the tokens, return top-k by score.
77
+
78
+ NOTE: it’s exact token match, multi-word aware, and understands time + scope, but deliberately dumb:
79
+ - No stemming (“run” vs “running”), no synonyms, no typo/fuzzy matching.
80
+ - No real IR scoring (no TF-IDF/BM25, no field weighting, no phrase queries).
81
+ - Quality will degrade for huge corpora because ranking is naive and all ranking happens in Python.
82
+ - But it’s cheap, local, deterministic, and good enough for “I remember some words from that thing I saved.”
83
+ """
84
+
85
+ db_path: str
86
+
87
+ def __post_init__(self) -> None:
88
+ _ensure_db(self.db_path)
89
+
90
+ def _connect(self) -> sqlite3.Connection:
91
+ return sqlite3.connect(self.db_path, check_same_thread=False)
92
+
93
+ # -------- helpers ----------------------------------------------------
94
+
95
+ @staticmethod
96
+ def _parse_time_window(
97
+ time_window: str | None,
98
+ created_at_min: float | None,
99
+ created_at_max: float | None,
100
+ ) -> tuple[float | None, float | None]:
101
+ if not time_window:
102
+ return created_at_min, created_at_max
103
+
104
+ if created_at_min is not None and created_at_max is not None:
105
+ return created_at_min, created_at_max
106
+
107
+ # very simple parser: "7d", "24h", "30m", "60s"
108
+ import re
109
+
110
+ m = re.match(r"^\s*(\d+)\s*([smhd])\s*$", time_window)
111
+ if not m:
112
+ return created_at_min, created_at_max
113
+
114
+ value = int(m.group(1))
115
+ unit = m.group(2)
116
+ factor = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
117
+
118
+ now_ts = time.time()
119
+ duration = value * factor
120
+
121
+ if created_at_min is None:
122
+ created_at_min = now_ts - duration
123
+ if created_at_max is None:
124
+ created_at_max = now_ts
125
+
126
+ return created_at_min, created_at_max
127
+
128
+ # -------- public APIs -----------------------------------------------
129
+
130
+ async def upsert(
131
+ self,
132
+ *,
133
+ corpus: str,
134
+ item_id: str,
135
+ text: str,
136
+ metadata: dict[str, Any],
137
+ ) -> None:
138
+ """
139
+ Store text + metadata in docs table.
140
+
141
+ We mirror common promoted fields into columns for cheap filtering.
142
+ """
143
+ if not text:
144
+ text = ""
145
+
146
+ # Extract promoted fields from metadata
147
+ org_id = metadata.get("org_id")
148
+ user_id = metadata.get("user_id")
149
+ scope_id = metadata.get("scope_id")
150
+ client_id = metadata.get("client_id")
151
+ app_id = metadata.get("app_id")
152
+ session_id = metadata.get("session_id")
153
+ run_id = metadata.get("run_id")
154
+ graph_id = metadata.get("graph_id")
155
+ node_id = metadata.get("node_id")
156
+ kind = metadata.get("kind")
157
+ source = metadata.get("source")
158
+ created_at_ts = metadata.get("created_at_ts")
159
+
160
+ # If no created_at_ts given, fallback to "now" (cheap and good enough)
161
+ if created_at_ts is None:
162
+ created_at_ts = time.time()
163
+
164
+ meta_json = json.dumps(metadata, ensure_ascii=False)
165
+
166
+ def _upsert_sync() -> None:
167
+ conn = self._connect()
168
+ try:
169
+ cur = conn.cursor()
170
+ cur.execute(
171
+ """
172
+ REPLACE INTO docs(
173
+ corpus_id,
174
+ item_id,
175
+ text,
176
+ meta_json,
177
+ created_at_ts,
178
+ org_id,
179
+ user_id,
180
+ scope_id,
181
+ client_id,
182
+ app_id,
183
+ session_id,
184
+ run_id,
185
+ graph_id,
186
+ node_id,
187
+ kind,
188
+ source
189
+ )
190
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
191
+ """,
192
+ (
193
+ corpus,
194
+ item_id,
195
+ text,
196
+ meta_json,
197
+ float(created_at_ts),
198
+ org_id,
199
+ user_id,
200
+ scope_id,
201
+ client_id,
202
+ app_id,
203
+ session_id,
204
+ run_id,
205
+ graph_id,
206
+ node_id,
207
+ kind,
208
+ source,
209
+ ),
210
+ )
211
+ conn.commit()
212
+ finally:
213
+ conn.close()
214
+
215
+ await asyncio.to_thread(_upsert_sync)
216
+
217
+ async def search(
218
+ self,
219
+ *,
220
+ corpus: str,
221
+ query: str,
222
+ top_k: int = 10,
223
+ filters: dict[str, Any] | None = None,
224
+ time_window: str | None = None,
225
+ created_at_min: float | None = None,
226
+ created_at_max: float | None = None,
227
+ ) -> list[ScoredItem]:
228
+ if not query.strip():
229
+ return []
230
+
231
+ filters = filters or {}
232
+
233
+ # Compute final time bounds
234
+ created_at_min, created_at_max = self._parse_time_window(
235
+ time_window, created_at_min, created_at_max
236
+ )
237
+
238
+ # We’ll do a cheap LIKE search on text and apply filters in SQL where possible,
239
+ # remaining filters in Python.
240
+
241
+ def _search_sync() -> list[ScoredItem]:
242
+ conn = self._connect()
243
+ try:
244
+ cur = conn.cursor()
245
+
246
+ sql = """
247
+ SELECT item_id, text, meta_json, created_at_ts
248
+ FROM docs
249
+ WHERE corpus_id = ?
250
+ """
251
+ params: list[Any] = [corpus]
252
+
253
+ # subset of filters we can push into SQL
254
+ promoted_cols = {
255
+ "org_id",
256
+ "user_id",
257
+ "scope_id",
258
+ "client_id",
259
+ "app_id",
260
+ "session_id",
261
+ "run_id",
262
+ "graph_id",
263
+ "node_id",
264
+ "kind",
265
+ "source",
266
+ }
267
+
268
+ sql_filters: dict[str, Any] = {}
269
+ py_filters: dict[str, Any] = {}
270
+
271
+ for k, v in filters.items():
272
+ if v is None:
273
+ continue
274
+ if k in promoted_cols and not isinstance(v, (list, tuple, set)): # noqa: UP038
275
+ sql_filters[k] = v
276
+ else:
277
+ py_filters[k] = v
278
+
279
+ for key, val in sql_filters.items():
280
+ sql += f" AND {key} = ?"
281
+ params.append(val)
282
+
283
+ # Time window
284
+ if created_at_min is not None:
285
+ sql += " AND created_at_ts >= ?"
286
+ params.append(created_at_min)
287
+ if created_at_max is not None:
288
+ sql += " AND created_at_ts <= ?"
289
+ params.append(created_at_max)
290
+
291
+ # Bias toward recent, like vector backend
292
+ sql += " ORDER BY created_at_ts DESC LIMIT ?"
293
+ params.append(max(top_k * 50, top_k))
294
+
295
+ cur.execute(sql, params)
296
+ rows = cur.fetchall()
297
+ finally:
298
+ conn.close()
299
+
300
+ # Build results, apply any remaining filters in Python, and
301
+ # assign a simple "score" (e.g., count of occurrences)
302
+ results: list[ScoredItem] = []
303
+
304
+ # Basic bag-of-words: split query into tokens
305
+ tokens = [t for t in query.lower().split() if t]
306
+
307
+ for item_id, text, meta_json, _ in rows:
308
+ meta = json.loads(meta_json)
309
+
310
+ # Python-level filters (e.g., list-valued filters)
311
+ match = True
312
+ for key, val in py_filters.items():
313
+ if key not in meta:
314
+ match = False
315
+ break
316
+ mv = meta[key]
317
+ if not self._match_value(mv, val):
318
+ match = False
319
+ break
320
+ if not match:
321
+ continue
322
+
323
+ text_lower = (text or "").lower()
324
+
325
+ # Naive scoring: token-based exact matches
326
+ match_tokens = 0
327
+ total_hits = 0
328
+ for tok in tokens:
329
+ c = text_lower.count(tok)
330
+ if c > 0:
331
+ match_tokens += 1
332
+ total_hits += c
333
+
334
+ # If none of the tokens appear, skip
335
+ if match_tokens == 0:
336
+ continue
337
+
338
+ # Score: prioritize docs that match more distinct tokens,
339
+ # with a small bump for repeated occurrences.
340
+ score = float(match_tokens) + 0.1 * float(total_hits)
341
+
342
+ results.append(
343
+ ScoredItem(
344
+ item_id=item_id,
345
+ corpus=corpus,
346
+ score=score,
347
+ metadata=meta,
348
+ )
349
+ )
350
+
351
+ if len(results) >= top_k:
352
+ break
353
+
354
+ return results
355
+
356
+ return await asyncio.to_thread(_search_sync)
357
+
358
+ @staticmethod
359
+ def _match_value(mv: Any, val: Any) -> bool:
360
+ """
361
+ Rich matching semantics for filters:
362
+ - If val is list/tuple/set:
363
+ - if mv is list-like too -> match if intersection is non-empty
364
+ - else -> match if mv is in val
365
+ - If val is scalar:
366
+ - if mv is list-like -> match if val is in mv
367
+ - else -> match if mv == val
368
+ """
369
+ if val is None:
370
+ return True
371
+
372
+ def _is_list_like(x: Any) -> bool:
373
+ return isinstance(x, (list, tuple, set)) # noqa: UP038
374
+
375
+ if _is_list_like(val):
376
+ if _is_list_like(mv):
377
+ # any overlap between filter values and meta values
378
+ return any(x in val for x in mv)
379
+ else:
380
+ # meta is scalar, filter is list-like
381
+ return mv in val
382
+
383
+ # val is scalar
384
+ if _is_list_like(mv):
385
+ return val in mv
386
+
387
+ return mv == val
@@ -0,0 +1,31 @@
1
+ import re
2
+
3
+ _DURATION_PATTERN = re.compile(r"^\s*(\d+)\s*([smhd])\s*$")
4
+
5
+
6
+ def _parse_time_window(window: str) -> float:
7
+ """
8
+ Parse a simple duration string like:
9
+ - "30s" (seconds)
10
+ - "15m" (minutes)
11
+ - "2h" (hours)
12
+ - "7d" (days)
13
+
14
+ Returns duration in seconds.
15
+ Raises ValueError on invalid format.
16
+ """
17
+ m = _DURATION_PATTERN.match(window)
18
+ if not m:
19
+ raise ValueError(f"Invalid time_window format: {window!r}")
20
+ value = int(m.group(1))
21
+ unit = m.group(2)
22
+
23
+ if unit == "s":
24
+ return float(value)
25
+ if unit == "m":
26
+ return float(value) * 60.0
27
+ if unit == "h":
28
+ return float(value) * 3600.0
29
+ if unit == "d":
30
+ return float(value) * 86400.0
31
+ raise ValueError(f"Unknown time unit in time_window: {window!r}")
@@ -0,0 +1,75 @@
1
+ # search_factory.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from aethergraph.config.config import AppSettings
8
+ from aethergraph.config.search import SearchBackendSettings
9
+ from aethergraph.contracts.services.llm import EmbeddingClientProtocol
10
+ from aethergraph.contracts.storage.search_backend import SearchBackend
11
+ from aethergraph.contracts.storage.vector_index import VectorIndex
12
+ from aethergraph.storage.search_backend.generic_vector_backend import GenericVectorSearchBackend
13
+ from aethergraph.storage.search_backend.null_backend import NullSearchBackend
14
+ from aethergraph.storage.search_backend.sqlite_lexical_backend import SQLiteLexicalSearchBackend
15
+ from aethergraph.storage.vector_index.faiss_index import FAISSVectorIndex
16
+ from aethergraph.storage.vector_index.sqlite_index import SQLiteVectorIndex
17
+
18
+
19
+ def build_vector_index_for_search(root: str, cfg: SearchBackendSettings) -> VectorIndex:
20
+ """
21
+ Helper to build a VectorIndex specifically for search, based on cfg.search.backend.
22
+ This is intentionally separate from storage.vector_index (legacy RAG index).
23
+ """
24
+ if cfg.backend == "sqlite_vector":
25
+ s = cfg.sqlite_vector
26
+ index_root = os.path.join(root, s.dir)
27
+ return SQLiteVectorIndex(root=index_root)
28
+
29
+ if cfg.backend == "faiss_vector":
30
+ s = cfg.faiss_vector
31
+ index_root = os.path.join(root, s.dir)
32
+ return FAISSVectorIndex(root=index_root, dim=s.dim)
33
+
34
+ raise ValueError(f"build_vector_index_for_search: unsupported backend {cfg.backend!r}")
35
+
36
+
37
+ def build_search_backend(
38
+ cfg: AppSettings,
39
+ *,
40
+ embedder: EmbeddingClientProtocol | None,
41
+ ) -> SearchBackend:
42
+ """
43
+ Factory to build the high-level SearchBackend used by ScopedIndices.
44
+
45
+ Respects cfg.search.backend:
46
+ - "none" -> NullSearchBackend
47
+ - "sqlite_lexical"-> SQLiteLexicalSearchBackend
48
+ - "sqlite_vector" -> VectorSearchBackend + SQLiteVectorIndex
49
+ - "faiss_vector" -> VectorSearchBackend + FAISSVectorIndex
50
+ """
51
+ scfg = cfg.search
52
+ root = os.path.abspath(cfg.root)
53
+
54
+ # 1) No search at all
55
+ if scfg.backend == "none":
56
+ return NullSearchBackend()
57
+
58
+ # 2) Pure lexical, no LLM / embeddings
59
+ if scfg.backend == "sqlite_lexical":
60
+ lcfg = scfg.sqlite_lexical
61
+ db_path = os.path.join(root, lcfg.dir, lcfg.filename)
62
+ return SQLiteLexicalSearchBackend(db_path=db_path)
63
+
64
+ # 3) Vector search backends (sqlite or faiss)
65
+ if scfg.backend in ("sqlite_vector", "faiss_vector"):
66
+ if embedder is None:
67
+ raise RuntimeError(
68
+ f"Search backend {scfg.backend!r} requires an embedding client. "
69
+ "Pass an EmbeddingClientProtocol instance into build_search_backend()."
70
+ )
71
+
72
+ index = build_vector_index_for_search(root, scfg)
73
+ return GenericVectorSearchBackend(index=index, embedder=embedder)
74
+
75
+ raise ValueError(f"Unknown search backend: {scfg.backend!r}")
@@ -148,10 +148,29 @@ class FAISSVectorIndex(VectorIndex):
148
148
  corpus_id: str,
149
149
  query_vec: list[float],
150
150
  k: int,
151
+ where: dict[str, Any] | None = None,
152
+ max_candidates: int | None = None,
153
+ created_at_min: float | None = None,
154
+ created_at_max: float | None = None,
151
155
  ) -> list[dict[str, Any]]:
156
+ """
157
+ FAISS-backed search with compatibility to SQLiteVectorIndex:
158
+
159
+ - where: equality filters on metadata (e.g., org_id, user_id, scope_id, etc.)
160
+ - created_at_min / created_at_max: numeric UNIX timestamps for time-range filtering.
161
+ - max_candidates: how many FAISS hits to retrieve before filtering.
162
+
163
+ Since FAISS doesn't support filtering natively, we:
164
+ 1) Search across all vectors (or up to max_candidates).
165
+ 2) Manually filter results by `where` and time bounds.
166
+ """
167
+
152
168
  if faiss is None:
153
169
  raise RuntimeError("FAISS not installed")
154
170
 
171
+ where = where or {}
172
+
173
+ # Normalize query vector for cosine similarity
155
174
  q = np.asarray([query_vec], dtype=np.float32)
156
175
  q = q / (np.linalg.norm(q, axis=1, keepdims=True) + 1e-9)
157
176
 
@@ -159,21 +178,70 @@ class FAISSVectorIndex(VectorIndex):
159
178
  index, metas = self._load_sync(corpus_id)
160
179
  if index is None or not metas:
161
180
  return []
162
- D, I = index.search(q, k) # noqa: E741
163
- out: list[dict[str, Any]] = []
181
+
182
+ n = len(metas)
183
+ if n == 0:
184
+ return []
185
+
186
+ # How many neighbors to ask FAISS for:
187
+ # - k here is "raw_k" from SearchBackend (e.g., top_k * 3)
188
+ # - max_candidates is an outer cap (e.g., top_k * 50)
189
+ search_k = min(
190
+ n,
191
+ max_candidates or n,
192
+ )
193
+ if search_k <= 0:
194
+ return []
195
+
196
+ # Ask FAISS for the top search_k neighbors
197
+ D, I = index.search(q, search_k) # noqa: E741
164
198
  scores = D[0].tolist()
165
199
  idxs = I[0].tolist()
200
+
201
+ out: list[dict[str, Any]] = []
202
+
166
203
  for score, idx in zip(scores, idxs, strict=True):
167
204
  if idx < 0 or idx >= len(metas):
168
205
  continue
169
- m = metas[idx]
206
+
207
+ m = metas[idx] # {"chunk_id": ..., "meta": {...}}
208
+ meta = dict(m.get("meta") or {})
209
+
210
+ # --- Apply `where` equality filters ----------------------
211
+ match = True
212
+ for key, val in where.items():
213
+ if val is None:
214
+ continue
215
+ if meta.get(key) != val:
216
+ match = False
217
+ break
218
+ if not match:
219
+ continue
220
+
221
+ # --- Apply time-window filters ---------------------------
222
+ cat = meta.get("created_at_ts")
223
+ # If we have a time bound but no created_at_ts, we treat as non-match
224
+ if created_at_min is not None and (
225
+ cat is None or float(cat) < float(created_at_min)
226
+ ):
227
+ continue
228
+ if created_at_max is not None and (
229
+ cat is None or float(cat) > float(created_at_max)
230
+ ):
231
+ continue
232
+
170
233
  out.append(
171
234
  {
172
235
  "chunk_id": m["chunk_id"],
173
236
  "score": float(score),
174
- "meta": m["meta"],
237
+ "meta": meta,
175
238
  }
176
239
  )
240
+
241
+ # Stop once we've collected k matches
242
+ if len(out) >= k:
243
+ break
244
+
177
245
  return out
178
246
 
179
247
  return await asyncio.to_thread(_search_sync)