lorebase-keeper 0.2.3__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 (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,708 @@
1
+ """Search over the materialized head (design 8.4).
2
+
3
+ - lexical: `search @@ websearch_to_tsquery(q)`, ranked by `ts_rank_cd`, with typo tolerance:
4
+ each word of `q` the branch does not hold is corrected to its closest words in the branch's
5
+ lexicon (`lorebase_store.reads.search_corrections`, `search.trigram_threshold` and
6
+ `search.typo_*`) and searched as `(word | correction | ...)`. Hits of `q` as written come
7
+ first; the others follow at `search.trigram_weight` times their rank. The response lists
8
+ the corrections. When q as written finds fewer than `search.partial.min_results` Records,
9
+ Records holding only some of its terms follow (`lorebase_store.reads.PartialMatching`:
10
+ the terms joined by OR, negations and phrases kept, scored by `ts_rank_cd` plus
11
+ `search.partial.term_bonus` per term held, corrections applied per term); each hit
12
+ reports `match: all|partial`;
13
+ - semantic: cosine distance between the caller's `query_vector` and the stored embedding of
14
+ each Record's object hash for the configured model and the fingerprint of its Kind's
15
+ embedding template in the searched branch (Keeper never computes vectors);
16
+ - hybrid: reciprocal rank fusion of both candidate lists, computed in SQL:
17
+ `w_lex / (k + rank_lex) + w_sem / (k + rank_sem)`.
18
+
19
+ Filters (`kinds`, `where`) apply before ranking and fusion. Snippets come from `ts_headline`
20
+ over the title and the searchable Attributes.
21
+
22
+ Lexical search without `where` goes through the store primitive `search_records` (design
23
+ 8.7), through the security definer function `lb_search_bounded`, so its index plans hold
24
+ under row-level security; snippets are computed for the returned page only. The word lexicon
25
+ bounds the matches of q (`lorebase_store.reads.CommonTerms`): within
26
+ `search.common_term_docs` every match is ranked; when every term is more common, only the
27
+ first `search.common_term_candidates` matches in index order are, and the response says
28
+ `truncated_candidates`. With `search.backend: bm25` the BM25 index of `pg_search` ranks it
29
+ instead (`lorebase_store.bm25`): every match is ranked by BM25, and the store keeps the
30
+ default ranking while the database has no valid BM25 index. Filtered, semantic and hybrid
31
+ searches are compiled here, with `ts_rank_cd`, whatever the backend.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import copy
37
+ from typing import Any, Literal
38
+
39
+ from pgvector.sqlalchemy import HALFVEC, VECTOR
40
+ from sqlalchemy import (
41
+ ARRAY,
42
+ Boolean,
43
+ ColumnElement,
44
+ Float,
45
+ Select,
46
+ Table,
47
+ Text,
48
+ and_,
49
+ case,
50
+ cast,
51
+ false,
52
+ func,
53
+ literal,
54
+ not_,
55
+ null,
56
+ or_,
57
+ over,
58
+ select,
59
+ true,
60
+ tuple_,
61
+ union_all,
62
+ )
63
+ from sqlalchemy.ext.asyncio import AsyncSession
64
+
65
+ from lorebase_core.model import Kind
66
+ from lorebase_core.model.embedding import TemplateError, kind_template
67
+ from lorebase_core.query import QueryError, Target, condition_kinds, resolve_where
68
+ from lorebase_keeper.reading.compiler import Compiler, json_text
69
+ from lorebase_keeper.reading.points import ReadPoint
70
+ from lorebase_keeper.reading.problems import invalid, query_problem
71
+ from lorebase_keeper.reading.records import compiler_for
72
+ from lorebase_keeper.reading.requests import SearchMode, SearchRequest, SearchTuning
73
+ from lorebase_keeper.reading.runtime import ReadRuntime
74
+ from lorebase_keeper.reading.sensitivity import Masking, condition_fields, require_comparable
75
+ from lorebase_keeper.reading.snapshots import with_rows
76
+ from lorebase_keeper.reading.views import (
77
+ SearchCorrection,
78
+ SearchHit,
79
+ SearchResponse,
80
+ kind_name,
81
+ served,
82
+ )
83
+ from lorebase_keeper.tracing.explain import explain
84
+ from lorebase_store.embeddings import fingerprint_of, index_precision, template_fingerprints
85
+ from lorebase_store.models import metadata
86
+ from lorebase_store.reads import (
87
+ CommonTerms,
88
+ Correction,
89
+ PartialMatching,
90
+ PartialQuery,
91
+ TypoTolerance,
92
+ partial_terms,
93
+ search_corrections,
94
+ search_records,
95
+ )
96
+
97
+ EMBEDDINGS: Table = metadata.tables["embeddings"]
98
+
99
+
100
+ def search_mode(request: SearchRequest) -> SearchMode:
101
+ """Requested mode, checked against the inputs it needs; 422 otherwise."""
102
+ has_text, has_vector = request.q is not None, request.query_vector is not None
103
+ mode = request.mode
104
+ if mode is None:
105
+ if has_text and has_vector:
106
+ return "hybrid"
107
+ if has_vector:
108
+ return "semantic"
109
+ if has_text:
110
+ return "lexical"
111
+ raise invalid("/body/q", "give q, query_vector or both", "missing")
112
+ if mode in ("lexical", "hybrid") and not has_text:
113
+ raise invalid("/body/q", f"{mode} search needs q", "missing")
114
+ if mode in ("semantic", "hybrid") and not has_vector:
115
+ raise invalid("/body/query_vector", f"{mode} search needs query_vector", "missing")
116
+ return mode
117
+
118
+
119
+ def _target(point: ReadPoint, request: SearchRequest) -> Target:
120
+ if request.kinds is None:
121
+ # Default: every Kind the caller may read; Kinds named explicitly must be readable.
122
+ kinds = tuple(
123
+ sorted(
124
+ (k for k in point.schema.kinds.values() if point.scope.allows(k.api_name)),
125
+ key=lambda k: k.id,
126
+ )
127
+ )
128
+ return Target("search", kinds)
129
+ point.scope.precheck(request.kinds)
130
+ found: list[Kind] = []
131
+ for index, name in enumerate(request.kinds):
132
+ kind = point.schema.kind_by_api_name(name)
133
+ if kind is None:
134
+ raise invalid(f"/body/kinds/{index}", f"unknown Kind {name!r}", "unknown_kind")
135
+ found.append(kind)
136
+ if not found:
137
+ raise invalid("/body/kinds", "name at least one Kind", "missing")
138
+ return Target(found[0].api_name if len(found) == 1 else "search", tuple(found))
139
+
140
+
141
+ async def search(
142
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, request: SearchRequest
143
+ ) -> SearchResponse:
144
+ runtime = tuned(runtime, request.tuning)
145
+ settings = runtime.search
146
+ mode = search_mode(request)
147
+ limit = request.limit or settings.results.default_limit
148
+ if limit > settings.results.max_limit:
149
+ raise invalid(
150
+ "/body/limit", f"must not exceed {settings.results.max_limit}", "limit_exceeded"
151
+ )
152
+ dims = runtime.store_settings.embeddings.dims
153
+ if request.query_vector is not None and len(request.query_vector) != dims:
154
+ raise invalid(
155
+ "/body/query_vector",
156
+ f"has {len(request.query_vector)} dimensions; the configured model "
157
+ f"{settings.semantic.model!r} has {dims}",
158
+ "dimension_mismatch",
159
+ )
160
+ target = _target(point, request)
161
+ if mode in ("lexical", "hybrid"):
162
+ target = _indexed_readable(point, target)
163
+ if not target.kinds:
164
+ return SearchResponse(**served(point), mode=mode, items=[])
165
+ where = None
166
+ if request.where is not None:
167
+ if len(target.kinds) != 1:
168
+ raise invalid("/body/where", "filters need exactly one Kind in kinds", "ambiguous")
169
+ try:
170
+ where = resolve_where(
171
+ request.where, target, point.schema, runtime.read.query_limits(), "/where"
172
+ )
173
+ except QueryError as error:
174
+ raise query_problem(error, "/body") from None
175
+ point.scope.require(condition_kinds(where))
176
+ require_comparable(
177
+ point.schema, point.masking, condition_fields(where, target.kinds), "/body/where"
178
+ )
179
+
180
+ point = await with_rows(session, runtime, point, "Search")
181
+ compiler = compiler_for(point, runtime)
182
+ corrections: list[Correction] = []
183
+ if request.q is not None and mode in ("lexical", "hybrid"):
184
+ corrections = await search_corrections(
185
+ session,
186
+ point.domain.id,
187
+ point.layout,
188
+ request.q,
189
+ typo_tolerance(runtime),
190
+ kind_ids=list(target.kind_ids),
191
+ runtime=runtime.store,
192
+ )
193
+ partial = partial_matching(runtime)
194
+ if mode == "lexical" and where is None:
195
+ assert request.q is not None
196
+ return await _lexical_page(
197
+ session, runtime, compiler, point, target, request.q, limit, corrections, partial
198
+ )
199
+ r = compiler.records("r")
200
+ base = [compiler.record_scope(r, target)]
201
+ if where is not None:
202
+ base.append(compiler.condition(r, where, target))
203
+
204
+ alternatives = None
205
+ if request.q is not None and partial is not None and mode in ("lexical", "hybrid"):
206
+ alternatives = await partial_terms(session, request.q, partial.stop_words)
207
+ lists: list[tuple[Select[Any], float]] = []
208
+ if mode in ("lexical", "hybrid"):
209
+ assert request.q is not None
210
+ lists.append(
211
+ (
212
+ _lexical(compiler, r, base, request.q, corrections, runtime, alternatives),
213
+ settings.hybrid.lexical_weight,
214
+ )
215
+ )
216
+ if mode in ("semantic", "hybrid"):
217
+ assert request.query_vector is not None
218
+ precision = await index_precision(session, dims)
219
+ lists.append(
220
+ (
221
+ _semantic(
222
+ r,
223
+ [*base, _embedded_readable(r, point, target)],
224
+ request.query_vector,
225
+ runtime,
226
+ target,
227
+ half=precision == "half",
228
+ ),
229
+ settings.hybrid.semantic_weight,
230
+ )
231
+ )
232
+ ranked = lists[0][0].subquery("ranked") if len(lists) == 1 else _fused(lists, runtime)
233
+
234
+ final = point.layout.records("hit")
235
+ snippet: ColumnElement[Any] = literal(None)
236
+ if request.q is not None:
237
+ snippet = func.ts_headline(
238
+ compiler.regconfig(),
239
+ func.concat_ws(
240
+ " ",
241
+ _snippet_title(final, target, point.masking),
242
+ *_searchable_text(final, target, point.masking),
243
+ ),
244
+ _corrected(
245
+ compiler, request.q if alternatives is None else alternatives.text, corrections
246
+ ),
247
+ literal(settings.headline_options),
248
+ )
249
+ # A lexical list puts full matches first, then the hits of q as written; fusion ranks by
250
+ # position already (its `full` is null for semantic-only hits, which DESC would put first).
251
+ fused = len(lists) > 1
252
+ exact_first = [] if fused else [ranked.c[c].desc() for c in ("full", "exact") if c in ranked.c]
253
+ full = ranked.c.full if "full" in ranked.c else literal(None, Boolean)
254
+ query = (
255
+ select(
256
+ ranked.c.kind_id,
257
+ ranked.c.key,
258
+ ranked.c.score,
259
+ full.label("full"),
260
+ final.c.title,
261
+ snippet.label("snippet"),
262
+ )
263
+ .select_from(ranked)
264
+ .join(
265
+ final,
266
+ and_(
267
+ final.c.domain_id == literal(point.domain.id),
268
+ final.c.branch == literal(point.layout.branch),
269
+ final.c.kind_id == ranked.c.kind_id,
270
+ final.c.key == ranked.c.key,
271
+ ),
272
+ )
273
+ .order_by(*exact_first, ranked.c.score.desc(), ranked.c.kind_id, ranked.c.key)
274
+ .limit(limit)
275
+ )
276
+ await explain(session, query, point.domain.id, f"search:{mode}")
277
+ rows = (await session.execute(query)).mappings()
278
+ items = [
279
+ SearchHit(
280
+ kind=kind_name(point.schema, row["kind_id"]),
281
+ key=row["key"],
282
+ title=point.masking.title(point.schema, row["kind_id"], row["title"]),
283
+ snippet=row["snippet"],
284
+ score=float(row["score"]),
285
+ match=_match(row["full"]),
286
+ )
287
+ for row in rows
288
+ ]
289
+ return SearchResponse(
290
+ **served(point), mode=mode, items=items, corrections=_applied(corrections)
291
+ )
292
+
293
+
294
+ def tuned(runtime: ReadRuntime, tuning: SearchTuning | None) -> ReadRuntime:
295
+ """`runtime` with the ranking knobs of `tuning` in place of the `search` settings. Knobs
296
+ that bound the work may only make it smaller: `candidates` and `typo_candidates` lower,
297
+ `trigram_threshold` higher; 422 otherwise."""
298
+ if tuning is None:
299
+ return runtime
300
+ settings = runtime.search
301
+ for name in ("candidates", "typo_candidates"):
302
+ value, bound = getattr(tuning, name), getattr(settings, name)
303
+ if value is not None and value > bound:
304
+ raise invalid(f"/body/tuning/{name}", f"must not exceed {bound}", "limit_exceeded")
305
+ threshold = tuning.trigram_threshold
306
+ if threshold is not None and threshold < settings.trigram_threshold:
307
+ raise invalid(
308
+ "/body/tuning/trigram_threshold",
309
+ f"must not be below {settings.trigram_threshold}",
310
+ "limit_exceeded",
311
+ )
312
+ given: dict[str, Any] = tuning.model_dump(exclude_none=True)
313
+ hybrid: dict[str, Any] = {
314
+ k: given.pop(k) for k in ("lexical_weight", "semantic_weight", "rrf_k") if k in given
315
+ }
316
+ effective = copy.copy(runtime)
317
+ effective.search = settings.model_copy(
318
+ update={**given, "hybrid": settings.hybrid.model_copy(update=hybrid)}
319
+ )
320
+ return effective
321
+
322
+
323
+ def typo_tolerance(runtime: ReadRuntime) -> TypoTolerance:
324
+ """Typo tolerance of lexical search from the `search` settings."""
325
+ settings = runtime.search
326
+ return TypoTolerance(
327
+ threshold=settings.trigram_threshold,
328
+ weight=settings.trigram_weight,
329
+ candidates=settings.typo_candidates,
330
+ min_length=settings.typo_min_length,
331
+ )
332
+
333
+
334
+ def partial_matching(runtime: ReadRuntime) -> PartialMatching | None:
335
+ """Partial matching of lexical search from `search.partial`; `None` when it is off."""
336
+ settings = runtime.search.partial
337
+ if settings.min_results < 1:
338
+ return None
339
+ return PartialMatching(
340
+ min_results=settings.min_results,
341
+ weight=settings.weight,
342
+ term_bonus=settings.term_bonus,
343
+ candidates=settings.candidates,
344
+ stop_words=settings.stop_words,
345
+ )
346
+
347
+
348
+ def common_terms(runtime: ReadRuntime) -> CommonTerms | None:
349
+ """How many matches lexical search ranks, from `search.common_term_*`; `None` (the store's
350
+ `store.reads.search_candidates`) when `common_term_docs` is 0."""
351
+ settings = runtime.search
352
+ if settings.common_term_docs < 1:
353
+ return None
354
+ return CommonTerms(docs=settings.common_term_docs, candidates=settings.common_term_candidates)
355
+
356
+
357
+ def _match(full: bool | None) -> Literal["all", "partial"] | None:
358
+ if full is None:
359
+ return None
360
+ return "all" if full else "partial"
361
+
362
+
363
+ def _applied(corrections: list[Correction]) -> list[SearchCorrection]:
364
+ return [
365
+ SearchCorrection(word=c.word, replacements=list(c.replacements))
366
+ for c in corrections
367
+ if c.replacements
368
+ ]
369
+
370
+
371
+ def _corrected(compiler: Compiler, q: str, corrections: list[Correction]) -> ColumnElement[Any]:
372
+ """The tsquery of q with each corrected word widened to `(word | replacement | ...)`, as
373
+ the store's search does (`lb_lexeme_query`, revision 0018)."""
374
+ return _rewritten(compiler.tsquery(q), corrections)
375
+
376
+
377
+ def _rewritten(query: ColumnElement[Any], corrections: list[Correction]) -> ColumnElement[Any]:
378
+ for correction in corrections:
379
+ if not correction.replacements:
380
+ continue
381
+ word = literal(correction.word, Text)
382
+ alternatives = literal(list(correction.replacements), ARRAY(Text))
383
+ query = func.ts_rewrite(
384
+ query, func.lb_lexeme_query(word), func.lb_lexeme_query(word, alternatives)
385
+ )
386
+ return query
387
+
388
+
389
+ async def _lexical_page(
390
+ session: AsyncSession,
391
+ runtime: ReadRuntime,
392
+ compiler: Compiler,
393
+ point: ReadPoint,
394
+ target: Target,
395
+ q: str,
396
+ limit: int,
397
+ corrections: list[Correction],
398
+ partial: PartialMatching | None,
399
+ ) -> SearchResponse:
400
+ """Lexical search without filters: the store primitive ranks a bounded candidate set
401
+ (index plans under row-level security too), then snippets for the page by key."""
402
+ settings = runtime.search
403
+ hits = await search_records(
404
+ session,
405
+ point.domain.id,
406
+ point.layout,
407
+ q,
408
+ limit=limit,
409
+ kind_ids=list(target.kind_ids),
410
+ corrections=corrections,
411
+ correction_weight=settings.trigram_weight,
412
+ partial=partial,
413
+ common=common_terms(runtime),
414
+ backend=settings.backend,
415
+ runtime=runtime.store,
416
+ )
417
+ snippets: dict[tuple[str, str], str | None] = {}
418
+ alternatives = None
419
+ if partial is not None and any(h.partial for h in hits):
420
+ alternatives = await partial_terms(session, q, partial.stop_words)
421
+ if hits:
422
+ hit = point.layout.records("hit")
423
+ headline = func.ts_headline(
424
+ compiler.regconfig(),
425
+ func.concat_ws(
426
+ " ",
427
+ _snippet_title(hit, target, point.masking),
428
+ *_searchable_text(hit, target, point.masking),
429
+ ),
430
+ _corrected(compiler, q if alternatives is None else alternatives.text, corrections),
431
+ literal(settings.headline_options),
432
+ )
433
+ query = select(hit.c.kind_id, hit.c.key, headline.label("snippet")).where(
434
+ hit.c.domain_id == literal(point.domain.id),
435
+ hit.c.branch == literal(point.layout.branch),
436
+ tuple_(hit.c.kind_id, hit.c.key).in_([(h.kind_id, h.key) for h in hits]),
437
+ )
438
+ for row in (await session.execute(query)).mappings():
439
+ snippets[(row["kind_id"], row["key"])] = row["snippet"]
440
+ items = [
441
+ SearchHit(
442
+ kind=kind_name(point.schema, h.kind_id),
443
+ key=h.key,
444
+ title=point.masking.title(point.schema, h.kind_id, h.title),
445
+ snippet=snippets.get((h.kind_id, h.key)),
446
+ score=h.score,
447
+ match="partial" if h.partial else "all",
448
+ )
449
+ for h in hits
450
+ ]
451
+ return SearchResponse(
452
+ **served(point),
453
+ mode="lexical",
454
+ items=items,
455
+ corrections=_applied(corrections),
456
+ truncated_candidates=hits.truncated,
457
+ )
458
+
459
+
460
+ def _lexical(
461
+ compiler: Compiler,
462
+ r: Any,
463
+ base: list[ColumnElement[bool]],
464
+ q: str,
465
+ corrections: list[Correction],
466
+ runtime: ReadRuntime,
467
+ alternatives: PartialQuery | None = None,
468
+ ) -> Select[Any]:
469
+ """Lexical candidates with `full` and `exact` columns: matches of q as written first, then
470
+ those found only through a correction at `trigram_weight` times their rank. With
471
+ `alternatives` (`search.partial`), fewer than `min_results` of them are followed by
472
+ Records holding only some terms (`full` false)."""
473
+ settings = runtime.search
474
+ tsquery = compiler.tsquery(q)
475
+ exact: ColumnElement[bool] = r.c.search.op("@@", return_type=Boolean)(tsquery)
476
+ score: ColumnElement[Any] = func.ts_rank_cd(r.c.search, tsquery)
477
+ match: ColumnElement[bool] = exact
478
+ fixes = [c for c in corrections if c.replacements]
479
+ if fixes:
480
+ fixed = _corrected(compiler, q, corrections)
481
+ match = or_(exact, r.c.search.op("@@", return_type=Boolean)(fixed))
482
+ score = case(
483
+ (exact, score),
484
+ else_=literal(settings.trigram_weight, Float) * func.ts_rank_cd(r.c.search, fixed),
485
+ )
486
+ else:
487
+ exact = true()
488
+ flag = func.coalesce(exact, False)
489
+ strict = (
490
+ select(
491
+ r.c.kind_id,
492
+ r.c.key,
493
+ cast(score, Float).label("score"),
494
+ flag.label("exact"),
495
+ true().label("full"),
496
+ )
497
+ .where(*base, match)
498
+ .order_by(not_(flag), score.desc(), r.c.kind_id, r.c.key)
499
+ .limit(settings.candidates)
500
+ )
501
+ if alternatives is None:
502
+ return strict
503
+ full = strict.cte("full_matches")
504
+ enough = select(func.count()).select_from(full).scalar_subquery()
505
+ partial = _partial(compiler, r, base, alternatives, fixes, runtime).where(
506
+ not_(func.coalesce(match, False)),
507
+ enough < literal(settings.partial.min_results),
508
+ )
509
+ lexical = union_all(select(*full.c), partial).subquery("lexical")
510
+ return select(*lexical.c)
511
+
512
+
513
+ def _partial(
514
+ compiler: Compiler,
515
+ r: Any,
516
+ base: list[ColumnElement[bool]],
517
+ alternatives: PartialQuery,
518
+ fixes: list[Correction],
519
+ runtime: ReadRuntime,
520
+ ) -> Select[Any]:
521
+ """Records holding any term of q, scored `weight * (rank + term_bonus * terms held)`, as
522
+ the store's `search_records` does with `PartialMatching`."""
523
+ settings = runtime.search
524
+ partial = settings.partial
525
+ written = compiler.tsquery(alternatives.text)
526
+ any_term = r.c.search.op("@@", return_type=Boolean)(written)
527
+ rank: ColumnElement[Any] = func.ts_rank_cd(r.c.search, written)
528
+ if fixes:
529
+ fixed = _corrected(compiler, alternatives.text, fixes)
530
+ rank = case(
531
+ (any_term, rank),
532
+ else_=literal(settings.trigram_weight, Float) * func.ts_rank_cd(r.c.search, fixed),
533
+ )
534
+ any_term = r.c.search.op("@@", return_type=Boolean)(fixed)
535
+ terms = (
536
+ func.unnest(literal(list(alternatives.terms), ARRAY(Text)))
537
+ .table_valued("term")
538
+ .render_derived(name="terms")
539
+ )
540
+ term_query = _rewritten(func.websearch_to_tsquery(compiler.regconfig(), terms.c.term), fixes)
541
+ held = (
542
+ select(func.count())
543
+ .select_from(terms)
544
+ .where(r.c.search.op("@@", return_type=Boolean)(term_query))
545
+ .scalar_subquery()
546
+ )
547
+ score = literal(partial.weight, Float) * (
548
+ rank + literal(partial.term_bonus, Float) * cast(held, Float)
549
+ )
550
+ return (
551
+ select(
552
+ r.c.kind_id,
553
+ r.c.key,
554
+ cast(score, Float).label("score"),
555
+ true().label("exact"),
556
+ false().label("full"),
557
+ )
558
+ .where(*base, any_term)
559
+ .order_by(score.desc(), r.c.kind_id, r.c.key)
560
+ .limit(min(partial.candidates, settings.candidates))
561
+ )
562
+
563
+
564
+ def _indexed_readable(point: ReadPoint, target: Target) -> Target:
565
+ """The Kinds whose full-text index the caller may search. The index of a branch holds the
566
+ words of the Attributes its schema leaves untagged; when the default branch tagged one of
567
+ them after the branch forked (a searchable or title Attribute), its words are still
568
+ indexed there, so only callers cleared for it search that Kind."""
569
+ readable = []
570
+ for kind in target.kinds:
571
+ stale = [
572
+ a
573
+ for a in kind.attributes.values()
574
+ if (a.searchable or a.id == kind.title_attribute)
575
+ and not a.is_sensitive
576
+ and point.masking.hidden(a, (kind.id,))
577
+ ]
578
+ if not stale:
579
+ readable.append(kind)
580
+ return Target(target.name, tuple(readable), target.trait)
581
+
582
+
583
+ def _embedded_readable(r: Any, point: ReadPoint, target: Target) -> ColumnElement[bool]:
584
+ """Kinds whose vectors embed only values the caller may read: a Kind that embeds a
585
+ sensitive Attribute (`search.embedding_sensitive`) ranks by it only for cleared callers."""
586
+ readable = []
587
+ for kind in target.kinds:
588
+ try:
589
+ template = kind_template(kind)
590
+ except TemplateError:
591
+ template = None
592
+ attributes = [] if template is None else template.attributes.values()
593
+ if not any(
594
+ point.masking.hidden(kind.attributes[a], (kind.id,))
595
+ for a in attributes
596
+ if a in kind.attributes
597
+ ):
598
+ readable.append(kind.id)
599
+ return r.c.kind_id.in_(readable) if readable else false()
600
+
601
+
602
+ def _semantic(
603
+ r: Any,
604
+ base: list[ColumnElement[bool]],
605
+ vector: list[float],
606
+ runtime: ReadRuntime,
607
+ target: Target,
608
+ *,
609
+ half: bool = False,
610
+ ) -> Select[Any]:
611
+ """Records ranked by the vector stored for their object hash under the fingerprint of
612
+ their Kind's embedding template in the searched branch's schema: another branch's vector
613
+ for the same content, rendered by a different template, is never used. With `half`, the
614
+ distance is computed in half precision, as the HNSW index of the storage profile holds it
615
+ (`lorebase_store.embeddings.index_precision`)."""
616
+ dims = runtime.store_settings.embeddings.dims
617
+ fingerprint = fingerprint_of(r.c.kind_id, template_fingerprints(target.kinds))
618
+ e = EMBEDDINGS.alias("e")
619
+ kind = HALFVEC(dims) if half else VECTOR(dims)
620
+ distance = cast(e.c.embedding, kind).op("<=>", return_type=Float)(literal(vector, kind))
621
+ return (
622
+ select(r.c.kind_id, r.c.key, (literal(1.0, Float) - distance).label("score"))
623
+ .join(
624
+ e,
625
+ and_(
626
+ e.c.domain_id == r.c.domain_id,
627
+ e.c.object_hash == r.c.object_hash,
628
+ e.c.model == literal(runtime.search.semantic.model),
629
+ e.c.fingerprint == fingerprint,
630
+ e.c.dims == literal(dims, literal_execute=True),
631
+ ),
632
+ )
633
+ .where(*base)
634
+ .order_by(distance, r.c.kind_id, r.c.key)
635
+ .limit(runtime.search.candidates)
636
+ )
637
+
638
+
639
+ def _fused(lists: list[tuple[Select[Any], float]], runtime: ReadRuntime) -> Any:
640
+ """Reciprocal rank fusion of the ranked lists (full outer join on the Record)."""
641
+ k = runtime.search.hybrid.rrf_k
642
+ ranked = []
643
+ for index, (candidates, weight) in enumerate(lists):
644
+ inner = candidates.subquery(f"c{index}")
645
+ exact_first = [inner.c[c].desc() for c in ("full", "exact") if c in inner.c]
646
+ position = over(
647
+ func.row_number(),
648
+ order_by=(*exact_first, inner.c.score.desc(), inner.c.kind_id, inner.c.key),
649
+ )
650
+ carried = [inner.c.full] if "full" in inner.c else []
651
+ ranked.append(
652
+ (
653
+ select(inner.c.kind_id, inner.c.key, position.label("rank"), *carried).subquery(
654
+ f"l{index}"
655
+ ),
656
+ weight,
657
+ )
658
+ )
659
+ (first, w_first), (second, w_second) = ranked
660
+ full = [ranks.c.full for ranks, _ in ranked if "full" in ranks.c]
661
+ score = func.coalesce(literal(w_first, Float) / (literal(k) + first.c["rank"]), 0.0) + (
662
+ func.coalesce(literal(w_second, Float) / (literal(k) + second.c["rank"]), 0.0)
663
+ )
664
+ return (
665
+ select(
666
+ func.coalesce(first.c.kind_id, second.c.kind_id).label("kind_id"),
667
+ func.coalesce(first.c.key, second.c.key).label("key"),
668
+ cast(score, Float).label("score"),
669
+ *([func.coalesce(*full).label("full")] if full else []),
670
+ )
671
+ .select_from(
672
+ first.join(
673
+ second,
674
+ and_(first.c.kind_id == second.c.kind_id, first.c.key == second.c.key),
675
+ full=True,
676
+ )
677
+ )
678
+ .subquery("ranked")
679
+ )
680
+
681
+
682
+ def _snippet_title(rows: Any, target: Target, masking: Masking) -> ColumnElement[Any]:
683
+ """The stored title in snippets, except for Kinds whose title Attribute is sensitive (by
684
+ the default branch's tags too): a branch forked before the tag may still store it."""
685
+ hidden = [
686
+ kind.id
687
+ for kind in target.kinds
688
+ if kind.title_attribute in kind.attributes
689
+ and masking.sensitive(kind.attributes[kind.title_attribute])
690
+ ]
691
+ title: ColumnElement[Any] = rows.c.title
692
+ if not hidden:
693
+ return title
694
+ return case((rows.c.kind_id.in_(hidden), null()), else_=title)
695
+
696
+
697
+ def _searchable_text(rows: Any, target: Target, masking: Masking) -> list[ColumnElement[Any]]:
698
+ """Text of the snippets: searchable Attributes that are not sensitive (by the tags of the
699
+ default branch too)."""
700
+ attributes = sorted(
701
+ {
702
+ a.id
703
+ for kind in target.kinds
704
+ for a in kind.attributes.values()
705
+ if a.searchable and not masking.sensitive(a)
706
+ }
707
+ )
708
+ return [json_text(rows.c["values"], attribute) for attribute in attributes]