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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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
|
+
]
|