rememberstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,577 @@
1
+ """The E0 chain (D36): upload → ingest → convert → structure.
2
+
3
+ The upload connector performs ingest synchronously (bytes to the raw store,
4
+ rows + convert work atomically through the catalog); convert and structure are
5
+ queued stage handlers. Artifacts land ID-addressed in the artifacts store
6
+ (`<doc_id>/<content_hash>/<representation_id>/…`, D37/D65); Postgres carries
7
+ only the index.
8
+
9
+ The structure stage runs the full D39 route when a model provider is
10
+ composed: the structurer LLM proposes a section tree + placement hint from
11
+ `document.md`, the deterministic snap (`core/section_snap.py`) normalizes it
12
+ onto the block grid, and the tree lands as `document_sections` rows plus the
13
+ `pageindex.json` sidecar. Every degradation path — no provider, a short
14
+ document, a failed or malformed LLM call — falls back to the synthetic root:
15
+ a document never fails structuring.
16
+ """
17
+
18
+ from datetime import datetime
19
+ import hashlib
20
+ import json
21
+ from pathlib import PurePosixPath
22
+ from typing import Final
23
+ from typing import Protocol
24
+ from uuid import NAMESPACE_URL
25
+ from uuid import UUID
26
+ from uuid import uuid4
27
+ from uuid import uuid5
28
+
29
+ from pydantic import Field
30
+ from pydantic_settings import BaseSettings
31
+ from pydantic_settings import SettingsConfigDict
32
+
33
+ from rememberstack.core import blockize
34
+ from rememberstack.core import BLOCKIZER_VERSION
35
+ from rememberstack.core import ConversionRouter
36
+ from rememberstack.core import SECTION_ROLES
37
+ from rememberstack.core import snap_sections
38
+ from rememberstack.core import storage_class_for
39
+ from rememberstack.model import Block
40
+ from rememberstack.model import ClaimedWork
41
+ from rememberstack.model import ConversionError
42
+ from rememberstack.model import DocumentUpload
43
+ from rememberstack.model import EnqueueWork
44
+ from rememberstack.model import IngestedVersion
45
+ from rememberstack.model import ModelRequest
46
+ from rememberstack.model import NonRetryableHandlerError
47
+ from rememberstack.model import ObjectAlreadyExistsError
48
+ from rememberstack.model import ObjectKey
49
+ from rememberstack.model import PipelineStage
50
+ from rememberstack.model import ProcessingLane
51
+ from rememberstack.model import ProviderAccountingError
52
+ from rememberstack.model import RepresentationRecord
53
+ from rememberstack.model import SectionTreeRecord
54
+ from rememberstack.model import SnappedSection
55
+ from rememberstack.model import StructureResponse
56
+ from rememberstack.model import StructureSource
57
+ from rememberstack.model import UnroutableMimeError
58
+ from rememberstack.model import UploadRecord
59
+ from rememberstack.ports.cost_meter import CostMeterPort
60
+ from rememberstack.ports.model_provider import ModelProviderPort
61
+ from rememberstack.ports.object_store import ObjectStorePort
62
+ from rememberstack.spine.document_catalog import DocumentCatalog
63
+ from rememberstack.workers.base import HandlerOutcome
64
+ from rememberstack.workers.e1 import E1_CHUNK_VERSION
65
+
66
+ E0_CONVERT_VERSION: Final = "e0-convert-2026.07"
67
+ """The convert sub-worker's component version (D12 idempotency key member)."""
68
+
69
+ E0_STRUCTURE_VERSION: Final = "e0-structure-2026.07b:pageindex-snap-1"
70
+ """The structure stage's component version (D39): LLM route + snap algorithm."""
71
+
72
+ UPLOAD_SOURCE_KIND: Final = "upload"
73
+ """The one-shot upload connector's source kind (D55 lineage identity)."""
74
+
75
+
76
+ class IngestAdmission(Protocol):
77
+ """The one D74 check required before an ingest writes any bytes."""
78
+
79
+ def guard_ingest(
80
+ self,
81
+ *,
82
+ deployment_id: UUID,
83
+ source_kind: str,
84
+ source_ref: str,
85
+ content_hash: str,
86
+ ) -> None:
87
+ """Raise when admission is closed or the identity was forgotten."""
88
+ ...
89
+
90
+
91
+ class UploadIngestor:
92
+ """The upload connector's ingest: bytes to the raw store, rows + work to the spine.
93
+
94
+ A one-shot upload has no connector-native identity, so its lineage IS its
95
+ content: `source_ref = content_hash` and a content-derived `doc_id`, which
96
+ makes re-ingesting identical bytes a deterministic no-op (D55) and lets
97
+ the raw object be written before any row exists.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ catalog: DocumentCatalog,
104
+ raw_store: ObjectStorePort,
105
+ admission: IngestAdmission,
106
+ ) -> None:
107
+ """Bind the connector to the catalog and the deployment's raw bucket."""
108
+ self._catalog = catalog
109
+ self._raw_store = raw_store
110
+ self._admission = admission
111
+
112
+ def ingest(
113
+ self,
114
+ *,
115
+ deployment_id: UUID,
116
+ upload: DocumentUpload,
117
+ lane: ProcessingLane = ProcessingLane.STEADY,
118
+ ) -> IngestedVersion:
119
+ """Ingest one uploaded file and enqueue its convert work."""
120
+ content_hash = hashlib.sha256(upload.content).hexdigest()
121
+ self._guard_ingest(
122
+ deployment_id=deployment_id,
123
+ source_kind=UPLOAD_SOURCE_KIND,
124
+ source_ref=content_hash,
125
+ content_hash=content_hash,
126
+ )
127
+ doc_id = uuid5(
128
+ NAMESPACE_URL, f"rememberstack:upload:{deployment_id}:{content_hash}"
129
+ )
130
+ suffix = PurePosixPath(upload.filename).suffix
131
+ raw_uri = f"{doc_id}/{content_hash}/original{suffix}"
132
+ try:
133
+ self._raw_store.write_bytes(
134
+ key=ObjectKey(raw_uri),
135
+ content=upload.content,
136
+ # D51: media a harness reads stays hot; text originals
137
+ # kept only for audit go cold — routed at the write
138
+ storage_class=storage_class_for(mime=upload.mime),
139
+ )
140
+ except ObjectAlreadyExistsError:
141
+ pass # identical bytes already landed — ingest retries are no-ops
142
+ return self._catalog.record_upload(
143
+ record=UploadRecord(
144
+ deployment_id=deployment_id,
145
+ doc_id=doc_id,
146
+ source_kind=UPLOAD_SOURCE_KIND,
147
+ source_ref=content_hash,
148
+ source_uri=None,
149
+ title=upload.title or PurePosixPath(upload.filename).stem,
150
+ content_hash=content_hash,
151
+ mime=upload.mime,
152
+ byte_size=len(upload.content),
153
+ raw_uri=raw_uri,
154
+ ),
155
+ convert_component_version=E0_CONVERT_VERSION,
156
+ lane=lane,
157
+ )
158
+
159
+ def ingest_observed(
160
+ self,
161
+ *,
162
+ deployment_id: UUID,
163
+ source_kind: str,
164
+ source_ref: str,
165
+ upload: DocumentUpload,
166
+ versioning_mode: str,
167
+ source_modified_at: datetime | None,
168
+ source_version_ref: str | None,
169
+ sync_cycle_id: UUID | None,
170
+ lane: ProcessingLane = ProcessingLane.STEADY,
171
+ ) -> IngestedVersion:
172
+ """Ingest one WATCHED observation of a lineage (D55).
173
+
174
+ Identity is connector-native (source_kind, source_ref) — bytes
175
+ cannot identify a lineage (they change; that is the premise). A
176
+ changed file becomes a new VERSION of its lineage; identical bytes
177
+ are the content-hash no-op.
178
+ """
179
+ content_hash = hashlib.sha256(upload.content).hexdigest()
180
+ self._guard_ingest(
181
+ deployment_id=deployment_id,
182
+ source_kind=source_kind,
183
+ source_ref=source_ref,
184
+ content_hash=content_hash,
185
+ )
186
+ doc_id = uuid5(
187
+ NAMESPACE_URL, f"rememberstack:{source_kind}:{deployment_id}:{source_ref}"
188
+ )
189
+ suffix = PurePosixPath(upload.filename).suffix
190
+ raw_uri = f"{doc_id}/{content_hash}/original{suffix}"
191
+ try:
192
+ self._raw_store.write_bytes(
193
+ key=ObjectKey(raw_uri),
194
+ content=upload.content,
195
+ # D51: media a harness reads stays hot; text originals
196
+ # kept only for audit go cold — routed at the write
197
+ storage_class=storage_class_for(mime=upload.mime),
198
+ )
199
+ except ObjectAlreadyExistsError:
200
+ pass
201
+ return self._catalog.record_upload(
202
+ record=UploadRecord(
203
+ deployment_id=deployment_id,
204
+ doc_id=doc_id,
205
+ source_kind=source_kind,
206
+ source_ref=source_ref,
207
+ source_uri=source_ref,
208
+ title=upload.title or PurePosixPath(upload.filename).stem,
209
+ content_hash=content_hash,
210
+ mime=upload.mime,
211
+ byte_size=len(upload.content),
212
+ raw_uri=raw_uri,
213
+ versioning_mode=versioning_mode,
214
+ source_modified_at=source_modified_at,
215
+ source_version_ref=source_version_ref,
216
+ sync_cycle_id=sync_cycle_id,
217
+ ),
218
+ convert_component_version=E0_CONVERT_VERSION,
219
+ lane=lane,
220
+ )
221
+
222
+ def _guard_ingest(
223
+ self,
224
+ *,
225
+ deployment_id: UUID,
226
+ source_kind: str,
227
+ source_ref: str,
228
+ content_hash: str,
229
+ ) -> None:
230
+ """Check D74 before writing forgotten bytes back into the raw store."""
231
+ self._admission.guard_ingest(
232
+ deployment_id=deployment_id,
233
+ source_kind=source_kind,
234
+ source_ref=source_ref,
235
+ content_hash=content_hash,
236
+ )
237
+
238
+
239
+ class ConvertHandler:
240
+ """The convert stage (D38/D57): raw bytes → document.md + blocks + manifest.
241
+
242
+ One representation per run: converter output and the deterministic block
243
+ sequence are written ID-addressed to the artifacts store, then recorded as
244
+ an immutable `document_representations` row (D65). Chains structure.
245
+ """
246
+
247
+ def __init__(
248
+ self,
249
+ *,
250
+ catalog: DocumentCatalog,
251
+ raw_store: ObjectStorePort,
252
+ artifact_store: ObjectStorePort,
253
+ router: ConversionRouter,
254
+ ) -> None:
255
+ """Bind the handler to its catalog, both stores, and the route table."""
256
+ self._catalog = catalog
257
+ self._raw_store = raw_store
258
+ self._artifact_store = artifact_store
259
+ self._router = router
260
+
261
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
262
+ """Convert one document version and record its representation.
263
+
264
+ Replay before regenerate (D65/D7): a representation this toolchain
265
+ already produced for the version is re-chained as-is — the converter
266
+ is never re-called on a retried or replayed attempt.
267
+ """
268
+ del meter
269
+ source = self._catalog.convert_source(
270
+ version_id=_payload_uuid(work=work, field="version_id")
271
+ )
272
+ try:
273
+ converter = self._router.converter_for(mime=source.mime)
274
+ except UnroutableMimeError as err:
275
+ # deterministic for this input — retrying cannot help (D12); the
276
+ # version's own status must not keep claiming in-flight work:
277
+ self._catalog.mark_version_failed(
278
+ version_id=source.version_id, error=str(err)
279
+ )
280
+ raise NonRetryableHandlerError(str(err)) from err
281
+ existing = self._catalog.existing_representation(
282
+ version_id=source.version_id,
283
+ route=converter.name,
284
+ converter_version=converter.version,
285
+ blockizer_version=BLOCKIZER_VERSION,
286
+ )
287
+ if existing is not None:
288
+ return self._structure_follow_up(
289
+ work=work, version_id=source.version_id, representation_id=existing
290
+ )
291
+ content = self._raw_store.read_bytes(key=ObjectKey(source.raw_uri))
292
+ try:
293
+ result = converter.convert(content=content, mime=source.mime)
294
+ except ConversionError as err:
295
+ self._catalog.mark_version_failed(
296
+ version_id=source.version_id, error=str(err)
297
+ )
298
+ raise NonRetryableHandlerError(str(err)) from err
299
+ blocks = blockize(document_md=result.document_md)
300
+
301
+ representation_id = uuid4()
302
+ base = f"{source.doc_id}/{source.content_hash}/{representation_id}"
303
+ markdown_bytes = result.document_md.encode("utf-8")
304
+ markdown_hash = hashlib.sha256(markdown_bytes).hexdigest()
305
+ blocks_bytes = _json_bytes(
306
+ payload={
307
+ "blockizer_version": BLOCKIZER_VERSION,
308
+ "block_count": len(blocks),
309
+ "markdown_chars": len(result.document_md),
310
+ "blocks": [block.model_dump(mode="json") for block in blocks],
311
+ }
312
+ )
313
+ manifest_bytes = _json_bytes(
314
+ payload={
315
+ "route": converter.name,
316
+ "converter": {"name": converter.name, "version": converter.version},
317
+ "blockizer_version": BLOCKIZER_VERSION,
318
+ "execution": "library-local",
319
+ "markdown_sha256": markdown_hash,
320
+ "source_map": None,
321
+ "derived_assets": [],
322
+ "warnings": list(result.warnings),
323
+ }
324
+ )
325
+ meta_bytes = _json_bytes(
326
+ payload={
327
+ "doc_id": str(source.doc_id),
328
+ "version_id": str(source.version_id),
329
+ "representation_id": str(representation_id),
330
+ "content_hash": source.content_hash,
331
+ "mime": source.mime,
332
+ "title": source.title,
333
+ "route": converter.name,
334
+ }
335
+ )
336
+ artifacts = {
337
+ f"{base}/document.md": markdown_bytes,
338
+ f"{base}/blocks.json": blocks_bytes,
339
+ f"{base}/conversion.json": manifest_bytes,
340
+ f"{base}/meta.json": meta_bytes,
341
+ }
342
+ for uri, payload_bytes in artifacts.items():
343
+ self._artifact_store.write_bytes(key=ObjectKey(uri), content=payload_bytes)
344
+
345
+ self._catalog.record_representation(
346
+ record=RepresentationRecord(
347
+ representation_id=representation_id,
348
+ deployment_id=source.deployment_id,
349
+ version_id=source.version_id,
350
+ route=converter.name,
351
+ converter_name=converter.name,
352
+ converter_version=converter.version,
353
+ blockizer_version=BLOCKIZER_VERSION,
354
+ markdown_uri=f"{base}/document.md",
355
+ blocks_uri=f"{base}/blocks.json",
356
+ conversion_uri=f"{base}/conversion.json",
357
+ meta_uri=f"{base}/meta.json",
358
+ markdown_hash=markdown_hash,
359
+ manifest_hash=hashlib.sha256(manifest_bytes).hexdigest(),
360
+ )
361
+ )
362
+ return self._structure_follow_up(
363
+ work=work, version_id=source.version_id, representation_id=representation_id
364
+ )
365
+
366
+ def _structure_follow_up(
367
+ self, *, work: ClaimedWork, version_id: UUID, representation_id: UUID
368
+ ) -> HandlerOutcome:
369
+ """Chain the structure stage for one (version, representation)."""
370
+ return HandlerOutcome(
371
+ follow_up=(
372
+ EnqueueWork(
373
+ deployment_id=work.deployment_id,
374
+ target_kind=work.target_kind,
375
+ target_id=work.target_id,
376
+ stage=PipelineStage.STRUCTURE,
377
+ component_version=E0_STRUCTURE_VERSION,
378
+ content_hash=work.content_hash,
379
+ lane=work.lane,
380
+ payload={
381
+ "version_id": str(version_id),
382
+ "representation_id": str(representation_id),
383
+ },
384
+ ),
385
+ )
386
+ )
387
+
388
+
389
+ class StructurerSettings(BaseSettings):
390
+ """The full structure route's knobs (D39/D70 port defaults; D22 numbers).
391
+
392
+ The model seat follows the D70 principle — a per-deployment port
393
+ configuration, defaulting to the extraction tier. The thresholds are
394
+ starting points to be measured: a document below ``min_blocks_for_llm``
395
+ is served by the synthetic root alone (the LLM cannot improve a
396
+ three-paragraph note), and the prompt reads at most
397
+ ``max_prompt_chars`` of `document.md`.
398
+ """
399
+
400
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_STRUCTURER_")
401
+
402
+ model: str = Field(default="openai/gpt-5.6-luna")
403
+ min_blocks_for_llm: int = Field(default=8, ge=1)
404
+ max_prompt_chars: int = Field(default=200_000, ge=1_000)
405
+
406
+
407
+ _STRUCTURE_PROMPT: Final = """You are a document structurer. Read the document \
408
+ and propose its hierarchical section tree (a table of contents with spans).
409
+
410
+ Rules:
411
+ - Return sections as a JSON tree: each node has "title", "role", \
412
+ "char_start", "char_end", "summary" (one line), and "children" (nested nodes).
413
+ - Character offsets index into EXACTLY the document text below (0-based; \
414
+ char_end is exclusive). Top-level sections should cover the document in order.
415
+ - "role" must be one of: {roles}.
416
+ - Also return "placement": a proposed corpus path for this document, e.g. \
417
+ "/finance/annual-reports/2023/" — where it would live in an ideal directory \
418
+ tree of a whole document collection. Advisory only.
419
+ - Do not invent sections a short flat document does not have; return an empty \
420
+ "sections" list if the document has no internal structure worth naming.
421
+
422
+ Document title: {title}
423
+
424
+ DOCUMENT:
425
+ {document}"""
426
+
427
+
428
+ class StructureHandler:
429
+ """The structure stage (D39): the full PageIndex-style route, snap-guarded.
430
+
431
+ With a composed model provider and a long-enough document, the LLM
432
+ proposes the section tree and placement hint; the deterministic snap
433
+ normalizes it onto the block grid; rows + `pageindex.json` sidecar land
434
+ and the chain completes — representation ready, live-reading pointer
435
+ set, lineage currency moved (D54). Without a provider — or when the
436
+ call fails or returns nothing usable — the document gets the synthetic
437
+ root: structuring never fails a document.
438
+ """
439
+
440
+ def __init__(
441
+ self,
442
+ *,
443
+ catalog: DocumentCatalog,
444
+ artifact_store: ObjectStorePort,
445
+ model_provider: ModelProviderPort | None = None,
446
+ settings: StructurerSettings | None = None,
447
+ ) -> None:
448
+ """Bind the handler to its catalog, artifacts bucket, and model seat."""
449
+ self._catalog = catalog
450
+ self._artifact_store = artifact_store
451
+ self._model_provider = model_provider
452
+ self._settings = settings or StructurerSettings()
453
+
454
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
455
+ """Structure one representation and flip currency."""
456
+ source = self._catalog.structure_source(
457
+ representation_id=_payload_uuid(work=work, field="representation_id")
458
+ )
459
+ blocks_doc = json.loads(
460
+ self._artifact_store.read_bytes(key=ObjectKey(source.blocks_uri))
461
+ )
462
+ blocks = tuple(
463
+ Block.model_validate(payload) for payload in blocks_doc["blocks"]
464
+ )
465
+ response = self._propose(source=source, block_count=len(blocks), meter=meter)
466
+ proposed = response.sections if response is not None else ()
467
+ placement = (response.placement or None) if response is not None else None
468
+ sections = snap_sections(
469
+ proposed=proposed,
470
+ blocks=blocks,
471
+ title=source.title,
472
+ markdown_chars=blocks_doc["markdown_chars"],
473
+ )
474
+ structurer_name = "pageindex_llm" if len(sections) > 1 else "synthetic_root"
475
+ persisted = self._catalog.record_section_tree(
476
+ record=SectionTreeRecord(
477
+ deployment_id=source.deployment_id,
478
+ doc_id=source.doc_id,
479
+ version_id=source.version_id,
480
+ representation_id=source.representation_id,
481
+ sections=sections,
482
+ placement_path=placement,
483
+ structurer_name=structurer_name,
484
+ structurer_version=E0_STRUCTURE_VERSION,
485
+ )
486
+ )
487
+ # the sidecar is derived from what the database actually persisted —
488
+ # a retry that lost to an earlier attempt must not write a fresher
489
+ # LLM proposal beside rows carrying the first one (Codex review):
490
+ self._write_sidecar(
491
+ source=source,
492
+ sections=persisted.sections,
493
+ placement=persisted.placement_path,
494
+ )
495
+ return HandlerOutcome(
496
+ follow_up=(
497
+ EnqueueWork(
498
+ deployment_id=work.deployment_id,
499
+ target_kind=work.target_kind,
500
+ target_id=work.target_id,
501
+ stage=PipelineStage.CHUNK,
502
+ component_version=E1_CHUNK_VERSION,
503
+ content_hash=work.content_hash,
504
+ lane=work.lane,
505
+ payload={
506
+ "version_id": str(source.version_id),
507
+ "representation_id": str(source.representation_id),
508
+ },
509
+ ),
510
+ )
511
+ )
512
+
513
+ def _propose(
514
+ self, *, source: StructureSource, block_count: int, meter: CostMeterPort
515
+ ) -> StructureResponse | None:
516
+ """Ask the structurer LLM for a tree; every failure degrades to None."""
517
+ if self._model_provider is None:
518
+ return None
519
+ if block_count < self._settings.min_blocks_for_llm:
520
+ return None # a short document: the synthetic root serves it
521
+ markdown = self._artifact_store.read_bytes(
522
+ key=ObjectKey(source.markdown_uri)
523
+ ).decode("utf-8")
524
+ prompt = _STRUCTURE_PROMPT.format(
525
+ roles=", ".join(sorted(SECTION_ROLES)),
526
+ title=source.title or "(untitled)",
527
+ document=markdown[: self._settings.max_prompt_chars],
528
+ )
529
+ try:
530
+ generated = self._model_provider.generate(
531
+ request=ModelRequest(model=self._settings.model, prompt=prompt),
532
+ response_type=StructureResponse,
533
+ )
534
+ except ProviderAccountingError:
535
+ raise # budget enforcement must never degrade missing usage to zero
536
+ except Exception: # noqa: BLE001 — a document never fails structuring
537
+ return None
538
+ meter.record(call_key="structure", tier="structure", usage=generated.usage)
539
+ return generated.output
540
+
541
+ def _write_sidecar(
542
+ self,
543
+ *,
544
+ source: StructureSource,
545
+ sections: tuple[SnappedSection, ...],
546
+ placement: str | None,
547
+ ) -> None:
548
+ """Write the reproducible `pageindex.json` next to `document.md` (D39)."""
549
+ sidecar_key = source.blocks_uri.rsplit("/", 1)[0] + "/pageindex.json"
550
+ payload = _json_bytes(
551
+ payload={
552
+ "structurer_version": E0_STRUCTURE_VERSION,
553
+ "placement": placement,
554
+ "sections": [section.model_dump(mode="json") for section in sections],
555
+ }
556
+ )
557
+ try:
558
+ self._artifact_store.write_bytes(
559
+ key=ObjectKey(sidecar_key), content=payload
560
+ )
561
+ except ObjectAlreadyExistsError:
562
+ pass # a retried attempt replays; the first write is the truth
563
+
564
+
565
+ def _payload_uuid(*, work: ClaimedWork, field: str) -> UUID:
566
+ """Read a required UUID from the claimed payload; absence is non-retryable."""
567
+ value = (work.payload or {}).get(field)
568
+ if not isinstance(value, str):
569
+ raise NonRetryableHandlerError(
570
+ f"stage {work.stage} work {work.processing_id} carries no {field!r} payload"
571
+ )
572
+ return UUID(value)
573
+
574
+
575
+ def _json_bytes(*, payload: dict[str, object]) -> bytes:
576
+ """Serialize one artifact JSON document deterministically."""
577
+ return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")