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