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,417 @@
1
+ """Merge attempts, their Conflicts and the resolutions of a Proposal (design 6.8 step 5, 6.10).
2
+
3
+ A merge attempt records one run of the merge for a Proposal: base, ours (target head) and
4
+ theirs (source head), the outcome and the Conflicts. Conflicts are stored per attempt and
5
+ identified by their fingerprint (the public Conflict id is its hex form). Resolutions are
6
+ stored per Proposal by fingerprint, so they survive the recomputation of an attempt.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Sequence
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from typing import Any
15
+
16
+ from sqlalchemy import Select, Table, delete, func, insert, or_, select, update
17
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from lorebase_store.bulk import chunks
21
+ from lorebase_store.models import metadata
22
+ from lorebase_store.models.enums import MergeAttemptStatus
23
+
24
+ ATTEMPTS: Table = metadata.tables["merge_attempts"]
25
+ CONFLICTS: Table = metadata.tables["conflicts"]
26
+ RESOLUTIONS: Table = metadata.tables["conflict_resolutions"]
27
+
28
+ _INSERT_BATCH = 500
29
+ """Rows per multi-row INSERT of Conflicts (each row carries a JSON payload)."""
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class MergeAttemptRow:
34
+ id: str
35
+ proposal_id: str
36
+ base: bytes | None
37
+ ours: bytes
38
+ theirs: bytes
39
+ status: MergeAttemptStatus
40
+ created_at: datetime
41
+ virtual_base: bool = False
42
+ result: str | None = None
43
+ policy: dict[str, Any] | None = None
44
+ created_by: str | None = None
45
+ outcome: dict[str, Any] | None = None
46
+ revision: bytes | None = None
47
+ idempotency_key: str | None = None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ConflictRow:
52
+ """A stored Conflict. `resolution` and `resolved_by` are set when the attempt applied a
53
+ resolution to it (resolved); queries test `resolved_by`, which is SQL null otherwise."""
54
+
55
+ fingerprint: str
56
+ code: str
57
+ partition: str
58
+ element_key: str | None
59
+ attribute_id: str | None
60
+ payload: dict[str, Any]
61
+ member_count: int
62
+ resolution: dict[str, Any] | None = None
63
+ resolved_by: str | None = None
64
+ resolved_at: datetime | None = None
65
+
66
+ @property
67
+ def resolved(self) -> bool:
68
+ return self.resolved_by is not None
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class ResolutionRow:
73
+ fingerprint: str
74
+ resolution: dict[str, Any]
75
+ resolved_by: str
76
+ at: datetime
77
+
78
+
79
+ def _attempt(found: Any) -> MergeAttemptRow:
80
+ return MergeAttemptRow(**{k: v for k, v in found.items() if k != "domain_id"})
81
+
82
+
83
+ def _conflict(found: Any) -> ConflictRow:
84
+ return ConflictRow(
85
+ fingerprint=found["id"],
86
+ code=found["code"],
87
+ partition=found["partition"],
88
+ element_key=found["element_key"],
89
+ attribute_id=found["attribute_id"],
90
+ payload=found["payload"],
91
+ member_count=found["member_count"],
92
+ resolution=found["resolution"],
93
+ resolved_by=found["resolved_by"],
94
+ resolved_at=found["resolved_at"],
95
+ )
96
+
97
+
98
+ class MergeAttemptRepository:
99
+ """Merge attempts of one Domain."""
100
+
101
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
102
+ self._session = session
103
+ self._domain = domain_id
104
+
105
+ async def insert(self, row: MergeAttemptRow) -> None:
106
+ await self._session.execute(
107
+ insert(ATTEMPTS).values(
108
+ domain_id=self._domain,
109
+ id=row.id,
110
+ proposal_id=row.proposal_id,
111
+ base=row.base,
112
+ ours=row.ours,
113
+ theirs=row.theirs,
114
+ status=row.status,
115
+ created_at=row.created_at,
116
+ virtual_base=row.virtual_base,
117
+ result=row.result,
118
+ policy=row.policy,
119
+ created_by=row.created_by,
120
+ outcome=row.outcome,
121
+ revision=row.revision,
122
+ idempotency_key=row.idempotency_key,
123
+ )
124
+ )
125
+
126
+ async def get(self, attempt_id: str) -> MergeAttemptRow | None:
127
+ result = await self._session.execute(
128
+ select(ATTEMPTS).where(
129
+ ATTEMPTS.c.domain_id == self._domain, ATTEMPTS.c.id == attempt_id
130
+ )
131
+ )
132
+ found = result.mappings().one_or_none()
133
+ return None if found is None else _attempt(found)
134
+
135
+ async def latest(self, proposal_id: str) -> MergeAttemptRow | None:
136
+ """The most recent attempt of a Proposal."""
137
+ result = await self._session.execute(
138
+ select(ATTEMPTS)
139
+ .where(ATTEMPTS.c.domain_id == self._domain, ATTEMPTS.c.proposal_id == proposal_id)
140
+ .order_by(ATTEMPTS.c.created_at.desc(), ATTEMPTS.c.id.desc())
141
+ .limit(1)
142
+ )
143
+ found = result.mappings().one_or_none()
144
+ return None if found is None else _attempt(found)
145
+
146
+ async def latest_of(self, proposal_ids: Sequence[str]) -> dict[str, MergeAttemptRow]:
147
+ """The most recent attempt of each of several Proposals."""
148
+ if not proposal_ids:
149
+ return {}
150
+ result = await self._session.execute(
151
+ select(ATTEMPTS)
152
+ .distinct(ATTEMPTS.c.proposal_id)
153
+ .where(
154
+ ATTEMPTS.c.domain_id == self._domain,
155
+ ATTEMPTS.c.proposal_id.in_(list(proposal_ids)),
156
+ )
157
+ .order_by(ATTEMPTS.c.proposal_id, ATTEMPTS.c.created_at.desc(), ATTEMPTS.c.id.desc())
158
+ )
159
+ return {row["proposal_id"]: _attempt(row) for row in result.mappings()}
160
+
161
+ async def by_key(self, proposal_id: str, idempotency_key: str) -> MergeAttemptRow | None:
162
+ """The attempt of a Proposal recorded under an idempotency key."""
163
+ result = await self._session.execute(
164
+ select(ATTEMPTS)
165
+ .where(
166
+ ATTEMPTS.c.domain_id == self._domain,
167
+ ATTEMPTS.c.proposal_id == proposal_id,
168
+ ATTEMPTS.c.idempotency_key == idempotency_key,
169
+ )
170
+ .order_by(ATTEMPTS.c.created_at.desc())
171
+ .limit(1)
172
+ )
173
+ found = result.mappings().one_or_none()
174
+ return None if found is None else _attempt(found)
175
+
176
+ async def supersede(self, proposal_id: str, *, status: MergeAttemptStatus) -> int:
177
+ """Set `status` (`stale` or `abandoned`) on the open attempts of a Proposal."""
178
+ result = await self._session.execute(
179
+ update(ATTEMPTS)
180
+ .where(
181
+ ATTEMPTS.c.domain_id == self._domain,
182
+ ATTEMPTS.c.proposal_id == proposal_id,
183
+ ATTEMPTS.c.status.in_([MergeAttemptStatus.CONFLICTED, MergeAttemptStatus.RESOLVED]),
184
+ )
185
+ .values(status=status)
186
+ )
187
+ return int(getattr(result, "rowcount", 0) or 0)
188
+
189
+ async def prune(self, proposal_id: str, *, keep: int) -> int:
190
+ """Delete all but the `keep` most recent attempts of a Proposal (Conflicts cascade).
191
+
192
+ Merged attempts are kept: they document how the Proposal was merged.
193
+ """
194
+ recent = (
195
+ select(ATTEMPTS.c.id)
196
+ .where(ATTEMPTS.c.domain_id == self._domain, ATTEMPTS.c.proposal_id == proposal_id)
197
+ .order_by(ATTEMPTS.c.created_at.desc(), ATTEMPTS.c.id.desc())
198
+ .limit(max(keep, 1))
199
+ )
200
+ result = await self._session.execute(
201
+ delete(ATTEMPTS).where(
202
+ ATTEMPTS.c.domain_id == self._domain,
203
+ ATTEMPTS.c.proposal_id == proposal_id,
204
+ ATTEMPTS.c.status != MergeAttemptStatus.MERGED,
205
+ ATTEMPTS.c.id.not_in(recent.scalar_subquery()),
206
+ )
207
+ )
208
+ return int(getattr(result, "rowcount", 0) or 0)
209
+
210
+ async def count(self, proposal_id: str) -> int:
211
+ return int(
212
+ await self._session.scalar(
213
+ select(func.count())
214
+ .select_from(ATTEMPTS)
215
+ .where(ATTEMPTS.c.domain_id == self._domain, ATTEMPTS.c.proposal_id == proposal_id)
216
+ )
217
+ or 0
218
+ )
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class PartitionFilter:
223
+ """The partitions whose Conflicts a reader sees: those in `include` (every partition when
224
+ None) except those in `exclude`; Conflicts about one of the Attributes in
225
+ `exclude_attributes` are left out too."""
226
+
227
+ include: frozenset[str] | None = None
228
+ exclude: frozenset[str] = frozenset()
229
+ exclude_attributes: frozenset[str] = frozenset()
230
+
231
+ @property
232
+ def everything(self) -> bool:
233
+ return self.include is None and not self.exclude and not self.exclude_attributes
234
+
235
+ def allows(self, partition: str, attribute: str | None = None) -> bool:
236
+ return (
237
+ (self.include is None or partition in self.include)
238
+ and partition not in self.exclude
239
+ and (attribute is None or attribute not in self.exclude_attributes)
240
+ )
241
+
242
+ def apply[T: Select[Any]](self, query: T) -> T:
243
+ if self.include is not None:
244
+ query = query.where(CONFLICTS.c.partition.in_(sorted(self.include)))
245
+ if self.exclude:
246
+ query = query.where(CONFLICTS.c.partition.not_in(sorted(self.exclude)))
247
+ if self.exclude_attributes:
248
+ column = CONFLICTS.c.attribute_id
249
+ query = query.where(
250
+ or_(column.is_(None), column.not_in(sorted(self.exclude_attributes)))
251
+ )
252
+ return query
253
+
254
+
255
+ EVERY_PARTITION = PartitionFilter()
256
+
257
+
258
+ class ConflictRepository:
259
+ """Conflicts of the merge attempts of one Domain."""
260
+
261
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
262
+ self._session = session
263
+ self._domain = domain_id
264
+
265
+ async def insert_many(self, attempt_id: str, rows: Iterable[ConflictRow]) -> None:
266
+ values = [
267
+ {
268
+ "domain_id": self._domain,
269
+ "attempt_id": attempt_id,
270
+ "id": row.fingerprint,
271
+ "code": row.code,
272
+ "fingerprint": bytes.fromhex(row.fingerprint),
273
+ "partition": row.partition,
274
+ "element_key": row.element_key,
275
+ "attribute_id": row.attribute_id,
276
+ "payload": row.payload,
277
+ "member_count": row.member_count,
278
+ "resolution": row.resolution,
279
+ "resolved_by": row.resolved_by,
280
+ "resolved_at": row.resolved_at,
281
+ }
282
+ for row in rows
283
+ ]
284
+ for chunk in chunks(values, _INSERT_BATCH):
285
+ await self._session.execute(insert(CONFLICTS), chunk)
286
+
287
+ async def get(self, attempt_id: str, fingerprint: str) -> ConflictRow | None:
288
+ result = await self._session.execute(
289
+ select(CONFLICTS).where(
290
+ CONFLICTS.c.domain_id == self._domain,
291
+ CONFLICTS.c.attempt_id == attempt_id,
292
+ CONFLICTS.c.id == fingerprint,
293
+ )
294
+ )
295
+ found = result.mappings().one_or_none()
296
+ return None if found is None else _conflict(found)
297
+
298
+ async def page(
299
+ self,
300
+ attempt_id: str,
301
+ *,
302
+ codes: Sequence[str] = (),
303
+ resolved: bool | None = None,
304
+ limit: int,
305
+ after: str | None = None,
306
+ partitions: PartitionFilter = EVERY_PARTITION,
307
+ ) -> list[ConflictRow]:
308
+ """Conflicts of an attempt ordered by fingerprint, starting after `after`."""
309
+ query = partitions.apply(
310
+ select(CONFLICTS).where(
311
+ CONFLICTS.c.domain_id == self._domain, CONFLICTS.c.attempt_id == attempt_id
312
+ )
313
+ )
314
+ if codes:
315
+ query = query.where(CONFLICTS.c.code.in_(list(codes)))
316
+ if resolved is not None:
317
+ query = query.where(
318
+ CONFLICTS.c.resolved_by.is_not(None)
319
+ if resolved
320
+ else CONFLICTS.c.resolved_by.is_(None)
321
+ )
322
+ if after is not None:
323
+ query = query.where(CONFLICTS.c.id > after)
324
+ query = query.order_by(CONFLICTS.c.id).limit(limit)
325
+ result = await self._session.execute(query)
326
+ return [_conflict(row) for row in result.mappings()]
327
+
328
+ async def counts(
329
+ self, attempt_id: str, partitions: PartitionFilter = EVERY_PARTITION
330
+ ) -> dict[tuple[str, bool], int]:
331
+ """Number of Conflicts of an attempt per `(code, resolved)`."""
332
+ resolved = CONFLICTS.c.resolved_by.is_not(None)
333
+ query = partitions.apply(
334
+ select(CONFLICTS.c.code, resolved, func.count()).where(
335
+ CONFLICTS.c.domain_id == self._domain, CONFLICTS.c.attempt_id == attempt_id
336
+ )
337
+ )
338
+ result = await self._session.execute(query.group_by(CONFLICTS.c.code, resolved))
339
+ return {(code, bool(done)): int(count) for code, done, count in result}
340
+
341
+ async def fingerprints(self, attempt_id: str, partitions: PartitionFilter) -> frozenset[str]:
342
+ """Ids of the Conflicts of an attempt in the partitions `partitions` lets through."""
343
+ query = partitions.apply(
344
+ select(CONFLICTS.c.id).where(
345
+ CONFLICTS.c.domain_id == self._domain, CONFLICTS.c.attempt_id == attempt_id
346
+ )
347
+ )
348
+ return frozenset((await self._session.execute(query)).scalars())
349
+
350
+
351
+ class ResolutionRepository:
352
+ """Resolutions of the Proposals of one Domain, by Conflict fingerprint."""
353
+
354
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
355
+ self._session = session
356
+ self._domain = domain_id
357
+
358
+ async def upsert_many(self, proposal_id: str, rows: Sequence[ResolutionRow]) -> None:
359
+ """Store resolutions; a later resolution of the same fingerprint replaces the earlier."""
360
+ if not rows:
361
+ return
362
+ latest = {row.fingerprint: row for row in rows}
363
+ statement = pg_insert(RESOLUTIONS)
364
+ statement = statement.on_conflict_do_update(
365
+ index_elements=["domain_id", "proposal_id", "fingerprint"],
366
+ set_={
367
+ "resolution": statement.excluded.resolution,
368
+ "resolved_by": statement.excluded.resolved_by,
369
+ "at": statement.excluded.at,
370
+ },
371
+ )
372
+ await self._session.execute(
373
+ statement,
374
+ [
375
+ {
376
+ "domain_id": self._domain,
377
+ "proposal_id": proposal_id,
378
+ "fingerprint": bytes.fromhex(row.fingerprint),
379
+ "resolution": row.resolution,
380
+ "resolved_by": row.resolved_by,
381
+ "at": row.at,
382
+ }
383
+ for row in latest.values()
384
+ ],
385
+ )
386
+
387
+ async def of(self, proposal_id: str) -> list[ResolutionRow]:
388
+ """Resolutions of a Proposal ordered by time."""
389
+ result = await self._session.execute(
390
+ select(RESOLUTIONS)
391
+ .where(
392
+ RESOLUTIONS.c.domain_id == self._domain,
393
+ RESOLUTIONS.c.proposal_id == proposal_id,
394
+ )
395
+ .order_by(RESOLUTIONS.c.at, RESOLUTIONS.c.fingerprint)
396
+ )
397
+ return [
398
+ ResolutionRow(
399
+ fingerprint=bytes(row["fingerprint"]).hex(),
400
+ resolution=row["resolution"],
401
+ resolved_by=row["resolved_by"],
402
+ at=row["at"],
403
+ )
404
+ for row in result.mappings()
405
+ ]
406
+
407
+ async def delete(self, proposal_id: str, fingerprints: Sequence[str]) -> int:
408
+ if not fingerprints:
409
+ return 0
410
+ result = await self._session.execute(
411
+ delete(RESOLUTIONS).where(
412
+ RESOLUTIONS.c.domain_id == self._domain,
413
+ RESOLUTIONS.c.proposal_id == proposal_id,
414
+ RESOLUTIONS.c.fingerprint.in_([bytes.fromhex(f) for f in fingerprints]),
415
+ )
416
+ )
417
+ return int(getattr(result, "rowcount", 0) or 0)
@@ -0,0 +1,199 @@
1
+ """Items of extraction Proposals (design 7.3, 9.2 step 11): one proposed Record, Attribute value
2
+ or Link each, with evidence, confidence, match details and the reviewer's decision.
3
+
4
+ `subject` holds what the item proposes, with Attribute values in canonical form:
5
+
6
+ - `record`: `{"kind", "key", "values": {attribute id: value}, "evidence": {attribute id:
7
+ evidence}}`, a new Record;
8
+ - `value`: `{"kind", "key", "attribute", "value", "current"}`, a value of an existing Record;
9
+ - `link`: `{"relation", "from", "to", "values"}`.
10
+
11
+ `evidence` is the primary evidence, `{"document_id", "chunk", "start", "end", "quote"}` with
12
+ offsets into the parsed document text. `edits` holds the reviewer's changes, applied when the
13
+ item is accepted.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, fields
20
+ from datetime import datetime
21
+ from typing import Any
22
+
23
+ from sqlalchemy import Table, func, insert, or_, select, update
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from lorebase_store.models import metadata
27
+ from lorebase_store.models.enums import ItemDecision
28
+
29
+ ITEMS: Table = metadata.tables["proposal_items"]
30
+
31
+ RECORD_ITEM = "record"
32
+ VALUE_ITEM = "value"
33
+ LINK_ITEM = "link"
34
+ ITEM_TYPES = (RECORD_ITEM, VALUE_ITEM, LINK_ITEM)
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ItemRow:
39
+ item_id: str
40
+ item_type: str
41
+ seq: int
42
+ subject: dict[str, Any]
43
+ partition: str
44
+ element_key: str
45
+ attribute_id: str | None = None
46
+ evidence: dict[str, Any] | None = None
47
+ confidence: float | None = None
48
+ decision: ItemDecision = ItemDecision.PENDING
49
+ decided_by: str | None = None
50
+ decided_at: datetime | None = None
51
+ job_id: str | None = None
52
+ match: dict[str, Any] | None = None
53
+ tier: int | None = None
54
+ model: str | None = None
55
+ edits: dict[str, Any] | None = None
56
+ created_at: datetime | None = None
57
+
58
+ @property
59
+ def target(self) -> str:
60
+ """The Kind id of a Record or value item, the Relation id of a Link item."""
61
+ return str(self.subject.get("relation") or self.subject.get("kind") or "")
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class ItemFilter:
66
+ """Optional filters of an item listing; `targets` are Kind or Relation ids."""
67
+
68
+ decisions: tuple[ItemDecision, ...] = ()
69
+ targets: tuple[str, ...] = ()
70
+ item_types: tuple[str, ...] = ()
71
+ min_confidence: float | None = None
72
+ max_confidence: float | None = None
73
+ item_ids: tuple[str, ...] = ()
74
+
75
+
76
+ _COLUMNS = tuple(f.name for f in fields(ItemRow))
77
+
78
+
79
+ def _item(found: Any) -> ItemRow:
80
+ return ItemRow(**{name: found[name] for name in _COLUMNS})
81
+
82
+
83
+ class ProposalItemRepository:
84
+ """Items of the Proposals of one Domain, in the caller's transaction."""
85
+
86
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
87
+ self._session = session
88
+ self._domain = domain_id
89
+
90
+ async def insert(self, proposal_id: str, rows: Sequence[ItemRow]) -> None:
91
+ if not rows:
92
+ return
93
+ await self._session.execute(
94
+ insert(ITEMS),
95
+ [
96
+ {
97
+ "domain_id": self._domain,
98
+ "proposal_id": proposal_id,
99
+ **{
100
+ name: getattr(row, name)
101
+ for name in _COLUMNS
102
+ if not (name == "created_at" and row.created_at is None)
103
+ },
104
+ }
105
+ for row in rows
106
+ ],
107
+ )
108
+
109
+ def _where(self, proposal_id: str, filters: ItemFilter) -> list[Any]:
110
+ table = ITEMS
111
+ conditions: list[Any] = [
112
+ table.c.domain_id == self._domain,
113
+ table.c.proposal_id == proposal_id,
114
+ ]
115
+ if filters.decisions:
116
+ conditions.append(table.c.decision.in_(filters.decisions))
117
+ if filters.item_types:
118
+ conditions.append(table.c.item_type.in_(filters.item_types))
119
+ if filters.targets:
120
+ targets = list(filters.targets)
121
+ conditions.append(
122
+ or_(
123
+ table.c.subject["kind"].astext.in_(targets),
124
+ table.c.subject["relation"].astext.in_(targets),
125
+ )
126
+ )
127
+ if filters.min_confidence is not None:
128
+ conditions.append(table.c.confidence >= filters.min_confidence)
129
+ if filters.max_confidence is not None:
130
+ conditions.append(table.c.confidence < filters.max_confidence)
131
+ if filters.item_ids:
132
+ conditions.append(table.c.item_id.in_(filters.item_ids))
133
+ return conditions
134
+
135
+ async def page(
136
+ self,
137
+ proposal_id: str,
138
+ filters: ItemFilter,
139
+ *,
140
+ limit: int,
141
+ after: tuple[int, str] | None = None,
142
+ ) -> list[ItemRow]:
143
+ """Items in proposal order, after the keyset `(seq, item_id)`."""
144
+ table = ITEMS
145
+ query = select(table).where(*self._where(proposal_id, filters))
146
+ if after is not None:
147
+ seq, last = after
148
+ query = query.where(
149
+ or_(table.c.seq > seq, (table.c.seq == seq) & (table.c.item_id > last))
150
+ )
151
+ query = query.order_by(table.c.seq, table.c.item_id).limit(limit)
152
+ return [_item(row) for row in (await self._session.execute(query)).mappings()]
153
+
154
+ async def all(
155
+ self,
156
+ proposal_id: str,
157
+ filters: ItemFilter | None = None,
158
+ *,
159
+ for_update: bool = False,
160
+ ) -> list[ItemRow]:
161
+ """Every item matching `filters`, in proposal order."""
162
+ table = ITEMS
163
+ query = (
164
+ select(table)
165
+ .where(*self._where(proposal_id, filters or ItemFilter()))
166
+ .order_by(table.c.seq, table.c.item_id)
167
+ )
168
+ if for_update:
169
+ query = query.with_for_update()
170
+ return [_item(row) for row in (await self._session.execute(query)).mappings()]
171
+
172
+ async def decide(
173
+ self,
174
+ proposal_id: str,
175
+ item_id: str,
176
+ *,
177
+ decision: ItemDecision,
178
+ decided_by: str | None,
179
+ decided_at: datetime | None,
180
+ edits: dict[str, Any] | None,
181
+ ) -> None:
182
+ await self._session.execute(
183
+ update(ITEMS)
184
+ .where(
185
+ ITEMS.c.domain_id == self._domain,
186
+ ITEMS.c.proposal_id == proposal_id,
187
+ ITEMS.c.item_id == item_id,
188
+ )
189
+ .values(decision=decision, decided_by=decided_by, decided_at=decided_at, edits=edits)
190
+ )
191
+
192
+ async def counts(self, proposal_id: str) -> dict[str, int]:
193
+ """Number of items per decision."""
194
+ result = await self._session.execute(
195
+ select(ITEMS.c.decision, func.count())
196
+ .where(ITEMS.c.domain_id == self._domain, ITEMS.c.proposal_id == proposal_id)
197
+ .group_by(ITEMS.c.decision)
198
+ )
199
+ return {ItemDecision(decision).value: int(count) for decision, count in result}