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.
- rag_wright/__init__.py +13 -0
- rag_wright/api/__init__.py +33 -0
- rag_wright/api/config.py +59 -0
- rag_wright/api/discover.py +70 -0
- rag_wright/api/documents.py +39 -0
- rag_wright/api/ids.py +31 -0
- rag_wright/api/invoke.py +99 -0
- rag_wright/api/kg.py +61 -0
- rag_wright/api/mcp.py +94 -0
- rag_wright/api/usage.py +30 -0
- rag_wright/api/workspace.py +85 -0
- rag_wright/capabilities/__init__.py +8 -0
- rag_wright/capabilities/answer_generator.py +427 -0
- rag_wright/capabilities/ard.py +286 -0
- rag_wright/capabilities/assertion_extraction.py +79 -0
- rag_wright/capabilities/chunk_read.py +58 -0
- rag_wright/capabilities/chunk_write.py +163 -0
- rag_wright/capabilities/claim_extraction.py +153 -0
- rag_wright/capabilities/clause_exception_linking.py +117 -0
- rag_wright/capabilities/compliance_judgment.py +322 -0
- rag_wright/capabilities/compliance_store.py +87 -0
- rag_wright/capabilities/contract_kg_serve.py +156 -0
- rag_wright/capabilities/contract_kg_store.py +251 -0
- rag_wright/capabilities/dg_extraction.py +585 -0
- rag_wright/capabilities/disambiguation.py +163 -0
- rag_wright/capabilities/document_parse.py +87 -0
- rag_wright/capabilities/document_scope.py +49 -0
- rag_wright/capabilities/embedding.py +164 -0
- rag_wright/capabilities/embedding_profiles.py +43 -0
- rag_wright/capabilities/entity_resolution.py +154 -0
- rag_wright/capabilities/fusion.py +64 -0
- rag_wright/capabilities/graph_extraction.py +243 -0
- rag_wright/capabilities/graph_query.py +73 -0
- rag_wright/capabilities/graph_storage.py +111 -0
- rag_wright/capabilities/highlight_serve.py +142 -0
- rag_wright/capabilities/hybrid_search.py +65 -0
- rag_wright/capabilities/invoke.py +31 -0
- rag_wright/capabilities/jev_decision.py +38 -0
- rag_wright/capabilities/manifests.py +872 -0
- rag_wright/capabilities/okf_navigate.py +456 -0
- rag_wright/capabilities/parsing.py +286 -0
- rag_wright/capabilities/property_boosted_retrieval.py +125 -0
- rag_wright/capabilities/query_function_classifier.py +94 -0
- rag_wright/capabilities/query_understanding.py +109 -0
- rag_wright/capabilities/registry.py +262 -0
- rag_wright/capabilities/remote_encoders.py +94 -0
- rag_wright/capabilities/requirement_extraction.py +247 -0
- rag_wright/capabilities/reranking.py +123 -0
- rag_wright/capabilities/retrieval_core.py +126 -0
- rag_wright/capabilities/rlm_chunking.py +808 -0
- rag_wright/capabilities/rlm_synthesis.py +316 -0
- rag_wright/capabilities/scan_quality.py +136 -0
- rag_wright/capabilities/span_relevance_judgment.py +191 -0
- rag_wright/capabilities/vision_to_text.py +85 -0
- rag_wright/capabilities/vlm_ocr.py +85 -0
- rag_wright/contracts/__init__.py +6 -0
- rag_wright/contracts/chunk.py +79 -0
- rag_wright/contracts/compliance.py +303 -0
- rag_wright/contracts/contract_meta.py +27 -0
- rag_wright/contracts/extraction.py +130 -0
- rag_wright/contracts/function.py +167 -0
- rag_wright/contracts/function_routing.py +91 -0
- rag_wright/contracts/highlight.py +74 -0
- rag_wright/contracts/identifiers.py +153 -0
- rag_wright/contracts/jurisdiction.py +96 -0
- rag_wright/contracts/ontology.py +142 -0
- rag_wright/contracts/property.py +201 -0
- rag_wright/contracts/provenance.py +78 -0
- rag_wright/contracts/query_intent.py +53 -0
- rag_wright/contracts/span.py +76 -0
- rag_wright/contracts/value_match.py +84 -0
- rag_wright/corpus/__init__.py +0 -0
- rag_wright/corpus/canonicalize.py +116 -0
- rag_wright/corpus/cuad.py +153 -0
- rag_wright/corpus/cuad_ingestion.py +72 -0
- rag_wright/corpus/document_parser.py +299 -0
- rag_wright/corpus/edgar.py +231 -0
- rag_wright/corpus/gcs_ingestion.py +120 -0
- rag_wright/corpus/http.py +110 -0
- rag_wright/corpus/selection.py +152 -0
- rag_wright/mcp/__init__.py +11 -0
- rag_wright/mcp/compliance_server.py +299 -0
- rag_wright/mcp/intra_document_qa_server.py +170 -0
- rag_wright/mcp/relational_qa_server.py +171 -0
- rag_wright/mcp/session_store.py +64 -0
- rag_wright/mcp/typed_property_retrieval_server.py +191 -0
- rag_wright/models/__init__.py +8 -0
- rag_wright/models/profiles.py +331 -0
- rag_wright/models/seam.py +497 -0
- rag_wright/models/tag_structured.py +285 -0
- rag_wright/models/tracing.py +179 -0
- rag_wright/models/usage.py +102 -0
- rag_wright/okf/__init__.py +11 -0
- rag_wright/okf/compile.py +292 -0
- rag_wright/okf/document.py +47 -0
- rag_wright/okf/enrich.py +176 -0
- rag_wright/okf/links.py +190 -0
- rag_wright/okf/lint.py +105 -0
- rag_wright/ontology/__init__.py +6 -0
- rag_wright/ontology/_generated_template_meta.py +60 -0
- rag_wright/ontology/_generated_vocab.py +52 -0
- rag_wright/ontology/clause_template.py +964 -0
- rag_wright/ontology/codegen.py +84 -0
- rag_wright/ontology/compliance_bridge.ttl +186 -0
- rag_wright/ontology/contract_bridge.ttl +2685 -0
- rag_wright/ontology/contract_taxonomy.py +24 -0
- rag_wright/ontology/derive.py +58 -0
- rag_wright/ontology/loader.py +435 -0
- rag_wright/ontology/packs/ftc_16cfr255.ttl +29 -0
- rag_wright/ontology/registry.py +87 -0
- rag_wright/ontology/template_introspect.py +100 -0
- rag_wright/py.typed +0 -0
- rag_wright/reference/__init__.py +2 -0
- rag_wright/reference/compliance.py +41 -0
- rag_wright/reference/contract_seam.py +123 -0
- rag_wright/skills/__init__.py +7 -0
- rag_wright/skills/claim_extraction/SKILL.md +47 -0
- rag_wright/skills/claim_extraction/__init__.py +1 -0
- rag_wright/skills/claim_extraction/template.py +50 -0
- rag_wright/skills/compliance_judgment/SKILL.md +59 -0
- rag_wright/skills/corpus_ingest/SKILL.md +106 -0
- rag_wright/skills/extraction_semantic_judge/SKILL.md +51 -0
- rag_wright/skills/extraction_semantic_judge/__init__.py +1 -0
- rag_wright/skills/generation/SKILL.md +64 -0
- rag_wright/skills/generation/__init__.py +1 -0
- rag_wright/skills/generic_compliance_judgment/SKILL.md +58 -0
- rag_wright/skills/okf_navigate/SKILL.md +137 -0
- rag_wright/skills/requirement_extraction/SKILL.md +47 -0
- rag_wright/skills/requirement_extraction/__init__.py +1 -0
- rag_wright/skills/requirement_extraction/template.py +50 -0
- rag_wright/skills/rlm/SKILL.md +186 -0
- rag_wright/skills/rlm/__init__.py +31 -0
- rag_wright/skills/rlm/agent.py +292 -0
- rag_wright/skills/span_relevance_judgment/SKILL.md +67 -0
- rag_wright/skills/vision_to_text/SKILL.md +36 -0
- rag_wright/skills/vision_to_text/__init__.py +1 -0
- rag_wright/spans/__init__.py +1 -0
- rag_wright/spans/boundary.py +78 -0
- rag_wright/spans/clause_function_classifier.py +490 -0
- rag_wright/spans/clause_kg_extractor.py +337 -0
- rag_wright/spans/cuad_labels.py +81 -0
- rag_wright/spans/dim_classifier.py +158 -0
- rag_wright/spans/dim_fleet.json +411 -0
- rag_wright/spans/function_classifier.py +77 -0
- rag_wright/spans/function_families.py +62 -0
- rag_wright/spans/hybrid_classifier.py +103 -0
- rag_wright/spans/legalbert_classifier.py +83 -0
- rag_wright/spans/model_capabilities.py +107 -0
- rag_wright/spans/new_function_labels.py +111 -0
- rag_wright/spans/page_map.py +68 -0
- rag_wright/spans/property_extractor.py +365 -0
- rag_wright/spans/property_grounding.py +182 -0
- rag_wright/spans/reclassify.py +77 -0
- rag_wright/spans/scarce_function_labels.py +105 -0
- rag_wright/spans/segment.py +341 -0
- rag_wright/spans/semantic_judge.py +197 -0
- rag_wright/spans/symbolic_validation.py +131 -0
- rag_wright/spans/tag_clause_extractor.py +182 -0
- rag_wright/store/__init__.py +6 -0
- rag_wright/store/arcadedb.py +1135 -0
- rag_wright/store/chunk_text.py +66 -0
- rag_wright/store/seam.py +213 -0
- rag_wright/subgraphs/__init__.py +0 -0
- rag_wright/subgraphs/async_ingestion.py +204 -0
- rag_wright/subgraphs/compliance_check.py +1042 -0
- rag_wright/subgraphs/compliance_ingestion.py +306 -0
- rag_wright/subgraphs/contract_ingestion_pipeline.py +999 -0
- rag_wright/subgraphs/graph_extraction.py +102 -0
- rag_wright/subgraphs/intra_document_qa.py +328 -0
- rag_wright/subgraphs/observability.py +140 -0
- rag_wright/subgraphs/query_constraint_extraction.py +73 -0
- rag_wright/subgraphs/relational_qa.py +165 -0
- rag_wright/subgraphs/requirement_extraction.py +137 -0
- rag_wright/subgraphs/scaffold.py +65 -0
- rag_wright/subgraphs/semantic_chunking.py +183 -0
- rag_wright/subgraphs/typed_clause_extraction.py +172 -0
- rag_wright/subgraphs/typed_property_retrieval.py +278 -0
- rag_wright/util/__init__.py +1 -0
- rag_wright/util/concurrent.py +153 -0
- rag_wright/util/spacy_model.py +45 -0
- rag_wright-0.1.0.dist-info/METADATA +168 -0
- rag_wright-0.1.0.dist-info/RECORD +184 -0
- rag_wright-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
|