lorebase-store 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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,444 @@
1
+ """Storage side of the embedding job (design 9.3): what to embed, and writing vectors.
2
+
3
+ Vectors live in `embeddings`, keyed by `(domain_id, object_hash, model, fingerprint)`, the
4
+ fingerprint being that of the Kind's embedding template (`KindTemplate.fingerprint`): every
5
+ branch and Revision holding the same Record content under the same template shares one vector,
6
+ so an overlay branch or a branch forked from `main` adds work only for the Records it changed,
7
+ while branches whose templates differ keep a vector each. Readers look a vector up with the
8
+ fingerprint of the template of the branch they read (`template_fingerprints`). `text_hash`
9
+ (SHA-256 of the embedded text) lets a pass skip Records whose text did not change and copy the
10
+ vector of an identical text, whatever its template, instead of calling the model.
11
+
12
+ `embedding_cursors` records, per branch and model, the head and the template fingerprints of
13
+ the last complete pass, so a pass over an unchanged branch reads no Record rows.
14
+
15
+ Reads of a branch scan its own `mat_records` rows only: an overlay's inherited rows belong to
16
+ its base branch, whose pass covers them. Everything here runs in the caller's transaction,
17
+ in the Domain's row-level security scope (`scope_to_domain`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import time
24
+ from collections import OrderedDict
25
+ from collections.abc import Collection, Iterable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from typing import Any
29
+
30
+ from sqlalchemy import (
31
+ ARRAY,
32
+ LargeBinary,
33
+ Table,
34
+ Text,
35
+ and_,
36
+ bindparam,
37
+ case,
38
+ delete,
39
+ literal,
40
+ or_,
41
+ select,
42
+ tuple_,
43
+ )
44
+ from sqlalchemy import text as sql_text
45
+ from sqlalchemy.dialects.postgresql import insert
46
+ from sqlalchemy.ext.asyncio import AsyncSession
47
+
48
+ from lorebase_core.model import Kind, Schema
49
+ from lorebase_core.model.embedding import TemplateError, kind_template
50
+ from lorebase_store.commit.schema import load_compiled
51
+ from lorebase_store.models import metadata
52
+ from lorebase_store.models.enums import Materialization, RefKind
53
+ from lorebase_store.storage import PRECISION_TTL_S, Precision, hnsw_index_name
54
+
55
+ EMBEDDINGS: Table = metadata.tables["embeddings"]
56
+ EMBEDDING_CURSORS: Table = metadata.tables["embedding_cursors"]
57
+ MAT_RECORDS: Table = metadata.tables["mat_records"]
58
+ MAT_SCHEMA: Table = metadata.tables["mat_schema"]
59
+ REFS: Table = metadata.tables["refs"]
60
+
61
+ _LOCK_SCOPE = b"lorebase.embeddings\x00"
62
+
63
+ _REUSE = sql_text(
64
+ """
65
+ INSERT INTO embeddings (domain_id, object_hash, model, fingerprint, dims, embedding,
66
+ text_hash)
67
+ SELECT DISTINCT ON (t.object_hash, t.fingerprint) :domain, t.object_hash, e.model,
68
+ t.fingerprint, e.dims, e.embedding, e.text_hash
69
+ FROM unnest(CAST(:hashes AS bytea[]), CAST(:fingerprints AS text[]),
70
+ CAST(:texts AS bytea[])) AS t(object_hash, fingerprint, text_hash)
71
+ JOIN embeddings e ON e.domain_id = :domain AND e.model = :model AND e.dims = :dims
72
+ AND e.text_hash = t.text_hash
73
+ ORDER BY t.object_hash, t.fingerprint
74
+ ON CONFLICT (domain_id, object_hash, model, fingerprint) DO UPDATE
75
+ SET dims = EXCLUDED.dims, embedding = EXCLUDED.embedding, text_hash = EXCLUDED.text_hash
76
+ RETURNING object_hash, fingerprint
77
+ """
78
+ ).bindparams(
79
+ bindparam("hashes", type_=ARRAY(LargeBinary())),
80
+ bindparam("fingerprints", type_=ARRAY(Text())),
81
+ bindparam("texts", type_=ARRAY(LargeBinary())),
82
+ )
83
+
84
+
85
+ def lock_key(domain_id: str) -> int:
86
+ """Signed 64-bit advisory lock key of a Domain's embedding pass (one at a time)."""
87
+ digest = hashlib.sha256(_LOCK_SCOPE + domain_id.encode("utf-8")).digest()
88
+ return int.from_bytes(digest[:8], "big", signed=True)
89
+
90
+
91
+ def text_hash(text: str) -> bytes:
92
+ """SHA-256 of an embedded text."""
93
+ return hashlib.sha256(text.encode("utf-8")).digest()
94
+
95
+
96
+ def template_fingerprints(kinds: Iterable[Kind]) -> dict[str, str]:
97
+ """Fingerprint of the embedding template of each of `kinds` that has a usable one, by Kind
98
+ id: the fingerprint its vectors are stored and looked up under."""
99
+ found: dict[str, str] = {}
100
+ for kind in kinds:
101
+ try:
102
+ template = kind_template(kind)
103
+ except TemplateError:
104
+ continue
105
+ if template is not None:
106
+ found[kind.id] = template.fingerprint
107
+ return found
108
+
109
+
110
+ class SchemaCache[V]:
111
+ """What was derived from a compiled schema (its embedding templates), by schema hash, for
112
+ the `size` schemas used last: branches sharing a schema, and passes over an unchanged one,
113
+ read and parse it once."""
114
+
115
+ def __init__(self, size: int) -> None:
116
+ if size < 1:
117
+ raise ValueError("a schema cache holds at least one schema")
118
+ self._size = size
119
+ self._entries: OrderedDict[bytes, V] = OrderedDict()
120
+ self.hits = 0
121
+ self.misses = 0
122
+
123
+ def get(self, schema_hash: bytes) -> V | None:
124
+ found = self._entries.get(schema_hash)
125
+ if found is None:
126
+ self.misses += 1
127
+ return None
128
+ self.hits += 1
129
+ self._entries.move_to_end(schema_hash)
130
+ return found
131
+
132
+ def put(self, schema_hash: bytes, value: V) -> None:
133
+ self._entries[schema_hash] = value
134
+ self._entries.move_to_end(schema_hash)
135
+ while len(self._entries) > self._size:
136
+ self._entries.popitem(last=False)
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class BranchHead:
141
+ name: str
142
+ head: bytes
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class Cursor:
147
+ head: bytes
148
+ fingerprints: Mapping[str, str]
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class Candidate:
153
+ """A Record row that may need a vector."""
154
+
155
+ kind_id: str
156
+ key: str
157
+ object_hash: bytes
158
+ fingerprint: str
159
+ """Fingerprint of the template of the row's Kind on the branch."""
160
+ values: Mapping[str, Any]
161
+ text_hash: bytes | None
162
+ """Text hash of the stored vector, None when there is none (or it predates 0014)."""
163
+ embedded: bool
164
+ """A vector of the model exists for the row's object hash and template fingerprint."""
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class NewVector:
169
+ object_hash: bytes
170
+ fingerprint: str
171
+ text_hash: bytes
172
+ vector: Sequence[float]
173
+
174
+
175
+ async def materialized_branches(
176
+ session: AsyncSession, domain_id: str, names: Collection[str] | None = None
177
+ ) -> list[BranchHead]:
178
+ """Materialized branches of the Domain (all, or those of `names`), by name."""
179
+ query = (
180
+ select(REFS.c.name, REFS.c.head)
181
+ .where(
182
+ REFS.c.domain_id == domain_id,
183
+ REFS.c.kind == RefKind.BRANCH,
184
+ # A branch still being built from its tree has no complete rows yet.
185
+ REFS.c.materialization.not_in([Materialization.NONE, Materialization.BUILDING]),
186
+ REFS.c.deleted_at.is_(None),
187
+ )
188
+ .order_by(REFS.c.name)
189
+ )
190
+ if names is not None:
191
+ query = query.where(REFS.c.name.in_(list(names)))
192
+ return [BranchHead(name, bytes(head)) for name, head in (await session.execute(query)).all()]
193
+
194
+
195
+ async def branch_schema_hash(session: AsyncSession, domain_id: str, branch: str) -> bytes | None:
196
+ """Hash of the schema materialized at the branch head (the compiled document is not read)."""
197
+ found = (
198
+ await session.execute(
199
+ select(MAT_SCHEMA.c.schema_hash).where(
200
+ MAT_SCHEMA.c.domain_id == domain_id, MAT_SCHEMA.c.branch == branch
201
+ )
202
+ )
203
+ ).scalar_one_or_none()
204
+ return None if found is None else bytes(found)
205
+
206
+
207
+ async def branch_schema(session: AsyncSession, domain_id: str, branch: str) -> Schema | None:
208
+ """The compiled schema materialized at the branch head."""
209
+ compiled = (
210
+ await session.execute(
211
+ select(MAT_SCHEMA.c.compiled).where(
212
+ MAT_SCHEMA.c.domain_id == domain_id, MAT_SCHEMA.c.branch == branch
213
+ )
214
+ )
215
+ ).scalar_one_or_none()
216
+ return None if compiled is None else load_compiled(compiled)
217
+
218
+
219
+ async def read_cursor(
220
+ session: AsyncSession, domain_id: str, branch: str, model: str
221
+ ) -> Cursor | None:
222
+ c = EMBEDDING_CURSORS
223
+ found = (
224
+ await session.execute(
225
+ select(c.c.head, c.c.fingerprints).where(
226
+ c.c.domain_id == domain_id, c.c.branch == branch, c.c.model == model
227
+ )
228
+ )
229
+ ).one_or_none()
230
+ if found is None:
231
+ return None
232
+ return Cursor(bytes(found[0]), {str(k): str(v) for k, v in dict(found[1]).items()})
233
+
234
+
235
+ async def save_cursor(
236
+ session: AsyncSession,
237
+ domain_id: str,
238
+ branch: str,
239
+ model: str,
240
+ cursor: Cursor,
241
+ *,
242
+ at: datetime,
243
+ ) -> None:
244
+ """Record a complete pass. A branch deleted meanwhile has no cursor to keep: nothing is
245
+ written (the row would violate the reference to `refs`)."""
246
+ c = EMBEDDING_CURSORS
247
+ exists = (
248
+ await session.execute(
249
+ select(literal(1)).where(REFS.c.domain_id == domain_id, REFS.c.name == branch)
250
+ )
251
+ ).scalar_one_or_none()
252
+ if exists is None:
253
+ return
254
+ values = {"head": cursor.head, "fingerprints": dict(cursor.fingerprints), "updated_at": at}
255
+ await session.execute(
256
+ insert(c)
257
+ .values(domain_id=domain_id, branch=branch, model=model, **values)
258
+ .on_conflict_do_update(index_elements=[c.c.domain_id, c.c.branch, c.c.model], set_=values)
259
+ )
260
+
261
+
262
+ async def candidates(
263
+ session: AsyncSession,
264
+ domain_id: str,
265
+ branch: str,
266
+ *,
267
+ model: str,
268
+ dims: int,
269
+ fingerprints: Mapping[str, str],
270
+ rerender: Collection[str],
271
+ after: tuple[str, str] | None,
272
+ limit: int,
273
+ ) -> list[Candidate]:
274
+ """The branch's own live rows of the Kinds of `fingerprints` (Kind id to template
275
+ fingerprint) in `(kind_id, key)` order after `after`: those without a vector of the model
276
+ under their Kind's fingerprint, plus every row of the `rerender` Kinds (template changed or
277
+ a reindex), with the text hash of their stored vector."""
278
+ if not fingerprints:
279
+ return []
280
+ kinds = sorted(fingerprints)
281
+ r = MAT_RECORDS.alias("r")
282
+ e = EMBEDDINGS.alias("e")
283
+ fingerprint = fingerprint_of(r.c.kind_id, fingerprints)
284
+ missing = e.c.object_hash.is_(None)
285
+ wanted = [k for k in rerender if k in fingerprints]
286
+ query = (
287
+ select(
288
+ r.c.kind_id,
289
+ r.c.key,
290
+ r.c.object_hash,
291
+ fingerprint.label("fingerprint"),
292
+ r.c["values"],
293
+ e.c.text_hash,
294
+ e.c.object_hash.is_not(None).label("embedded"),
295
+ )
296
+ .select_from(
297
+ r.outerjoin(
298
+ e,
299
+ and_(
300
+ e.c.domain_id == r.c.domain_id,
301
+ e.c.object_hash == r.c.object_hash,
302
+ e.c.model == model,
303
+ e.c.fingerprint == fingerprint,
304
+ e.c.dims == dims,
305
+ ),
306
+ )
307
+ )
308
+ .where(
309
+ r.c.domain_id == domain_id,
310
+ r.c.branch == branch,
311
+ r.c.deleted.is_(False),
312
+ r.c.kind_id.in_(kinds),
313
+ or_(missing, r.c.kind_id.in_(wanted)) if wanted else missing,
314
+ )
315
+ .order_by(r.c.kind_id, r.c.key)
316
+ .limit(limit)
317
+ )
318
+ if after is not None:
319
+ query = query.where(
320
+ tuple_(r.c.kind_id, r.c.key) > tuple_(literal(after[0]), literal(after[1]))
321
+ )
322
+ rows = (await session.execute(query)).all()
323
+ return [
324
+ Candidate(
325
+ kind_id=row.kind_id,
326
+ key=row.key,
327
+ object_hash=bytes(row.object_hash),
328
+ fingerprint=str(row.fingerprint),
329
+ values=row[4] or {},
330
+ text_hash=None if row.text_hash is None else bytes(row.text_hash),
331
+ embedded=bool(row.embedded),
332
+ )
333
+ for row in rows
334
+ ]
335
+
336
+
337
+ async def reuse_vectors(
338
+ session: AsyncSession,
339
+ domain_id: str,
340
+ *,
341
+ model: str,
342
+ dims: int,
343
+ wanted: Sequence[tuple[bytes, str, bytes]],
344
+ ) -> set[tuple[bytes, str]]:
345
+ """Copy, for each `(object_hash, fingerprint, text_hash)`, a stored vector of the same text
346
+ and model (whatever template rendered it); returns the `(object_hash, fingerprint)` pairs
347
+ served this way."""
348
+ if not wanted:
349
+ return set()
350
+ result = await session.execute(
351
+ _REUSE,
352
+ {
353
+ "domain": domain_id,
354
+ "model": model,
355
+ "dims": dims,
356
+ "hashes": [h for h, _, _ in wanted],
357
+ "fingerprints": [f for _, f, _ in wanted],
358
+ "texts": [t for _, _, t in wanted],
359
+ },
360
+ )
361
+ return {(bytes(row[0]), str(row[1])) for row in result.all()}
362
+
363
+
364
+ async def store_vectors(
365
+ session: AsyncSession, domain_id: str, *, model: str, vectors: Sequence[NewVector]
366
+ ) -> None:
367
+ """Insert or replace the vectors of the model."""
368
+ if not vectors:
369
+ return
370
+ e = EMBEDDINGS
371
+ statement = insert(e).values(
372
+ [
373
+ {
374
+ "domain_id": domain_id,
375
+ "object_hash": v.object_hash,
376
+ "model": model,
377
+ "fingerprint": v.fingerprint,
378
+ "dims": len(v.vector),
379
+ "embedding": list(v.vector),
380
+ "text_hash": v.text_hash,
381
+ }
382
+ for v in vectors
383
+ ]
384
+ )
385
+ await session.execute(
386
+ statement.on_conflict_do_update(
387
+ index_elements=[e.c.domain_id, e.c.object_hash, e.c.model, e.c.fingerprint],
388
+ set_={
389
+ "dims": statement.excluded.dims,
390
+ "embedding": statement.excluded.embedding,
391
+ "text_hash": statement.excluded.text_hash,
392
+ },
393
+ )
394
+ )
395
+
396
+
397
+ def fingerprint_of(kind_id: Any, fingerprints: Mapping[str, str]) -> Any:
398
+ """SQL expression of the template fingerprint of a row's Kind (`kind_id`), from
399
+ `fingerprints` (Kind id to fingerprint); NULL for other Kinds, which match no vector."""
400
+ if not fingerprints:
401
+ return literal(None, Text())
402
+ return case(
403
+ {kind: literal(f, Text()) for kind, f in sorted(fingerprints.items())}, value=kind_id
404
+ )
405
+
406
+
407
+ async def prune_models(session: AsyncSession, domain_id: str, *, keep: str) -> int:
408
+ """Delete the vectors and cursors of every model but `keep`; returns the vectors deleted."""
409
+ await session.execute(
410
+ delete(EMBEDDING_CURSORS).where(
411
+ EMBEDDING_CURSORS.c.domain_id == domain_id, EMBEDDING_CURSORS.c.model != keep
412
+ )
413
+ )
414
+ result = await session.execute(
415
+ delete(EMBEDDINGS).where(EMBEDDINGS.c.domain_id == domain_id, EMBEDDINGS.c.model != keep)
416
+ )
417
+ return int(getattr(result, "rowcount", 0) or 0)
418
+
419
+
420
+ # Index precision --------------------------------------------------------------------------------
421
+
422
+ _INDEX_NAMES = sql_text(
423
+ "SELECT c.relname FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid "
424
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
425
+ "WHERE n.nspname = current_schema() AND c.relname IN (:full, :half) AND x.indisvalid "
426
+ "ORDER BY c.oid DESC LIMIT 1"
427
+ )
428
+ _precision: dict[int, tuple[float, Precision]] = {}
429
+
430
+
431
+ async def index_precision(session: AsyncSession, dims: int) -> Precision:
432
+ """`half` when the HNSW index of `dims` is on `halfvec` (storage profile, see
433
+ `lorebase_store.storage`), else `full`: searches cast to the indexed type so the index
434
+ serves them. With both valid (during a switch) the newest one; looked up again after
435
+ `PRECISION_TTL_S`, which `storage apply` waits before it drops the older one."""
436
+ now = time.monotonic()
437
+ cached = _precision.get(dims)
438
+ if cached is not None and now - cached[0] < PRECISION_TTL_S:
439
+ return cached[1]
440
+ full, half = hnsw_index_name(dims, "full"), hnsw_index_name(dims, "half")
441
+ newest = (await session.execute(_INDEX_NAMES, {"full": full, "half": half})).scalar()
442
+ precision: Precision = "half" if newest == half else "full"
443
+ _precision[dims] = (now, precision)
444
+ return precision
@@ -0,0 +1,59 @@
1
+ """Durable memory of the structured output mode each chat endpoint needs.
2
+
3
+ `lorebase_inference.chat.ModeMemory` over `inference_endpoint_modes`: the mode only moves
4
+ towards the weaker ones, so concurrent writers converge on the weakest mode any of them saw,
5
+ and an entry older than the caller's maximum age counts as absent (the next write replaces it
6
+ and drops the other expired ones).
7
+ `clear_modes` forgets every entry (an endpoint that got stronger).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlalchemy import text
13
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
14
+
15
+ _RECALL = text(
16
+ "SELECT mode_index FROM inference_endpoint_modes "
17
+ "WHERE key = :key AND updated_at > now() - make_interval(secs => :age)"
18
+ )
19
+ _REMEMBER = text(
20
+ """
21
+ INSERT INTO inference_endpoint_modes AS m (key, mode_index) VALUES (:key, :index)
22
+ ON CONFLICT (key) DO UPDATE
23
+ SET mode_index = CASE
24
+ WHEN m.updated_at > now() - make_interval(secs => :age)
25
+ THEN GREATEST(m.mode_index, EXCLUDED.mode_index)
26
+ ELSE EXCLUDED.mode_index
27
+ END,
28
+ updated_at = now()
29
+ """
30
+ )
31
+ _PRUNE = text(
32
+ "DELETE FROM inference_endpoint_modes WHERE updated_at <= now() - make_interval(secs => :age)"
33
+ )
34
+ _CLEAR = text("DELETE FROM inference_endpoint_modes")
35
+
36
+
37
+ class StoreModeMemory:
38
+ """One transaction per call."""
39
+
40
+ def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
41
+ self._sessions = sessions
42
+
43
+ async def recall(self, digest: bytes, max_age_s: float) -> int | None:
44
+ async with self._sessions() as session:
45
+ found: int | None = (
46
+ await session.execute(_RECALL, {"key": digest, "age": max_age_s})
47
+ ).scalar_one_or_none()
48
+ return found
49
+
50
+ async def remember(self, digest: bytes, index: int, max_age_s: float) -> None:
51
+ async with self._sessions.begin() as session:
52
+ await session.execute(_REMEMBER, {"key": digest, "index": index, "age": max_age_s})
53
+ await session.execute(_PRUNE, {"age": max_age_s})
54
+
55
+
56
+ async def clear_modes(sessions: async_sessionmaker[AsyncSession]) -> int:
57
+ """Forget every remembered mode; returns how many entries were removed."""
58
+ async with sessions.begin() as session:
59
+ return (await session.execute(_CLEAR)).rowcount # type: ignore[attr-defined,no-any-return]
@@ -0,0 +1,61 @@
1
+ """Extraction Proposals without models: item edits, materialization on the extraction branch,
2
+ the Proposal summary (design 9.2 steps 11 and 12). Scribe produces the items; Keeper records
3
+ review decisions; both materialize through this package."""
4
+
5
+ from lorebase_store.extraction.items import (
6
+ ItemEditError,
7
+ effective_subject,
8
+ link_element,
9
+ normalize_edits,
10
+ proposal_text,
11
+ record_element,
12
+ summarize,
13
+ )
14
+ from lorebase_store.extraction.materialize import (
15
+ EXTRACTION_ORIGIN,
16
+ RULES_MODEL,
17
+ MaterializeContext,
18
+ Materialized,
19
+ materialize_items,
20
+ )
21
+ from lorebase_store.extraction.proposal import (
22
+ EXTRACTION_CHECK,
23
+ extraction_check,
24
+ refresh_proposal,
25
+ )
26
+ from lorebase_store.extraction.review import (
27
+ MERGE_POLICY_KEY,
28
+ Decision,
29
+ Reviewed,
30
+ ReviewError,
31
+ decide_items,
32
+ extraction_proposal,
33
+ )
34
+
35
+ EXTRACTION_BRANCH_PREFIX = "extract/"
36
+ """Extraction branches are named `extract/<extraction job id>`."""
37
+
38
+ __all__ = [
39
+ "EXTRACTION_BRANCH_PREFIX",
40
+ "EXTRACTION_CHECK",
41
+ "EXTRACTION_ORIGIN",
42
+ "MERGE_POLICY_KEY",
43
+ "RULES_MODEL",
44
+ "Decision",
45
+ "ItemEditError",
46
+ "MaterializeContext",
47
+ "Materialized",
48
+ "ReviewError",
49
+ "Reviewed",
50
+ "decide_items",
51
+ "effective_subject",
52
+ "extraction_check",
53
+ "extraction_proposal",
54
+ "link_element",
55
+ "materialize_items",
56
+ "normalize_edits",
57
+ "proposal_text",
58
+ "record_element",
59
+ "refresh_proposal",
60
+ "summarize",
61
+ ]