rag-wright 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 (184) hide show
  1. rag_wright/__init__.py +13 -0
  2. rag_wright/api/__init__.py +33 -0
  3. rag_wright/api/config.py +59 -0
  4. rag_wright/api/discover.py +70 -0
  5. rag_wright/api/documents.py +39 -0
  6. rag_wright/api/ids.py +31 -0
  7. rag_wright/api/invoke.py +99 -0
  8. rag_wright/api/kg.py +61 -0
  9. rag_wright/api/mcp.py +94 -0
  10. rag_wright/api/usage.py +30 -0
  11. rag_wright/api/workspace.py +85 -0
  12. rag_wright/capabilities/__init__.py +8 -0
  13. rag_wright/capabilities/answer_generator.py +427 -0
  14. rag_wright/capabilities/ard.py +286 -0
  15. rag_wright/capabilities/assertion_extraction.py +79 -0
  16. rag_wright/capabilities/chunk_read.py +58 -0
  17. rag_wright/capabilities/chunk_write.py +163 -0
  18. rag_wright/capabilities/claim_extraction.py +153 -0
  19. rag_wright/capabilities/clause_exception_linking.py +117 -0
  20. rag_wright/capabilities/compliance_judgment.py +322 -0
  21. rag_wright/capabilities/compliance_store.py +87 -0
  22. rag_wright/capabilities/contract_kg_serve.py +156 -0
  23. rag_wright/capabilities/contract_kg_store.py +251 -0
  24. rag_wright/capabilities/dg_extraction.py +585 -0
  25. rag_wright/capabilities/disambiguation.py +163 -0
  26. rag_wright/capabilities/document_parse.py +87 -0
  27. rag_wright/capabilities/document_scope.py +49 -0
  28. rag_wright/capabilities/embedding.py +164 -0
  29. rag_wright/capabilities/embedding_profiles.py +43 -0
  30. rag_wright/capabilities/entity_resolution.py +154 -0
  31. rag_wright/capabilities/fusion.py +64 -0
  32. rag_wright/capabilities/graph_extraction.py +243 -0
  33. rag_wright/capabilities/graph_query.py +73 -0
  34. rag_wright/capabilities/graph_storage.py +111 -0
  35. rag_wright/capabilities/highlight_serve.py +142 -0
  36. rag_wright/capabilities/hybrid_search.py +65 -0
  37. rag_wright/capabilities/invoke.py +31 -0
  38. rag_wright/capabilities/jev_decision.py +38 -0
  39. rag_wright/capabilities/manifests.py +872 -0
  40. rag_wright/capabilities/okf_navigate.py +456 -0
  41. rag_wright/capabilities/parsing.py +286 -0
  42. rag_wright/capabilities/property_boosted_retrieval.py +125 -0
  43. rag_wright/capabilities/query_function_classifier.py +94 -0
  44. rag_wright/capabilities/query_understanding.py +109 -0
  45. rag_wright/capabilities/registry.py +262 -0
  46. rag_wright/capabilities/remote_encoders.py +94 -0
  47. rag_wright/capabilities/requirement_extraction.py +247 -0
  48. rag_wright/capabilities/reranking.py +123 -0
  49. rag_wright/capabilities/retrieval_core.py +126 -0
  50. rag_wright/capabilities/rlm_chunking.py +808 -0
  51. rag_wright/capabilities/rlm_synthesis.py +316 -0
  52. rag_wright/capabilities/scan_quality.py +136 -0
  53. rag_wright/capabilities/span_relevance_judgment.py +191 -0
  54. rag_wright/capabilities/vision_to_text.py +85 -0
  55. rag_wright/capabilities/vlm_ocr.py +85 -0
  56. rag_wright/contracts/__init__.py +6 -0
  57. rag_wright/contracts/chunk.py +79 -0
  58. rag_wright/contracts/compliance.py +303 -0
  59. rag_wright/contracts/contract_meta.py +27 -0
  60. rag_wright/contracts/extraction.py +130 -0
  61. rag_wright/contracts/function.py +167 -0
  62. rag_wright/contracts/function_routing.py +91 -0
  63. rag_wright/contracts/highlight.py +74 -0
  64. rag_wright/contracts/identifiers.py +153 -0
  65. rag_wright/contracts/jurisdiction.py +96 -0
  66. rag_wright/contracts/ontology.py +142 -0
  67. rag_wright/contracts/property.py +201 -0
  68. rag_wright/contracts/provenance.py +78 -0
  69. rag_wright/contracts/query_intent.py +53 -0
  70. rag_wright/contracts/span.py +76 -0
  71. rag_wright/contracts/value_match.py +84 -0
  72. rag_wright/corpus/__init__.py +0 -0
  73. rag_wright/corpus/canonicalize.py +116 -0
  74. rag_wright/corpus/cuad.py +153 -0
  75. rag_wright/corpus/cuad_ingestion.py +72 -0
  76. rag_wright/corpus/document_parser.py +299 -0
  77. rag_wright/corpus/edgar.py +231 -0
  78. rag_wright/corpus/gcs_ingestion.py +120 -0
  79. rag_wright/corpus/http.py +110 -0
  80. rag_wright/corpus/selection.py +152 -0
  81. rag_wright/mcp/__init__.py +11 -0
  82. rag_wright/mcp/compliance_server.py +299 -0
  83. rag_wright/mcp/intra_document_qa_server.py +170 -0
  84. rag_wright/mcp/relational_qa_server.py +171 -0
  85. rag_wright/mcp/session_store.py +64 -0
  86. rag_wright/mcp/typed_property_retrieval_server.py +191 -0
  87. rag_wright/models/__init__.py +8 -0
  88. rag_wright/models/profiles.py +331 -0
  89. rag_wright/models/seam.py +497 -0
  90. rag_wright/models/tag_structured.py +285 -0
  91. rag_wright/models/tracing.py +179 -0
  92. rag_wright/models/usage.py +102 -0
  93. rag_wright/okf/__init__.py +11 -0
  94. rag_wright/okf/compile.py +292 -0
  95. rag_wright/okf/document.py +47 -0
  96. rag_wright/okf/enrich.py +176 -0
  97. rag_wright/okf/links.py +190 -0
  98. rag_wright/okf/lint.py +105 -0
  99. rag_wright/ontology/__init__.py +6 -0
  100. rag_wright/ontology/_generated_template_meta.py +60 -0
  101. rag_wright/ontology/_generated_vocab.py +52 -0
  102. rag_wright/ontology/clause_template.py +964 -0
  103. rag_wright/ontology/codegen.py +84 -0
  104. rag_wright/ontology/compliance_bridge.ttl +186 -0
  105. rag_wright/ontology/contract_bridge.ttl +2685 -0
  106. rag_wright/ontology/contract_taxonomy.py +24 -0
  107. rag_wright/ontology/derive.py +58 -0
  108. rag_wright/ontology/loader.py +435 -0
  109. rag_wright/ontology/packs/ftc_16cfr255.ttl +29 -0
  110. rag_wright/ontology/registry.py +87 -0
  111. rag_wright/ontology/template_introspect.py +100 -0
  112. rag_wright/py.typed +0 -0
  113. rag_wright/reference/__init__.py +2 -0
  114. rag_wright/reference/compliance.py +41 -0
  115. rag_wright/reference/contract_seam.py +123 -0
  116. rag_wright/skills/__init__.py +7 -0
  117. rag_wright/skills/claim_extraction/SKILL.md +47 -0
  118. rag_wright/skills/claim_extraction/__init__.py +1 -0
  119. rag_wright/skills/claim_extraction/template.py +50 -0
  120. rag_wright/skills/compliance_judgment/SKILL.md +59 -0
  121. rag_wright/skills/corpus_ingest/SKILL.md +106 -0
  122. rag_wright/skills/extraction_semantic_judge/SKILL.md +51 -0
  123. rag_wright/skills/extraction_semantic_judge/__init__.py +1 -0
  124. rag_wright/skills/generation/SKILL.md +64 -0
  125. rag_wright/skills/generation/__init__.py +1 -0
  126. rag_wright/skills/generic_compliance_judgment/SKILL.md +58 -0
  127. rag_wright/skills/okf_navigate/SKILL.md +137 -0
  128. rag_wright/skills/requirement_extraction/SKILL.md +47 -0
  129. rag_wright/skills/requirement_extraction/__init__.py +1 -0
  130. rag_wright/skills/requirement_extraction/template.py +50 -0
  131. rag_wright/skills/rlm/SKILL.md +186 -0
  132. rag_wright/skills/rlm/__init__.py +31 -0
  133. rag_wright/skills/rlm/agent.py +292 -0
  134. rag_wright/skills/span_relevance_judgment/SKILL.md +67 -0
  135. rag_wright/skills/vision_to_text/SKILL.md +36 -0
  136. rag_wright/skills/vision_to_text/__init__.py +1 -0
  137. rag_wright/spans/__init__.py +1 -0
  138. rag_wright/spans/boundary.py +78 -0
  139. rag_wright/spans/clause_function_classifier.py +490 -0
  140. rag_wright/spans/clause_kg_extractor.py +337 -0
  141. rag_wright/spans/cuad_labels.py +81 -0
  142. rag_wright/spans/dim_classifier.py +158 -0
  143. rag_wright/spans/dim_fleet.json +411 -0
  144. rag_wright/spans/function_classifier.py +77 -0
  145. rag_wright/spans/function_families.py +62 -0
  146. rag_wright/spans/hybrid_classifier.py +103 -0
  147. rag_wright/spans/legalbert_classifier.py +83 -0
  148. rag_wright/spans/model_capabilities.py +107 -0
  149. rag_wright/spans/new_function_labels.py +111 -0
  150. rag_wright/spans/page_map.py +68 -0
  151. rag_wright/spans/property_extractor.py +365 -0
  152. rag_wright/spans/property_grounding.py +182 -0
  153. rag_wright/spans/reclassify.py +77 -0
  154. rag_wright/spans/scarce_function_labels.py +105 -0
  155. rag_wright/spans/segment.py +341 -0
  156. rag_wright/spans/semantic_judge.py +197 -0
  157. rag_wright/spans/symbolic_validation.py +131 -0
  158. rag_wright/spans/tag_clause_extractor.py +182 -0
  159. rag_wright/store/__init__.py +6 -0
  160. rag_wright/store/arcadedb.py +1135 -0
  161. rag_wright/store/chunk_text.py +66 -0
  162. rag_wright/store/seam.py +213 -0
  163. rag_wright/subgraphs/__init__.py +0 -0
  164. rag_wright/subgraphs/async_ingestion.py +204 -0
  165. rag_wright/subgraphs/compliance_check.py +1042 -0
  166. rag_wright/subgraphs/compliance_ingestion.py +306 -0
  167. rag_wright/subgraphs/contract_ingestion_pipeline.py +999 -0
  168. rag_wright/subgraphs/graph_extraction.py +102 -0
  169. rag_wright/subgraphs/intra_document_qa.py +328 -0
  170. rag_wright/subgraphs/observability.py +140 -0
  171. rag_wright/subgraphs/query_constraint_extraction.py +73 -0
  172. rag_wright/subgraphs/relational_qa.py +165 -0
  173. rag_wright/subgraphs/requirement_extraction.py +137 -0
  174. rag_wright/subgraphs/scaffold.py +65 -0
  175. rag_wright/subgraphs/semantic_chunking.py +183 -0
  176. rag_wright/subgraphs/typed_clause_extraction.py +172 -0
  177. rag_wright/subgraphs/typed_property_retrieval.py +278 -0
  178. rag_wright/util/__init__.py +1 -0
  179. rag_wright/util/concurrent.py +153 -0
  180. rag_wright/util/spacy_model.py +45 -0
  181. rag_wright-0.1.0.dist-info/METADATA +168 -0
  182. rag_wright-0.1.0.dist-info/RECORD +184 -0
  183. rag_wright-0.1.0.dist-info/WHEEL +4 -0
  184. rag_wright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,808 @@
1
+ """RLM chunking capability (FR-I.1): a parsed document -> coherent, capped, summarized chunks.
2
+
3
+ Chunk boundaries are decided by an **LLM exploring the document** (the RLM machinery, T15): a strong
4
+ model reads the parsed document's structure, greps for structural markers, examines section sizes, and
5
+ returns semantically coherent boundary spans over the document's items. This is the capability, and it is
6
+ mandatory — no fixed-size chunking, ever (SPEC): abrupt fixed-size cuts destroy clause-level retrieval,
7
+ and the LLM-found semantic boundary is what prevents that. Recursion is available-when-warranted (a
8
+ section too large to judge in one pass may be decomposed further) but is not required of chunking; that
9
+ is intrinsic to synthesis (T28) and the RLM method, not chunking (ADR-0019).
10
+
11
+ Boundary discovery does not need to be reproducible: ingestion chunks a document once and persists the
12
+ result; retrieval never re-chunks. Only a document *change* re-chunks (a delete-and-re-chunk, task T34).
13
+ Stability of `chunk_id` comes from persistence, not from a repeatable algorithm.
14
+
15
+ The layer that runs **after** boundaries are chosen is deterministic given those boundaries and lives in
16
+ a separate function (`_finalize_chunks`): it joins each span's items, enforces the token cap, applies the
17
+ minimum-size floor and tiny-fragment merge (T-CHK — an LLM can just as easily return a boundary around a
18
+ lone heading), computes `chunk_id`, and validates. Summaries come from a `Summarizer` seam, filled
19
+ concurrently (a flat map, the right shape for independent summaries). Both the boundary discoverer and the
20
+ summarizer sit behind seams so the deterministic layer is tested hermetically with stubs and the real
21
+ (model-calling) implementations are exercised opt-in.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ from pathlib import Path
29
+ from typing import Any, Optional, Protocol, runtime_checkable
30
+
31
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
32
+ from langchain_core.tools import tool
33
+ from pydantic import BaseModel, Field
34
+
35
+ from rag_wright.capabilities.parsing import ParsedDocument, load_document
36
+ from rag_wright.capabilities.registry import CapabilityRegistry
37
+ from rag_wright.corpus.document_parser import _HEADING_LABELS # the single docling heading-label authority (ADR-0058)
38
+ from rag_wright.corpus.document_parser import content_items # issue 0014: reading-order body (text+tables+pictures)
39
+ from rag_wright.models.tag_structured import build_tag_structured
40
+ from rag_wright.spans.segment import _is_bare_heading # the single bare-heading text authority (0006-B)
41
+ from rag_wright.contracts.identifiers import ChunkId
42
+ from rag_wright.models.profiles import ModelRole, model_for
43
+ from rag_wright.models.seam import build_structured
44
+ from rag_wright.skills.rlm.agent import build_rlm_agent, rlm_interpreter_session
45
+
46
+ DEFAULT_TOKEN_CAP = 20_000
47
+ DEFAULT_SUMMARY_CONCURRENCY = 8 # in-flight summary calls (backpressure); summaries are network-bound
48
+ _SUMMARIZE_PROMPT = "Summarize this contract chunk in one or two sentences, factually:"
49
+
50
+ # Minimum chunk size floor (~250 tokens, T-CHK). Applied in `_finalize_chunks` over the DISCOVERER's
51
+ # spans: an LLM boundary discoverer can just as easily emit a boundary around a lone heading or a
52
+ # two-token fragment as the old mechanical splitter could, so below-floor spans fold into a neighbour and
53
+ # heading-only fragments are rejected. The floor coalesces adjacent spans; it never splits.
54
+ MIN_CHUNK_CHARS = 1000
55
+ _MIN_NONEMPTY_CHARS = 20 # a chunk shorter than this is degenerate (heading-only); never emitted
56
+
57
+
58
+ class BoundaryValidationError(ValueError):
59
+ """Raised when produced chunks violate a boundary invariant (cap, uniqueness, ordering, coverage)."""
60
+
61
+
62
+ class Chunk(BaseModel):
63
+ """One chunk: its stable id, position, text span, summary, and token estimate.
64
+
65
+ CU-B1 (ADR-0029): `doc_start`/`doc_end` are this chunk's character range in the CANONICAL document text
66
+ (`canonical_document_text()` = the `_SEP`-join of the finalized chunk texts), so
67
+ `canonical_document_text(chunks)[doc_start:doc_end] == text` byte-faithfully. This is the citation
68
+ coordinate the CUAD highlight pipeline indexes into (span offsets, CU-B2, compose as chunk.doc_start +
69
+ the clause-relative span offset). The chunker strips/merges/splits item text, so the canonical text is
70
+ the reconstruction from chunks (what the app renders + highlights), not the raw parsed text. Page/bbox
71
+ overlay is a separate, DEFERRED concern (CU-B5) but NOT lost: the parsed `DoclingDocument` (persisted at
72
+ parse) retains per-item page+bbox provenance, so a later canonical-char-offset -> Docling-item -> bbox
73
+ mapping stays possible. Optional/defaulted so old cached manifests still validate (a re-chunk regenerates
74
+ the offsets).
75
+ """
76
+
77
+ model_config = {"frozen": True}
78
+
79
+ chunk_id: str # canonical ChunkId form: <source_doc_id>:<chunk_index>:<content_hash>
80
+ chunk_index: int
81
+ text: str
82
+ summary: str
83
+ token_estimate: int
84
+ doc_start: int | None = None # CU-B1: char offset in the canonical document text
85
+ doc_end: int | None = None # CU-B1: exclusive; canonical_document_text(chunks)[doc_start:doc_end] == text
86
+
87
+
88
+ class ChunkManifest(BaseModel):
89
+ """The per-document chunking output (FR-I.1): the chunk set for one source document."""
90
+
91
+ model_config = {"frozen": True}
92
+
93
+ source_doc_id: str
94
+ content_hash: str
95
+ token_cap: int
96
+ chunks: list[Chunk] = Field(default_factory=list)
97
+
98
+
99
+ class BoundarySpan(BaseModel):
100
+ """One candidate chunk as an inclusive item-index range over the parsed document's `texts`.
101
+
102
+ Spans-over-items is what makes "not fixed-size" structural rather than asserted: a chunk is always a
103
+ contiguous run of the document's own structural items, never an arbitrary character interval. The
104
+ discoverer returns a partition of the document (contiguous, gap-free, covering every item)."""
105
+
106
+ model_config = {"frozen": True}
107
+
108
+ start_index: int
109
+ end_index: int # inclusive
110
+
111
+
112
+ @runtime_checkable
113
+ class BoundaryDiscoverer(Protocol):
114
+ """The semantic boundary-discovery seam: decide chunk boundaries as a partition of item-index spans."""
115
+
116
+ def discover(self, document) -> list[BoundarySpan]: ...
117
+
118
+
119
+ # --- the real, LLM-driven boundary discoverer (opt-in / live) ------------------------------------
120
+
121
+ _DISCOVERY_INSTRUCTIONS = (
122
+ "Call `const items = await tools.workingSet();` to get the parsed document as a list of structural "
123
+ "items (each has id, index, label, level, and full text); it is a JavaScript value in the interpreter, "
124
+ "never in your context. LOAD CHECK (do not skip): immediately verify you loaded the WHOLE document — "
125
+ "`const _n = await tools.workingSetSize(); if (items.length !== _n) throw new Error('LOAD UNDER-READ: ' "
126
+ "+ items.length + ' of ' + _n);` — the size is the truthful runtime count and a short load is a silent "
127
+ "text drop. Partition it into semantically coherent chunks by grouping CONTIGUOUS items so "
128
+ "that each chunk is one coherent unit (a clause, a section, a related run) and no chunk exceeds ~{cap} "
129
+ "characters. Never split a single coherent clause across two chunks, and never cut at a fixed size. "
130
+ "COVERAGE TAIL (do not skip): before returning, verify in code that your spans cover EVERY item from 0 "
131
+ "to items.length-1 with no gap; for any item range the recursion missed, add a span covering it — the "
132
+ "code guarantees coverage, a missed item is a silent text drop. Return ONLY a JSON array of the "
133
+ "boundaries as objects {{\"start_index\": i, \"end_index\": j}} (inclusive, contiguous, covering every "
134
+ "item from 0 to the last)."
135
+ )
136
+
137
+
138
+ class _ContentView:
139
+ """issue 0014: a parsed document viewed as its READING-ORDER chunkable content (text items + tables +
140
+ figures), so the chunker and segmenter index tables and figures -- not only `document.texts`, which excludes
141
+ them. `.texts` is the ONLY surface the chunking machinery reads off a document (`_document_items`, the
142
+ discoverers, `_join_span`, `_validate_partition`), so exposing the reading-order content items here routes all
143
+ of them through tables/figures with no other change, and `_SubDocument` slices of it stay consistent."""
144
+
145
+ __slots__ = ("texts",)
146
+
147
+ def __init__(self, document) -> None:
148
+ self.texts = content_items(document)
149
+
150
+
151
+ def _as_content_view(document) -> _ContentView:
152
+ """Wrap a loaded docling document as its reading-order content view, unless it already is one (idempotent, so
153
+ the transient `chunk_texts` seam and the ingest path can both wrap without double-wrapping)."""
154
+ return document if isinstance(document, _ContentView) else _ContentView(document)
155
+
156
+
157
+ def _document_items(document) -> list[dict]:
158
+ """The document's items as data for the working-set tool: id, index, label, level, and FULL text. This
159
+ is a JS value delivered through `tools.workingSet()` — it lives in the interpreter and never enters the
160
+ model's context (no truncation; a whole document survives intact), so no `peek` tool is needed. `id`
161
+ lets the workflow's coverage tail diff handled items against the whole set (T37)."""
162
+ items: list[dict] = []
163
+ for i, item in enumerate(document.texts):
164
+ items.append(
165
+ {
166
+ "id": i,
167
+ "index": i,
168
+ "label": str(getattr(item, "label", "")),
169
+ "level": getattr(item, "level", None),
170
+ "text": (getattr(item, "text", "") or "").strip(),
171
+ }
172
+ )
173
+ return items
174
+
175
+
176
+ def _extract_spans(text: str) -> list[BoundarySpan]:
177
+ """Parse the last JSON array of {start_index, end_index} objects from the model's final output."""
178
+ end = text.rfind("]")
179
+ start = text.rfind("[", 0, end)
180
+ if start == -1 or end == -1:
181
+ raise BoundaryValidationError("boundary discoverer returned no JSON span array")
182
+ raw = json.loads(text[start : end + 1])
183
+ return [BoundarySpan(start_index=int(o["start_index"]), end_index=int(o["end_index"])) for o in raw]
184
+
185
+
186
+ class SeamBoundaryDiscoverer:
187
+ """The real discoverer: a strong model explores the document via the RLM machinery and returns spans.
188
+
189
+ Uses the STRUCTURED_REASONING role (deepseek-v4-pro, the quality-sensitive model) through the T15
190
+ `build_rlm_agent` machinery: the document's structural view is handed to the orchestrator, which may
191
+ `peek()` at item text and (for a section too large to judge in one pass) recurse via the sub-agents —
192
+ available-when-warranted, not forced. It returns a partition of boundary spans.
193
+ """
194
+
195
+ def __init__(self, model: object = None, *, token_cap: int = DEFAULT_TOKEN_CAP) -> None:
196
+ # `model` is a model id (str) resolved through the profile seam, or a `BaseChatModel` instance
197
+ # (tests inject a fake); defaults to the quality-sensitive STRUCTURED_REASONING role.
198
+ self._model = model if model is not None else model_for(ModelRole.STRUCTURED_REASONING)
199
+ self._token_cap = token_cap
200
+
201
+ def discover(self, document) -> list[BoundarySpan]:
202
+ items = _document_items(document)
203
+
204
+ @tool
205
+ def working_set() -> list:
206
+ """Return the working set: the parsed document's items (id, index, label, level, full text)."""
207
+ return items
208
+
209
+ @tool
210
+ def working_set_size() -> int:
211
+ """Return the number of items in the delivered working set (a truthful count the workflow's
212
+ load-completeness assertion checks, so an under-read of the document fails loud, not silent)."""
213
+ return len(items)
214
+
215
+ instructions = _DISCOVERY_INSTRUCTIONS.format(cap=self._token_cap * 4)
216
+ request = f"Run this as a workflow.\n\n{instructions}"
217
+ # one model across all roles (the strong reasoning model): boundary discovery is a single
218
+ # exploration, and a sub-agent it dispatches for an over-large section warrants the same model.
219
+ # The working set is delivered as a PTC value (`tools.workingSet()`, T36): it stays in the
220
+ # interpreter and never enters the model's context. The interpreter session is serialized
221
+ # process-wide (KI-1, ADR-0020): build + run + teardown all inside the lock.
222
+ with rlm_interpreter_session(ptc=[working_set, working_set_size]) as interpreter:
223
+ agent = build_rlm_agent(
224
+ reasoning_model=self._model, decomposer_model=self._model, worker_model=self._model,
225
+ interpreter=interpreter,
226
+ )
227
+ messages = agent.invoke({"messages": [HumanMessage(content=request)]})["messages"]
228
+ final = _final_text(messages)
229
+ spans = _extract_spans(final)
230
+ # the workflow's coverage tail (in the skill, T37) guarantees full coverage; validate loudly here
231
+ # so an incomplete run fails visibly rather than silently dropping document text.
232
+ _validate_partition(spans, len(document.texts))
233
+ return spans
234
+
235
+ async def adiscover(self, document) -> list[BoundarySpan]:
236
+ # ASYNC-B2a: the agentic (RLM) discoverer is the optional path (production uses SingleCall); run its sync
237
+ # agent off the event loop. The deadline-critical production discoverer (SingleCall) is truly async.
238
+ return await asyncio.to_thread(self.discover, document)
239
+
240
+
241
+ def _final_text(messages) -> str:
242
+ """The model's final answer text (the last non-empty assistant message, or the last eval result)."""
243
+ for message in reversed(messages):
244
+ if isinstance(message, AIMessage) and (message.text or "").strip():
245
+ return message.text
246
+ for message in reversed(messages):
247
+ if isinstance(message, ToolMessage) and message.name == "eval":
248
+ return str(message.content)
249
+ return ""
250
+
251
+
252
+ # --- single-call (non-agentic) boundary discovery + deterministic repair (CU-B4) ------------------
253
+
254
+ _SINGLE_CALL_PROMPT = (
255
+ "Below are {n} numbered structural items of a contract. Partition them into semantically coherent "
256
+ "clauses/sections: CONTIGUOUS groups of item indices, each one coherent clause or section (never split a "
257
+ "single clause), together covering EVERY item from 0 to {last} with no gap or overlap. Return the spans "
258
+ "as {{start_index, end_index}} (inclusive).\n\n{body}"
259
+ )
260
+
261
+
262
+ class _BoundaryList(BaseModel):
263
+ spans: list[BoundarySpan]
264
+
265
+
266
+ def repair_partition(raw: list[tuple[int, int]], n: int) -> list[BoundarySpan]:
267
+ """Deterministically repair ANY set of (start, end) index ranges into a VALID partition of [0, n): each
268
+ span START in (0, n) becomes a break-before point, and the partition is the contiguous, gap-free run of
269
+ item ranges between the (sorted, de-duplicated) breaks -- always covering every item 0..n-1, ordered,
270
+ non-empty. Robust to overlap / gap / out-of-range / start>end / unordered / duplicate input, because a
271
+ single LLM call (unlike the agentic coverage-tail) does not guarantee a clean partition. No valid break ->
272
+ one span over the whole document (valid, if coarse)."""
273
+ if n <= 0:
274
+ return []
275
+ breaks = sorted({int(s) for (s, _e) in raw if 0 < int(s) < n})
276
+ bounds = [0, *breaks, n]
277
+ return [BoundarySpan(start_index=bounds[i], end_index=bounds[i + 1] - 1) for i in range(len(bounds) - 1)]
278
+
279
+
280
+ class SingleCallBoundaryDiscoverer:
281
+ """A NON-agentic boundary discoverer: ONE structured LLM call -> a boundary partition, deterministically
282
+ repaired (`repair_partition`) to a valid partition. For well-structured documents (CUAD contracts) this
283
+ replaces the agentic `SeamBoundaryDiscoverer` at ~100x less cost/latency (measured 3.4s vs 5-9min) with
284
+ equal clause integrity. Uses the GENERAL role (Gemma-4-class). No interpreter -> no process-wide lock ->
285
+ ordinary async concurrency (no process pool needed). `structured_factory` is injectable for tests."""
286
+
287
+ def __init__(self, model_id: str | None = None, *, structured_factory=build_structured) -> None:
288
+ self._model_id = model_id or model_for(ModelRole.GENERAL)
289
+ self._factory = structured_factory
290
+
291
+ def _prompt(self, document) -> tuple[str | None, int]:
292
+ items = _document_items(document)
293
+ n = len(items)
294
+ if n == 0:
295
+ return None, 0
296
+ body = "\n".join(f"[{it['index']}] {it['text'][:140]}" for it in items)
297
+ return _SINGLE_CALL_PROMPT.format(n=n, last=n - 1, body=body), n
298
+
299
+ # ADR-0058 side-fix (issue 0004): name the stage so a deadline warning says WHICH call was cancelled.
300
+ _STAGE = "semantic_chunking.discover"
301
+
302
+ def discover(self, document) -> list[BoundarySpan]:
303
+ prompt, n = self._prompt(document)
304
+ if prompt is None:
305
+ return []
306
+ out = self._factory(self._model_id, _BoundaryList, label=self._STAGE).invoke(prompt)
307
+ return repair_partition([(s.start_index, s.end_index) for s in out.spans], n)
308
+
309
+ async def adiscover(self, document) -> list[BoundarySpan]:
310
+ # ASYNC-B2a (ADR-0057): the structured boundary call on the async seam (true wall-clock deadline).
311
+ prompt, n = self._prompt(document)
312
+ if prompt is None:
313
+ return []
314
+ out = await self._factory(self._model_id, _BoundaryList, label=self._STAGE).ainvoke(prompt)
315
+ return repair_partition([(s.start_index, s.end_index) for s in out.spans], n)
316
+
317
+
318
+ _CUT_PROMPT = (
319
+ "Below are {n} numbered structural items of a document (indices 0 to {last}). Group CONTIGUOUS items into "
320
+ "semantically coherent clauses/sections (never split a single clause). List the item index where EACH new "
321
+ "chunk BEGINS -- the first item of every clause/section, in increasing order; index 0 always begins the "
322
+ "first chunk.\n\n{body}"
323
+ )
324
+
325
+
326
+ class _CutIndices(BaseModel):
327
+ """CHUNK-3 (ADR-0058): the boundary output as a FLAT list of chunk-START item indices (a `list[int]`), so it
328
+ fits `tag_structured`'s flat-schema scope -- no nested `list[BaseModel]`. `repair_partition` forces full,
329
+ valid coverage regardless of what the model returns (missing 0, out-of-range, dupes, unordered)."""
330
+
331
+ cuts: list[int] = Field(default_factory=list,
332
+ description="the item index where each new chunk begins, one per line")
333
+
334
+
335
+ def _cuts_to_spans(cuts: list[int], n: int) -> list[BoundarySpan]:
336
+ """A flat cut-index list (chunk-start indices) -> a valid `BoundarySpan` partition. Each cut is a
337
+ break-before point; `repair_partition` sorts/de-dupes/drops out-of-range and guarantees a contiguous,
338
+ gap-free partition covering 0..n-1 (so a bad model list can never lose text)."""
339
+ return repair_partition([(c, c) for c in cuts], n)
340
+
341
+
342
+ class TagBoundaryDiscoverer:
343
+ """CHUNK-3 (ADR-0058): boundary discovery via CLIENT-SIDE tag-parse (`build_tag_structured`, ADR-0045) over
344
+ the FLAT `_CutIndices` contract -- the model emits chunk-start indices in light XML tags, parsed on our side.
345
+ Unlike the server-side guided-decoding `_BoundaryList` call (which ran away past the 180s deadline on
346
+ self-hosted Granite), a free-text tag call terminates cleanly. Seam-compatible; CHUNK-4's per-section
347
+ fallback runs it over one section's items at a time (small, bounded input). `structured_factory` injectable."""
348
+
349
+ _STAGE = "semantic_chunking.discover"
350
+
351
+ def __init__(self, model_id: str | None = None, *, structured_factory=build_tag_structured) -> None:
352
+ self._model_id = model_id or model_for(ModelRole.GENERAL)
353
+ self._factory = structured_factory
354
+
355
+ def _prompt(self, document) -> tuple[str | None, int]:
356
+ items = _document_items(document)
357
+ n = len(items)
358
+ if n == 0:
359
+ return None, 0
360
+ body = "\n".join(f"[{it['index']}] {it['text'][:140]}" for it in items)
361
+ return _CUT_PROMPT.format(n=n, last=n - 1, body=body), n
362
+
363
+ def discover(self, document) -> list[BoundarySpan]:
364
+ prompt, n = self._prompt(document)
365
+ if prompt is None:
366
+ return []
367
+ out = self._factory(self._model_id, _CutIndices, label=self._STAGE).invoke(prompt)
368
+ return _cuts_to_spans(out.cuts, n)
369
+
370
+ async def adiscover(self, document) -> list[BoundarySpan]:
371
+ prompt, n = self._prompt(document)
372
+ if prompt is None:
373
+ return []
374
+ out = await self._factory(self._model_id, _CutIndices, label=self._STAGE).ainvoke(prompt)
375
+ return _cuts_to_spans(out.cuts, n)
376
+
377
+
378
+ class StructuralBoundaryDiscoverer:
379
+ """Tier-1 (ADR-0058, issue 0004): DETERMINISTIC boundaries from docling's structural labels -- a new chunk
380
+ starts at every heading item (SECTION_HEADER / TITLE / FIELD_HEADING, the `_HEADING_LABELS` authority docling
381
+ already assigns while parsing). NO model call, so cost is O(items) and INDEPENDENT of document length -- the
382
+ fix for the whole-document boundary call that blew the 180s deadline. Generic across any docling-parsed
383
+ document (contract, policy, regulation), because the structural labels are domain-neutral.
384
+
385
+ Only decides the semantic boundaries; the token cap (hard-split) and the lone-heading fold are enforced
386
+ downstream by `_finalize_chunks`, and `repair_partition` guarantees a valid partition. A document with NO
387
+ headings degrades to one span (then cap-split) -- the case CHUNK-4's per-section model fallback improves."""
388
+
389
+ respects_structure = True # 0006-A: authoritative section boundaries -> finalize keeps each section, does NOT
390
+ # apply the 1000-char prose floor that would coalesce short clauses into one chunk.
391
+
392
+ def _starts(self, document) -> list[int]:
393
+ # boundary START indices: 0, plus every heading-labelled item's index (deduped, sorted).
394
+ return sorted({0} | {i for i, item in enumerate(document.texts)
395
+ if getattr(item, "label", None) in _HEADING_LABELS})
396
+
397
+ def discover(self, document) -> list[BoundarySpan]:
398
+ n = len(document.texts)
399
+ if n == 0:
400
+ return []
401
+ starts = self._starts(document)
402
+ pairs = [(starts[k], (starts[k + 1] - 1 if k + 1 < len(starts) else n - 1)) for k in range(len(starts))]
403
+ return repair_partition(pairs, n) # safety net; the partition is valid by construction
404
+
405
+ async def adiscover(self, document) -> list[BoundarySpan]:
406
+ return self.discover(document) # deterministic, no IO/model -> the async twin just delegates
407
+
408
+
409
+ class _SubDocument:
410
+ """A contiguous item-range viewed as a stand-alone document (`.texts`), so a per-section refine call runs the
411
+ model over ONLY that section's items (0-based indices) -- never the whole document."""
412
+
413
+ __slots__ = ("texts",)
414
+
415
+ def __init__(self, texts: list) -> None:
416
+ self.texts = texts
417
+
418
+
419
+ class StructuralModelFallbackDiscoverer:
420
+ """CHUNK-4 (ADR-0058, issue 0004, Tier-2 b1): structural boundaries first (deterministic, no model); any
421
+ section that would be HARD-SPLIT by the token cap (over-cap -- and structureless by construction, since the
422
+ structural pass already cut at every heading) is refined by a BOUNDED PER-SECTION model call
423
+ (`TagBoundaryDiscoverer`, tag-parse), run CONCURRENTLY. The model only ever sees ONE over-cap section at a
424
+ time, so cost never scales with document length. A section within the cap keeps its structural boundary (no
425
+ model call); a fully-structured document makes ZERO model calls. `structural`/`fallback` are injectable."""
426
+
427
+ respects_structure = True # 0006-A: structural section boundaries are authoritative (see StructuralBoundaryDiscoverer)
428
+
429
+ def __init__(self, model_id: str | None = None, *, token_cap: int = DEFAULT_TOKEN_CAP,
430
+ structural: Any = None, fallback: Any = None,
431
+ max_concurrency: int = DEFAULT_SUMMARY_CONCURRENCY) -> None:
432
+ self._cap_chars = token_cap * 4 # matches _finalize_chunks' hard-split trigger exactly
433
+ self._structural = structural if structural is not None else StructuralBoundaryDiscoverer()
434
+ self._fallback = fallback if fallback is not None else TagBoundaryDiscoverer(model_id)
435
+ self._max_concurrency = max_concurrency
436
+
437
+ def _needs_refine(self, document, span: BoundarySpan) -> bool:
438
+ # exactly the multi-item sections _finalize_chunks would hard-split (fixed-size, SPEC-forbidden). A
439
+ # single over-cap item cannot be split by boundaries -> left for finalize's hard-split.
440
+ return span.end_index > span.start_index and len(_join_span(document, span)) > self._cap_chars
441
+
442
+ def _map_back(self, sub_spans: list[BoundarySpan], span: BoundarySpan) -> list[BoundarySpan]:
443
+ offset = span.start_index
444
+ mapped = [BoundarySpan(start_index=s.start_index + offset, end_index=s.end_index + offset)
445
+ for s in sub_spans]
446
+ return mapped or [span] # a non-splittable section keeps its span (finalize hard-splits it)
447
+
448
+ def discover(self, document) -> list[BoundarySpan]:
449
+ out: list[BoundarySpan] = []
450
+ for span in self._structural.discover(document):
451
+ if self._needs_refine(document, span):
452
+ sub = _SubDocument(document.texts[span.start_index:span.end_index + 1])
453
+ out.extend(self._map_back(self._fallback.discover(sub), span))
454
+ else:
455
+ out.append(span)
456
+ return out
457
+
458
+ async def adiscover(self, document) -> list[BoundarySpan]:
459
+ base = await self._structural.adiscover(document)
460
+ sem = asyncio.Semaphore(self._max_concurrency)
461
+
462
+ async def _one(span: BoundarySpan) -> list[BoundarySpan]:
463
+ if not self._needs_refine(document, span):
464
+ return [span]
465
+ sub = _SubDocument(document.texts[span.start_index:span.end_index + 1])
466
+ async with sem: # bound the concurrent per-section model calls
467
+ sub_spans = await self._fallback.adiscover(sub)
468
+ return self._map_back(sub_spans, span)
469
+
470
+ groups = await asyncio.gather(*(_one(span) for span in base)) # order preserved
471
+ return [sp for group in groups for sp in group]
472
+
473
+
474
+ @runtime_checkable
475
+ class Summarizer(Protocol):
476
+ """The per-chunk summarization seam."""
477
+
478
+ def summarize(self, text: str) -> str: ...
479
+
480
+
481
+ class _Summary(BaseModel):
482
+ summary: str
483
+
484
+
485
+ class SeamSummarizer:
486
+ """The real summarizer: a structured-output call through the model-profile seam.
487
+
488
+ Uses the SUMMARIZATION role (a smaller model, FR-I.6). Structured output plus the seam's
489
+ temperature-zero base keep summaries stable per call.
490
+ """
491
+
492
+ def __init__(self, model_id: str | None = None) -> None:
493
+ self._model_id = model_id or model_for(ModelRole.SUMMARIZATION)
494
+
495
+ def summarize(self, text: str) -> str:
496
+ result = build_structured(self._model_id, _Summary).invoke(f"{_SUMMARIZE_PROMPT}\n\n{text}")
497
+ return result.summary
498
+
499
+
500
+ def _estimate_tokens(text: str) -> int:
501
+ """A tokenizer-free token estimate (~4 chars/token)."""
502
+ return max(1, len(text) // 4)
503
+
504
+
505
+ def _hard_split(text: str, token_cap: int) -> list[str]:
506
+ """Split text that alone exceeds the cap into <= cap-sized pieces (by chars)."""
507
+ cap_chars = token_cap * 4
508
+ return [text[i : i + cap_chars] for i in range(0, len(text), cap_chars)]
509
+
510
+
511
+ _SEP = "\n\n"
512
+
513
+
514
+ def _chunk_offsets(texts: list[str]) -> list[tuple[int, int]]:
515
+ """CU-B1: the (start, end) char range of each finalized chunk text in the canonical document text
516
+ (the `_SEP`-join of the chunk texts). Deterministic cumulative positions; by construction
517
+ `_SEP.join(texts)[start:end] == texts[i]`."""
518
+ offsets: list[tuple[int, int]] = []
519
+ pos = 0
520
+ for text in texts:
521
+ offsets.append((pos, pos + len(text)))
522
+ pos += len(text) + len(_SEP) # the trailing +_SEP on the last is unused (no trailing separator)
523
+ return offsets
524
+
525
+
526
+ def canonical_document_text(chunks: list[Chunk]) -> str:
527
+ """CU-B1: the document text the highlight offsets index into -- the `_SEP`-join of the finalized chunk
528
+ texts. The app renders and highlights on THIS (whitespace-normalized) reconstruction; each chunk/span's
529
+ `doc_start`/`doc_end` slice it byte-faithfully. Not the raw parsed text (the chunker strips/merges)."""
530
+ return _SEP.join(c.text for c in chunks)
531
+
532
+
533
+ def _validate_partition(spans: list[BoundarySpan], n_items: int) -> None:
534
+ """The discoverer must return a contiguous, gap-free partition covering every item (no lost text)."""
535
+ if n_items == 0:
536
+ return
537
+ if not spans:
538
+ raise BoundaryValidationError("boundary discoverer returned no spans")
539
+ ordered = sorted(spans, key=lambda s: s.start_index)
540
+ if ordered[0].start_index != 0 or ordered[-1].end_index != n_items - 1:
541
+ raise BoundaryValidationError("boundary spans do not cover the whole document")
542
+ for prev, nxt in zip(ordered, ordered[1:]):
543
+ if nxt.start_index != prev.end_index + 1:
544
+ raise BoundaryValidationError("boundary spans overlap or leave a gap")
545
+ if prev.end_index < prev.start_index:
546
+ raise BoundaryValidationError("boundary span end precedes its start")
547
+
548
+
549
+ def _join_span(document, span: BoundarySpan) -> str:
550
+ """Join the non-empty text of the items a span covers (the deterministic-given-boundaries join)."""
551
+ parts = []
552
+ for i in range(span.start_index, span.end_index + 1):
553
+ text = (getattr(document.texts[i], "text", "") or "").strip()
554
+ if text:
555
+ parts.append(text)
556
+ return _SEP.join(parts)
557
+
558
+
559
+ def _finalize_chunks(document, spans: list[BoundarySpan], token_cap: int,
560
+ *, respect_structure: bool = False) -> list[str]:
561
+ """Deterministic given the boundaries: join each span, enforce the cap, apply the T-CHK floor/merge.
562
+
563
+ Runs after the discoverer chooses boundaries and is independent of how they were found. The token cap
564
+ always wins (an over-cap span is hard-split). Then either:
565
+ - prose/heuristic boundaries (default): below-floor spans fold into a neighbour so a discoverer that
566
+ returned a lone-heading or two-token span does not become a tiny fragment (T-CHK); or
567
+ - `respect_structure` (ADR-0058/0006-A, set by the structural discoverers): the boundaries are
568
+ AUTHORITATIVE docling sections, so the 1000-char prose floor does NOT apply -- each complete section
569
+ keeps its own chunk however short -- and only a bare heading is folded so it never stands alone."""
570
+ cap_chars = token_cap * 4
571
+ texts: list[str] = []
572
+ for span in spans:
573
+ text = _join_span(document, span)
574
+ if not text:
575
+ continue
576
+ if len(text) > cap_chars: # cap safety net: a single over-cap span is hard-split
577
+ texts.extend(_hard_split(text, token_cap))
578
+ else:
579
+ texts.append(text)
580
+ if respect_structure:
581
+ return _merge_bare_headings(texts, token_cap)
582
+ floor = min(MIN_CHUNK_CHARS, cap_chars) # a tiny cap (tests) cannot demand a larger floor
583
+ return _merge_below_floor(texts, floor, cap_chars)
584
+
585
+
586
+ def _merge_bare_headings(chunks: list[str], token_cap: int) -> list[str]:
587
+ """Structure-first (ADR-0058 / 0006-A): every docling section is a legitimate chunk, however short, so the
588
+ 1000-char prose floor does NOT apply -- a complete short clause keeps its own chunk. Only a BARE HEADING (a
589
+ lone section header / title with no operative body) is folded into a neighbour, so a heading never becomes a
590
+ standalone chunk (issue 0006 Problem 2 at chunk granularity): leading heading(s) fold FORWARD into the next
591
+ section, a trailing heading folds BACKWARD. The token cap still wins (a rare heading+near-cap section is
592
+ hard-split), so no chunk exceeds the cap and no text is lost."""
593
+ if len(chunks) <= 1:
594
+ return chunks
595
+ cap_chars = token_cap * 4
596
+
597
+ def _capped(text: str) -> list[str]:
598
+ return _hard_split(text, token_cap) if len(text) > cap_chars else [text]
599
+
600
+ merged: list[str] = []
601
+ carry = "" # leading bare heading(s) awaiting the next body-bearing section
602
+ for c in chunks:
603
+ if _is_bare_heading(c) or len(c.strip()) < _MIN_NONEMPTY_CHARS:
604
+ carry = f"{carry}{_SEP}{c}" if carry else c
605
+ continue
606
+ merged.extend(_capped(f"{carry}{_SEP}{c}" if carry else c))
607
+ carry = ""
608
+ if carry: # trailing heading(s) with no following body -> fold backward into the last section
609
+ if merged:
610
+ merged[-1:] = _capped(f"{merged[-1]}{_SEP}{carry}")
611
+ else:
612
+ merged.append(carry) # a document that is ONLY headings (degenerate; nothing to fold into)
613
+ return merged
614
+
615
+
616
+ def _merge_below_floor(chunks: list[str], floor: int, cap_chars: int) -> list[str]:
617
+ """Fold any below-floor chunk into an adjacent chunk (backward first, then the first chunk forward),
618
+ never exceeding the cap. Eliminates heading-only/near-empty fragments; a lone chunk is left as-is."""
619
+ if len(chunks) <= 1:
620
+ return chunks
621
+ merged: list[str] = []
622
+ for c in chunks:
623
+ if merged and len(c) < floor and len(merged[-1]) + len(_SEP) + len(c) <= cap_chars:
624
+ merged[-1] = merged[-1] + _SEP + c # fold backward into the previous chunk
625
+ else:
626
+ merged.append(c)
627
+ if len(merged) > 1 and len(merged[0]) < floor and len(merged[0]) + len(_SEP) + len(merged[1]) <= cap_chars:
628
+ merged[1] = merged[0] + _SEP + merged[1] # a below-floor first chunk folds forward
629
+ merged.pop(0)
630
+ # a below-floor LAST chunk that could not fold backward within the cap (a small residual after a full
631
+ # chunk): fold it back if it fits, else rebalance the pair into two >= floor halves, so the floor
632
+ # holds even when a discoverer emits a tiny trailing span (T-CHK). Only when the floor is genuinely
633
+ # below the cap (real usage: floor 1000 << cap); a degenerate floor==cap leaves a cap-forced residual.
634
+ if len(merged) > 1 and len(merged[-1]) < floor and floor < cap_chars:
635
+ tail = merged.pop()
636
+ combined = merged[-1] + _SEP + tail
637
+ if len(combined) <= cap_chars:
638
+ merged[-1] = combined
639
+ else:
640
+ half = len(combined) // 2 # both halves land in [floor, cap] since floor < cap and combined > cap
641
+ merged[-1] = combined[:half]
642
+ merged.append(combined[half:])
643
+ return merged
644
+
645
+
646
+ def _validate_boundaries(chunks: list[Chunk], token_cap: int) -> None:
647
+ if not chunks:
648
+ raise BoundaryValidationError("no chunks produced")
649
+ ids = [c.chunk_id for c in chunks]
650
+ if len(ids) != len(set(ids)):
651
+ raise BoundaryValidationError("duplicate chunk_ids")
652
+ for index, chunk_ in enumerate(chunks):
653
+ if chunk_.chunk_index != index:
654
+ raise BoundaryValidationError(f"non-sequential chunk_index at {index}")
655
+ if not chunk_.text.strip():
656
+ raise BoundaryValidationError(f"empty chunk at {index}")
657
+ if len(chunks) > 1 and len(chunk_.text.strip()) < _MIN_NONEMPTY_CHARS:
658
+ raise BoundaryValidationError(f"near-empty (heading-only) chunk at {index} (T-CHK)")
659
+ if chunk_.token_estimate > token_cap:
660
+ raise BoundaryValidationError(f"chunk {index} exceeds token cap ({token_cap})")
661
+
662
+
663
+ async def _summarize_all(
664
+ texts: list[str], summarizer: Summarizer, max_concurrency: int
665
+ ) -> list[str]:
666
+ """Summarize chunk texts concurrently, bounded by a semaphore (the embedding capability's
667
+ async+backpressure pattern, T19). Each blocking `summarize()` runs in a thread so the network-bound
668
+ calls overlap; the semaphore caps in-flight requests. `gather` preserves order, so each summary lines
669
+ up with its text. Summarization is a flat map (independent per chunk), which is the right shape."""
670
+ semaphore = asyncio.Semaphore(max_concurrency)
671
+
672
+ async def _one(text: str) -> str:
673
+ async with semaphore: # backpressure
674
+ return await asyncio.to_thread(summarizer.summarize, text)
675
+
676
+ return list(await asyncio.gather(*(_one(text) for text in texts)))
677
+
678
+
679
+ def chunk(
680
+ parsed: ParsedDocument,
681
+ *,
682
+ summarizer: Summarizer,
683
+ cache_dir: Path,
684
+ discoverer: Optional[BoundaryDiscoverer] = None,
685
+ token_cap: int = DEFAULT_TOKEN_CAP,
686
+ max_concurrency: int = DEFAULT_SUMMARY_CONCURRENCY,
687
+ ) -> ChunkManifest:
688
+ """Chunk a parsed document into a `ChunkManifest`, content-hash gated (chunked once, then persisted).
689
+
690
+ An LLM `discoverer` chooses the semantic boundaries (defaults to the live `SeamBoundaryDiscoverer`;
691
+ hermetic tests inject a stub); `_finalize_chunks` turns its spans into capped, floor-respecting chunk
692
+ texts; each is summarized concurrently; boundaries are validated; the manifest is cached. If a manifest
693
+ for this document's content hash already exists it is reused (the gate; no re-chunk, no LLM call).
694
+ """
695
+ cached, manifest_path, document, disc = _chunk_prepare(parsed, cache_dir, discoverer, token_cap)
696
+ if cached is not None:
697
+ return cached
698
+ spans = disc.discover(document)
699
+ texts = _chunk_texts(document, spans, token_cap, respect_structure=getattr(disc, "respects_structure", False))
700
+ summaries = asyncio.run(_summarize_all(texts, summarizer, max_concurrency))
701
+ return _chunk_manifest(parsed, texts, summaries, token_cap, manifest_path)
702
+
703
+
704
+ async def achunk(
705
+ parsed: ParsedDocument,
706
+ *,
707
+ summarizer: Summarizer,
708
+ cache_dir: Path,
709
+ discoverer: Optional[BoundaryDiscoverer] = None,
710
+ token_cap: int = DEFAULT_TOKEN_CAP,
711
+ max_concurrency: int = DEFAULT_SUMMARY_CONCURRENCY,
712
+ ) -> ChunkManifest:
713
+ """ASYNC-B2a (ADR-0057): the async twin of `chunk`. Awaits the discoverer's async boundary call (the true
714
+ wall-clock deadline) and the concurrent summarize directly (no `asyncio.run` island), so it runs on the
715
+ ingestion event loop. Same content-hash gate + manifest as `chunk`."""
716
+ cached, manifest_path, document, disc = _chunk_prepare(parsed, cache_dir, discoverer, token_cap)
717
+ if cached is not None:
718
+ return cached
719
+ spans = await disc.adiscover(document)
720
+ texts = _chunk_texts(document, spans, token_cap, respect_structure=getattr(disc, "respects_structure", False))
721
+ summaries = await _summarize_all(texts, summarizer, max_concurrency)
722
+ return _chunk_manifest(parsed, texts, summaries, token_cap, manifest_path)
723
+
724
+
725
+ def _chunk_prepare(parsed: ParsedDocument, cache_dir: Path, discoverer: Optional[BoundaryDiscoverer],
726
+ token_cap: int) -> tuple[Optional[ChunkManifest], Path, Any, Any]:
727
+ """Shared chunk/achunk head: the content-hash gate (return the cached manifest if present) and, otherwise,
728
+ the loaded document + resolved discoverer."""
729
+ cache_dir.mkdir(parents=True, exist_ok=True)
730
+ manifest_path = cache_dir / f"{parsed.source_doc_id}.{parsed.content_hash[:16]}.chunks.json"
731
+ if manifest_path.exists(): # content-hash gate
732
+ return ChunkManifest.model_validate_json(manifest_path.read_text(encoding="utf-8")), manifest_path, None, None
733
+ document = _as_content_view(load_document(parsed)) # issue 0014: chunk the reading-order body (incl. tables)
734
+ disc = discoverer if discoverer is not None else SeamBoundaryDiscoverer(token_cap=token_cap)
735
+ return None, manifest_path, document, disc
736
+
737
+
738
+ def _chunk_texts(document: Any, spans: list[BoundarySpan], token_cap: int,
739
+ *, respect_structure: bool = False) -> list[str]:
740
+ """Shared: validate the boundary partition and finalize it into capped chunk texts."""
741
+ _validate_partition(spans, len(document.texts))
742
+ return _finalize_chunks(document, spans, token_cap, respect_structure=respect_structure)
743
+
744
+
745
+ def chunk_texts(document: Any, *, discoverer: Optional[BoundaryDiscoverer] = None,
746
+ token_cap: int = DEFAULT_TOKEN_CAP, model_id: str | None = None) -> list[str]:
747
+ """SEG-2/SEG-5: the shared semantic-chunk seam WITHOUT the retrieval-only cache/summarize steps -- for a
748
+ TRANSIENT document (a compliance subject) that is chunked but never embedded or persisted. Reuses the exact
749
+ ingestion path: a `discoverer` partitions the document's structural items, then `_chunk_texts` validates +
750
+ caps + floors + merges into coherent chunk texts. `document` is any parsed doc exposing `.texts` (docling
751
+ items).
752
+
753
+ SEG-5: the default discoverer is `StructuralModelFallbackDiscoverer` -- the SAME discoverer PRODUCTION
754
+ INGESTION uses (`contract_ingestion_pipeline` passes exactly this). Structural boundaries first (deterministic,
755
+ cut at every heading, ZERO model calls for a structured doc); only an over-cap section gets a BOUNDED
756
+ per-section model refinement, run concurrently, seeing one section at a time -- so cost never scales with
757
+ document length (no size bottleneck), NO subject-specific large-doc code. RLM (`SeamBoundaryDiscoverer`) is a
758
+ FUTURE escalation, as on the ingestion side, not wired here."""
759
+ document = _as_content_view(document) # issue 0014: segment the reading-order body (incl. tables)
760
+ disc = discoverer if discoverer is not None else StructuralModelFallbackDiscoverer(model_id)
761
+ spans = disc.discover(document)
762
+ return _chunk_texts(document, spans, token_cap, respect_structure=getattr(disc, "respects_structure", False))
763
+
764
+
765
+ async def achunk_texts(document: Any, *, discoverer: Optional[BoundaryDiscoverer] = None,
766
+ token_cap: int = DEFAULT_TOKEN_CAP, model_id: str | None = None) -> list[str]:
767
+ """ASYNC twin of `chunk_texts` (SEG-2/SEG-5): awaits the discoverer's async boundary call, so it runs on the
768
+ subject verdict's event loop. Same reuse, same default = the production ingestion `StructuralModelFallbackDiscoverer`."""
769
+ document = _as_content_view(document) # issue 0014: segment the reading-order body (incl. tables)
770
+ disc = discoverer if discoverer is not None else StructuralModelFallbackDiscoverer(model_id)
771
+ spans = await disc.adiscover(document)
772
+ return _chunk_texts(document, spans, token_cap, respect_structure=getattr(disc, "respects_structure", False))
773
+
774
+
775
+ def _chunk_manifest(parsed: ParsedDocument, texts: list[str], summaries: list[str], token_cap: int,
776
+ manifest_path: Path) -> ChunkManifest:
777
+ """Shared chunk/achunk tail: assemble + validate the chunks, write and return the manifest."""
778
+ offsets = _chunk_offsets(texts) # CU-B1: char ranges in the canonical document text
779
+ chunks = [
780
+ Chunk(
781
+ chunk_id=ChunkId.of(parsed.source_doc_id, index, text).value,
782
+ chunk_index=index,
783
+ text=text,
784
+ summary=summary,
785
+ token_estimate=_estimate_tokens(text),
786
+ doc_start=offsets[index][0],
787
+ doc_end=offsets[index][1],
788
+ )
789
+ for index, (text, summary) in enumerate(zip(texts, summaries))
790
+ ]
791
+ _validate_boundaries(chunks, token_cap)
792
+ manifest = ChunkManifest(
793
+ source_doc_id=parsed.source_doc_id, content_hash=parsed.content_hash,
794
+ token_cap=token_cap, chunks=chunks)
795
+ manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
796
+ return manifest
797
+
798
+
799
+ def register_rlm_chunking(registry: CapabilityRegistry) -> None:
800
+ """Register the RLM chunking capability (FR-I.1) as an `agent_skill` (it applies `rlm_method`)."""
801
+ registry.register(
802
+ "rlm_chunking",
803
+ contract=ChunkManifest,
804
+ kind="agent_skill",
805
+ display_name="RLM chunking",
806
+ )
807
+
808
+