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,525 @@
|
|
|
1
|
+
"""The E2 extractor (D31-D35): two-call Claimify over the context bundle.
|
|
2
|
+
|
|
3
|
+
Per chunk: a Selection call judges every proposition (keep / keep-flagged /
|
|
4
|
+
drop — drops and flags go to the D33 ledger), then one fused call
|
|
5
|
+
decontextualizes, decomposes, and self-grounds the keeps. The deterministic
|
|
6
|
+
grounding gate (D32 layers 1-2) accepts a claim only if its verbatim source
|
|
7
|
+
span anchors inside the chunk and every added substring exists in the bundle
|
|
8
|
+
element it was attributed to — a check the model cannot talk its way past.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Final
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
from pydantic_settings import BaseSettings
|
|
18
|
+
from pydantic_settings import SettingsConfigDict
|
|
19
|
+
|
|
20
|
+
from rememberstack.model import CandidateClaim
|
|
21
|
+
from rememberstack.model import ChunkForEmbedding
|
|
22
|
+
from rememberstack.model import ChunkSource
|
|
23
|
+
from rememberstack.model import ClaimedWork
|
|
24
|
+
from rememberstack.model import ClaimifyResponse
|
|
25
|
+
from rememberstack.model import ClaimRecord
|
|
26
|
+
from rememberstack.model import DecisionRecord
|
|
27
|
+
from rememberstack.model import DecisionType
|
|
28
|
+
from rememberstack.model import EnqueueWork
|
|
29
|
+
from rememberstack.model import ModelRequest
|
|
30
|
+
from rememberstack.model import NonRetryableHandlerError
|
|
31
|
+
from rememberstack.model import ObjectKey
|
|
32
|
+
from rememberstack.model import PipelineStage
|
|
33
|
+
from rememberstack.model import SelectionCandidate
|
|
34
|
+
from rememberstack.model import SelectionResponse
|
|
35
|
+
from rememberstack.model import SelectionVerdict
|
|
36
|
+
from rememberstack.ports.cost_meter import CostMeterPort
|
|
37
|
+
from rememberstack.ports.model_provider import ModelProviderPort
|
|
38
|
+
from rememberstack.ports.object_store import ObjectStorePort
|
|
39
|
+
from rememberstack.spine.chunk_catalog import ChunkCatalog
|
|
40
|
+
from rememberstack.spine.claim_catalog import ClaimCatalog
|
|
41
|
+
from rememberstack.workers.base import HandlerOutcome
|
|
42
|
+
from rememberstack.workers.e1 import E2_EXTRACTOR_VERSION
|
|
43
|
+
from rememberstack.workers.e3 import E3_NORMALIZER_VERSION
|
|
44
|
+
|
|
45
|
+
_logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
_SELECTION_PROMPT: Final = """You are the Selection stage of a claim extractor.
|
|
48
|
+
Judge every proposition in the TARGET CHUNK: keep statements making a specific,
|
|
49
|
+
verifiable proposition (state, event, decision, quantity, policy, relationship).
|
|
50
|
+
Drop unattributed opinions, advice, hypotheticals, generic truisms, questions,
|
|
51
|
+
section intros/conclusions, and "we don't know" statements. An ATTRIBUTED
|
|
52
|
+
stance ("X said/believes/opposes Y") is a KEEP. Never-drop classes even if
|
|
53
|
+
phrased opinionatedly: quantities, dates, named-entity+predicate,
|
|
54
|
+
change-of-state. When unsure, prefer keep_flagged over drop. Each candidate's
|
|
55
|
+
source_span must be a verbatim substring of the target chunk.
|
|
56
|
+
|
|
57
|
+
{bundle}"""
|
|
58
|
+
|
|
59
|
+
_CLAIMIFY_PROMPT: Final = """You are the decontextualize+decompose+ground stage
|
|
60
|
+
of a claim extractor. For each KEPT proposition below: resolve every pronoun,
|
|
61
|
+
partial name, acronym, and relative date USING ONLY THE BUNDLE (never outside
|
|
62
|
+
knowledge), adding the minimum context needed; split into the simplest
|
|
63
|
+
standalone claims, preserving attribution ("X said Y" stays attributed); if a
|
|
64
|
+
careful reader could not pick one interpretation from the bundle, omit the
|
|
65
|
+
candidate. For each claim return: claim_text (standalone), source_span (the
|
|
66
|
+
verbatim chunk substring it derives from), added_context (every substring you
|
|
67
|
+
ADDED, each tagged header|neighbour|prefix with the exact text as it appears
|
|
68
|
+
in that bundle element), entailment_self_verdict (does chunk+bundle entail the
|
|
69
|
+
claim), is_attributed.
|
|
70
|
+
|
|
71
|
+
KEPT PROPOSITIONS:
|
|
72
|
+
{keeps}
|
|
73
|
+
|
|
74
|
+
{bundle}"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class E2Settings(BaseSettings):
|
|
78
|
+
"""The E2 model binding (D70): interchangeable per-deployment port config."""
|
|
79
|
+
|
|
80
|
+
model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_E2_")
|
|
81
|
+
|
|
82
|
+
extract_model: str = Field(default="openai/gpt-5.6-luna")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ExtractClaimsHandler:
|
|
86
|
+
"""The extract stage: every chunk of one representation through Claimify."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
catalog: ClaimCatalog,
|
|
92
|
+
chunk_catalog: ChunkCatalog,
|
|
93
|
+
artifact_store: ObjectStorePort,
|
|
94
|
+
model_provider: ModelProviderPort,
|
|
95
|
+
settings: E2Settings,
|
|
96
|
+
chunker_version: str,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Bind the handler to its catalogs, store, provider, and generation."""
|
|
99
|
+
self._catalog = catalog
|
|
100
|
+
self._chunk_catalog = chunk_catalog
|
|
101
|
+
self._artifact_store = artifact_store
|
|
102
|
+
self._model_provider = model_provider
|
|
103
|
+
self._settings = settings
|
|
104
|
+
self._chunker_version = chunker_version
|
|
105
|
+
|
|
106
|
+
def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
|
|
107
|
+
"""Extract claims for one document version, chunk by chunk (D12 replay)."""
|
|
108
|
+
source = self._chunk_catalog.chunk_source(
|
|
109
|
+
representation_id=_payload_uuid(work=work, field="representation_id")
|
|
110
|
+
)
|
|
111
|
+
chunks = self._chunk_catalog.chunks_for_embedding(
|
|
112
|
+
representation_id=source.representation_id,
|
|
113
|
+
chunker_version=self._chunker_version,
|
|
114
|
+
)
|
|
115
|
+
if not chunks:
|
|
116
|
+
return HandlerOutcome()
|
|
117
|
+
document_md = self._artifact_store.read_bytes(
|
|
118
|
+
key=ObjectKey(source.markdown_uri)
|
|
119
|
+
).decode("utf-8")
|
|
120
|
+
for index, chunk in enumerate(chunks):
|
|
121
|
+
if self._catalog.chunk_already_extracted(
|
|
122
|
+
chunk_id=chunk.chunk_id, extractor_version=E2_EXTRACTOR_VERSION
|
|
123
|
+
):
|
|
124
|
+
continue # replay: stored claims + decisions are the output (D7)
|
|
125
|
+
if self._reuse_prior_extraction(source=source, chunk=chunk):
|
|
126
|
+
continue # D56: the prior version's claims are re-attached
|
|
127
|
+
self._extract_chunk(
|
|
128
|
+
source=source,
|
|
129
|
+
chunks=chunks,
|
|
130
|
+
index=index,
|
|
131
|
+
document_md=document_md,
|
|
132
|
+
meter=meter,
|
|
133
|
+
)
|
|
134
|
+
return HandlerOutcome(
|
|
135
|
+
follow_up=(
|
|
136
|
+
EnqueueWork(
|
|
137
|
+
deployment_id=work.deployment_id,
|
|
138
|
+
target_kind=work.target_kind,
|
|
139
|
+
target_id=work.target_id,
|
|
140
|
+
stage=PipelineStage.NORMALIZE_RELATIONS,
|
|
141
|
+
component_version=E3_NORMALIZER_VERSION,
|
|
142
|
+
content_hash=work.content_hash,
|
|
143
|
+
lane=work.lane,
|
|
144
|
+
payload={
|
|
145
|
+
"version_id": str(source.version_id),
|
|
146
|
+
"representation_id": str(source.representation_id),
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _reuse_prior_extraction(
|
|
153
|
+
self, *, source: ChunkSource, chunk: ChunkForEmbedding
|
|
154
|
+
) -> bool:
|
|
155
|
+
"""The D56 chunk-grain reuse rung: re-attach instead of re-extract.
|
|
156
|
+
|
|
157
|
+
An unchanged ``extraction_input_hash`` within the lineage means some
|
|
158
|
+
already-extracted chunk read the exact same stable inputs — its
|
|
159
|
+
claims are re-attached to this version's chunk row (occurrence
|
|
160
|
+
links, F4) and no model is called. A prior extraction that found
|
|
161
|
+
nothing claim-worthy carries its terminal marker forward the same
|
|
162
|
+
way. Returns False when the lineage holds no extracted match.
|
|
163
|
+
"""
|
|
164
|
+
prior = self._catalog.prior_extracted_chunk(
|
|
165
|
+
deployment_id=source.deployment_id,
|
|
166
|
+
doc_id=source.doc_id,
|
|
167
|
+
version_id=chunk.version_id,
|
|
168
|
+
extraction_input_hash=chunk.extraction_input_hash,
|
|
169
|
+
)
|
|
170
|
+
if prior is None:
|
|
171
|
+
return False
|
|
172
|
+
attached = self._catalog.attach_reused_claims(
|
|
173
|
+
deployment_id=source.deployment_id,
|
|
174
|
+
chunk_id=chunk.chunk_id,
|
|
175
|
+
prior_chunk_id=prior,
|
|
176
|
+
)
|
|
177
|
+
if attached == 0:
|
|
178
|
+
# the prior chunk carries no claims — a terminal no_info: carry
|
|
179
|
+
# the marker forward so replay stays closed for this chunk too
|
|
180
|
+
self._catalog.record_extraction(
|
|
181
|
+
claims=(),
|
|
182
|
+
decisions=(_empty_extraction_marker(source=source, chunk=chunk),),
|
|
183
|
+
)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def _extract_chunk(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
source: ChunkSource,
|
|
190
|
+
chunks: tuple[ChunkForEmbedding, ...],
|
|
191
|
+
index: int,
|
|
192
|
+
document_md: str,
|
|
193
|
+
meter: CostMeterPort,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Run the two Claimify calls for one chunk and land the results."""
|
|
196
|
+
chunk = chunks[index]
|
|
197
|
+
bundle = _bundle_text(
|
|
198
|
+
source=source, chunks=chunks, index=index, document_md=document_md
|
|
199
|
+
)
|
|
200
|
+
selection_call = self._model_provider.generate(
|
|
201
|
+
request=ModelRequest(
|
|
202
|
+
model=self._settings.extract_model,
|
|
203
|
+
prompt=_SELECTION_PROMPT.format(bundle=bundle),
|
|
204
|
+
),
|
|
205
|
+
response_type=SelectionResponse,
|
|
206
|
+
)
|
|
207
|
+
meter.record(
|
|
208
|
+
call_key=f"selection:{chunk.chunk_id}",
|
|
209
|
+
tier="selection",
|
|
210
|
+
usage=selection_call.usage,
|
|
211
|
+
)
|
|
212
|
+
selection = selection_call.output
|
|
213
|
+
decisions = list(
|
|
214
|
+
_selection_decisions(source=source, chunk=chunk, selection=selection)
|
|
215
|
+
)
|
|
216
|
+
keeps = tuple(
|
|
217
|
+
candidate
|
|
218
|
+
for candidate in selection.candidates
|
|
219
|
+
if candidate.verdict is not SelectionVerdict.DROP
|
|
220
|
+
)
|
|
221
|
+
claims: list[ClaimRecord] = []
|
|
222
|
+
if keeps:
|
|
223
|
+
kept_ranges = _kept_ranges(
|
|
224
|
+
keeps=keeps, chunk=chunk, document_md=document_md
|
|
225
|
+
)
|
|
226
|
+
flagged_spans = {
|
|
227
|
+
candidate.source_span
|
|
228
|
+
for candidate in keeps
|
|
229
|
+
if candidate.verdict is SelectionVerdict.KEEP_FLAGGED
|
|
230
|
+
}
|
|
231
|
+
response_call = self._model_provider.generate(
|
|
232
|
+
request=ModelRequest(
|
|
233
|
+
model=self._settings.extract_model,
|
|
234
|
+
prompt=_CLAIMIFY_PROMPT.format(
|
|
235
|
+
keeps="\n".join(f"- {keep.source_span}" for keep in keeps),
|
|
236
|
+
bundle=bundle,
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
response_type=ClaimifyResponse,
|
|
240
|
+
)
|
|
241
|
+
meter.record(
|
|
242
|
+
call_key=f"decontextualize:{chunk.chunk_id}",
|
|
243
|
+
tier="decontextualize",
|
|
244
|
+
usage=response_call.usage,
|
|
245
|
+
)
|
|
246
|
+
response = response_call.output
|
|
247
|
+
for candidate in response.claims:
|
|
248
|
+
record = _grounded_claim(
|
|
249
|
+
candidate=candidate,
|
|
250
|
+
source=source,
|
|
251
|
+
chunk=chunk,
|
|
252
|
+
chunks=chunks,
|
|
253
|
+
index=index,
|
|
254
|
+
document_md=document_md,
|
|
255
|
+
flagged_spans=flagged_spans,
|
|
256
|
+
kept_ranges=kept_ranges,
|
|
257
|
+
)
|
|
258
|
+
if record is None:
|
|
259
|
+
_logger.warning(
|
|
260
|
+
"grounding gate rejected candidate %r on chunk %s",
|
|
261
|
+
candidate.claim_text,
|
|
262
|
+
chunk.chunk_id,
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
claims.append(record)
|
|
266
|
+
if record.added_context:
|
|
267
|
+
decisions.append(_edit_decision(source=source, record=record))
|
|
268
|
+
decisions = _link_flagged_decisions(decisions=decisions, claims=claims)
|
|
269
|
+
if not claims and not decisions:
|
|
270
|
+
# terminal marker (D7): an extraction that found nothing claim-worthy
|
|
271
|
+
# is DONE — without it, replay would re-call the model.
|
|
272
|
+
decisions = [_empty_extraction_marker(source=source, chunk=chunk)]
|
|
273
|
+
self._catalog.record_extraction(
|
|
274
|
+
claims=tuple(claims), decisions=tuple(decisions)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _grounded_claim(
|
|
279
|
+
*,
|
|
280
|
+
candidate: CandidateClaim,
|
|
281
|
+
source: ChunkSource,
|
|
282
|
+
chunk: ChunkForEmbedding,
|
|
283
|
+
chunks: tuple[ChunkForEmbedding, ...],
|
|
284
|
+
index: int,
|
|
285
|
+
document_md: str,
|
|
286
|
+
flagged_spans: set[str],
|
|
287
|
+
kept_ranges: tuple[tuple[int, int], ...],
|
|
288
|
+
) -> ClaimRecord | None:
|
|
289
|
+
"""Apply the deterministic grounding gate (D32 layers 1-2).
|
|
290
|
+
|
|
291
|
+
Layer 1 (anchor): the source span must be a real in-bounds slice of the
|
|
292
|
+
target chunk, and must overlap a span Selection kept — the fused call can
|
|
293
|
+
never resurrect a dropped proposition. Layer 2 (window membership): every
|
|
294
|
+
added substring must verbatim-exist in the bundle element it was
|
|
295
|
+
attributed to. A failed check returns None — the candidate never becomes
|
|
296
|
+
a claims row. Semantic invention behind a real span is layer-3/4
|
|
297
|
+
territory: the in-call self-verdict is stored advisory, and the sampled
|
|
298
|
+
independent audit owns the honest measurement.
|
|
299
|
+
"""
|
|
300
|
+
anchor_at = document_md.find(
|
|
301
|
+
candidate.source_span, chunk.char_start, chunk.char_end
|
|
302
|
+
)
|
|
303
|
+
if anchor_at < 0:
|
|
304
|
+
return None
|
|
305
|
+
anchor_end = anchor_at + len(candidate.source_span)
|
|
306
|
+
if not any(
|
|
307
|
+
anchor_at < kept_end and kept_start < anchor_end
|
|
308
|
+
for kept_start, kept_end in kept_ranges
|
|
309
|
+
):
|
|
310
|
+
return None # Selection is enforced, not advisory
|
|
311
|
+
for added in candidate.added_context:
|
|
312
|
+
element = _bundle_element(
|
|
313
|
+
kind=added.source_kind,
|
|
314
|
+
source=source,
|
|
315
|
+
chunks=chunks,
|
|
316
|
+
index=index,
|
|
317
|
+
document_md=document_md,
|
|
318
|
+
)
|
|
319
|
+
if element is None or added.text not in element:
|
|
320
|
+
return None
|
|
321
|
+
return ClaimRecord(
|
|
322
|
+
claim_id=uuid4(),
|
|
323
|
+
deployment_id=source.deployment_id,
|
|
324
|
+
doc_id=source.doc_id,
|
|
325
|
+
chunk_id=chunk.chunk_id,
|
|
326
|
+
section_id=None,
|
|
327
|
+
claim_text=candidate.claim_text,
|
|
328
|
+
source_span=candidate.source_span,
|
|
329
|
+
char_start=anchor_at,
|
|
330
|
+
char_end=anchor_at + len(candidate.source_span),
|
|
331
|
+
added_context=candidate.added_context,
|
|
332
|
+
is_attributed=candidate.is_attributed,
|
|
333
|
+
entailment_self_verdict=candidate.entailment_self_verdict,
|
|
334
|
+
kept_flagged=candidate.source_span in flagged_spans,
|
|
335
|
+
extractor_version=E2_EXTRACTOR_VERSION,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _bundle_text(
|
|
340
|
+
*,
|
|
341
|
+
source: ChunkSource,
|
|
342
|
+
chunks: tuple[ChunkForEmbedding, ...],
|
|
343
|
+
index: int,
|
|
344
|
+
document_md: str,
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Assemble the D31 context bundle for one target chunk."""
|
|
347
|
+
chunk = chunks[index]
|
|
348
|
+
return (
|
|
349
|
+
f"DOCUMENT HEADER: {_header_text(source=source)}\n"
|
|
350
|
+
f"SECTION: path {chunk.section_path}, role {chunk.section_role}\n"
|
|
351
|
+
f"CONTEXT PREFIX: {chunk.context_prefix or '(none)'}\n"
|
|
352
|
+
f"PREVIOUS CHUNK:\n{_neighbour_text(chunks=chunks, index=index - 1, document_md=document_md, section_path=chunk.section_path)}\n"
|
|
353
|
+
f"NEXT CHUNK:\n{_neighbour_text(chunks=chunks, index=index + 1, document_md=document_md, section_path=chunk.section_path)}\n"
|
|
354
|
+
f"TARGET CHUNK:\n{document_md[chunk.char_start : chunk.char_end]}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _bundle_element(
|
|
359
|
+
*,
|
|
360
|
+
kind: str,
|
|
361
|
+
source: ChunkSource,
|
|
362
|
+
chunks: tuple[ChunkForEmbedding, ...],
|
|
363
|
+
index: int,
|
|
364
|
+
document_md: str,
|
|
365
|
+
) -> str | None:
|
|
366
|
+
"""The bundle element an added substring claims to come from, or None."""
|
|
367
|
+
if kind == "header":
|
|
368
|
+
return _header_text(source=source)
|
|
369
|
+
if kind == "prefix":
|
|
370
|
+
return chunks[index].context_prefix
|
|
371
|
+
if kind == "neighbour":
|
|
372
|
+
return "\n".join(
|
|
373
|
+
_neighbour_text(
|
|
374
|
+
chunks=chunks,
|
|
375
|
+
index=neighbour,
|
|
376
|
+
document_md=document_md,
|
|
377
|
+
section_path=chunks[index].section_path,
|
|
378
|
+
)
|
|
379
|
+
for neighbour in (index - 1, index + 1)
|
|
380
|
+
)
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _header_text(*, source: ChunkSource) -> str:
|
|
385
|
+
"""The deterministic document header shared by every chunk's bundle."""
|
|
386
|
+
modified = source.source_modified_at or source.published_at
|
|
387
|
+
return (
|
|
388
|
+
f"title {source.title or 'untitled'}; source {source.source_kind};"
|
|
389
|
+
f" date {modified.date().isoformat() if modified else 'unknown'};"
|
|
390
|
+
f" language {source.language or 'unknown'}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _neighbour_text(
|
|
395
|
+
*,
|
|
396
|
+
chunks: tuple[ChunkForEmbedding, ...],
|
|
397
|
+
index: int,
|
|
398
|
+
document_md: str,
|
|
399
|
+
section_path: str,
|
|
400
|
+
) -> str:
|
|
401
|
+
"""A same-section neighbour's verbatim text, or a placeholder.
|
|
402
|
+
|
|
403
|
+
The D31 bundle rule is same-scope only: an ordinal-adjacent chunk from a
|
|
404
|
+
different section is not a neighbour and can never ground an addition.
|
|
405
|
+
"""
|
|
406
|
+
if 0 <= index < len(chunks) and chunks[index].section_path == section_path:
|
|
407
|
+
neighbour = chunks[index]
|
|
408
|
+
return document_md[neighbour.char_start : neighbour.char_end]
|
|
409
|
+
return "(none)"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _kept_ranges(
|
|
413
|
+
*, keeps: tuple[SelectionCandidate, ...], chunk: ChunkForEmbedding, document_md: str
|
|
414
|
+
) -> tuple[tuple[int, int], ...]:
|
|
415
|
+
"""Absolute char ranges of the kept Selection spans inside the chunk."""
|
|
416
|
+
ranges: list[tuple[int, int]] = []
|
|
417
|
+
for keep in keeps:
|
|
418
|
+
found = document_md.find(keep.source_span, chunk.char_start, chunk.char_end)
|
|
419
|
+
if found >= 0:
|
|
420
|
+
ranges.append((found, found + len(keep.source_span)))
|
|
421
|
+
return tuple(ranges)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _link_flagged_decisions(
|
|
425
|
+
*, decisions: list[DecisionRecord], claims: list[ClaimRecord]
|
|
426
|
+
) -> list[DecisionRecord]:
|
|
427
|
+
"""Pair each keep-flagged ledger row with its grounded claim (schema §8).
|
|
428
|
+
|
|
429
|
+
The invariant: a kept_flagged claim is the pair (claims row) + (a
|
|
430
|
+
selection_keep_flagged decision naming it). A flag whose span grounded no
|
|
431
|
+
claim keeps claim_id NULL — the flag stands, nothing to pair.
|
|
432
|
+
"""
|
|
433
|
+
linked: list[DecisionRecord] = []
|
|
434
|
+
for decision in decisions:
|
|
435
|
+
if decision.decision_type is DecisionType.SELECTION_KEEP_FLAGGED:
|
|
436
|
+
match = next(
|
|
437
|
+
(
|
|
438
|
+
claim
|
|
439
|
+
for claim in claims
|
|
440
|
+
if claim.kept_flagged and claim.source_span == decision.source_span
|
|
441
|
+
),
|
|
442
|
+
None,
|
|
443
|
+
)
|
|
444
|
+
if match is not None:
|
|
445
|
+
decision = decision.model_copy(update={"claim_id": match.claim_id})
|
|
446
|
+
linked.append(decision)
|
|
447
|
+
return linked
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _empty_extraction_marker(
|
|
451
|
+
*, source: ChunkSource, chunk: ChunkForEmbedding
|
|
452
|
+
) -> DecisionRecord:
|
|
453
|
+
"""The terminal no_info row for a chunk whose extraction found nothing."""
|
|
454
|
+
return DecisionRecord(
|
|
455
|
+
decision_id=uuid4(),
|
|
456
|
+
deployment_id=source.deployment_id,
|
|
457
|
+
doc_id=source.doc_id,
|
|
458
|
+
chunk_id=chunk.chunk_id,
|
|
459
|
+
claim_id=None,
|
|
460
|
+
decision_type=DecisionType.SELECTION_DROP,
|
|
461
|
+
source_span=None,
|
|
462
|
+
reason="no_info",
|
|
463
|
+
edit_detail=None,
|
|
464
|
+
protected_class=None,
|
|
465
|
+
extractor_version=E2_EXTRACTOR_VERSION,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _selection_decisions(
|
|
470
|
+
*, source: ChunkSource, chunk: ChunkForEmbedding, selection: SelectionResponse
|
|
471
|
+
) -> tuple[DecisionRecord, ...]:
|
|
472
|
+
"""The D33 ledger rows for one Selection call: drops and keep-flags."""
|
|
473
|
+
return tuple(
|
|
474
|
+
DecisionRecord(
|
|
475
|
+
decision_id=uuid4(),
|
|
476
|
+
deployment_id=source.deployment_id,
|
|
477
|
+
doc_id=source.doc_id,
|
|
478
|
+
chunk_id=chunk.chunk_id,
|
|
479
|
+
claim_id=None,
|
|
480
|
+
decision_type=DecisionType.SELECTION_DROP
|
|
481
|
+
if candidate.verdict is SelectionVerdict.DROP
|
|
482
|
+
else DecisionType.SELECTION_KEEP_FLAGGED,
|
|
483
|
+
source_span=candidate.source_span,
|
|
484
|
+
reason=candidate.drop_reason
|
|
485
|
+
if candidate.verdict is SelectionVerdict.DROP
|
|
486
|
+
else None,
|
|
487
|
+
edit_detail=None,
|
|
488
|
+
protected_class=candidate.protected_class,
|
|
489
|
+
extractor_version=E2_EXTRACTOR_VERSION,
|
|
490
|
+
)
|
|
491
|
+
for candidate in selection.candidates
|
|
492
|
+
if candidate.verdict is not SelectionVerdict.KEEP
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _edit_decision(*, source: ChunkSource, record: ClaimRecord) -> DecisionRecord:
|
|
497
|
+
"""The D33 decontextualization-edit row for one accepted claim."""
|
|
498
|
+
return DecisionRecord(
|
|
499
|
+
decision_id=uuid4(),
|
|
500
|
+
deployment_id=source.deployment_id,
|
|
501
|
+
doc_id=source.doc_id,
|
|
502
|
+
chunk_id=record.chunk_id,
|
|
503
|
+
claim_id=record.claim_id,
|
|
504
|
+
decision_type=DecisionType.DECONTEXT_EDIT,
|
|
505
|
+
source_span=record.source_span,
|
|
506
|
+
reason=None,
|
|
507
|
+
edit_detail={
|
|
508
|
+
"added": [
|
|
509
|
+
{"text": added.text, "source_kind": added.source_kind}
|
|
510
|
+
for added in record.added_context
|
|
511
|
+
]
|
|
512
|
+
},
|
|
513
|
+
protected_class=None,
|
|
514
|
+
extractor_version=E2_EXTRACTOR_VERSION,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _payload_uuid(*, work: ClaimedWork, field: str) -> UUID:
|
|
519
|
+
"""Read a required UUID from the claimed payload; absence is non-retryable."""
|
|
520
|
+
value = (work.payload or {}).get(field)
|
|
521
|
+
if not isinstance(value, str):
|
|
522
|
+
raise NonRetryableHandlerError(
|
|
523
|
+
f"stage {work.stage} work {work.processing_id} carries no {field!r} payload"
|
|
524
|
+
)
|
|
525
|
+
return UUID(value)
|