search-as-code 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- search_as_code/__init__.py +89 -0
- search_as_code/_resilience.py +99 -0
- search_as_code/adapters/__init__.py +5 -0
- search_as_code/adapters/base.py +108 -0
- search_as_code/adapters/chroma.py +95 -0
- search_as_code/adapters/faiss_store.py +90 -0
- search_as_code/adapters/memory.py +148 -0
- search_as_code/adapters/milvus_store.py +87 -0
- search_as_code/adapters/nmslib_store.py +102 -0
- search_as_code/adapters/opensearch.py +370 -0
- search_as_code/adapters/pgvector.py +110 -0
- search_as_code/adapters/qdrant.py +133 -0
- search_as_code/adapters/registry.py +98 -0
- search_as_code/adapters/sqlite_store.py +105 -0
- search_as_code/embeddings.py +155 -0
- search_as_code/errors.py +145 -0
- search_as_code/explore/__init__.py +31 -0
- search_as_code/explore/engine.py +160 -0
- search_as_code/explore/pack.py +127 -0
- search_as_code/explore/report.py +72 -0
- search_as_code/explore/stages.py +349 -0
- search_as_code/filters.py +102 -0
- search_as_code/primitives.py +422 -0
- search_as_code/rerankers.py +94 -0
- search_as_code/sandbox.py +116 -0
- search_as_code/session.py +460 -0
- search_as_code/types.py +129 -0
- search_as_code-0.0.1.dist-info/METADATA +234 -0
- search_as_code-0.0.1.dist-info/RECORD +32 -0
- search_as_code-0.0.1.dist-info/WHEEL +5 -0
- search_as_code-0.0.1.dist-info/licenses/LICENSE +201 -0
- search_as_code-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Exploration stages.
|
|
2
|
+
|
|
3
|
+
Implemented: :class:`SampleStage` (stratified sample), :class:`ProfileStage` (content-type
|
|
4
|
+
mix + LLM characterization), :class:`SynthesizeStage` (grounded synthetic queries) and
|
|
5
|
+
:class:`ValidateStage` (held-out retrieval eval + keep-if-better report). The rest
|
|
6
|
+
(ontology/crossdoc/router/codegen) are typed stubs recorded as ``planned`` so the pipeline
|
|
7
|
+
runs end-to-end and the pack shows the roadmap.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections import Counter
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .engine import ExploreContext, Stage # noqa: F401 (re-exported for convenience)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --------------------------------------------------------------------------- #
|
|
22
|
+
# tiny, dependency-free k-means so sampling stays stratified without sklearn #
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
def _kmeans(x: np.ndarray, k: int, iters: int = 25, seed: int = 0):
|
|
25
|
+
"""Return (labels, centroids). k-means++ init, cosine-friendly on L2-normed x."""
|
|
26
|
+
n = len(x)
|
|
27
|
+
k = max(1, min(k, n))
|
|
28
|
+
rng = np.random.default_rng(seed)
|
|
29
|
+
# k-means++ seeding
|
|
30
|
+
centers = [int(rng.integers(n))]
|
|
31
|
+
d2 = ((x - x[centers[0]]) ** 2).sum(1)
|
|
32
|
+
for _ in range(1, k):
|
|
33
|
+
probs = d2 / (d2.sum() or 1.0)
|
|
34
|
+
nxt = int(rng.choice(n, p=probs))
|
|
35
|
+
centers.append(nxt)
|
|
36
|
+
d2 = np.minimum(d2, ((x - x[nxt]) ** 2).sum(1))
|
|
37
|
+
cent = x[centers].copy()
|
|
38
|
+
labels = np.zeros(n, dtype=int)
|
|
39
|
+
for _ in range(iters):
|
|
40
|
+
# assign
|
|
41
|
+
dists = ((x[:, None, :] - cent[None, :, :]) ** 2).sum(2)
|
|
42
|
+
new = dists.argmin(1)
|
|
43
|
+
if np.array_equal(new, labels) and _ > 0:
|
|
44
|
+
break
|
|
45
|
+
labels = new
|
|
46
|
+
# update
|
|
47
|
+
for j in range(k):
|
|
48
|
+
m = labels == j
|
|
49
|
+
if m.any():
|
|
50
|
+
cent[j] = x[m].mean(0)
|
|
51
|
+
return labels, cent
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
# Stage 1 — stratified sample #
|
|
56
|
+
# --------------------------------------------------------------------------- #
|
|
57
|
+
class SampleStage(Stage):
|
|
58
|
+
"""Draw a *representative* working sample: pull a larger random pool, cluster it in
|
|
59
|
+
embedding space, then keep a few docs nearest each centroid. This surfaces rare doc
|
|
60
|
+
types that a flat random-n sample would miss."""
|
|
61
|
+
|
|
62
|
+
name = "sample"
|
|
63
|
+
produces = ["sample.jsonl", "sample_meta.json"]
|
|
64
|
+
|
|
65
|
+
def run(self, ctx: ExploreContext) -> dict:
|
|
66
|
+
pool_size = int(ctx.cfg("pool_size", 200))
|
|
67
|
+
per_cluster = int(ctx.cfg("per_cluster", 3))
|
|
68
|
+
pool = ctx.store.sample(pool_size)
|
|
69
|
+
pool = [d for d in pool if (d.text or "").strip()]
|
|
70
|
+
if not pool:
|
|
71
|
+
ctx.pack.write_jsonl("sample.jsonl", [])
|
|
72
|
+
ctx.pack.write_json("sample_meta.json", {"pool": 0, "clusters": 0, "sample": 0})
|
|
73
|
+
return {"pool": 0, "clusters": 0, "sample": 0}
|
|
74
|
+
|
|
75
|
+
vecs = np.asarray(ctx.embedder.embed([d.text or "" for d in pool]), dtype=np.float32)
|
|
76
|
+
vecs = vecs / (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-9)
|
|
77
|
+
n_clusters = int(ctx.cfg("n_clusters", max(1, min(8, len(pool) // 5))))
|
|
78
|
+
labels, cent = _kmeans(vecs, n_clusters, seed=int(ctx.cfg("seed", 0)))
|
|
79
|
+
|
|
80
|
+
rows, sizes = [], {}
|
|
81
|
+
for j in range(len(cent)):
|
|
82
|
+
idx = np.where(labels == j)[0]
|
|
83
|
+
if len(idx) == 0:
|
|
84
|
+
continue
|
|
85
|
+
sizes[str(j)] = int(len(idx))
|
|
86
|
+
# closest to centroid first → most typical of the cluster
|
|
87
|
+
order = idx[np.argsort(((vecs[idx] - cent[j]) ** 2).sum(1))]
|
|
88
|
+
for i in order[:per_cluster]:
|
|
89
|
+
d = pool[int(i)]
|
|
90
|
+
rows.append({"id": d.id, "cluster": j, "text": d.text,
|
|
91
|
+
"metadata": d.metadata or {}})
|
|
92
|
+
ctx.pack.write_jsonl("sample.jsonl", rows)
|
|
93
|
+
ctx.pack.write_json("sample_meta.json",
|
|
94
|
+
{"pool": len(pool), "clusters": len(sizes),
|
|
95
|
+
"cluster_sizes": sizes, "sample": len(rows)})
|
|
96
|
+
return {"pool": len(pool), "clusters": len(sizes), "sample": len(rows)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
# Stage 2 — profile #
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
102
|
+
class ProfileStage(Stage):
|
|
103
|
+
"""Characterize the corpus: schema + per-chunk content-type mix (heuristic) and,
|
|
104
|
+
when a generator is present, an LLM-written profile of the data (data type, key
|
|
105
|
+
entities, recommended primitives) — both overall and per cluster."""
|
|
106
|
+
|
|
107
|
+
name = "profile"
|
|
108
|
+
requires = ["sample"]
|
|
109
|
+
produces = ["content_profile.json"]
|
|
110
|
+
|
|
111
|
+
def run(self, ctx: ExploreContext) -> dict:
|
|
112
|
+
from ..primitives import content_type
|
|
113
|
+
|
|
114
|
+
sample = ctx.pack.read_jsonl("sample.jsonl")
|
|
115
|
+
use_llm = bool(ctx.cfg("llm", ctx.generator is not None))
|
|
116
|
+
|
|
117
|
+
types: dict[str, int] = {}
|
|
118
|
+
for r in sample:
|
|
119
|
+
ct = content_type(r.get("text") or "")
|
|
120
|
+
types[ct] = types.get(ct, 0) + 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
schema = ctx.store.describe_schema()
|
|
124
|
+
except Exception:
|
|
125
|
+
schema = {"backend": getattr(ctx.store, "backend", "?")}
|
|
126
|
+
|
|
127
|
+
overall_llm = None
|
|
128
|
+
cluster_profiles: dict[str, str] = {}
|
|
129
|
+
if use_llm and ctx.generator is not None:
|
|
130
|
+
overall_llm = _llm_profile(ctx, schema, [r["text"] for r in sample[:8]])
|
|
131
|
+
by_cluster: dict[int, list[str]] = {}
|
|
132
|
+
for r in sample:
|
|
133
|
+
by_cluster.setdefault(r.get("cluster", 0), []).append(r.get("text") or "")
|
|
134
|
+
for c, texts in sorted(by_cluster.items()):
|
|
135
|
+
cluster_profiles[str(c)] = _llm_profile(ctx, schema, texts[:4])
|
|
136
|
+
|
|
137
|
+
profile = {
|
|
138
|
+
"schema": schema,
|
|
139
|
+
"content_types": types,
|
|
140
|
+
"n_sampled": len(sample),
|
|
141
|
+
"llm_overall": overall_llm,
|
|
142
|
+
"llm_by_cluster": cluster_profiles,
|
|
143
|
+
}
|
|
144
|
+
ctx.pack.write_json("content_profile.json", profile)
|
|
145
|
+
return {"content_types": types, "clusters_profiled": len(cluster_profiles),
|
|
146
|
+
"llm": bool(overall_llm)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _llm_profile(ctx: ExploreContext, schema: dict, texts: list[str]) -> str:
|
|
150
|
+
fields = schema.get("fields") or schema.get("metadata_keys") or {}
|
|
151
|
+
rows = "\n".join(f"- {(t or '')[:300]}" for t in texts if t) or "(no text)"
|
|
152
|
+
prompt = (
|
|
153
|
+
"You are profiling a search corpus before writing retrieval code.\n"
|
|
154
|
+
f"Backend: {schema.get('backend') or schema.get('index')} "
|
|
155
|
+
f"Approx docs: {schema.get('count')}\nFields: {fields}\n\n"
|
|
156
|
+
f"Sample documents:\n{rows}\n\n"
|
|
157
|
+
"In 4-6 short lines: (1) what kind of data this is (prose, tables, curated "
|
|
158
|
+
"fact-cards, code, mixed?), (2) key entities/fields a query targets, (3) which "
|
|
159
|
+
"retrieval primitives fit best (keyword/exact & regex for part-numbers & fact-cards, "
|
|
160
|
+
"dense/hyde for prose, fielded/phrase for structured fields)."
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
out = ctx.generator(prompt)
|
|
164
|
+
return out[0] if isinstance(out, list) else str(out)
|
|
165
|
+
except Exception as e: # pragma: no cover
|
|
166
|
+
return f"(llm profile unavailable: {e})"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
# Stages 3-7 — typed stubs (recorded as ``planned`` until implemented) #
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
class _PlannedStage(Stage):
|
|
173
|
+
_todo = "not implemented yet"
|
|
174
|
+
|
|
175
|
+
def run(self, ctx: ExploreContext) -> dict:
|
|
176
|
+
raise NotImplementedError(self._todo)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class OntologyStage(_PlannedStage):
|
|
180
|
+
"""LLM-induce a domain ontology (entities/relations/synonyms) from the sample; then
|
|
181
|
+
best-effort enrich from the web and reconcile; surface for review."""
|
|
182
|
+
name = "ontology"
|
|
183
|
+
requires = ["profile"]
|
|
184
|
+
produces = ["ontology.json"]
|
|
185
|
+
_todo = "LLM-induced + web-enriched ontology (next pass)"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class CrossDocStage(_PlannedStage):
|
|
189
|
+
"""Link documents via the ontology (entity co-occurrence / KG edges) — the
|
|
190
|
+
cross-document relation layer used for candidate expansion."""
|
|
191
|
+
name = "crossdoc"
|
|
192
|
+
requires = ["ontology"]
|
|
193
|
+
produces = ["crossdoc.json"]
|
|
194
|
+
_todo = "cross-document relation graph (next pass)"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class SynthesizeStage(Stage):
|
|
198
|
+
"""Generate stratified easy/medium/hard queries **grounded in the sampled docs** — each
|
|
199
|
+
query's answer lives in a known document (its ``gold_id``). This is a leakage-free eval/
|
|
200
|
+
training set (built from the corpus, never from the downstream test set) used by
|
|
201
|
+
``validate`` (and later the router/few-shot mining)."""
|
|
202
|
+
|
|
203
|
+
name = "synthesize"
|
|
204
|
+
requires = ["sample"]
|
|
205
|
+
produces = ["synth_queries.jsonl"]
|
|
206
|
+
|
|
207
|
+
def run(self, ctx: ExploreContext) -> dict:
|
|
208
|
+
if ctx.generator is None:
|
|
209
|
+
raise RuntimeError("synthesize needs a Session generator")
|
|
210
|
+
sample = ctx.pack.read_jsonl("sample.jsonl")
|
|
211
|
+
max_docs = int(ctx.cfg("synth_docs", 30))
|
|
212
|
+
per_doc = int(ctx.cfg("synth_per_doc", 3))
|
|
213
|
+
rows = []
|
|
214
|
+
for r in sample[:max_docs]:
|
|
215
|
+
for diff, q in _gen_queries(ctx, r.get("text") or "", per_doc):
|
|
216
|
+
rows.append({"query": q, "gold_id": r["id"],
|
|
217
|
+
"cluster": r.get("cluster"), "difficulty": diff})
|
|
218
|
+
ctx.pack.write_jsonl("synth_queries.jsonl", rows)
|
|
219
|
+
return {"queries": len(rows), "from_docs": min(len(sample), max_docs),
|
|
220
|
+
"by_difficulty": dict(Counter(x["difficulty"] for x in rows))}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _gen_queries(ctx: ExploreContext, text: str, per_doc: int):
|
|
224
|
+
if not text.strip():
|
|
225
|
+
return []
|
|
226
|
+
prompt = (
|
|
227
|
+
f"From the technical document below, write {per_doc} distinct search questions whose "
|
|
228
|
+
"answer is IN this document. Vary difficulty across: 'easy' (direct keyword/fact "
|
|
229
|
+
"lookup), 'medium' (paraphrased/conceptual), 'hard' (indirect or multi-constraint). "
|
|
230
|
+
'Return ONLY a JSON list of {"difficulty": "...", "query": "..."}. Keep each query '
|
|
231
|
+
f"self-contained (no 'this document').\n\nDOCUMENT:\n{text[:1200]}"
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
out = ctx.generator(prompt)
|
|
235
|
+
txt = out[0] if isinstance(out, list) else str(out)
|
|
236
|
+
except Exception:
|
|
237
|
+
return []
|
|
238
|
+
m = re.search(r"\[.*\]", txt, re.DOTALL)
|
|
239
|
+
if not m:
|
|
240
|
+
return []
|
|
241
|
+
res = []
|
|
242
|
+
try:
|
|
243
|
+
for o in json.loads(m.group(0)):
|
|
244
|
+
q = (o.get("query") or "").strip()
|
|
245
|
+
if q:
|
|
246
|
+
res.append((o.get("difficulty", "?"), q))
|
|
247
|
+
except Exception:
|
|
248
|
+
return []
|
|
249
|
+
return res[:per_doc]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class RouterStage(_PlannedStage):
|
|
253
|
+
"""Run primitive combos over the synth queries, label which retrieves gold, train
|
|
254
|
+
the XGBoost primitive router (query+profile features → best combo)."""
|
|
255
|
+
name = "router"
|
|
256
|
+
requires = ["synthesize"]
|
|
257
|
+
produces = ["router.pkl", "router_meta.json"]
|
|
258
|
+
_todo = "combo-exploration + XGB router (port from phase4)"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class CodegenStage(_PlannedStage):
|
|
262
|
+
"""Mine winning combos into (situation→chain) templates and (query→code) few-shots;
|
|
263
|
+
optionally LLM-codegen new sandbox-validated primitives; write prompt overrides."""
|
|
264
|
+
name = "codegen"
|
|
265
|
+
requires = ["router"]
|
|
266
|
+
produces = ["templates.json", "few_shots.json", "prompt_overrides.md"]
|
|
267
|
+
_todo = "template/few-shot mining + sandbox-validated primitive codegen (next pass)"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ValidateStage(Stage):
|
|
271
|
+
"""Measure retrieval on the grounded synth queries: recall@k for each base strategy
|
|
272
|
+
(dense/keyword/hybrid), overall and per cluster. Establishes the baseline and the
|
|
273
|
+
best strategy per data-shape — the signal later tunings must beat. Writes a JSON +
|
|
274
|
+
a human-readable REPORT.md."""
|
|
275
|
+
|
|
276
|
+
name = "validate"
|
|
277
|
+
requires = ["synthesize"]
|
|
278
|
+
produces = ["validation.json", "REPORT.md"]
|
|
279
|
+
|
|
280
|
+
STRATEGIES = ["dense", "keyword", "hybrid"]
|
|
281
|
+
|
|
282
|
+
def run(self, ctx: ExploreContext) -> dict:
|
|
283
|
+
qs = ctx.pack.read_jsonl("synth_queries.jsonl")
|
|
284
|
+
if not qs:
|
|
285
|
+
raise RuntimeError("no synth queries to validate on")
|
|
286
|
+
k = int(ctx.cfg("validate_k", 10))
|
|
287
|
+
hit = {s: 0 for s in self.STRATEGIES}
|
|
288
|
+
by_cluster: dict = {} # cluster -> strategy -> [hits, n]
|
|
289
|
+
by_diff: dict = {} # difficulty -> strategy -> [hits, n]
|
|
290
|
+
for row in qs:
|
|
291
|
+
gold, c, d = row["gold_id"], row.get("cluster"), row.get("difficulty", "?")
|
|
292
|
+
for s in self.STRATEGIES:
|
|
293
|
+
ids = _search_ids(ctx, s, row["query"], k)
|
|
294
|
+
h = int(gold in ids)
|
|
295
|
+
hit[s] += h
|
|
296
|
+
by_cluster.setdefault(c, {}).setdefault(s, [0, 0])
|
|
297
|
+
by_cluster[c][s][0] += h; by_cluster[c][s][1] += 1
|
|
298
|
+
by_diff.setdefault(d, {}).setdefault(s, [0, 0])
|
|
299
|
+
by_diff[d][s][0] += h; by_diff[d][s][1] += 1
|
|
300
|
+
|
|
301
|
+
n = len(qs)
|
|
302
|
+
recall = {s: hit[s] / n for s in self.STRATEGIES}
|
|
303
|
+
best = max(recall, key=recall.get)
|
|
304
|
+
cluster_best = {str(c): _best(d) for c, d in by_cluster.items()}
|
|
305
|
+
result = {"n": n, "k": k, "recall_at_k": recall, "best_overall": best,
|
|
306
|
+
"cluster_best": cluster_best,
|
|
307
|
+
"by_difficulty": {d: _rates(v) for d, v in by_diff.items()}}
|
|
308
|
+
ctx.pack.write_json("validation.json", result)
|
|
309
|
+
ctx.pack.path("REPORT.md").write_text(_report_md(ctx, result))
|
|
310
|
+
return {"n": n, "best": best,
|
|
311
|
+
"recall_at_k": {s: round(recall[s], 3) for s in self.STRATEGIES}}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _search_ids(ctx: ExploreContext, mode: str, query: str, k: int) -> set:
|
|
315
|
+
try:
|
|
316
|
+
return set(ctx.session.search(query, top_k=k, mode=mode).ids())
|
|
317
|
+
except Exception:
|
|
318
|
+
return set()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _best(strat_counts: dict) -> str:
|
|
322
|
+
return max(strat_counts, key=lambda s: (strat_counts[s][0] / strat_counts[s][1]
|
|
323
|
+
if strat_counts[s][1] else 0.0))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _rates(strat_counts: dict) -> dict:
|
|
327
|
+
return {s: round(v[0] / v[1], 3) if v[1] else None for s, v in strat_counts.items()}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _report_md(ctx: ExploreContext, res: dict) -> str:
|
|
331
|
+
lines = ["# Exploration validation report", "",
|
|
332
|
+
f"Grounded synthetic queries: **{res['n']}** · recall@{res['k']}", "",
|
|
333
|
+
"## Recall@k by strategy",
|
|
334
|
+
"| strategy | recall |", "|---|---|"]
|
|
335
|
+
for s, r in sorted(res["recall_at_k"].items(), key=lambda x: -x[1]):
|
|
336
|
+
star = " ⭐" if s == res["best_overall"] else ""
|
|
337
|
+
lines.append(f"| {s} | {r:.3f}{star} |")
|
|
338
|
+
lines += ["", "## Best strategy per cluster", "| cluster | best |", "|---|---|"]
|
|
339
|
+
for c, s in sorted(res["cluster_best"].items()):
|
|
340
|
+
lines.append(f"| {c} | {s} |")
|
|
341
|
+
lines += ["", "## Recall@k by difficulty", "| difficulty | " +
|
|
342
|
+
" | ".join(ValidateStage.STRATEGIES) + " |",
|
|
343
|
+
"|---|" + "---|" * len(ValidateStage.STRATEGIES)]
|
|
344
|
+
for d, rates in res["by_difficulty"].items():
|
|
345
|
+
lines.append(f"| {d} | " + " | ".join(str(rates.get(s)) for s in
|
|
346
|
+
ValidateStage.STRATEGIES) + " |")
|
|
347
|
+
lines += ["", "_Baseline for keep-if-better gating: later tunings (router, codegen) must "
|
|
348
|
+
"beat these recall numbers on the same queries to be kept._"]
|
|
349
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""A tiny, portable metadata-filter language.
|
|
2
|
+
|
|
3
|
+
Agent code always writes filters in one Mongo-ish dialect; each adapter either
|
|
4
|
+
translates it to its native filter DSL or, when the backend can't filter
|
|
5
|
+
server-side, we evaluate it client-side with :func:`matches`.
|
|
6
|
+
|
|
7
|
+
{"lang": "en"} # equality shorthand
|
|
8
|
+
{"year": {"$gte": 2020}} # operators
|
|
9
|
+
{"tag": {"$in": ["cve", "advisory"]}}
|
|
10
|
+
{"$and": [{"lang": "en"}, {"year": {"$gte": 2020}}]}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
from .errors import InvalidFilterError
|
|
18
|
+
|
|
19
|
+
_OPS = {
|
|
20
|
+
"$eq": lambda a, b: a == b,
|
|
21
|
+
"$ne": lambda a, b: a != b,
|
|
22
|
+
"$gt": lambda a, b: a is not None and a > b,
|
|
23
|
+
"$gte": lambda a, b: a is not None and a >= b,
|
|
24
|
+
"$lt": lambda a, b: a is not None and a < b,
|
|
25
|
+
"$lte": lambda a, b: a is not None and a <= b,
|
|
26
|
+
"$in": lambda a, b: a in b,
|
|
27
|
+
"$nin": lambda a, b: a not in b,
|
|
28
|
+
"$exists": lambda a, b: (a is not None) == bool(b),
|
|
29
|
+
"$contains": lambda a, b: b in a if a is not None else False,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def matches(metadata: Mapping[str, Any], flt: Mapping[str, Any] | None) -> bool:
|
|
34
|
+
"""Evaluate a filter against a metadata dict (client-side emulation)."""
|
|
35
|
+
if not flt:
|
|
36
|
+
return True
|
|
37
|
+
for key, cond in flt.items():
|
|
38
|
+
if key == "$and":
|
|
39
|
+
if not all(matches(metadata, c) for c in cond):
|
|
40
|
+
return False
|
|
41
|
+
elif key == "$or":
|
|
42
|
+
if not any(matches(metadata, c) for c in cond):
|
|
43
|
+
return False
|
|
44
|
+
elif key == "$not":
|
|
45
|
+
if matches(metadata, cond):
|
|
46
|
+
return False
|
|
47
|
+
elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
|
|
48
|
+
value = metadata.get(key)
|
|
49
|
+
for op, operand in cond.items():
|
|
50
|
+
fn = _OPS.get(op)
|
|
51
|
+
if fn is None:
|
|
52
|
+
raise InvalidFilterError("unknown filter operator", operator=op)
|
|
53
|
+
if not fn(value, operand):
|
|
54
|
+
return False
|
|
55
|
+
else:
|
|
56
|
+
if metadata.get(key) != cond:
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_LOGICAL = {"$and", "$or"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate(flt: Mapping[str, Any] | None) -> None:
|
|
65
|
+
"""Raise :class:`InvalidFilterError` for malformed filters (unknown operators,
|
|
66
|
+
bad logical structure). Called at the boundary so every backend — including
|
|
67
|
+
server-side adapters that would otherwise silently drop bad operators — fails
|
|
68
|
+
fast with a clear, typed error."""
|
|
69
|
+
if not flt:
|
|
70
|
+
return
|
|
71
|
+
if not isinstance(flt, Mapping):
|
|
72
|
+
raise InvalidFilterError("filter must be a mapping", got=type(flt).__name__)
|
|
73
|
+
for key, cond in flt.items():
|
|
74
|
+
if key in _LOGICAL:
|
|
75
|
+
if not isinstance(cond, (list, tuple)):
|
|
76
|
+
raise InvalidFilterError(f"{key} expects a list of filters", operator=key)
|
|
77
|
+
for c in cond:
|
|
78
|
+
validate(c)
|
|
79
|
+
elif key == "$not":
|
|
80
|
+
validate(cond)
|
|
81
|
+
elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
|
|
82
|
+
for op in cond:
|
|
83
|
+
if op not in _OPS:
|
|
84
|
+
raise InvalidFilterError("unknown filter operator", operator=op)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize(flt: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
88
|
+
"""Expand equality shorthand into explicit ``$eq`` so adapters translate a
|
|
89
|
+
single canonical form."""
|
|
90
|
+
if not flt:
|
|
91
|
+
return {}
|
|
92
|
+
out: dict[str, Any] = {}
|
|
93
|
+
for key, cond in flt.items():
|
|
94
|
+
if key in ("$and", "$or"):
|
|
95
|
+
out[key] = [normalize(c) for c in cond]
|
|
96
|
+
elif key == "$not":
|
|
97
|
+
out[key] = normalize(cond)
|
|
98
|
+
elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
|
|
99
|
+
out[key] = dict(cond)
|
|
100
|
+
else:
|
|
101
|
+
out[key] = {"$eq": cond}
|
|
102
|
+
return out
|