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,187 @@
1
+ """Extraction items as data: element identity, reviewer edits and the Proposal summary.
2
+
3
+ Shared by Scribe, which writes the items of a new extraction Proposal, and Keeper, which
4
+ records reviewer decisions; neither needs a model for this.
5
+
6
+ Reviewer edits, by item type (Attributes named by id or api name, values in API form):
7
+
8
+ - `record`: `{"values": {attribute: value}}`, merged over the proposed values;
9
+ - `value`: `{"value": value, "key": key}`, the value, or the Record it belongs to (the
10
+ ambiguous match picker);
11
+ - `link`: `{"from": key, "to": key, "values": {attribute: value}}`.
12
+
13
+ `normalize_edits` validates them against the schema and returns them with Attribute ids and
14
+ canonical values, the form stored in `proposal_items.edits` and applied by
15
+ `effective_subject`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections import Counter
21
+ from collections.abc import Iterable, Mapping
22
+ from typing import Any
23
+
24
+ from lorebase_core.model import Attribute, Schema
25
+ from lorebase_core.tree import kind_partition, link_key, relation_partition
26
+ from lorebase_core.types import EncodingError, encode_value, normalize_key
27
+ from lorebase_store.models.enums import ItemDecision
28
+ from lorebase_store.repositories.proposal_items import (
29
+ LINK_ITEM,
30
+ RECORD_ITEM,
31
+ VALUE_ITEM,
32
+ ItemRow,
33
+ )
34
+
35
+
36
+ class ItemEditError(ValueError):
37
+ """Reviewer edits that do not fit the item or the schema."""
38
+
39
+ def __init__(self, item_id: str, message: str) -> None:
40
+ super().__init__(f"item {item_id}: {message}")
41
+ self.item_id = item_id
42
+ self.detail = message
43
+
44
+
45
+ def record_element(kind_id: str, key: str) -> tuple[str, str]:
46
+ """`(partition, element key)` of a Record."""
47
+ return kind_partition(kind_id), key
48
+
49
+
50
+ def link_element(relation_id: str, from_key: str, to_key: str) -> tuple[str, str]:
51
+ """`(partition, element key)` of a Link."""
52
+ return relation_partition(relation_id), link_key(from_key, to_key)
53
+
54
+
55
+ def _attributes(schema: Schema, item: ItemRow) -> Mapping[str, Attribute]:
56
+ subject = item.subject
57
+ if item.item_type == LINK_ITEM:
58
+ relation = schema.relations.get(str(subject.get("relation")))
59
+ if relation is None:
60
+ raise ItemEditError(item.item_id, "the Relation no longer exists")
61
+ return relation.attributes
62
+ kind = schema.kinds.get(str(subject.get("kind")))
63
+ if kind is None:
64
+ raise ItemEditError(item.item_id, "the Kind no longer exists")
65
+ return kind.attributes
66
+
67
+
68
+ def _encode(item_id: str, attribute: Attribute, raw: Any) -> Any:
69
+ if attribute.is_derived:
70
+ raise ItemEditError(item_id, f"{attribute.api_name} is derived")
71
+ try:
72
+ return encode_value(attribute.type, raw)
73
+ except EncodingError as error:
74
+ raise ItemEditError(item_id, f"{attribute.api_name}: {error}") from None
75
+
76
+
77
+ def _values(item_id: str, attributes: Mapping[str, Attribute], raw: Any) -> dict[str, Any]:
78
+ if not isinstance(raw, Mapping):
79
+ raise ItemEditError(item_id, "values must be an object")
80
+ by_name = {a.api_name: a for a in attributes.values()}
81
+ encoded: dict[str, Any] = {}
82
+ for name, value in raw.items():
83
+ attribute = attributes.get(name) or by_name.get(name)
84
+ if attribute is None:
85
+ raise ItemEditError(item_id, f"unknown Attribute {name!r}")
86
+ encoded[attribute.id] = _encode(item_id, attribute, value)
87
+ return encoded
88
+
89
+
90
+ def _key(item_id: str, raw: Any) -> str:
91
+ if not isinstance(raw, str) or not raw.strip():
92
+ raise ItemEditError(item_id, "keys must be non-empty strings")
93
+ return normalize_key(raw.strip())
94
+
95
+
96
+ def normalize_edits(
97
+ item: ItemRow, edits: Mapping[str, Any] | None, schema: Schema
98
+ ) -> dict[str, Any]:
99
+ """Edits validated against the schema, with Attribute ids and canonical values."""
100
+ if not edits:
101
+ return {}
102
+ allowed = {
103
+ RECORD_ITEM: {"values"},
104
+ VALUE_ITEM: {"value", "key"},
105
+ LINK_ITEM: {"from", "to", "values"},
106
+ }[item.item_type]
107
+ unknown = sorted(set(edits) - allowed)
108
+ if unknown:
109
+ raise ItemEditError(
110
+ item.item_id, f"{item.item_type} items accept edits of {sorted(allowed)}, not {unknown}"
111
+ )
112
+ attributes = _attributes(schema, item)
113
+ normalized: dict[str, Any] = {}
114
+ if "values" in edits:
115
+ normalized["values"] = _values(item.item_id, attributes, edits["values"])
116
+ if "value" in edits:
117
+ attribute = attributes.get(str(item.subject.get("attribute")))
118
+ if attribute is None:
119
+ raise ItemEditError(item.item_id, "the Attribute no longer exists")
120
+ normalized["value"] = _encode(item.item_id, attribute, edits["value"])
121
+ for name in ("key", "from", "to"):
122
+ if name in edits:
123
+ normalized[name] = _key(item.item_id, edits[name])
124
+ return normalized
125
+
126
+
127
+ def effective_subject(item: ItemRow) -> dict[str, Any]:
128
+ """The subject with the stored reviewer edits applied."""
129
+ subject = dict(item.subject)
130
+ edits = item.edits or {}
131
+ if "values" in edits:
132
+ subject["values"] = {**(subject.get("values") or {}), **edits["values"]}
133
+ for name in ("value", "key", "from", "to"):
134
+ if name in edits:
135
+ subject[name] = edits[name]
136
+ return subject
137
+
138
+
139
+ def summarize(items: Iterable[ItemRow], schema: Schema | None = None) -> dict[str, Any]:
140
+ """Counts of a Proposal's items: per decision, per item type and per Kind or Relation (api
141
+ names when `schema` is given), and the mean confidence."""
142
+ rows = list(items)
143
+ decisions: Counter[str] = Counter({d.value: 0 for d in ItemDecision})
144
+ types: Counter[str] = Counter()
145
+ targets: Counter[str] = Counter()
146
+ confidences = [row.confidence for row in rows if row.confidence is not None]
147
+ for row in rows:
148
+ decisions[row.decision.value] += 1
149
+ types[row.item_type] += 1
150
+ targets[_target_name(row, schema)] += 1
151
+ return {
152
+ "items": len(rows),
153
+ "decisions": dict(decisions),
154
+ "types": dict(sorted(types.items())),
155
+ "targets": dict(sorted(targets.items())),
156
+ "mean_confidence": (round(sum(confidences) / len(confidences), 4) if confidences else None),
157
+ }
158
+
159
+
160
+ def _target_name(row: ItemRow, schema: Schema | None) -> str:
161
+ target = row.target
162
+ if schema is None:
163
+ return target
164
+ kind = schema.kinds.get(target)
165
+ if kind is not None:
166
+ return kind.api_name
167
+ relation = schema.relations.get(target)
168
+ return relation.api_name if relation is not None else target
169
+
170
+
171
+ def proposal_text(summary: Mapping[str, Any], documents: Iterable[str]) -> tuple[str, str]:
172
+ """Title and description of an extraction Proposal from its summary."""
173
+ decisions = summary.get("decisions", {})
174
+ names = sorted(documents)
175
+ shown = ", ".join(names[:3]) + (f" and {len(names) - 3} more" if len(names) > 3 else "")
176
+ title = (
177
+ f"Extract {shown}: {summary.get('items', 0)} item(s), "
178
+ f"{decisions.get('accepted', 0)} accepted, {decisions.get('pending', 0)} pending"
179
+ )
180
+ targets = ", ".join(f"{name} {count}" for name, count in summary.get("targets", {}).items())
181
+ description = (
182
+ f"Proposed from {len(names)} document(s). Items by type: "
183
+ + ", ".join(f"{name} {count}" for name, count in summary.get("types", {}).items())
184
+ + (f"; by Kind or Relation: {targets}" if targets else "")
185
+ + f". Rejected: {decisions.get('rejected', 0)}. Review the items, then merge."
186
+ )
187
+ return title[:300], description
@@ -0,0 +1,364 @@
1
+ """Materialize the accepted items of an extraction Proposal on its branch (design 9.2 step 12).
2
+
3
+ The extraction branch holds, for every element an item touches, the target branch's current
4
+ content with the accepted items applied, and nothing else changes. Each call compares that
5
+ desired state with the branch head and commits the difference as one Revision, so accepting
6
+ an item adds its change and rejecting an accepted one reverts it. Elements no item touches keep
7
+ whatever the branch holds. Calling it again without new decisions commits nothing.
8
+
9
+ Rules:
10
+
11
+ - a `record` item creates the Record (or sets its values on a Record that exists by now);
12
+ - a `value` item sets one Attribute of an existing Record; it is skipped when the Record does
13
+ not exist;
14
+ - a `link` item adds the Link when both endpoints exist (or are created by accepted items);
15
+ on a Relation that allows one Link per `from` (or `to`) Record, the Links it replaces are
16
+ removed; Links of foreign-key backed Relations are skipped (they derive from an Attribute);
17
+ - every value set by an item is attributed to an `extraction` provenance entry with the job,
18
+ document, evidence offsets, model and prompt version.
19
+
20
+ Validation is the commit pipeline's: an accepted Record that misses a required value fails the
21
+ commit with `ValidationFailedError` and nothing is written.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections import defaultdict
27
+ from collections.abc import Callable, Mapping, Sequence
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime
30
+ from typing import Any
31
+
32
+ from sqlalchemy.ext.asyncio import AsyncSession
33
+
34
+ from lorebase_core.model import (
35
+ ExtractionOrigin,
36
+ IdPrefix,
37
+ Link,
38
+ ProvenanceEntry,
39
+ Record,
40
+ RelationBacking,
41
+ Schema,
42
+ new_id,
43
+ )
44
+ from lorebase_core.revision import Author, Origin
45
+ from lorebase_core.types.encoding import format_timestamp
46
+ from lorebase_core.validate import DataChange, LinkDelete, LinkUpsert, RecordDelete, RecordUpsert
47
+ from lorebase_store.commit import Change, commit
48
+ from lorebase_store.extraction.items import effective_subject
49
+ from lorebase_store.models.enums import ItemDecision
50
+ from lorebase_store.repositories.materialized import MaterializedRepository
51
+ from lorebase_store.repositories.proposal_items import LINK_ITEM, RECORD_ITEM, VALUE_ITEM, ItemRow
52
+ from lorebase_store.runtime import StoreRuntime
53
+
54
+ EXTRACTION_ORIGIN = "extraction"
55
+ RULES_MODEL = "rules"
56
+ """Model id recorded for items produced without a model (tier 0)."""
57
+
58
+ RecordId = tuple[str, str]
59
+ LinkId = tuple[str, str, str]
60
+ Clock = Callable[[], datetime]
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class MaterializeContext:
65
+ """Where and as whom to materialize."""
66
+
67
+ domain_id: str
68
+ branch: str
69
+ target: str
70
+ job_id: str
71
+ prompt_version: str
72
+ actor: str
73
+ runtime: StoreRuntime
74
+ clock: Clock
75
+
76
+
77
+ @dataclass
78
+ class Materialized:
79
+ """Outcome: the Revision committed (none when the branch already matched), the number of
80
+ changes, and the accepted items that could not be applied with the reason."""
81
+
82
+ revision: bytes | None = None
83
+ changes: int = 0
84
+ applied: list[str] = field(default_factory=list)
85
+ skipped: dict[str, str] = field(default_factory=dict)
86
+
87
+ def as_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "revision": None if self.revision is None else self.revision.hex(),
90
+ "changes": self.changes,
91
+ "applied": len(self.applied),
92
+ "skipped": dict(self.skipped),
93
+ }
94
+
95
+
96
+ @dataclass
97
+ class _Plan:
98
+ records: dict[RecordId, dict[str, Any] | None] = field(default_factory=dict)
99
+ links: dict[LinkId, dict[str, Any] | None] = field(default_factory=dict)
100
+ record_sources: dict[RecordId, dict[str, str]] = field(default_factory=dict)
101
+ link_sources: dict[LinkId, str] = field(default_factory=dict)
102
+
103
+
104
+ class _Provenance:
105
+ """One provenance entry per distinct evidence span of an item."""
106
+
107
+ def __init__(self, context: MaterializeContext) -> None:
108
+ self._context = context
109
+ self._entries: dict[tuple[str, int, int, str], ProvenanceEntry] = {}
110
+
111
+ def entry(self, item: ItemRow, evidence: Mapping[str, Any] | None) -> str:
112
+ found = evidence or item.evidence or {}
113
+ start = int(found.get("start") or 0)
114
+ end = max(int(found.get("end") or 0), start)
115
+ document = str(found.get("document_id") or "")
116
+ model = str(found.get("model") or item.model or RULES_MODEL)
117
+ key = (document, start, end, model)
118
+ if key not in self._entries:
119
+ self._entries[key] = ProvenanceEntry(
120
+ id=new_id(IdPrefix.PROVENANCE),
121
+ origin=ExtractionOrigin(
122
+ job_id=item.job_id or self._context.job_id,
123
+ document_id=document,
124
+ span_start=start,
125
+ span_end=end,
126
+ model_id=model,
127
+ prompt_version=self._context.prompt_version,
128
+ ),
129
+ actor=self._context.actor,
130
+ at=format_timestamp(self._context.clock()),
131
+ )
132
+ return self._entries[key].id
133
+
134
+ def used(self, ids: set[str]) -> list[ProvenanceEntry]:
135
+ return [entry for entry in self._entries.values() if entry.id in ids]
136
+
137
+
138
+ async def materialize_items(
139
+ session: AsyncSession,
140
+ context: MaterializeContext,
141
+ items: Sequence[ItemRow],
142
+ schema: Schema,
143
+ *,
144
+ message: str,
145
+ ) -> Materialized:
146
+ """Make the extraction branch hold the accepted `items` (see the module docstring)."""
147
+ writes = context.runtime.settings.writes
148
+ target = MaterializedRepository(session, context.domain_id, context.target, writes=writes)
149
+ branch = MaterializedRepository(session, context.domain_id, context.branch, writes=writes)
150
+ ordered = sorted(items, key=lambda item: (item.seq, item.item_id))
151
+ subjects = {item.item_id: effective_subject(item) for item in ordered}
152
+
153
+ record_keys: dict[str, set[str]] = defaultdict(set)
154
+ link_pairs: dict[str, set[tuple[str, str]]] = defaultdict(set)
155
+ endpoints: dict[str, set[str]] = defaultdict(set)
156
+ for item in ordered:
157
+ for subject in (item.subject, subjects[item.item_id]):
158
+ if item.item_type == LINK_ITEM:
159
+ relation = schema.relations.get(str(subject["relation"]))
160
+ if relation is None:
161
+ continue
162
+ pair = (str(subject["from"]), str(subject["to"]))
163
+ link_pairs[relation.id].add(pair)
164
+ endpoints[relation.from_kind].add(pair[0])
165
+ endpoints[relation.to_kind].add(pair[1])
166
+ else:
167
+ record_keys[str(subject["kind"])].add(str(subject["key"]))
168
+ await _replaced_links(target, schema, ordered, subjects, link_pairs)
169
+
170
+ plan = _Plan()
171
+ current_records: dict[RecordId, dict[str, Any]] = {}
172
+ for kind_id, keys in record_keys.items():
173
+ on_target = await target.records(kind_id, keys)
174
+ on_branch = await branch.records(kind_id, keys)
175
+ for key in keys:
176
+ row = on_target.get(key)
177
+ plan.records[(kind_id, key)] = None if row is None else dict(row.values)
178
+ if key in on_branch:
179
+ current_records[(kind_id, key)] = dict(on_branch[key].values)
180
+ current_links: dict[LinkId, dict[str, Any]] = {}
181
+ for relation_id, pairs in link_pairs.items():
182
+ on_target_links = await target.links(relation_id, pairs)
183
+ on_branch_links = await branch.links(relation_id, pairs)
184
+ for pair in pairs:
185
+ link = on_target_links.get(pair)
186
+ plan.links[(relation_id, *pair)] = None if link is None else dict(link.values)
187
+ if pair in on_branch_links:
188
+ current_links[(relation_id, *pair)] = dict(on_branch_links[pair].values)
189
+ existing: dict[str, set[str]] = {}
190
+ for kind_id, keys in endpoints.items():
191
+ existing[kind_id] = set(await branch.records(kind_id, keys))
192
+
193
+ provenance = _Provenance(context)
194
+ outcome = Materialized()
195
+ accepted = [item for item in ordered if item.decision is ItemDecision.ACCEPTED]
196
+ for item in accepted:
197
+ if item.item_type != LINK_ITEM:
198
+ _apply_record_item(item, subjects[item.item_id], plan, provenance, outcome)
199
+ for item in accepted:
200
+ if item.item_type == LINK_ITEM:
201
+ _apply_link_item(
202
+ item, subjects[item.item_id], schema, plan, existing, provenance, outcome
203
+ )
204
+
205
+ changes = _diff(plan, current_records, current_links)
206
+ outcome.changes = len(changes)
207
+ if changes:
208
+ used = {
209
+ value
210
+ for change in changes
211
+ for value in (
212
+ change.provenance.values()
213
+ if isinstance(change.provenance, Mapping)
214
+ else [change.provenance]
215
+ )
216
+ if value is not None
217
+ }
218
+ result = await commit(
219
+ session,
220
+ context.domain_id,
221
+ context.branch,
222
+ changes,
223
+ author=Author(principal=context.actor),
224
+ message=message,
225
+ origin=Origin(type=EXTRACTION_ORIGIN, ref=context.job_id),
226
+ provenance=provenance.used(used),
227
+ runtime=context.runtime,
228
+ clock=context.clock,
229
+ )
230
+ if not result.no_op:
231
+ outcome.revision = result.revision_id
232
+ return outcome
233
+
234
+
235
+ async def _replaced_links(
236
+ target: MaterializedRepository,
237
+ schema: Schema,
238
+ items: Sequence[ItemRow],
239
+ subjects: Mapping[str, Mapping[str, Any]],
240
+ link_pairs: dict[str, set[tuple[str, str]]],
241
+ ) -> None:
242
+ """Add the target's Links that a Link item would replace on single-valued Relations."""
243
+ for item in items:
244
+ if item.item_type != LINK_ITEM:
245
+ continue
246
+ subject = subjects[item.item_id]
247
+ relation = schema.relations.get(str(subject["relation"]))
248
+ if relation is None:
249
+ continue
250
+ from_key, to_key = str(subject["from"]), str(subject["to"])
251
+ if relation.single_per_from:
252
+ for _near, far, _values in await target.linked_keys(
253
+ relation.id, near="from_key", keys=[from_key]
254
+ ):
255
+ link_pairs[relation.id].add((from_key, far))
256
+ if relation.single_per_to:
257
+ for _near, far, _values in await target.linked_keys(
258
+ relation.id, near="to_key", keys=[to_key]
259
+ ):
260
+ link_pairs[relation.id].add((far, to_key))
261
+
262
+
263
+ def _apply_record_item(
264
+ item: ItemRow,
265
+ subject: Mapping[str, Any],
266
+ plan: _Plan,
267
+ provenance: _Provenance,
268
+ outcome: Materialized,
269
+ ) -> None:
270
+ element = (str(subject["kind"]), str(subject["key"]))
271
+ desired = plan.records.get(element)
272
+ sources = plan.record_sources.setdefault(element, {})
273
+ if item.item_type == RECORD_ITEM:
274
+ values = dict(subject.get("values") or {})
275
+ evidence = subject.get("evidence") or {}
276
+ desired = {**(desired or {}), **values}
277
+ for attribute_id in values:
278
+ sources[attribute_id] = provenance.entry(item, evidence.get(attribute_id))
279
+ elif item.item_type == VALUE_ITEM:
280
+ if desired is None:
281
+ outcome.skipped[item.item_id] = "the Record does not exist"
282
+ return
283
+ attribute_id = str(subject["attribute"])
284
+ desired = {**desired, attribute_id: subject.get("value")}
285
+ sources[attribute_id] = provenance.entry(item, None)
286
+ plan.records[element] = {k: v for k, v in (desired or {}).items() if v is not None}
287
+ outcome.applied.append(item.item_id)
288
+
289
+
290
+ def _apply_link_item(
291
+ item: ItemRow,
292
+ subject: Mapping[str, Any],
293
+ schema: Schema,
294
+ plan: _Plan,
295
+ existing: Mapping[str, set[str]],
296
+ provenance: _Provenance,
297
+ outcome: Materialized,
298
+ ) -> None:
299
+ relation = schema.relations.get(str(subject["relation"]))
300
+ if relation is None:
301
+ outcome.skipped[item.item_id] = "the Relation no longer exists"
302
+ return
303
+ if relation.backing is RelationBacking.FOREIGN_KEY:
304
+ outcome.skipped[item.item_id] = "Links of this Relation derive from an Attribute"
305
+ return
306
+ from_key, to_key = str(subject["from"]), str(subject["to"])
307
+
308
+ def present(kind_id: str, key: str) -> bool:
309
+ if (kind_id, key) in plan.records:
310
+ return plan.records[(kind_id, key)] is not None
311
+ return key in existing.get(kind_id, set())
312
+
313
+ if not (present(relation.from_kind, from_key) and present(relation.to_kind, to_key)):
314
+ outcome.skipped[item.item_id] = "an endpoint Record does not exist"
315
+ return
316
+ element = (relation.id, from_key, to_key)
317
+ for other in list(plan.links):
318
+ if other[0] != relation.id or other == element:
319
+ continue
320
+ if (relation.single_per_from and other[1] == from_key) or (
321
+ relation.single_per_to and other[2] == to_key
322
+ ):
323
+ plan.links[other] = None
324
+ plan.links[element] = {**(plan.links.get(element) or {}), **(subject.get("values") or {})}
325
+ plan.link_sources[element] = provenance.entry(item, None)
326
+ outcome.applied.append(item.item_id)
327
+
328
+
329
+ def _diff(
330
+ plan: _Plan,
331
+ current_records: Mapping[RecordId, dict[str, Any]],
332
+ current_links: Mapping[LinkId, dict[str, Any]],
333
+ ) -> list[Change]:
334
+ removals: list[Change] = []
335
+ upserts: list[Change] = []
336
+ for (relation_id, from_key, to_key), values in sorted(
337
+ plan.links.items(), key=lambda entry: entry[0]
338
+ ):
339
+ current = current_links.get((relation_id, from_key, to_key))
340
+ if values is None and current is not None:
341
+ removals.append(
342
+ Change(LinkDelete(relation=relation_id, from_key=from_key, to_key=to_key))
343
+ )
344
+ for (kind_id, key), values in sorted(plan.records.items(), key=lambda entry: entry[0]):
345
+ current = current_records.get((kind_id, key))
346
+ if values is None:
347
+ if current is not None:
348
+ removals.append(Change(RecordDelete(kind=kind_id, key=key)))
349
+ elif current != values:
350
+ sources = plan.record_sources.get((kind_id, key)) or {}
351
+ change: DataChange = RecordUpsert(record=Record(kind=kind_id, key=key, values=values))
352
+ upserts.append(Change(change, {a: p for a, p in sources.items() if a in values}))
353
+ for (relation_id, from_key, to_key), values in sorted(
354
+ plan.links.items(), key=lambda entry: entry[0]
355
+ ):
356
+ current = current_links.get((relation_id, from_key, to_key))
357
+ if values is not None and current != values:
358
+ link = Link(relation=relation_id, from_key=from_key, to_key=to_key, values=values)
359
+ upserts.append(
360
+ Change(
361
+ LinkUpsert(link=link), plan.link_sources.get((relation_id, from_key, to_key))
362
+ )
363
+ )
364
+ return removals + upserts
@@ -0,0 +1,65 @@
1
+ """Keep an extraction Proposal in step with its items: summary, checks and merge preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from lorebase_core.merge import MergePolicy
11
+ from lorebase_core.model import Schema
12
+ from lorebase_store.extraction.items import proposal_text, summarize
13
+ from lorebase_store.extraction.materialize import MaterializeContext, Materialized
14
+ from lorebase_store.merging import AttemptResult, MergeRequest, MergeService
15
+ from lorebase_store.models.enums import ProposalStatus
16
+ from lorebase_store.repositories.proposal_items import ItemRow
17
+ from lorebase_store.repositories.proposals import ProposalRepository, ProposalRow
18
+
19
+ EXTRACTION_CHECK = "extraction"
20
+ """Key of the item summary in `proposals.checks`."""
21
+
22
+
23
+ def extraction_check(
24
+ items: Sequence[ItemRow], schema: Schema, outcome: Materialized
25
+ ) -> dict[str, Any]:
26
+ """The `extraction` entry of a Proposal's checks."""
27
+ return {
28
+ **summarize(items, schema),
29
+ "applied": len(outcome.applied),
30
+ "skipped": dict(outcome.skipped),
31
+ "revision": None if outcome.revision is None else outcome.revision.hex(),
32
+ }
33
+
34
+
35
+ async def refresh_proposal(
36
+ session: AsyncSession,
37
+ context: MaterializeContext,
38
+ proposal: ProposalRow,
39
+ items: Sequence[ItemRow],
40
+ schema: Schema,
41
+ outcome: Materialized,
42
+ *,
43
+ documents: Sequence[str],
44
+ policy: MergePolicy | None = None,
45
+ ) -> tuple[ProposalRow, AttemptResult | None]:
46
+ """Rewrite the Proposal's title, description and `extraction` check from its items, then
47
+ recompute the merge preview when the Proposal is open."""
48
+ summary = summarize(items, schema)
49
+ title, description = proposal_text(summary, documents)
50
+ checks = dict(proposal.checks or {})
51
+ checks[EXTRACTION_CHECK] = extraction_check(items, schema, outcome)
52
+ proposals = ProposalRepository(session, context.domain_id)
53
+ updated = await proposals.update(
54
+ proposal.id, at=context.clock(), title=title, description=description, checks=checks
55
+ )
56
+ assert updated is not None
57
+ if updated.status is not ProposalStatus.OPEN:
58
+ return updated, None
59
+ service = MergeService(session, context.domain_id, runtime=context.runtime, clock=context.clock)
60
+ result = await service.recompute(
61
+ MergeRequest(proposal=updated, actor=context.actor, policy=policy or MergePolicy())
62
+ )
63
+ final = await proposals.get(proposal.id)
64
+ assert final is not None
65
+ return final, result