lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Items of extraction Proposals (design 9.2 step 12, 12.3 Review: extraction items).
|
|
2
|
+
|
|
3
|
+
| Method and path | Permission | Notes |
|
|
4
|
+
|-----------------|------------|-------|
|
|
5
|
+
| `GET .../proposals/{p}/items` | `document.read` | filters `status`, `kind`, `type`, confidence |
|
|
6
|
+
| `POST .../proposals/{p}/items:decide` | `proposal.review` (target branch) | listed or by filter |
|
|
7
|
+
|
|
8
|
+
Items quote their documents, so listing them needs `document.read` (Domain-wide). An item
|
|
9
|
+
names its Kind, Relation and Attributes by api name and by id; `subject` keeps the ids.
|
|
10
|
+
|
|
11
|
+
A decision accepts, rejects or resets (`pending`) items, by id with optional reviewer `edits`,
|
|
12
|
+
or all items matching `where` with one `decision`. Edits by item type: `record` takes
|
|
13
|
+
`{"values": {attribute: value}}`, `value` takes `{"value": v, "key": k}` (`key` picks another
|
|
14
|
+
Record, for ambiguous matches), `link` takes `{"from": key, "to": key, "values": {...}}`.
|
|
15
|
+
Edits that set a value of a vault Attribute also need `pii.import` on the whole Domain
|
|
16
|
+
(`services.pii_import`), checked before anything is decided.
|
|
17
|
+
After the decisions, the extraction branch is re-materialized to hold exactly the accepted
|
|
18
|
+
items (with `extraction` Lineage) and the Proposal's summary and merge preview are refreshed,
|
|
19
|
+
in one transaction: when the branch rejects the accepted set (a new Record missing a required
|
|
20
|
+
value or Link), the answer is 422 with the issues and nothing changes. Decisions are audited
|
|
21
|
+
(`proposal.items.decide`).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Annotated, Any, Literal, Self
|
|
29
|
+
|
|
30
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
31
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
|
32
|
+
|
|
33
|
+
from lorebase_core.model import Schema, Sensitivity
|
|
34
|
+
from lorebase_keeper.auth.audit import annotate
|
|
35
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
|
|
36
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
37
|
+
from lorebase_keeper.auth.principals import Principal
|
|
38
|
+
from lorebase_keeper.auth.rbac import AccessContext
|
|
39
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
40
|
+
from lorebase_keeper.context import StoreDep, require_domain
|
|
41
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
42
|
+
from lorebase_keeper.errors import (
|
|
43
|
+
CONFLICT,
|
|
44
|
+
NOT_FOUND,
|
|
45
|
+
VALIDATION_FAILED,
|
|
46
|
+
Problem,
|
|
47
|
+
ProblemError,
|
|
48
|
+
ValidationProblem,
|
|
49
|
+
)
|
|
50
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
51
|
+
from lorebase_keeper.reading.sensitivity import (
|
|
52
|
+
Masking,
|
|
53
|
+
default_schema,
|
|
54
|
+
generalize_issues,
|
|
55
|
+
masking_for,
|
|
56
|
+
relation_kinds,
|
|
57
|
+
)
|
|
58
|
+
from lorebase_keeper.services.errors import commit_problem, findings_of
|
|
59
|
+
from lorebase_keeper.services.pii_import import item_edits_vault_attributes, require_pii_import
|
|
60
|
+
from lorebase_store.commit import CommitError
|
|
61
|
+
from lorebase_store.commit.schema import load_compiled
|
|
62
|
+
from lorebase_store.extraction import (
|
|
63
|
+
EXTRACTION_CHECK,
|
|
64
|
+
Decision,
|
|
65
|
+
ReviewError,
|
|
66
|
+
decide_items,
|
|
67
|
+
extraction_proposal,
|
|
68
|
+
)
|
|
69
|
+
from lorebase_store.models.enums import ItemDecision
|
|
70
|
+
from lorebase_store.repositories import (
|
|
71
|
+
ItemFilter,
|
|
72
|
+
ItemRow,
|
|
73
|
+
MaterializedRepository,
|
|
74
|
+
ProposalItemRepository,
|
|
75
|
+
ProposalRow,
|
|
76
|
+
)
|
|
77
|
+
from lorebase_store.repositories.proposal_items import ITEM_TYPES
|
|
78
|
+
|
|
79
|
+
router = APIRouter(tags=["proposals"])
|
|
80
|
+
|
|
81
|
+
ITEMS = "/domains/{domain_id}/proposals/{proposal_id}/items"
|
|
82
|
+
DecisionName = Literal["pending", "accepted", "rejected"]
|
|
83
|
+
ItemType = Literal["record", "value", "link"]
|
|
84
|
+
|
|
85
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
86
|
+
403: {"model": Problem, "description": "The caller lacks the permission."},
|
|
87
|
+
404: {"model": Problem, "description": "Domain or Proposal not found."},
|
|
88
|
+
409: {"model": Problem, "description": "Not an extraction Proposal, or not open."},
|
|
89
|
+
422: {
|
|
90
|
+
"model": ValidationProblem,
|
|
91
|
+
"description": "Invalid request, unknown items, invalid edits, or the branch rejects "
|
|
92
|
+
"the accepted items.",
|
|
93
|
+
},
|
|
94
|
+
503: {"model": Problem, "description": "The extraction branch is locked by another writer."},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Read = Annotated[Access, Depends(require(Permission.DOCUMENT_READ))]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ItemOut(BaseModel):
|
|
101
|
+
id: str
|
|
102
|
+
type: ItemType
|
|
103
|
+
seq: int
|
|
104
|
+
decision: DecisionName
|
|
105
|
+
confidence: float | None
|
|
106
|
+
tier: int | None
|
|
107
|
+
model: str | None
|
|
108
|
+
kind: str | None = Field(default=None, description="Kind api name (record, value items).")
|
|
109
|
+
relation: str | None = Field(default=None, description="Relation api name (link items).")
|
|
110
|
+
key: str | None = None
|
|
111
|
+
attribute: str | None = Field(default=None, description="Attribute api name (value items).")
|
|
112
|
+
value: JsonValue = None
|
|
113
|
+
current: JsonValue = Field(default=None, description="The value on the target branch.")
|
|
114
|
+
from_key: str | None = Field(default=None, alias="from")
|
|
115
|
+
to_key: str | None = Field(default=None, alias="to")
|
|
116
|
+
values: dict[str, JsonValue] = Field(
|
|
117
|
+
default_factory=dict, description="Proposed values by Attribute api name, edits applied."
|
|
118
|
+
)
|
|
119
|
+
subject: dict[str, Any] = Field(description="What the item proposes, by ids, before edits.")
|
|
120
|
+
evidence: dict[str, Any] | None
|
|
121
|
+
match: dict[str, Any] | None
|
|
122
|
+
edits: dict[str, Any] | None
|
|
123
|
+
decided_by: str | None
|
|
124
|
+
decided_at: datetime | None
|
|
125
|
+
not_applied: str | None = Field(
|
|
126
|
+
default=None, description="Why an accepted item is not on the branch."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ItemSelection(BaseModel):
|
|
133
|
+
"""Items matching every given filter."""
|
|
134
|
+
|
|
135
|
+
model_config = ConfigDict(extra="forbid")
|
|
136
|
+
|
|
137
|
+
status: list[DecisionName] = Field(default_factory=list)
|
|
138
|
+
kind: list[str] = Field(
|
|
139
|
+
default_factory=list, description="Kind or Relation api names (or ids)."
|
|
140
|
+
)
|
|
141
|
+
type: list[ItemType] = Field(default_factory=list)
|
|
142
|
+
min_confidence: float | None = Field(default=None, ge=0, le=1)
|
|
143
|
+
max_confidence: float | None = Field(
|
|
144
|
+
default=None, ge=0, le=1.01, description="Exclusive upper bound."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ItemDecisionIn(BaseModel):
|
|
149
|
+
model_config = ConfigDict(extra="forbid")
|
|
150
|
+
|
|
151
|
+
item: str = Field(min_length=1)
|
|
152
|
+
decision: DecisionName
|
|
153
|
+
edits: dict[str, Any] | None = Field(
|
|
154
|
+
default=None, description="Reviewer edits; omitted keeps the stored ones, {} clears."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DecideIn(BaseModel):
|
|
159
|
+
"""Decisions by item, and or one decision for every item matching `where`."""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(extra="forbid")
|
|
162
|
+
|
|
163
|
+
decisions: list[ItemDecisionIn] = Field(default_factory=list)
|
|
164
|
+
where: ItemSelection | None = None
|
|
165
|
+
decision: DecisionName | None = Field(default=None, description="Decision for `where`.")
|
|
166
|
+
|
|
167
|
+
@model_validator(mode="after")
|
|
168
|
+
def _something(self) -> Self:
|
|
169
|
+
if (self.where is None) != (self.decision is None):
|
|
170
|
+
raise ValueError("`where` and `decision` go together")
|
|
171
|
+
if not self.decisions and self.where is None:
|
|
172
|
+
raise ValueError("give `decisions`, or `where` with `decision`")
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MaterializedOut(BaseModel):
|
|
177
|
+
revision: str | None
|
|
178
|
+
changes: int
|
|
179
|
+
applied: int
|
|
180
|
+
skipped: dict[str, str]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DecideOut(BaseModel):
|
|
184
|
+
proposal_id: str
|
|
185
|
+
title: str
|
|
186
|
+
checks: dict[str, Any] = Field(description="The `extraction` summary and `merge` preview.")
|
|
187
|
+
items: list[ItemOut] = Field(description="The decided items.")
|
|
188
|
+
materialized: MaterializedOut
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _names(schema: Schema) -> dict[str, str]:
|
|
192
|
+
names: dict[str, str] = {}
|
|
193
|
+
for kind in schema.kinds.values():
|
|
194
|
+
names[kind.id] = kind.api_name
|
|
195
|
+
names.update({a.id: a.api_name for a in kind.attributes.values()})
|
|
196
|
+
for relation in schema.relations.values():
|
|
197
|
+
names[relation.id] = relation.api_name
|
|
198
|
+
names.update({a.id: a.api_name for a in relation.attributes.values()})
|
|
199
|
+
return names
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class _Shown:
|
|
204
|
+
"""Which Attribute values of extraction items the caller may see."""
|
|
205
|
+
|
|
206
|
+
hidden: frozenset[str] = frozenset()
|
|
207
|
+
known: frozenset[str] | None = None
|
|
208
|
+
"""Attribute ids of the schema; None trusts every id (no masking)."""
|
|
209
|
+
full: bool = True
|
|
210
|
+
masked: str = "***"
|
|
211
|
+
|
|
212
|
+
def masks(self, attribute_id: Any) -> bool:
|
|
213
|
+
"""Values of a hidden Attribute, or of one the schema does not know unless the caller
|
|
214
|
+
is cleared for everything."""
|
|
215
|
+
if attribute_id in self.hidden:
|
|
216
|
+
return True
|
|
217
|
+
return self.known is not None and attribute_id not in self.known and not self.full
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _shown(schema: Schema, masking: Masking) -> _Shown:
|
|
221
|
+
hidden = {
|
|
222
|
+
attribute.id
|
|
223
|
+
for kind in schema.kinds.values()
|
|
224
|
+
for attribute in kind.attributes.values()
|
|
225
|
+
if masking.hidden(attribute, (kind.id,))
|
|
226
|
+
}
|
|
227
|
+
hidden |= {
|
|
228
|
+
attribute.id
|
|
229
|
+
for relation in schema.relations.values()
|
|
230
|
+
for attribute in relation.attributes.values()
|
|
231
|
+
if masking.hidden(attribute, relation_kinds(schema, relation))
|
|
232
|
+
}
|
|
233
|
+
known = {a.id for kind in schema.kinds.values() for a in kind.attributes.values()}
|
|
234
|
+
known |= {a.id for relation in schema.relations.values() for a in relation.attributes.values()}
|
|
235
|
+
return _Shown(
|
|
236
|
+
frozenset(hidden),
|
|
237
|
+
frozenset(known),
|
|
238
|
+
masking.clearance.covers(Sensitivity, ()),
|
|
239
|
+
masking.masked,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _scrub(document: Any, shown: _Shown, attribute: Any = None) -> Any:
|
|
244
|
+
"""An item's subject or edits with the values the caller may not see masked: entries of
|
|
245
|
+
`values`, and `value` and `current` when the item is about such an Attribute."""
|
|
246
|
+
if not isinstance(document, dict):
|
|
247
|
+
return document
|
|
248
|
+
out = dict(document)
|
|
249
|
+
if isinstance(out.get("values"), dict):
|
|
250
|
+
out["values"] = {
|
|
251
|
+
k: (shown.masked if shown.masks(k) and v is not None else v)
|
|
252
|
+
for k, v in out["values"].items()
|
|
253
|
+
}
|
|
254
|
+
about = attribute or out.get("attribute")
|
|
255
|
+
if about is not None and shown.masks(about):
|
|
256
|
+
for name in ("value", "current"):
|
|
257
|
+
if out.get(name) is not None:
|
|
258
|
+
out[name] = shown.masked
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _touches_masked(row: ItemRow, shown: _Shown) -> bool:
|
|
263
|
+
"""Whether an item is about a value the caller may not see: its evidence and match quote
|
|
264
|
+
the document and candidate Records, so they are left out then."""
|
|
265
|
+
subject = row.subject or {}
|
|
266
|
+
keys = [subject.get("attribute"), *(subject.get("values") or {})]
|
|
267
|
+
keys += list((row.edits or {}).get("values") or {})
|
|
268
|
+
return any(key is not None and shown.masks(key) for key in keys)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _item(
|
|
272
|
+
row: ItemRow,
|
|
273
|
+
names: dict[str, str],
|
|
274
|
+
skipped: dict[str, Any],
|
|
275
|
+
shown: _Shown | None = None,
|
|
276
|
+
) -> ItemOut:
|
|
277
|
+
shown = shown or _Shown()
|
|
278
|
+
subject = _scrub(row.subject, shown)
|
|
279
|
+
edits = _scrub(row.edits or {}, shown, subject.get("attribute"))
|
|
280
|
+
quoted = not _touches_masked(row, shown)
|
|
281
|
+
values = {**(subject.get("values") or {}), **(edits.get("values") or {})}
|
|
282
|
+
kind = subject.get("kind")
|
|
283
|
+
relation = subject.get("relation")
|
|
284
|
+
attribute = subject.get("attribute")
|
|
285
|
+
return ItemOut(
|
|
286
|
+
id=row.item_id,
|
|
287
|
+
type=row.item_type,
|
|
288
|
+
seq=row.seq,
|
|
289
|
+
decision=row.decision.value,
|
|
290
|
+
confidence=row.confidence,
|
|
291
|
+
tier=row.tier,
|
|
292
|
+
model=row.model,
|
|
293
|
+
kind=None if kind is None else names.get(kind, kind),
|
|
294
|
+
relation=None if relation is None else names.get(relation, relation),
|
|
295
|
+
key=edits.get("key", subject.get("key")),
|
|
296
|
+
attribute=None if attribute is None else names.get(attribute, attribute),
|
|
297
|
+
value=edits.get("value", subject.get("value")),
|
|
298
|
+
current=subject.get("current"),
|
|
299
|
+
**{
|
|
300
|
+
"from": edits.get("from", subject.get("from")),
|
|
301
|
+
"to": edits.get("to", subject.get("to")),
|
|
302
|
+
},
|
|
303
|
+
values={names.get(k, k): v for k, v in values.items()},
|
|
304
|
+
subject=subject,
|
|
305
|
+
evidence=row.evidence if quoted else None,
|
|
306
|
+
match=row.match if quoted else None,
|
|
307
|
+
edits=edits if row.edits else row.edits,
|
|
308
|
+
decided_by=row.decided_by,
|
|
309
|
+
decided_at=row.decided_at,
|
|
310
|
+
not_applied=(
|
|
311
|
+
str(skipped[row.item_id])
|
|
312
|
+
if row.decision is ItemDecision.ACCEPTED and row.item_id in skipped
|
|
313
|
+
else None
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _targets(names: list[str], schema: Schema) -> tuple[str, ...]:
|
|
319
|
+
found: list[str] = []
|
|
320
|
+
for name in names:
|
|
321
|
+
kind = schema.kinds.get(name) or schema.kind_by_api_name(name)
|
|
322
|
+
if kind is not None:
|
|
323
|
+
found.append(kind.id)
|
|
324
|
+
continue
|
|
325
|
+
relation = schema.relations.get(name)
|
|
326
|
+
by_name = schema.relation_by_api_name(name)
|
|
327
|
+
if relation is None and by_name is not None:
|
|
328
|
+
relation = by_name[0]
|
|
329
|
+
if relation is None:
|
|
330
|
+
raise ProblemError(
|
|
331
|
+
VALIDATION_FAILED,
|
|
332
|
+
"One or more request members are invalid.",
|
|
333
|
+
extensions={
|
|
334
|
+
"errors": [
|
|
335
|
+
{"pointer": "/kind", "message": f"unknown Kind or Relation {name!r}"}
|
|
336
|
+
]
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
found.append(relation.id)
|
|
340
|
+
return tuple(found)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _filter(selection: ItemSelection, schema: Schema) -> ItemFilter:
|
|
344
|
+
return ItemFilter(
|
|
345
|
+
decisions=tuple(ItemDecision(s) for s in selection.status),
|
|
346
|
+
targets=_targets(selection.kind, schema),
|
|
347
|
+
item_types=tuple(t for t in selection.type if t in ITEM_TYPES),
|
|
348
|
+
min_confidence=selection.min_confidence,
|
|
349
|
+
max_confidence=selection.max_confidence,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _review_problem(error: ReviewError) -> ProblemError:
|
|
354
|
+
if error.reason == "not_found":
|
|
355
|
+
return ProblemError(NOT_FOUND, str(error))
|
|
356
|
+
if error.reason in ("not_extraction", "not_open"):
|
|
357
|
+
return ProblemError(CONFLICT, str(error))
|
|
358
|
+
return ProblemError(
|
|
359
|
+
VALIDATION_FAILED,
|
|
360
|
+
str(error),
|
|
361
|
+
extensions={
|
|
362
|
+
"errors": [
|
|
363
|
+
{"pointer": "/body/decisions", "message": str(error), "item": item}
|
|
364
|
+
for item in (error.item_ids or [""])
|
|
365
|
+
]
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _schema(store: StoreDep, domain_id: str, proposal: ProposalRow) -> Schema:
|
|
371
|
+
async with store.sessions() as session, session.begin():
|
|
372
|
+
await require_domain(session, domain_id)
|
|
373
|
+
compiled = await MaterializedRepository(
|
|
374
|
+
session, domain_id, proposal.target_ref, writes=store.runtime.settings.writes
|
|
375
|
+
).schema()
|
|
376
|
+
if compiled is None:
|
|
377
|
+
raise ProblemError(NOT_FOUND, f"Branch {proposal.target_ref!r} not found.")
|
|
378
|
+
return load_compiled(compiled.compiled)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def _masking(
|
|
382
|
+
store: StoreDep, auth: AuthRuntime, principal: Principal, domain_id: str, settings: Any
|
|
383
|
+
) -> Masking:
|
|
384
|
+
"""The caller's masking in the Domain: items quote stored values (`current`)."""
|
|
385
|
+
async with store.sessions() as session, session.begin():
|
|
386
|
+
await require_domain(session, domain_id)
|
|
387
|
+
reference = await default_schema(session, domain_id, store.runtime)
|
|
388
|
+
return await masking_for(auth.authorizer, principal, domain_id, reference, settings)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def _proposal(store: StoreDep, domain_id: str, proposal_id: str) -> ProposalRow:
|
|
392
|
+
async with store.sessions() as session, session.begin():
|
|
393
|
+
await require_domain(session, domain_id)
|
|
394
|
+
try:
|
|
395
|
+
return await extraction_proposal(session, domain_id, proposal_id)
|
|
396
|
+
except ReviewError as error:
|
|
397
|
+
raise _review_problem(error) from None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@router.get(ITEMS, summary="List the items of an extraction Proposal", responses=_ERRORS)
|
|
401
|
+
async def list_items(
|
|
402
|
+
domain_id: str,
|
|
403
|
+
proposal_id: str,
|
|
404
|
+
access: Read,
|
|
405
|
+
store: StoreDep,
|
|
406
|
+
params: PageParamsDep,
|
|
407
|
+
auth: AuthDep,
|
|
408
|
+
keeper: KeeperSettingsDep,
|
|
409
|
+
status: Annotated[list[DecisionName] | None, Query()] = None,
|
|
410
|
+
kind: Annotated[
|
|
411
|
+
list[str] | None, Query(description="Kind or Relation api names (or ids).")
|
|
412
|
+
] = None,
|
|
413
|
+
type_: Annotated[list[ItemType] | None, Query(alias="type")] = None,
|
|
414
|
+
min_confidence: Annotated[float | None, Query(ge=0, le=1)] = None,
|
|
415
|
+
max_confidence: Annotated[float | None, Query(ge=0, le=1.01)] = None,
|
|
416
|
+
) -> Page[ItemOut]:
|
|
417
|
+
"""Items in proposal order (Records, values, Links), with evidence and match details."""
|
|
418
|
+
proposal = await _proposal(store, domain_id, proposal_id)
|
|
419
|
+
schema = await _schema(store, domain_id, proposal)
|
|
420
|
+
filters = _filter(
|
|
421
|
+
ItemSelection(
|
|
422
|
+
status=status or [],
|
|
423
|
+
kind=kind or [],
|
|
424
|
+
type=type_ or [],
|
|
425
|
+
min_confidence=min_confidence,
|
|
426
|
+
max_confidence=max_confidence,
|
|
427
|
+
),
|
|
428
|
+
schema,
|
|
429
|
+
)
|
|
430
|
+
after = None
|
|
431
|
+
if params.position is not None:
|
|
432
|
+
try:
|
|
433
|
+
after = (int(params.position["seq"]), str(params.position["id"]))
|
|
434
|
+
except (KeyError, ValueError, TypeError):
|
|
435
|
+
raise ProblemError(VALIDATION_FAILED, "The cursor is not an item cursor.") from None
|
|
436
|
+
async with store.sessions() as session, session.begin():
|
|
437
|
+
await require_domain(session, domain_id)
|
|
438
|
+
rows = await ProposalItemRepository(session, domain_id).page(
|
|
439
|
+
proposal_id, filters, limit=params.limit + 1, after=after
|
|
440
|
+
)
|
|
441
|
+
more = len(rows) > params.limit
|
|
442
|
+
rows = rows[: params.limit]
|
|
443
|
+
names = _names(schema)
|
|
444
|
+
skipped = ((proposal.checks or {}).get(EXTRACTION_CHECK) or {}).get("skipped") or {}
|
|
445
|
+
next_cursor = (
|
|
446
|
+
encode_cursor({"seq": rows[-1].seq, "id": rows[-1].item_id}) if more and rows else None
|
|
447
|
+
)
|
|
448
|
+
masking = (
|
|
449
|
+
await _masking(store, auth, access.principal, domain_id, keeper.reads.sensitivity)
|
|
450
|
+
).over(schema)
|
|
451
|
+
shown = _shown(schema, masking)
|
|
452
|
+
items = [_item(r, names, skipped, shown) for r in rows]
|
|
453
|
+
return Page[ItemOut](items=items, next_cursor=next_cursor)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def _check_vault_edits(
|
|
457
|
+
store: StoreDep,
|
|
458
|
+
auth: AuthRuntime,
|
|
459
|
+
principal: Principal,
|
|
460
|
+
domain_id: str,
|
|
461
|
+
proposal_id: str,
|
|
462
|
+
schema: Schema,
|
|
463
|
+
decisions: list[Decision],
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Edits setting a value of a vault Attribute need `pii.import` Domain-wide, before
|
|
466
|
+
anything is decided: the extraction commit checks vault values against erased ones."""
|
|
467
|
+
edited = {d.item_id: d.edits for d in decisions if d.edits}
|
|
468
|
+
if not edited:
|
|
469
|
+
return
|
|
470
|
+
async with store.sessions() as session, session.begin():
|
|
471
|
+
await require_domain(session, domain_id)
|
|
472
|
+
rows = await ProposalItemRepository(session, domain_id).all(proposal_id)
|
|
473
|
+
for row in rows:
|
|
474
|
+
edits = edited.get(row.item_id)
|
|
475
|
+
if edits and item_edits_vault_attributes(schema, row, edits):
|
|
476
|
+
await require_pii_import(auth.authorizer, principal, domain_id)
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@router.post(ITEMS + ":decide", summary="Accept or reject extraction items", responses=_ERRORS)
|
|
481
|
+
async def decide(
|
|
482
|
+
domain_id: str,
|
|
483
|
+
proposal_id: str,
|
|
484
|
+
body: DecideIn,
|
|
485
|
+
request: Request,
|
|
486
|
+
principal: PrincipalDep,
|
|
487
|
+
auth: AuthDep,
|
|
488
|
+
store: StoreDep,
|
|
489
|
+
keeper: KeeperSettingsDep,
|
|
490
|
+
) -> DecideOut:
|
|
491
|
+
"""Record decisions, re-materialize the extraction branch and refresh the Proposal."""
|
|
492
|
+
proposal = await _proposal(store, domain_id, proposal_id)
|
|
493
|
+
await authorize(
|
|
494
|
+
request,
|
|
495
|
+
auth,
|
|
496
|
+
principal,
|
|
497
|
+
Permission.PROPOSAL_REVIEW,
|
|
498
|
+
domain_id,
|
|
499
|
+
AccessContext(branch=proposal.target_ref),
|
|
500
|
+
)
|
|
501
|
+
schema = await _schema(store, domain_id, proposal)
|
|
502
|
+
decisions = [Decision(d.item, ItemDecision(d.decision), d.edits) for d in body.decisions]
|
|
503
|
+
await _check_vault_edits(store, auth, principal, domain_id, proposal_id, schema, decisions)
|
|
504
|
+
listed = {d.item_id for d in decisions}
|
|
505
|
+
if body.where is not None and body.decision is not None:
|
|
506
|
+
async with store.sessions() as session, session.begin():
|
|
507
|
+
await require_domain(session, domain_id)
|
|
508
|
+
selected = await ProposalItemRepository(session, domain_id).all(
|
|
509
|
+
proposal_id, _filter(body.where, schema)
|
|
510
|
+
)
|
|
511
|
+
decisions += [
|
|
512
|
+
Decision(row.item_id, ItemDecision(body.decision))
|
|
513
|
+
for row in selected
|
|
514
|
+
if row.item_id not in listed
|
|
515
|
+
]
|
|
516
|
+
limit = keeper.proposals.max_item_decisions
|
|
517
|
+
if len(decisions) > limit:
|
|
518
|
+
raise ProblemError(VALIDATION_FAILED, f"At most {limit} items per request.")
|
|
519
|
+
try:
|
|
520
|
+
async with store.sessions() as session, session.begin():
|
|
521
|
+
reviewed = await decide_items(
|
|
522
|
+
session,
|
|
523
|
+
domain_id,
|
|
524
|
+
proposal_id,
|
|
525
|
+
decisions,
|
|
526
|
+
actor=principal.id,
|
|
527
|
+
runtime=store.runtime,
|
|
528
|
+
clock=store.clock,
|
|
529
|
+
)
|
|
530
|
+
except ReviewError as error:
|
|
531
|
+
raise _review_problem(error) from None
|
|
532
|
+
except CommitError as error:
|
|
533
|
+
seen = (await _masking(store, auth, principal, domain_id, keeper.reads.sensitivity)).over(
|
|
534
|
+
schema
|
|
535
|
+
)
|
|
536
|
+
raise commit_problem(
|
|
537
|
+
error,
|
|
538
|
+
max_issues=20,
|
|
539
|
+
retry_after_s=keeper.branches.lock_retry_after_s,
|
|
540
|
+
locate=lambda issues: findings_of(generalize_issues(issues, (schema,), seen)),
|
|
541
|
+
) from None
|
|
542
|
+
outcome = reviewed.outcome
|
|
543
|
+
annotate(
|
|
544
|
+
request,
|
|
545
|
+
action="proposal.items.decide",
|
|
546
|
+
target=proposal_id,
|
|
547
|
+
accepted=sum(1 for d in decisions if d.decision is ItemDecision.ACCEPTED),
|
|
548
|
+
rejected=sum(1 for d in decisions if d.decision is ItemDecision.REJECTED),
|
|
549
|
+
reset=sum(1 for d in decisions if d.decision is ItemDecision.PENDING),
|
|
550
|
+
revision=None if outcome.revision is None else outcome.revision.hex(),
|
|
551
|
+
)
|
|
552
|
+
names = _names(schema)
|
|
553
|
+
masking = (await _masking(store, auth, principal, domain_id, keeper.reads.sensitivity)).over(
|
|
554
|
+
schema
|
|
555
|
+
)
|
|
556
|
+
shown = _shown(schema, masking)
|
|
557
|
+
return DecideOut(
|
|
558
|
+
proposal_id=reviewed.proposal.id,
|
|
559
|
+
title=reviewed.proposal.title,
|
|
560
|
+
checks=reviewed.proposal.checks or {},
|
|
561
|
+
items=[_item(r, names, outcome.skipped, shown) for r in reviewed.items],
|
|
562
|
+
materialized=MaterializedOut(**outcome.as_dict()),
|
|
563
|
+
)
|