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,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}
|