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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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
+ )