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.
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +3 -0
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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":
|
|
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)
|