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.
@@ -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
+ ]