doc-store-client-dev 0.1.185__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.
- doc_store_client/__init__.py +194 -0
- doc_store_client/cli.py +192 -0
- doc_store_client/client.py +1509 -0
- doc_store_client/models.py +1635 -0
- doc_store_client/owned_document_cleanup.py +143 -0
- doc_store_client/py.typed +0 -0
- doc_store_client/text_verification.py +21 -0
- doc_store_client_cli.py +16 -0
- doc_store_client_dev-0.1.185.dist-info/METADATA +133 -0
- doc_store_client_dev-0.1.185.dist-info/RECORD +13 -0
- doc_store_client_dev-0.1.185.dist-info/WHEEL +5 -0
- doc_store_client_dev-0.1.185.dist-info/entry_points.txt +2 -0
- doc_store_client_dev-0.1.185.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1635 @@
|
|
|
1
|
+
"""Stable, transport-neutral public contracts for ``doc-store-client``.
|
|
2
|
+
|
|
3
|
+
The models intentionally contain validation and payload conversion only. They
|
|
4
|
+
do not know how a command is sent, queued, retried, persisted, or transferred.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from hmac import compare_digest
|
|
15
|
+
from typing import Any, ClassVar, Final, Iterable, Literal, Mapping, Self, TypedDict
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from chunk_metadata_adapter import ChunkQuery
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Payload = Mapping[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackupEmergencyResult(TypedDict):
|
|
25
|
+
"""Final independent FULL salvage result; diagnostics are inside the archive."""
|
|
26
|
+
|
|
27
|
+
emergency_id: str
|
|
28
|
+
status: Literal["complete", "incomplete"]
|
|
29
|
+
mode: Literal["emergency"]
|
|
30
|
+
kind: Literal["full"]
|
|
31
|
+
capture_complete: bool
|
|
32
|
+
integrity_complete: bool
|
|
33
|
+
snapshot_at: str
|
|
34
|
+
completed_at: str
|
|
35
|
+
repository: str
|
|
36
|
+
point: str
|
|
37
|
+
base_point: str
|
|
38
|
+
previous_point: None
|
|
39
|
+
archive: str
|
|
40
|
+
archive_sha256: str
|
|
41
|
+
manifest: Literal["emergency.json"]
|
|
42
|
+
tables_saved: int
|
|
43
|
+
rows_saved: int
|
|
44
|
+
files_saved: int
|
|
45
|
+
bytes_saved: int
|
|
46
|
+
diagnostics_count: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BackupRunResult(TypedDict):
|
|
50
|
+
"""Result data returned by ``backup_run``."""
|
|
51
|
+
|
|
52
|
+
status: Literal["created", "unchanged", "dry_run"]
|
|
53
|
+
kind: Literal["full", "incremental", "none"]
|
|
54
|
+
point: str | None
|
|
55
|
+
previous_point: str | None
|
|
56
|
+
base_point: str | None
|
|
57
|
+
snapshot_at: str
|
|
58
|
+
archive: str | None
|
|
59
|
+
documents: int
|
|
60
|
+
changed_files: int
|
|
61
|
+
deleted_files: int
|
|
62
|
+
journal_events: int
|
|
63
|
+
dry_run: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BackupStatusResult(TypedDict):
|
|
67
|
+
"""Read-only server and worker state returned by ``backup_status``."""
|
|
68
|
+
|
|
69
|
+
enabled: bool
|
|
70
|
+
worker_state: Literal["disabled", "idle", "running", "failed", "unknown"]
|
|
71
|
+
interval_seconds: int
|
|
72
|
+
last_attempt_at: str | None
|
|
73
|
+
last_success_at: str | None
|
|
74
|
+
last_point: str | None
|
|
75
|
+
pending_events: int
|
|
76
|
+
last_error: Mapping[str, Any] | None
|
|
77
|
+
repository: str
|
|
78
|
+
catalog_directory: str
|
|
79
|
+
package_version: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BackupPoint(TypedDict):
|
|
83
|
+
"""One committed saved point returned by ``backup_points``."""
|
|
84
|
+
|
|
85
|
+
point: str
|
|
86
|
+
kind: Literal["full", "incremental"]
|
|
87
|
+
base_point: str
|
|
88
|
+
previous_point: str | None
|
|
89
|
+
snapshot_at: str
|
|
90
|
+
completed_at: str
|
|
91
|
+
archive: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BackupPointsResult(TypedDict):
|
|
95
|
+
"""Committed saved points returned by ``backup_points``."""
|
|
96
|
+
|
|
97
|
+
points: list[BackupPoint]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BackupInspectFile(TypedDict):
|
|
101
|
+
"""One archive member listed by ``backup_inspect``."""
|
|
102
|
+
|
|
103
|
+
path: str
|
|
104
|
+
sha256: str
|
|
105
|
+
size: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BackupInspectResult(TypedDict):
|
|
109
|
+
"""Inspection data for one saved point."""
|
|
110
|
+
|
|
111
|
+
point: str
|
|
112
|
+
kind: Literal["full", "incremental"]
|
|
113
|
+
base_point: str
|
|
114
|
+
previous_point: str | None
|
|
115
|
+
snapshot_at: str
|
|
116
|
+
chain: list[str]
|
|
117
|
+
files: list[BackupInspectFile]
|
|
118
|
+
deleted_paths: list[str]
|
|
119
|
+
restorable: Literal[True]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class BackupRestoreResult(TypedDict):
|
|
123
|
+
"""Restore outcome returned by ``backup_restore``."""
|
|
124
|
+
|
|
125
|
+
point: str
|
|
126
|
+
status: Literal["restored", "dry_run"]
|
|
127
|
+
documents: int
|
|
128
|
+
source_files: int
|
|
129
|
+
replaced: bool
|
|
130
|
+
derived_state: Literal["invalidated", "unchanged"]
|
|
131
|
+
dry_run: bool
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BackupRecoverCatalogResult(TypedDict):
|
|
135
|
+
"""Catalog recovery outcome; recovery never activates the new catalog."""
|
|
136
|
+
|
|
137
|
+
status: Literal["recovered", "dry_run"]
|
|
138
|
+
destination: str
|
|
139
|
+
points: int
|
|
140
|
+
dry_run: bool
|
|
141
|
+
activated: Literal[False]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _payload(model: Any, *, omit_none: bool = True) -> dict[str, Any]:
|
|
145
|
+
"""Return fields in declaration order using their public server names."""
|
|
146
|
+
|
|
147
|
+
result: dict[str, Any] = {}
|
|
148
|
+
for model_field in fields(model):
|
|
149
|
+
value = getattr(model, model_field.name)
|
|
150
|
+
if omit_none and value is None:
|
|
151
|
+
continue
|
|
152
|
+
result[model_field.name] = _json_ready(value)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _json_ready(value: Any) -> Any:
|
|
157
|
+
if isinstance(value, Mapping):
|
|
158
|
+
return {key: _json_ready(item) for key, item in value.items()}
|
|
159
|
+
if isinstance(value, tuple):
|
|
160
|
+
return [_json_ready(item) for item in value]
|
|
161
|
+
if isinstance(value, list):
|
|
162
|
+
return [_json_ready(item) for item in value]
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read(cls: type[Any], payload: Payload) -> Any:
|
|
167
|
+
"""Build a model while rejecting unknown server fields deterministically."""
|
|
168
|
+
|
|
169
|
+
known = {model_field.name for model_field in fields(cls)}
|
|
170
|
+
unknown = sorted(set(payload) - known)
|
|
171
|
+
if unknown:
|
|
172
|
+
raise ValueError(f"unknown {cls.__name__} fields: {', '.join(unknown)}")
|
|
173
|
+
return cls(
|
|
174
|
+
**{
|
|
175
|
+
model_field.name: payload[model_field.name]
|
|
176
|
+
for model_field in fields(cls)
|
|
177
|
+
if model_field.name in payload
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class PublicModel:
|
|
183
|
+
"""Small common API shared by all public payload models."""
|
|
184
|
+
|
|
185
|
+
_omit_none: ClassVar[bool] = True
|
|
186
|
+
|
|
187
|
+
def to_params(self) -> dict[str, Any]:
|
|
188
|
+
return _payload(self, omit_none=self._omit_none)
|
|
189
|
+
|
|
190
|
+
def to_payload(self) -> dict[str, Any]:
|
|
191
|
+
return self.to_params()
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
195
|
+
if not isinstance(payload, Mapping):
|
|
196
|
+
raise TypeError(f"{cls.__name__} payload must be an object")
|
|
197
|
+
return _read(cls, payload)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
#
|
|
201
|
+
# Bitemporal request bounds, resolved-pair disclosure and the stable cursor.
|
|
202
|
+
#
|
|
203
|
+
# A request never resolves ``act_date``/``known_at`` itself. Omitted bounds stay
|
|
204
|
+
# omitted on the wire so the one shared request-start resolver remains their sole
|
|
205
|
+
# source; the pair it resolved comes back disclosed on the result or pinned inside
|
|
206
|
+
# a cursor. A cursor may only be replayed against the very pair, ordering and
|
|
207
|
+
# canonical query it pins, which is what keeps a paged read stable.
|
|
208
|
+
#
|
|
209
|
+
|
|
210
|
+
TEMPORAL_BOUND_FIELDS: Final[tuple[str, str]] = ("act_date", "known_at")
|
|
211
|
+
"""Optional bitemporal request bounds understood by versioned reads."""
|
|
212
|
+
|
|
213
|
+
_QUERY_IDENTITY_EXCLUDED: Final[frozenset[str]] = frozenset(
|
|
214
|
+
{"act_date", "known_at", "cursor", "offset"}
|
|
215
|
+
)
|
|
216
|
+
"""Pinned separately by the cursor, so paging never changes the query identity."""
|
|
217
|
+
|
|
218
|
+
_OBJECT_IDENTITY_KEYS: Final[tuple[str, ...]] = (
|
|
219
|
+
"logical_chunk_id",
|
|
220
|
+
"entity_id",
|
|
221
|
+
"object_id",
|
|
222
|
+
"chunk_id",
|
|
223
|
+
"id",
|
|
224
|
+
"uuid",
|
|
225
|
+
)
|
|
226
|
+
"""Preferred order of the keys naming the logical object behind a listed item."""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_instant(value: Any, field_name: str) -> datetime:
|
|
230
|
+
"""Parse one ISO-8601 instant, rejecting anything else deterministically."""
|
|
231
|
+
|
|
232
|
+
if not isinstance(value, str) or not value.strip():
|
|
233
|
+
raise ValueError(f"{field_name} must be an ISO-8601 timestamp")
|
|
234
|
+
try:
|
|
235
|
+
return datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
236
|
+
except ValueError:
|
|
237
|
+
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_instant(value: Any, field_name: str) -> str:
|
|
241
|
+
_parse_instant(value, field_name)
|
|
242
|
+
return value.strip()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _validate_optional_instants(**values: Any) -> None:
|
|
246
|
+
for field_name, value in values.items():
|
|
247
|
+
if value is not None:
|
|
248
|
+
_validate_instant(value, field_name)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _same_instant(left: str, right: str, field_name: str) -> bool:
|
|
252
|
+
"""Compare two instants by the moment they denote, not by their spelling."""
|
|
253
|
+
|
|
254
|
+
return _parse_instant(left, field_name) == _parse_instant(right, field_name)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _canonical_json(payload: Any) -> str:
|
|
258
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _query_identity_payload(query: Any) -> dict[str, Any]:
|
|
262
|
+
if isinstance(query, PublicModel):
|
|
263
|
+
payload: Mapping[str, Any] = query.to_params()
|
|
264
|
+
elif hasattr(query, "model_dump"):
|
|
265
|
+
payload = query.model_dump(mode="python", exclude_none=True, exclude_unset=True)
|
|
266
|
+
elif isinstance(query, Mapping):
|
|
267
|
+
payload = {key: value for key, value in query.items() if value is not None}
|
|
268
|
+
else:
|
|
269
|
+
raise TypeError("query must be a public model, a ChunkQuery, or a mapping")
|
|
270
|
+
return {
|
|
271
|
+
key: _json_ready(value)
|
|
272
|
+
for key, value in payload.items()
|
|
273
|
+
if key not in _QUERY_IDENTITY_EXCLUDED
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def canonical_query_id(query: Any) -> str:
|
|
278
|
+
"""Return the canonical identity of one list or ChunkQuery search request.
|
|
279
|
+
|
|
280
|
+
Temporal bounds and continuation state are excluded on purpose: the cursor
|
|
281
|
+
pins those separately, so turning a page never changes the query identity
|
|
282
|
+
while any change to the query itself does.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
canonical = _canonical_json(_query_identity_payload(query))
|
|
286
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def with_temporal_bounds(
|
|
290
|
+
query: ChunkQuery,
|
|
291
|
+
*,
|
|
292
|
+
act_date: str | None = None,
|
|
293
|
+
known_at: str | None = None,
|
|
294
|
+
) -> ChunkQuery:
|
|
295
|
+
"""Return ``query`` carrying optional bitemporal bounds.
|
|
296
|
+
|
|
297
|
+
``ChunkQuery`` stays the sole public full-text, semantic and hybrid search
|
|
298
|
+
contract: the bounds ride on it instead of on a parallel search model, and
|
|
299
|
+
omitted bounds are left unset for the request-start resolver.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
_validate_optional_instants(act_date=act_date, known_at=known_at)
|
|
303
|
+
bounds = {
|
|
304
|
+
name: value
|
|
305
|
+
for name, value in (("act_date", act_date), ("known_at", known_at))
|
|
306
|
+
if value is not None
|
|
307
|
+
}
|
|
308
|
+
if not bounds:
|
|
309
|
+
return query
|
|
310
|
+
return query.model_copy(update=bounds)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def temporal_bounds(query: ChunkQuery) -> tuple[str | None, str | None]:
|
|
314
|
+
"""Return the optional ``(act_date, known_at)`` bounds carried by a query."""
|
|
315
|
+
|
|
316
|
+
return (getattr(query, "act_date", None), getattr(query, "known_at", None))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _reject_repeated_objects(items: Iterable[Any], *, context: str) -> None:
|
|
320
|
+
"""Reject a version-transparent listing that exposes one object twice.
|
|
321
|
+
|
|
322
|
+
After temporal resolution exactly one visible version exists per logical
|
|
323
|
+
object, so a repeated identity means the listing enumerated versions.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
seen: set[tuple[str, str]] = set()
|
|
327
|
+
for item in items:
|
|
328
|
+
payload = item if isinstance(item, Mapping) else getattr(item, "__dict__", None)
|
|
329
|
+
if not isinstance(payload, Mapping):
|
|
330
|
+
continue
|
|
331
|
+
for key in _OBJECT_IDENTITY_KEYS:
|
|
332
|
+
value = payload.get(key)
|
|
333
|
+
if not isinstance(value, str) or not value:
|
|
334
|
+
continue
|
|
335
|
+
marker = (key, value)
|
|
336
|
+
if marker in seen:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"{context} must expose one visible version per object; "
|
|
339
|
+
f"{key}={value} appears more than once"
|
|
340
|
+
)
|
|
341
|
+
seen.add(marker)
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True, kw_only=True)
|
|
346
|
+
class TemporalCursor(PublicModel):
|
|
347
|
+
"""Stable pagination cursor pinning one resolved bitemporal page.
|
|
348
|
+
|
|
349
|
+
The cursor binds the resolved ``act_date``/``known_at`` pair, the
|
|
350
|
+
deterministic ordering key, the continuation position and the canonical
|
|
351
|
+
query identity, and carries an integrity hash over exactly those values.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
act_date: str
|
|
355
|
+
known_at: str
|
|
356
|
+
order_key: str
|
|
357
|
+
query_id: str
|
|
358
|
+
position: int = 0
|
|
359
|
+
integrity: str = ""
|
|
360
|
+
|
|
361
|
+
def __post_init__(self) -> None:
|
|
362
|
+
object.__setattr__(self, "act_date", _validate_instant(self.act_date, "act_date"))
|
|
363
|
+
object.__setattr__(self, "known_at", _validate_instant(self.known_at, "known_at"))
|
|
364
|
+
if not isinstance(self.order_key, str) or not self.order_key.strip():
|
|
365
|
+
raise ValueError("order_key must be non-empty")
|
|
366
|
+
if not isinstance(self.query_id, str) or not self.query_id.strip():
|
|
367
|
+
raise ValueError("query_id must be non-empty")
|
|
368
|
+
if (
|
|
369
|
+
isinstance(self.position, bool)
|
|
370
|
+
or not isinstance(self.position, int)
|
|
371
|
+
or self.position < 0
|
|
372
|
+
):
|
|
373
|
+
raise ValueError("position must be a non-negative integer")
|
|
374
|
+
expected = self._expected_integrity()
|
|
375
|
+
if not self.integrity:
|
|
376
|
+
object.__setattr__(self, "integrity", expected)
|
|
377
|
+
elif not compare_digest(str(self.integrity), expected):
|
|
378
|
+
raise ValueError("cursor integrity hash does not match its pinned state")
|
|
379
|
+
|
|
380
|
+
def _expected_integrity(self) -> str:
|
|
381
|
+
pinned = {
|
|
382
|
+
"act_date": self.act_date,
|
|
383
|
+
"known_at": self.known_at,
|
|
384
|
+
"order_key": self.order_key,
|
|
385
|
+
"position": self.position,
|
|
386
|
+
"query_id": self.query_id,
|
|
387
|
+
}
|
|
388
|
+
return hashlib.sha256(_canonical_json(pinned).encode("utf-8")).hexdigest()
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def resolved_pair(self) -> tuple[str, str]:
|
|
392
|
+
"""Return the resolved ``(act_date, known_at)`` pair pinned here."""
|
|
393
|
+
|
|
394
|
+
return (self.act_date, self.known_at)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def pin(
|
|
398
|
+
cls,
|
|
399
|
+
*,
|
|
400
|
+
act_date: str,
|
|
401
|
+
known_at: str,
|
|
402
|
+
order_key: str,
|
|
403
|
+
query: Any,
|
|
404
|
+
position: int = 0,
|
|
405
|
+
) -> Self:
|
|
406
|
+
"""Pin one page of ``query`` to its resolved pair and ordering."""
|
|
407
|
+
|
|
408
|
+
return cls(
|
|
409
|
+
act_date=act_date,
|
|
410
|
+
known_at=known_at,
|
|
411
|
+
order_key=order_key,
|
|
412
|
+
query_id=canonical_query_id(query),
|
|
413
|
+
position=position,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def advance(self, position: int) -> Self:
|
|
417
|
+
"""Return the next page cursor, re-sealed over the new position."""
|
|
418
|
+
|
|
419
|
+
return type(self)(
|
|
420
|
+
act_date=self.act_date,
|
|
421
|
+
known_at=self.known_at,
|
|
422
|
+
order_key=self.order_key,
|
|
423
|
+
query_id=self.query_id,
|
|
424
|
+
position=position,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def encode(self) -> str:
|
|
428
|
+
"""Return the opaque, URL-safe token form of this cursor."""
|
|
429
|
+
|
|
430
|
+
raw = _canonical_json(self.to_params()).encode("utf-8")
|
|
431
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def decode(cls, token: str) -> Self:
|
|
435
|
+
"""Rebuild a cursor from its token, rejecting a tampered payload."""
|
|
436
|
+
|
|
437
|
+
if not isinstance(token, str) or not token.strip():
|
|
438
|
+
raise ValueError("cursor must be a non-empty token")
|
|
439
|
+
text = token.strip()
|
|
440
|
+
try:
|
|
441
|
+
raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
442
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
443
|
+
except (ValueError, TypeError, UnicodeDecodeError):
|
|
444
|
+
raise ValueError("cursor is not a valid cursor token") from None
|
|
445
|
+
if not isinstance(payload, Mapping):
|
|
446
|
+
raise ValueError("cursor is not a valid cursor token")
|
|
447
|
+
return cls.from_payload(payload)
|
|
448
|
+
|
|
449
|
+
def validate_replay(
|
|
450
|
+
self,
|
|
451
|
+
*,
|
|
452
|
+
act_date: str | None = None,
|
|
453
|
+
known_at: str | None = None,
|
|
454
|
+
order_key: str | None = None,
|
|
455
|
+
query_id: str | None = None,
|
|
456
|
+
query: Any = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Reject replay against a different pair, ordering, or query identity.
|
|
459
|
+
|
|
460
|
+
Only the values actually supplied are checked; anything omitted is
|
|
461
|
+
simply not being replayed and therefore cannot disagree.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
if query is not None:
|
|
465
|
+
if query_id is not None:
|
|
466
|
+
raise ValueError("pass either query or query_id, not both")
|
|
467
|
+
query_id = canonical_query_id(query)
|
|
468
|
+
mismatched = [
|
|
469
|
+
name
|
|
470
|
+
for name, supplied, pinned in (
|
|
471
|
+
("act_date", act_date, self.act_date),
|
|
472
|
+
("known_at", known_at, self.known_at),
|
|
473
|
+
)
|
|
474
|
+
if supplied is not None and not _same_instant(supplied, pinned, name)
|
|
475
|
+
]
|
|
476
|
+
mismatched += [
|
|
477
|
+
name
|
|
478
|
+
for name, supplied, pinned in (
|
|
479
|
+
("order_key", order_key, self.order_key),
|
|
480
|
+
("query_id", query_id, self.query_id),
|
|
481
|
+
)
|
|
482
|
+
if supplied is not None and supplied != pinned
|
|
483
|
+
]
|
|
484
|
+
if mismatched:
|
|
485
|
+
raise ValueError(
|
|
486
|
+
"cursor replay rejected: "
|
|
487
|
+
f"{', '.join(mismatched)} differ from the values pinned in the cursor"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass(frozen=True, kw_only=True)
|
|
492
|
+
class TemporalPayload(PublicModel):
|
|
493
|
+
"""Optional bitemporal bounds plus the cursor that may pin them."""
|
|
494
|
+
|
|
495
|
+
act_date: str | None = None
|
|
496
|
+
known_at: str | None = None
|
|
497
|
+
cursor: str | None = None
|
|
498
|
+
|
|
499
|
+
def pinned_cursor(self) -> TemporalCursor | None:
|
|
500
|
+
"""Return the decoded cursor, or ``None`` when none is carried."""
|
|
501
|
+
|
|
502
|
+
return None if self.cursor is None else TemporalCursor.decode(self.cursor)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass(frozen=True, kw_only=True)
|
|
506
|
+
class TemporalRequest(TemporalPayload):
|
|
507
|
+
"""Optional bitemporal bounds accepted by versioned reads.
|
|
508
|
+
|
|
509
|
+
Both bounds are optional and are never defaulted here: omitting them keeps
|
|
510
|
+
them off the wire so the shared request-start resolver stays their sole
|
|
511
|
+
source. A cursor already pins a resolved pair and a canonical query, so
|
|
512
|
+
bounds repeated next to one must agree with it.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __post_init__(self) -> None:
|
|
516
|
+
_validate_optional_instants(act_date=self.act_date, known_at=self.known_at)
|
|
517
|
+
pinned = self.pinned_cursor()
|
|
518
|
+
if pinned is not None:
|
|
519
|
+
pinned.validate_replay(
|
|
520
|
+
act_date=self.act_date,
|
|
521
|
+
known_at=self.known_at,
|
|
522
|
+
query_id=self.query_id(),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
def query_id(self) -> str:
|
|
526
|
+
"""Return the canonical identity of this request as a query."""
|
|
527
|
+
|
|
528
|
+
return canonical_query_id(self)
|
|
529
|
+
|
|
530
|
+
def resolved_pair(self) -> tuple[str, str] | None:
|
|
531
|
+
"""Return the pair pinned by the cursor, if this request carries one."""
|
|
532
|
+
|
|
533
|
+
pinned = self.pinned_cursor()
|
|
534
|
+
return None if pinned is None else pinned.resolved_pair
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@dataclass(frozen=True, kw_only=True)
|
|
538
|
+
class TemporalResult(TemporalPayload):
|
|
539
|
+
"""Result-side disclosure of the resolved bitemporal pair.
|
|
540
|
+
|
|
541
|
+
A bitemporal result either discloses ``act_date`` and ``known_at`` directly
|
|
542
|
+
or carries a cursor pinning them; when it does both, the two must agree.
|
|
543
|
+
"""
|
|
544
|
+
|
|
545
|
+
def __post_init__(self) -> None:
|
|
546
|
+
_validate_optional_instants(act_date=self.act_date, known_at=self.known_at)
|
|
547
|
+
if (self.act_date is None) != (self.known_at is None):
|
|
548
|
+
raise ValueError("act_date and known_at must be disclosed together")
|
|
549
|
+
pinned = self.pinned_cursor()
|
|
550
|
+
if pinned is not None and self.act_date is not None:
|
|
551
|
+
pinned.validate_replay(act_date=self.act_date, known_at=self.known_at)
|
|
552
|
+
|
|
553
|
+
def resolved_pair(self) -> tuple[str, str] | None:
|
|
554
|
+
"""Return the resolved pair, disclosed directly or through the cursor."""
|
|
555
|
+
|
|
556
|
+
if self.act_date is not None and self.known_at is not None:
|
|
557
|
+
return (self.act_date, self.known_at)
|
|
558
|
+
pinned = self.pinned_cursor()
|
|
559
|
+
return None if pinned is None else pinned.resolved_pair
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@dataclass(frozen=True, kw_only=True)
|
|
563
|
+
class DocumentWriteRequest(PublicModel):
|
|
564
|
+
"""Shared source contract for document create and update."""
|
|
565
|
+
|
|
566
|
+
document_id: str
|
|
567
|
+
source_version_id: str
|
|
568
|
+
raw_text: str | None = None
|
|
569
|
+
transferred_file: Mapping[str, Any] | None = None
|
|
570
|
+
chunking_strategy: str | None = None
|
|
571
|
+
|
|
572
|
+
def __post_init__(self) -> None:
|
|
573
|
+
if not self.document_id.strip() or not self.source_version_id.strip():
|
|
574
|
+
raise ValueError("document_id and source_version_id must be non-empty")
|
|
575
|
+
if self.raw_text is not None and self.transferred_file is not None:
|
|
576
|
+
raise ValueError("raw_text and transferred_file are mutually exclusive")
|
|
577
|
+
if self.raw_text is not None and not self.raw_text:
|
|
578
|
+
raise ValueError("raw_text must be non-empty")
|
|
579
|
+
if self.chunking_strategy is not None and self.chunking_strategy not in {
|
|
580
|
+
"paragraph",
|
|
581
|
+
"sentence",
|
|
582
|
+
"semantic",
|
|
583
|
+
}:
|
|
584
|
+
raise ValueError("chunking_strategy must be paragraph, sentence, or semantic")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@dataclass(frozen=True, kw_only=True)
|
|
588
|
+
class DocumentCreateRequest(DocumentWriteRequest):
|
|
589
|
+
"""Request payload for ``document_create``."""
|
|
590
|
+
|
|
591
|
+
def __post_init__(self) -> None:
|
|
592
|
+
super().__post_init__()
|
|
593
|
+
if self.chunking_strategy is None:
|
|
594
|
+
raise ValueError("chunking_strategy is required")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@dataclass(frozen=True, kw_only=True)
|
|
598
|
+
class DocumentUpdateRequest(DocumentWriteRequest):
|
|
599
|
+
"""Request payload for ``document_update``."""
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@dataclass(frozen=True, kw_only=True)
|
|
603
|
+
class DocumentWriteResult(PublicModel):
|
|
604
|
+
"""Stable result returned by document create/update operations."""
|
|
605
|
+
|
|
606
|
+
status: str
|
|
607
|
+
operation_id: str
|
|
608
|
+
document_id: str
|
|
609
|
+
source_version_id: str
|
|
610
|
+
received_verification_phrase: str | None = None
|
|
611
|
+
received_last_phrase: str | None = None
|
|
612
|
+
details: Mapping[str, Any] | None = None
|
|
613
|
+
|
|
614
|
+
@classmethod
|
|
615
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
616
|
+
values = dict(payload)
|
|
617
|
+
known = {model_field.name for model_field in fields(cls)}
|
|
618
|
+
extras = {key: values.pop(key) for key in sorted(set(values) - known)}
|
|
619
|
+
if extras:
|
|
620
|
+
details = dict(values.get("details") or {})
|
|
621
|
+
details.update(extras)
|
|
622
|
+
values["details"] = details
|
|
623
|
+
return _read(cls, values)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
DocumentCreateResult = DocumentWriteResult
|
|
627
|
+
DocumentUpdateResult = DocumentWriteResult
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@dataclass(frozen=True, kw_only=True)
|
|
631
|
+
class DocumentChunkRequest(PublicModel):
|
|
632
|
+
document_id: str
|
|
633
|
+
chunking_strategy: str | None = None
|
|
634
|
+
|
|
635
|
+
def __post_init__(self) -> None:
|
|
636
|
+
if not self.document_id.strip():
|
|
637
|
+
raise ValueError("document_id must be non-empty")
|
|
638
|
+
if self.chunking_strategy is not None and self.chunking_strategy not in {
|
|
639
|
+
"paragraph",
|
|
640
|
+
"sentence",
|
|
641
|
+
"semantic",
|
|
642
|
+
}:
|
|
643
|
+
raise ValueError("chunking_strategy must be paragraph, sentence, or semantic")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
DocumentChunkResult = DocumentWriteResult
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@dataclass(frozen=True, kw_only=True)
|
|
650
|
+
class ChunkVersionItem(PublicModel):
|
|
651
|
+
"""Public summary of one semantic chunk text version."""
|
|
652
|
+
|
|
653
|
+
version_no: int
|
|
654
|
+
id: str | None = None
|
|
655
|
+
logical_chunk_id: str | None = None
|
|
656
|
+
preview: str = ""
|
|
657
|
+
text: str | None = None
|
|
658
|
+
created_at: str | None = None
|
|
659
|
+
current: bool = False
|
|
660
|
+
status: str = "active"
|
|
661
|
+
valid_from: str | None = None
|
|
662
|
+
valid_to: str | None = None
|
|
663
|
+
char_count: int = 0
|
|
664
|
+
text_sha256: str | None = None
|
|
665
|
+
checksum: str | None = None
|
|
666
|
+
comment: str | None = None
|
|
667
|
+
actor: str | None = None
|
|
668
|
+
operation: str | None = None
|
|
669
|
+
previous_version_id: str | None = None
|
|
670
|
+
restored_from_version_id: str | None = None
|
|
671
|
+
|
|
672
|
+
def __post_init__(self) -> None:
|
|
673
|
+
_validate_version_no(self.version_no)
|
|
674
|
+
for field_name in ("id", "logical_chunk_id", "previous_version_id", "restored_from_version_id"):
|
|
675
|
+
value = getattr(self, field_name)
|
|
676
|
+
if value is not None:
|
|
677
|
+
_validate_uuid4(value, field_name)
|
|
678
|
+
if self.char_count < 0:
|
|
679
|
+
raise ValueError("char_count must be non-negative")
|
|
680
|
+
if self.status not in {"active", "retired", "deleted"}:
|
|
681
|
+
raise ValueError("status must be active, retired, or deleted")
|
|
682
|
+
|
|
683
|
+
@classmethod
|
|
684
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
685
|
+
values = dict(payload)
|
|
686
|
+
if "current" not in values and "is_current" in values:
|
|
687
|
+
values["current"] = values.pop("is_current")
|
|
688
|
+
# Set-current returns the runtime row, while list returns the public summary.
|
|
689
|
+
known = {model_field.name for model_field in fields(cls)}
|
|
690
|
+
values = {name: values[name] for name in known if name in values}
|
|
691
|
+
return _read(cls, values)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@dataclass(frozen=True, kw_only=True)
|
|
695
|
+
class ChunkVersionListRequest(TemporalRequest):
|
|
696
|
+
"""Explicit version history of one chunk, read at an optional time pair."""
|
|
697
|
+
|
|
698
|
+
chunk_id: str
|
|
699
|
+
include_deleted: bool = False
|
|
700
|
+
limit: int = 100
|
|
701
|
+
offset: int = 0
|
|
702
|
+
|
|
703
|
+
def __post_init__(self) -> None:
|
|
704
|
+
super().__post_init__()
|
|
705
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
706
|
+
if not isinstance(self.include_deleted, bool):
|
|
707
|
+
raise ValueError("include_deleted must be a boolean")
|
|
708
|
+
_validate_version_bounds(self.limit, self.offset)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@dataclass(frozen=True, kw_only=True)
|
|
712
|
+
class ChunkVersionListResult(TemporalResult):
|
|
713
|
+
"""Version history plus the resolved pair it was read at."""
|
|
714
|
+
|
|
715
|
+
chunk_id: str
|
|
716
|
+
items: tuple[ChunkVersionItem, ...] = ()
|
|
717
|
+
total: int = 0
|
|
718
|
+
limit: int = 100
|
|
719
|
+
offset: int = 0
|
|
720
|
+
|
|
721
|
+
def __post_init__(self) -> None:
|
|
722
|
+
super().__post_init__()
|
|
723
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
724
|
+
_validate_version_bounds(self.limit, self.offset)
|
|
725
|
+
if self.total < 0:
|
|
726
|
+
raise ValueError("total must be non-negative")
|
|
727
|
+
|
|
728
|
+
def to_params(self) -> dict[str, Any]:
|
|
729
|
+
result = _payload(self)
|
|
730
|
+
result["items"] = [item.to_payload() for item in self.items]
|
|
731
|
+
return result
|
|
732
|
+
|
|
733
|
+
@classmethod
|
|
734
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
735
|
+
values = dict(payload)
|
|
736
|
+
values["items"] = tuple(
|
|
737
|
+
item if isinstance(item, ChunkVersionItem) else ChunkVersionItem.from_payload(item)
|
|
738
|
+
for item in values.get("items", ())
|
|
739
|
+
)
|
|
740
|
+
return _read(cls, values)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@dataclass(frozen=True, kw_only=True)
|
|
744
|
+
class ChunkVersionSetCurrentRequest(PublicModel):
|
|
745
|
+
chunk_id: str
|
|
746
|
+
version_no: int
|
|
747
|
+
comment: str | None = None
|
|
748
|
+
actor: str | None = None
|
|
749
|
+
|
|
750
|
+
def __post_init__(self) -> None:
|
|
751
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
752
|
+
_validate_version_no(self.version_no)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@dataclass(frozen=True, kw_only=True)
|
|
756
|
+
class ChunkVersionSetCurrentResult(PublicModel):
|
|
757
|
+
chunk_id: str
|
|
758
|
+
outcome: str
|
|
759
|
+
version: ChunkVersionItem | None = None
|
|
760
|
+
received_verification_phrase: str | None = None
|
|
761
|
+
|
|
762
|
+
def __post_init__(self) -> None:
|
|
763
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
764
|
+
|
|
765
|
+
def to_params(self) -> dict[str, Any]:
|
|
766
|
+
result = _payload(self)
|
|
767
|
+
if self.version is not None:
|
|
768
|
+
result["version"] = self.version.to_payload()
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
@classmethod
|
|
772
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
773
|
+
values = dict(payload)
|
|
774
|
+
version = values.get("version")
|
|
775
|
+
if isinstance(version, Mapping):
|
|
776
|
+
values["version"] = ChunkVersionItem.from_payload(version)
|
|
777
|
+
return _read(cls, values)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@dataclass(frozen=True, kw_only=True)
|
|
781
|
+
class ChunkHistoryRequest(ChunkVersionListRequest):
|
|
782
|
+
"""Request payload for ``chunk_history``."""
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
ChunkHistoryResult = ChunkVersionListResult
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@dataclass(frozen=True, kw_only=True)
|
|
789
|
+
class ChunkVersionGetRequest(PublicModel):
|
|
790
|
+
chunk_id: str
|
|
791
|
+
version_no: int | None = None
|
|
792
|
+
current: bool = False
|
|
793
|
+
include_text: bool = True
|
|
794
|
+
|
|
795
|
+
def __post_init__(self) -> None:
|
|
796
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
797
|
+
if self.version_no is not None:
|
|
798
|
+
_validate_version_no(self.version_no)
|
|
799
|
+
if not isinstance(self.current, bool):
|
|
800
|
+
raise ValueError("current must be a boolean")
|
|
801
|
+
if not isinstance(self.include_text, bool):
|
|
802
|
+
raise ValueError("include_text must be a boolean")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@dataclass(frozen=True, kw_only=True)
|
|
806
|
+
class ChunkVersionGetResult(PublicModel):
|
|
807
|
+
chunk_id: str
|
|
808
|
+
version: ChunkVersionItem
|
|
809
|
+
|
|
810
|
+
def __post_init__(self) -> None:
|
|
811
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
812
|
+
|
|
813
|
+
def to_params(self) -> dict[str, Any]:
|
|
814
|
+
result = _payload(self)
|
|
815
|
+
result["version"] = self.version.to_payload()
|
|
816
|
+
return result
|
|
817
|
+
|
|
818
|
+
@classmethod
|
|
819
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
820
|
+
values = dict(payload)
|
|
821
|
+
if isinstance(values.get("version"), Mapping):
|
|
822
|
+
values["version"] = ChunkVersionItem.from_payload(values["version"])
|
|
823
|
+
return _read(cls, values)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@dataclass(frozen=True, kw_only=True)
|
|
827
|
+
class ChunkVersionTextMutationRequest(PublicModel):
|
|
828
|
+
chunk_id: str
|
|
829
|
+
text: str
|
|
830
|
+
comment: str | None = None
|
|
831
|
+
actor: str | None = None
|
|
832
|
+
expected_current_version: int | None = None
|
|
833
|
+
operation_id: str | None = None
|
|
834
|
+
|
|
835
|
+
def __post_init__(self) -> None:
|
|
836
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
837
|
+
if not isinstance(self.text, str):
|
|
838
|
+
raise ValueError("text must be a string")
|
|
839
|
+
if self.expected_current_version is not None:
|
|
840
|
+
_validate_version_no(self.expected_current_version)
|
|
841
|
+
if self.operation_id is not None:
|
|
842
|
+
_validate_uuid4(self.operation_id, "operation_id")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
ChunkVersionAddRequest = ChunkVersionTextMutationRequest
|
|
846
|
+
ChunkVersionUpdateRequest = ChunkVersionTextMutationRequest
|
|
847
|
+
ChunkVersionAddResult = ChunkVersionSetCurrentResult
|
|
848
|
+
ChunkVersionUpdateResult = ChunkVersionSetCurrentResult
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@dataclass(frozen=True, kw_only=True)
|
|
852
|
+
class ChunkVersionRestoreRequest(PublicModel):
|
|
853
|
+
chunk_id: str
|
|
854
|
+
version_no: int
|
|
855
|
+
comment: str | None = None
|
|
856
|
+
actor: str | None = None
|
|
857
|
+
expected_current_version: int | None = None
|
|
858
|
+
operation_id: str | None = None
|
|
859
|
+
|
|
860
|
+
def __post_init__(self) -> None:
|
|
861
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
862
|
+
_validate_version_no(self.version_no)
|
|
863
|
+
if self.expected_current_version is not None:
|
|
864
|
+
_validate_version_no(self.expected_current_version)
|
|
865
|
+
if self.operation_id is not None:
|
|
866
|
+
_validate_uuid4(self.operation_id, "operation_id")
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
ChunkVersionRestoreResult = ChunkVersionSetCurrentResult
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
@dataclass(frozen=True, kw_only=True)
|
|
873
|
+
class ChunkVersionRetireRequest(PublicModel):
|
|
874
|
+
chunk_id: str
|
|
875
|
+
version_no: int
|
|
876
|
+
replacement_version_no: int | None = None
|
|
877
|
+
comment: str | None = None
|
|
878
|
+
actor: str | None = None
|
|
879
|
+
|
|
880
|
+
def __post_init__(self) -> None:
|
|
881
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
882
|
+
_validate_version_no(self.version_no)
|
|
883
|
+
if self.replacement_version_no is not None:
|
|
884
|
+
_validate_version_no(self.replacement_version_no)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@dataclass(frozen=True, kw_only=True)
|
|
888
|
+
class ChunkVersionRetireResult(PublicModel):
|
|
889
|
+
chunk_id: str
|
|
890
|
+
outcome: str
|
|
891
|
+
retired_version_no: int
|
|
892
|
+
current_version_no: int | None = None
|
|
893
|
+
|
|
894
|
+
def __post_init__(self) -> None:
|
|
895
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
896
|
+
_validate_version_no(self.retired_version_no)
|
|
897
|
+
if self.current_version_no is not None:
|
|
898
|
+
_validate_version_no(self.current_version_no)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@dataclass(frozen=True, kw_only=True)
|
|
902
|
+
class ChunkVersionDiffRequest(PublicModel):
|
|
903
|
+
chunk_id: str
|
|
904
|
+
from_version_no: int
|
|
905
|
+
to_version_no: int
|
|
906
|
+
context_lines: int = 3
|
|
907
|
+
|
|
908
|
+
def __post_init__(self) -> None:
|
|
909
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
910
|
+
_validate_version_no(self.from_version_no)
|
|
911
|
+
_validate_version_no(self.to_version_no)
|
|
912
|
+
if isinstance(self.context_lines, bool) or not isinstance(self.context_lines, int) or not 0 <= self.context_lines <= 20:
|
|
913
|
+
raise ValueError("context_lines must be an integer between 0 and 20")
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@dataclass(frozen=True, kw_only=True)
|
|
917
|
+
class ChunkVersionDiffResult(PublicModel):
|
|
918
|
+
chunk_id: str
|
|
919
|
+
from_version: ChunkVersionItem
|
|
920
|
+
to_version: ChunkVersionItem
|
|
921
|
+
diff: tuple[str, ...] = ()
|
|
922
|
+
changed: bool = False
|
|
923
|
+
|
|
924
|
+
def __post_init__(self) -> None:
|
|
925
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
926
|
+
|
|
927
|
+
def to_params(self) -> dict[str, Any]:
|
|
928
|
+
result = _payload(self)
|
|
929
|
+
result["from_version"] = self.from_version.to_payload()
|
|
930
|
+
result["to_version"] = self.to_version.to_payload()
|
|
931
|
+
result["diff"] = list(self.diff)
|
|
932
|
+
return result
|
|
933
|
+
|
|
934
|
+
@classmethod
|
|
935
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
936
|
+
values = dict(payload)
|
|
937
|
+
if isinstance(values.get("from_version"), Mapping):
|
|
938
|
+
values["from_version"] = ChunkVersionItem.from_payload(values["from_version"])
|
|
939
|
+
if isinstance(values.get("to_version"), Mapping):
|
|
940
|
+
values["to_version"] = ChunkVersionItem.from_payload(values["to_version"])
|
|
941
|
+
values["diff"] = tuple(values.get("diff", ()))
|
|
942
|
+
return _read(cls, values)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
@dataclass(frozen=True, kw_only=True)
|
|
946
|
+
class ChunkVersionDeleteRequest(PublicModel):
|
|
947
|
+
chunk_id: str
|
|
948
|
+
version_no: int
|
|
949
|
+
|
|
950
|
+
def __post_init__(self) -> None:
|
|
951
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
952
|
+
_validate_version_no(self.version_no)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
@dataclass(frozen=True, kw_only=True)
|
|
956
|
+
class ChunkVersionDeleteResult(PublicModel):
|
|
957
|
+
chunk_id: str
|
|
958
|
+
outcome: str
|
|
959
|
+
deleted_version_no: int
|
|
960
|
+
current_version_no: int | None = None
|
|
961
|
+
|
|
962
|
+
def __post_init__(self) -> None:
|
|
963
|
+
_validate_uuid4(self.chunk_id, "chunk_id")
|
|
964
|
+
_validate_version_no(self.deleted_version_no)
|
|
965
|
+
if self.current_version_no is not None:
|
|
966
|
+
_validate_version_no(self.current_version_no)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
@dataclass(frozen=True, kw_only=True)
|
|
970
|
+
class DocumentRebindRequest(PublicModel):
|
|
971
|
+
document_id: str
|
|
972
|
+
project: str | None = None
|
|
973
|
+
project_id: str | None = None
|
|
974
|
+
project_description: str | None = None
|
|
975
|
+
document_properties: Mapping[str, Any] | None = None
|
|
976
|
+
chunk_properties: Mapping[str, Any] | None = None
|
|
977
|
+
|
|
978
|
+
def __post_init__(self) -> None:
|
|
979
|
+
if not self.document_id.strip():
|
|
980
|
+
raise ValueError("document_id must be non-empty")
|
|
981
|
+
if self.project is not None and not self.project.strip():
|
|
982
|
+
raise ValueError("project must be non-empty when supplied")
|
|
983
|
+
if self.project is not None:
|
|
984
|
+
if self.project_id is None or not self.project_id.strip():
|
|
985
|
+
raise ValueError("project_id is required when project is supplied")
|
|
986
|
+
if self.project_description is None or not self.project_description.strip():
|
|
987
|
+
raise ValueError("project_description is required when project is supplied")
|
|
988
|
+
elif self.project_id is not None or self.project_description is not None:
|
|
989
|
+
raise ValueError("project_id and project_description require project")
|
|
990
|
+
if not any(
|
|
991
|
+
value is not None
|
|
992
|
+
for value in (self.project, self.document_properties, self.chunk_properties)
|
|
993
|
+
):
|
|
994
|
+
raise ValueError("at least one rebind field is required")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@dataclass(frozen=True, kw_only=True)
|
|
998
|
+
class DocumentRebindResult(PublicModel):
|
|
999
|
+
outcome: str
|
|
1000
|
+
document_id: str
|
|
1001
|
+
project: str | None = None
|
|
1002
|
+
project_id: str | None = None
|
|
1003
|
+
project_description: str | None = None
|
|
1004
|
+
document_properties: Mapping[str, Any] | None = None
|
|
1005
|
+
chunk_properties: Mapping[str, Any] | None = None
|
|
1006
|
+
updated: Mapping[str, Any] | None = None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1010
|
+
class DocumentDeleteRequest(PublicModel):
|
|
1011
|
+
document_id: str
|
|
1012
|
+
version_token: str
|
|
1013
|
+
|
|
1014
|
+
def __post_init__(self) -> None:
|
|
1015
|
+
if not self.document_id.strip() or not self.version_token.strip():
|
|
1016
|
+
raise ValueError("document_id and version_token must be non-empty")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1020
|
+
class DocumentDeleteResult(PublicModel):
|
|
1021
|
+
outcome: str
|
|
1022
|
+
document_id: str
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1026
|
+
class EntityListRequest(TemporalRequest):
|
|
1027
|
+
"""Ordinary, version-transparent listing at an optional time pair."""
|
|
1028
|
+
|
|
1029
|
+
entity_type: str
|
|
1030
|
+
fields: tuple[str, ...] | None = None
|
|
1031
|
+
filters: Mapping[str, Any] | None = None
|
|
1032
|
+
limit: int = 50
|
|
1033
|
+
offset: int = 0
|
|
1034
|
+
show_deleted: bool = False
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1038
|
+
class EntityTextGetRequest(PublicModel):
|
|
1039
|
+
"""UUID scalar, ordered UUID list, or comma-separated UUIDs for canonical text."""
|
|
1040
|
+
|
|
1041
|
+
entity_id: str | list[str]
|
|
1042
|
+
|
|
1043
|
+
def __post_init__(self) -> None:
|
|
1044
|
+
values = self.entity_id.split(",") if isinstance(self.entity_id, str) else self.entity_id
|
|
1045
|
+
if not isinstance(values, list):
|
|
1046
|
+
raise TypeError("entity_id must be a string or list of strings")
|
|
1047
|
+
for value in values:
|
|
1048
|
+
if not isinstance(value, str):
|
|
1049
|
+
raise TypeError("entity_id members must be strings")
|
|
1050
|
+
try:
|
|
1051
|
+
identifier = UUID(value.strip())
|
|
1052
|
+
except ValueError as exc:
|
|
1053
|
+
raise ValueError("entity_id members must be UUID4 identifiers") from exc
|
|
1054
|
+
if identifier.version != 4:
|
|
1055
|
+
raise ValueError("entity_id members must be UUID4 identifiers")
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
EntityTextGetResult = str | list[str]
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1062
|
+
class EntityGetRequest(PublicModel):
|
|
1063
|
+
entity_type: str
|
|
1064
|
+
entity_id: str
|
|
1065
|
+
fields: tuple[str, ...] | None = None
|
|
1066
|
+
show_deleted: bool = False
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1070
|
+
class EntityIdsRequest(PublicModel):
|
|
1071
|
+
entity_type: str
|
|
1072
|
+
ids: tuple[str, ...]
|
|
1073
|
+
|
|
1074
|
+
def __post_init__(self) -> None:
|
|
1075
|
+
if not self.ids:
|
|
1076
|
+
raise ValueError("ids must be non-empty")
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1080
|
+
class EntityReferencesRequest(PublicModel):
|
|
1081
|
+
entity_type: str
|
|
1082
|
+
entity_id: str
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1086
|
+
class EntityListResult(TemporalResult):
|
|
1087
|
+
"""Ordinary listing: one resolved visible version per object, never a history."""
|
|
1088
|
+
|
|
1089
|
+
entity_type: str
|
|
1090
|
+
items: tuple[Mapping[str, Any], ...] = ()
|
|
1091
|
+
limit: int = 50
|
|
1092
|
+
offset: int = 0
|
|
1093
|
+
total: int = 0
|
|
1094
|
+
show_deleted: bool = False
|
|
1095
|
+
|
|
1096
|
+
def __post_init__(self) -> None:
|
|
1097
|
+
super().__post_init__()
|
|
1098
|
+
_reject_repeated_objects(self.items, context="entity_list")
|
|
1099
|
+
|
|
1100
|
+
@classmethod
|
|
1101
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1102
|
+
values = dict(payload)
|
|
1103
|
+
values["items"] = tuple(values.get("items", ()))
|
|
1104
|
+
return _read(cls, values)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1108
|
+
class EntityGetResult(PublicModel):
|
|
1109
|
+
entity_type: str
|
|
1110
|
+
id: str
|
|
1111
|
+
value: Mapping[str, Any]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1115
|
+
class EntityLifecycleResult(PublicModel):
|
|
1116
|
+
outcome: str
|
|
1117
|
+
updated: Mapping[str, int] | None = None
|
|
1118
|
+
deleted: Mapping[str, int] | None = None
|
|
1119
|
+
blocked: tuple[Mapping[str, Any], ...] = ()
|
|
1120
|
+
is_deleted: bool | None = None
|
|
1121
|
+
received_verification_phrase: str | None = None
|
|
1122
|
+
|
|
1123
|
+
@classmethod
|
|
1124
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1125
|
+
values = dict(payload)
|
|
1126
|
+
values["blocked"] = tuple(values.get("blocked", ()))
|
|
1127
|
+
return _read(cls, values)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1131
|
+
class EntityReferencesResult(PublicModel):
|
|
1132
|
+
entity_type: str
|
|
1133
|
+
id: str
|
|
1134
|
+
references: tuple[Mapping[str, Any], ...] = ()
|
|
1135
|
+
|
|
1136
|
+
@classmethod
|
|
1137
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1138
|
+
values = dict(payload)
|
|
1139
|
+
values["references"] = tuple(values.get("references", ()))
|
|
1140
|
+
return _read(cls, values)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _validate_text_offset(value: int | None) -> None:
|
|
1144
|
+
if value is not None and (value < 0 or value > 1_000_000_000):
|
|
1145
|
+
raise ValueError("text_offset must be between 0 and 1000000000")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1149
|
+
class TextReconstructionRequest(PublicModel):
|
|
1150
|
+
document_id: str | None = None
|
|
1151
|
+
file_id: str | None = None
|
|
1152
|
+
source_name: str | None = None
|
|
1153
|
+
source_path: str | None = None
|
|
1154
|
+
project_id: str | None = None
|
|
1155
|
+
metadata_filters: Mapping[str, Any] | None = None
|
|
1156
|
+
max_chars: int = 200_000
|
|
1157
|
+
limit: int = 10_000
|
|
1158
|
+
offset: int = 0
|
|
1159
|
+
text_offset: int | None = None
|
|
1160
|
+
|
|
1161
|
+
def __post_init__(self) -> None:
|
|
1162
|
+
_validate_text_offset(self.text_offset)
|
|
1163
|
+
_validate_reconstruction_text_selectors(
|
|
1164
|
+
document_id=self.document_id,
|
|
1165
|
+
file_id=self.file_id,
|
|
1166
|
+
source_name=self.source_name,
|
|
1167
|
+
source_path=self.source_path,
|
|
1168
|
+
project_id=self.project_id,
|
|
1169
|
+
)
|
|
1170
|
+
if not any(
|
|
1171
|
+
(
|
|
1172
|
+
self.document_id,
|
|
1173
|
+
self.file_id,
|
|
1174
|
+
self.source_name,
|
|
1175
|
+
self.source_path,
|
|
1176
|
+
self.project_id,
|
|
1177
|
+
self.metadata_filters,
|
|
1178
|
+
)
|
|
1179
|
+
):
|
|
1180
|
+
raise ValueError("at least one selector is required")
|
|
1181
|
+
if self.max_chars < 0:
|
|
1182
|
+
raise ValueError("max_chars must be non-negative")
|
|
1183
|
+
if self.limit < 1:
|
|
1184
|
+
raise ValueError("limit must be positive")
|
|
1185
|
+
if self.offset < 0:
|
|
1186
|
+
raise ValueError("offset must be non-negative")
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1190
|
+
class ChapterTextGetRequest(TextReconstructionRequest):
|
|
1191
|
+
chapter_id: str | None = None
|
|
1192
|
+
include_context: bool = False
|
|
1193
|
+
|
|
1194
|
+
def __post_init__(self) -> None:
|
|
1195
|
+
_validate_reconstruction_text_selectors(
|
|
1196
|
+
document_id=self.document_id,
|
|
1197
|
+
file_id=self.file_id,
|
|
1198
|
+
source_name=self.source_name,
|
|
1199
|
+
source_path=self.source_path,
|
|
1200
|
+
project_id=self.project_id,
|
|
1201
|
+
)
|
|
1202
|
+
if self.chapter_id is not None and not self.chapter_id.strip():
|
|
1203
|
+
raise ValueError("chapter_id must be non-empty when supplied")
|
|
1204
|
+
if self.chapter_id is not None:
|
|
1205
|
+
_validate_text_offset(self.text_offset)
|
|
1206
|
+
_validate_reconstruction_bounds(self.max_chars, self.limit, self.offset)
|
|
1207
|
+
return
|
|
1208
|
+
super().__post_init__()
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1212
|
+
class SourceFileReconstructRequest(TextReconstructionRequest):
|
|
1213
|
+
"""Request payload for reconstructing stored source text from chunks."""
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1217
|
+
class TextReconstructionResult(PublicModel):
|
|
1218
|
+
entity: str
|
|
1219
|
+
selector: Mapping[str, Any] = field(default_factory=dict)
|
|
1220
|
+
text: str = ""
|
|
1221
|
+
preview: str = ""
|
|
1222
|
+
body_sha256: str = ""
|
|
1223
|
+
char_count: int = 0
|
|
1224
|
+
chunk_count: int = 0
|
|
1225
|
+
paragraph_count: int = 0
|
|
1226
|
+
document_ids: tuple[str, ...] = ()
|
|
1227
|
+
chapter_ids: tuple[str, ...] = ()
|
|
1228
|
+
source_names: tuple[str, ...] = ()
|
|
1229
|
+
source_paths: tuple[str, ...] = ()
|
|
1230
|
+
range_map: tuple[Mapping[str, Any], ...] = ()
|
|
1231
|
+
truncated: bool = False
|
|
1232
|
+
limit: int = 10_000
|
|
1233
|
+
offset: int = 0
|
|
1234
|
+
max_chars: int = 200_000
|
|
1235
|
+
versioning: Mapping[str, Any] | None = None
|
|
1236
|
+
context: Mapping[str, Any] | None = None
|
|
1237
|
+
total_chars: int = 0
|
|
1238
|
+
total_sha256: str = ""
|
|
1239
|
+
text_offset: int = 0
|
|
1240
|
+
next_text_offset: int | None = None
|
|
1241
|
+
|
|
1242
|
+
@classmethod
|
|
1243
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1244
|
+
values = dict(payload)
|
|
1245
|
+
for name in ("document_ids", "chapter_ids", "source_names", "source_paths", "range_map"):
|
|
1246
|
+
values[name] = tuple(values.get(name, ()))
|
|
1247
|
+
return _read(cls, values)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1251
|
+
class EntityOwnerTreeRequest(PublicModel):
|
|
1252
|
+
entity_id: str
|
|
1253
|
+
entity_type: str | None = None
|
|
1254
|
+
max_depth: int = 5
|
|
1255
|
+
max_children_per_node: int = 200
|
|
1256
|
+
include_deleted: bool = False
|
|
1257
|
+
|
|
1258
|
+
def __post_init__(self) -> None:
|
|
1259
|
+
if not self.entity_id.strip():
|
|
1260
|
+
raise ValueError("entity_id must be non-empty")
|
|
1261
|
+
if self.max_depth < 0 or self.max_depth > 20:
|
|
1262
|
+
raise ValueError("max_depth must be between 0 and 20")
|
|
1263
|
+
if self.max_children_per_node < 1 or self.max_children_per_node > 500:
|
|
1264
|
+
raise ValueError("max_children_per_node must be between 1 and 500")
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1268
|
+
class EntityOwnerTreeResult(PublicModel):
|
|
1269
|
+
entity_type: str
|
|
1270
|
+
id: str
|
|
1271
|
+
tree: Mapping[str, Any]
|
|
1272
|
+
max_depth: int = 5
|
|
1273
|
+
max_children_per_node: int = 200
|
|
1274
|
+
include_deleted: bool = False
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1278
|
+
class SemanticChunkMetadataUpdateRequest(PublicModel):
|
|
1279
|
+
chunk_id: str | None = None
|
|
1280
|
+
chunk_ids: tuple[str, ...] | None = None
|
|
1281
|
+
filters: Mapping[str, Any] | None = None
|
|
1282
|
+
updates: Mapping[str, Any] | None = None
|
|
1283
|
+
items: tuple[Mapping[str, Any], ...] | None = None
|
|
1284
|
+
limit: int = 100
|
|
1285
|
+
offset: int = 0
|
|
1286
|
+
include_deleted: bool = False
|
|
1287
|
+
dry_run: bool = False
|
|
1288
|
+
|
|
1289
|
+
def __post_init__(self) -> None:
|
|
1290
|
+
selector_count = sum(
|
|
1291
|
+
value is not None for value in (self.chunk_id, self.chunk_ids, self.filters)
|
|
1292
|
+
)
|
|
1293
|
+
if self.items is not None:
|
|
1294
|
+
if self.updates is not None or selector_count:
|
|
1295
|
+
raise ValueError("items cannot be combined with selectors or updates")
|
|
1296
|
+
if not self.items:
|
|
1297
|
+
raise ValueError("items must not be empty")
|
|
1298
|
+
else:
|
|
1299
|
+
if self.updates is None:
|
|
1300
|
+
raise ValueError("updates is required without items")
|
|
1301
|
+
if selector_count != 1:
|
|
1302
|
+
raise ValueError("select exactly one of chunk_id, chunk_ids, or filters")
|
|
1303
|
+
if self.chunk_id is not None and not self.chunk_id.strip():
|
|
1304
|
+
raise ValueError("chunk_id must be non-empty")
|
|
1305
|
+
if self.chunk_ids is not None and not self.chunk_ids:
|
|
1306
|
+
raise ValueError("chunk_ids must not be empty")
|
|
1307
|
+
if self.limit < 1 or self.limit > 10000:
|
|
1308
|
+
raise ValueError("limit must be between 1 and 10000")
|
|
1309
|
+
if self.offset < 0:
|
|
1310
|
+
raise ValueError("offset must be non-negative")
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1314
|
+
class SemanticChunkMetadataUpdateResult(PublicModel):
|
|
1315
|
+
outcome: str
|
|
1316
|
+
requested: int
|
|
1317
|
+
matched: int
|
|
1318
|
+
updated: int
|
|
1319
|
+
items: tuple[Mapping[str, Any], ...] = ()
|
|
1320
|
+
dry_run: bool = False
|
|
1321
|
+
|
|
1322
|
+
@classmethod
|
|
1323
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1324
|
+
values = dict(payload)
|
|
1325
|
+
values["items"] = tuple(values.get("items", ()))
|
|
1326
|
+
return _read(cls, values)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1330
|
+
class ProcessingStatusRequest(PublicModel):
|
|
1331
|
+
operation_id: str
|
|
1332
|
+
document_id: str | None = None
|
|
1333
|
+
|
|
1334
|
+
def __post_init__(self) -> None:
|
|
1335
|
+
if not self.operation_id.strip() or (
|
|
1336
|
+
self.document_id is not None and not self.document_id.strip()
|
|
1337
|
+
):
|
|
1338
|
+
raise ValueError("operation_id and document_id must be non-empty when supplied")
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1342
|
+
class ServerError(PublicModel):
|
|
1343
|
+
"""Structured error data returned by a server command."""
|
|
1344
|
+
|
|
1345
|
+
code: str
|
|
1346
|
+
message: str
|
|
1347
|
+
details: Mapping[str, Any] | None = None
|
|
1348
|
+
type: str | None = None
|
|
1349
|
+
|
|
1350
|
+
@classmethod
|
|
1351
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1352
|
+
values = dict(payload)
|
|
1353
|
+
known = {model_field.name for model_field in fields(cls)}
|
|
1354
|
+
extras = {key: values.pop(key) for key in sorted(set(values) - known)}
|
|
1355
|
+
if extras:
|
|
1356
|
+
details = dict(values.get("details") or {})
|
|
1357
|
+
details.update(extras)
|
|
1358
|
+
values["details"] = details
|
|
1359
|
+
return _read(cls, values)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1363
|
+
class ProcessingStatusResult(PublicModel):
|
|
1364
|
+
operation_id: str
|
|
1365
|
+
status: str
|
|
1366
|
+
progress: int | float | None = None
|
|
1367
|
+
timestamps: Mapping[str, Any] = field(default_factory=dict)
|
|
1368
|
+
document_reference: str | None = None
|
|
1369
|
+
version_reference: str | None = None
|
|
1370
|
+
failure: ServerError | Mapping[str, Any] | None = None
|
|
1371
|
+
document_id: str | None = None
|
|
1372
|
+
requested_document_id: str | None = None
|
|
1373
|
+
|
|
1374
|
+
def to_params(self) -> dict[str, Any]:
|
|
1375
|
+
result = _payload(self)
|
|
1376
|
+
if isinstance(self.failure, ServerError):
|
|
1377
|
+
result["failure"] = self.failure.to_payload()
|
|
1378
|
+
return result
|
|
1379
|
+
|
|
1380
|
+
@classmethod
|
|
1381
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1382
|
+
values = dict(payload)
|
|
1383
|
+
failure = values.get("failure")
|
|
1384
|
+
if isinstance(failure, Mapping):
|
|
1385
|
+
values["failure"] = ServerError.from_payload(failure)
|
|
1386
|
+
return _read(cls, values)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _validate_reconstruction_bounds(max_chars: int, limit: int, offset: int) -> None:
|
|
1390
|
+
if max_chars < 0:
|
|
1391
|
+
raise ValueError("max_chars must be non-negative")
|
|
1392
|
+
if limit < 1:
|
|
1393
|
+
raise ValueError("limit must be positive")
|
|
1394
|
+
if offset < 0:
|
|
1395
|
+
raise ValueError("offset must be non-negative")
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _validate_uuid4(value: str, field_name: str) -> None:
|
|
1399
|
+
if not isinstance(value, str):
|
|
1400
|
+
raise ValueError(f"{field_name} must be a UUID4")
|
|
1401
|
+
try:
|
|
1402
|
+
parsed = UUID(value)
|
|
1403
|
+
except (AttributeError, ValueError):
|
|
1404
|
+
raise ValueError(f"{field_name} must be a UUID4") from None
|
|
1405
|
+
if parsed.version != 4:
|
|
1406
|
+
raise ValueError(f"{field_name} must be a UUID4")
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _validate_version_no(value: int) -> None:
|
|
1410
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
1411
|
+
raise ValueError("version_no must be a positive integer")
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _validate_version_bounds(limit: int, offset: int) -> None:
|
|
1415
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1 or limit > 1000:
|
|
1416
|
+
raise ValueError("limit must be between 1 and 1000")
|
|
1417
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0 or offset > 10_000_000:
|
|
1418
|
+
raise ValueError("offset must be between 0 and 10000000")
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _validate_reconstruction_text_selectors(
|
|
1422
|
+
*,
|
|
1423
|
+
document_id: str | None,
|
|
1424
|
+
file_id: str | None,
|
|
1425
|
+
source_name: str | None,
|
|
1426
|
+
source_path: str | None,
|
|
1427
|
+
project_id: str | None,
|
|
1428
|
+
) -> None:
|
|
1429
|
+
values = {
|
|
1430
|
+
"document_id": document_id,
|
|
1431
|
+
"file_id": file_id,
|
|
1432
|
+
"source_name": source_name,
|
|
1433
|
+
"source_path": source_path,
|
|
1434
|
+
"project_id": project_id,
|
|
1435
|
+
}
|
|
1436
|
+
for field_name, value in values.items():
|
|
1437
|
+
if value is not None and not value.strip():
|
|
1438
|
+
raise ValueError(f"{field_name} must be non-empty when supplied")
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1442
|
+
class RetrievalRequest(PublicModel):
|
|
1443
|
+
source_version: int | None = None
|
|
1444
|
+
|
|
1445
|
+
def __post_init__(self) -> None:
|
|
1446
|
+
if self.source_version is not None and (
|
|
1447
|
+
isinstance(self.source_version, bool) or self.source_version <= 0
|
|
1448
|
+
):
|
|
1449
|
+
raise ValueError("source_version must be a positive integer")
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1453
|
+
class DocumentGetRequest(RetrievalRequest):
|
|
1454
|
+
document_id: str
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1458
|
+
class ChapterGetRequest(RetrievalRequest):
|
|
1459
|
+
chapter_id: str
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1463
|
+
class ParagraphGetRequest(RetrievalRequest):
|
|
1464
|
+
paragraph_id: str
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1468
|
+
class ParagraphGetByNumberRequest(RetrievalRequest):
|
|
1469
|
+
document_id: str
|
|
1470
|
+
paragraph_number: int
|
|
1471
|
+
|
|
1472
|
+
def __post_init__(self) -> None:
|
|
1473
|
+
super().__post_init__()
|
|
1474
|
+
if not self.document_id.strip():
|
|
1475
|
+
raise ValueError("document_id must be non-empty")
|
|
1476
|
+
if isinstance(self.paragraph_number, bool) or self.paragraph_number < 1:
|
|
1477
|
+
raise ValueError("paragraph_number must be a positive integer")
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1481
|
+
class RetrievalResult(PublicModel):
|
|
1482
|
+
entity: str
|
|
1483
|
+
identifier: str
|
|
1484
|
+
source_version: int | None = None
|
|
1485
|
+
value: Any = None
|
|
1486
|
+
|
|
1487
|
+
@classmethod
|
|
1488
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1489
|
+
"""Expose the current version when the retrieval boundary nests it in ``value``.
|
|
1490
|
+
|
|
1491
|
+
The deployed document boundary returns the requested ``source_version`` at the
|
|
1492
|
+
envelope level and the current persisted version in the document payload. A
|
|
1493
|
+
current read therefore has a null envelope version even though callers need the
|
|
1494
|
+
persisted value for a following token-guarded mutation.
|
|
1495
|
+
"""
|
|
1496
|
+
|
|
1497
|
+
values = dict(payload)
|
|
1498
|
+
if values.get("source_version") is None and isinstance(values.get("value"), Mapping):
|
|
1499
|
+
nested_version = values["value"].get("source_version")
|
|
1500
|
+
if isinstance(nested_version, int) and not isinstance(nested_version, bool):
|
|
1501
|
+
values["source_version"] = nested_version
|
|
1502
|
+
return _read(cls, values)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
DocumentGetResult = RetrievalResult
|
|
1506
|
+
ChapterGetResult = RetrievalResult
|
|
1507
|
+
ParagraphGetResult = RetrievalResult
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1511
|
+
class ParagraphGetByNumberResult(PublicModel):
|
|
1512
|
+
entity: str
|
|
1513
|
+
document_id: str
|
|
1514
|
+
paragraph_number: int
|
|
1515
|
+
identifier: str | None = None
|
|
1516
|
+
source_version: int | None = None
|
|
1517
|
+
text: str | None = None
|
|
1518
|
+
value: Any = None
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1522
|
+
class RankedSearchHit(PublicModel):
|
|
1523
|
+
chunk_id: str
|
|
1524
|
+
chunk: Mapping[str, Any]
|
|
1525
|
+
bm25_score: float | None = None
|
|
1526
|
+
semantic_score: float | None = None
|
|
1527
|
+
hybrid_score: float | None = None
|
|
1528
|
+
rank: int = 0
|
|
1529
|
+
matched_fields: tuple[str, ...] | None = None
|
|
1530
|
+
highlights: Mapping[str, tuple[str, ...]] | None = None
|
|
1531
|
+
search_metadata: Mapping[str, Any] | None = None
|
|
1532
|
+
|
|
1533
|
+
def __post_init__(self) -> None:
|
|
1534
|
+
for name in ("bm25_score", "semantic_score", "hybrid_score"):
|
|
1535
|
+
score = getattr(self, name)
|
|
1536
|
+
if score is not None and not 0 <= score <= 1:
|
|
1537
|
+
raise ValueError(f"{name} must be between 0 and 1")
|
|
1538
|
+
if self.rank < 0:
|
|
1539
|
+
raise ValueError("rank must be non-negative")
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1543
|
+
class SearchResult(TemporalResult):
|
|
1544
|
+
"""ChunkQuery search result plus the resolved pair its ranking used."""
|
|
1545
|
+
|
|
1546
|
+
status: str
|
|
1547
|
+
results: tuple[RankedSearchHit, ...] = ()
|
|
1548
|
+
total_results: int | None = None
|
|
1549
|
+
search_time: float | None = None
|
|
1550
|
+
query_time: float | None = None
|
|
1551
|
+
metadata: Mapping[str, Any] | None = None
|
|
1552
|
+
error: ServerError | Mapping[str, Any] | None = None
|
|
1553
|
+
|
|
1554
|
+
def __post_init__(self) -> None:
|
|
1555
|
+
super().__post_init__()
|
|
1556
|
+
_reject_repeated_objects(
|
|
1557
|
+
({"chunk_id": hit.chunk_id} for hit in self.results),
|
|
1558
|
+
context="chunk_query_search",
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
def to_params(self) -> dict[str, Any]:
|
|
1562
|
+
result = _payload(self)
|
|
1563
|
+
result["results"] = [hit.to_payload() for hit in self.results]
|
|
1564
|
+
if isinstance(self.error, ServerError):
|
|
1565
|
+
result["error"] = self.error.to_payload()
|
|
1566
|
+
return result
|
|
1567
|
+
|
|
1568
|
+
@classmethod
|
|
1569
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1570
|
+
values = dict(payload)
|
|
1571
|
+
nested = values.pop("data", None)
|
|
1572
|
+
if isinstance(nested, Mapping):
|
|
1573
|
+
values = {**dict(nested), **values}
|
|
1574
|
+
values.setdefault("status", "success")
|
|
1575
|
+
values["results"] = tuple(
|
|
1576
|
+
RankedSearchHit.from_payload(item) for item in values.get("results", ())
|
|
1577
|
+
)
|
|
1578
|
+
if isinstance(values.get("error"), Mapping):
|
|
1579
|
+
values["error"] = ServerError.from_payload(values["error"])
|
|
1580
|
+
return _read(cls, values)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
@dataclass(frozen=True, kw_only=True)
|
|
1584
|
+
class OperationState(PublicModel):
|
|
1585
|
+
operation_id: str
|
|
1586
|
+
status: str
|
|
1587
|
+
document_id: str | None = None
|
|
1588
|
+
source_version_id: str | None = None
|
|
1589
|
+
message: str | None = None
|
|
1590
|
+
error: ServerError | Mapping[str, Any] | None = None
|
|
1591
|
+
|
|
1592
|
+
def to_params(self) -> dict[str, Any]:
|
|
1593
|
+
result = _payload(self)
|
|
1594
|
+
if isinstance(self.error, ServerError):
|
|
1595
|
+
result["error"] = self.error.to_payload()
|
|
1596
|
+
return result
|
|
1597
|
+
|
|
1598
|
+
@classmethod
|
|
1599
|
+
def from_payload(cls, payload: Payload) -> Self:
|
|
1600
|
+
values = dict(payload)
|
|
1601
|
+
if isinstance(values.get("error"), Mapping):
|
|
1602
|
+
values["error"] = ServerError.from_payload(values["error"])
|
|
1603
|
+
return _read(cls, values)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
__all__ = [
|
|
1607
|
+
"BackupInspectFile", "BackupInspectResult", "BackupPoint", "BackupPointsResult",
|
|
1608
|
+
"BackupRecoverCatalogResult", "BackupRestoreResult", "BackupRunResult",
|
|
1609
|
+
"BackupStatusResult", "ChapterGetRequest", "ChapterGetResult",
|
|
1610
|
+
"ChapterTextGetRequest", "ChunkQuery",
|
|
1611
|
+
"ChunkHistoryRequest", "ChunkHistoryResult", "ChunkVersionAddRequest",
|
|
1612
|
+
"ChunkVersionAddResult", "ChunkVersionDeleteRequest", "ChunkVersionDeleteResult",
|
|
1613
|
+
"ChunkVersionDiffRequest", "ChunkVersionDiffResult", "ChunkVersionGetRequest",
|
|
1614
|
+
"ChunkVersionGetResult", "ChunkVersionItem", "ChunkVersionListRequest",
|
|
1615
|
+
"ChunkVersionListResult", "ChunkVersionRestoreRequest", "ChunkVersionRestoreResult",
|
|
1616
|
+
"ChunkVersionRetireRequest", "ChunkVersionRetireResult", "ChunkVersionSetCurrentRequest",
|
|
1617
|
+
"ChunkVersionSetCurrentResult", "ChunkVersionTextMutationRequest",
|
|
1618
|
+
"ChunkVersionUpdateRequest", "ChunkVersionUpdateResult",
|
|
1619
|
+
"DocumentCreateRequest",
|
|
1620
|
+
"DocumentCreateResult", "DocumentChunkRequest", "DocumentChunkResult",
|
|
1621
|
+
"DocumentDeleteRequest", "DocumentDeleteResult", "DocumentGetRequest", "DocumentGetResult",
|
|
1622
|
+
"DocumentRebindRequest", "DocumentRebindResult", "DocumentUpdateRequest", "DocumentUpdateResult",
|
|
1623
|
+
"DocumentWriteRequest", "DocumentWriteResult", "EntityGetRequest", "EntityGetResult",
|
|
1624
|
+
"EntityTextGetRequest", "EntityTextGetResult",
|
|
1625
|
+
"EntityIdsRequest", "EntityLifecycleResult", "EntityListRequest", "EntityListResult",
|
|
1626
|
+
"EntityOwnerTreeRequest", "EntityOwnerTreeResult", "EntityReferencesRequest",
|
|
1627
|
+
"EntityReferencesResult", "OperationState",
|
|
1628
|
+
"ParagraphGetByNumberRequest", "ParagraphGetByNumberResult", "ParagraphGetRequest",
|
|
1629
|
+
"ParagraphGetResult", "ProcessingStatusRequest", "ProcessingStatusResult", "RankedSearchHit",
|
|
1630
|
+
"RetrievalRequest", "RetrievalResult", "SearchResult", "ServerError",
|
|
1631
|
+
"TEMPORAL_BOUND_FIELDS", "TemporalCursor", "TemporalPayload", "TemporalRequest",
|
|
1632
|
+
"TemporalResult", "canonical_query_id", "temporal_bounds", "with_temporal_bounds",
|
|
1633
|
+
"SemanticChunkMetadataUpdateRequest", "SemanticChunkMetadataUpdateResult",
|
|
1634
|
+
"SourceFileReconstructRequest", "TextReconstructionRequest", "TextReconstructionResult",
|
|
1635
|
+
]
|