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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- 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")
|