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,83 @@
1
+ """T56 (FR-R, ADR-0025): LegalBERT fine-tuned function classifier (the upgrade over the linear head).
2
+
3
+ A fine-tuned `nlpaueb/legal-bert-base-uncased` sequence classifier: an operative span's text -> one of the 41
4
+ CUAD clause types or NONE. Self-contained (its own tokenizer + encoder), sub-second local inference (MPS/CPU),
5
+ no LLM call. Chosen to make the classifier a controlled, high-accuracy variable (it captures fine legal
6
+ distinctions the frozen-embedding linear head could not, e.g. Cap-on-Liability vs Uncapped-Liability), so a
7
+ later downstream recall shortfall is attributable to the property/rerank stages, not the classifier.
8
+
9
+ The tokenizer + model are injected, so the classify plumbing (batching, argmax, id->label) is unit-tested with
10
+ fakes and no model download; training/loading use the real `transformers` classes (see
11
+ `scripts/train_legalbert_function.py`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import torch
20
+
21
+
22
+ class LegalBertFunctionClassifier:
23
+ """A fine-tuned LegalBERT sequence classifier over operative spans (predicts a CUAD type or NONE)."""
24
+
25
+ def __init__(self, model: Any, tokenizer: Any, *, device: str = "cpu", max_length: int = 256) -> None:
26
+ self._model = model.to(device).eval()
27
+ self._tokenizer = tokenizer
28
+ self._device = device
29
+ self._max_length = max_length
30
+
31
+ @classmethod
32
+ def load(cls, path: Path, *, device: str = "cpu") -> "LegalBertFunctionClassifier":
33
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
34
+
35
+ tokenizer = AutoTokenizer.from_pretrained(str(path))
36
+ model = AutoModelForSequenceClassification.from_pretrained(str(path))
37
+ return cls(model, tokenizer, device=device)
38
+
39
+ @torch.no_grad()
40
+ def classify(self, texts: list[str], *, batch_size: int = 32) -> list[str]:
41
+ """One label per span (a CUAD type or NONE). Empty input -> empty output. Batched forward on the device."""
42
+ if not texts:
43
+ return []
44
+ id2label = self._model.config.id2label
45
+ out: list[str] = []
46
+ for start in range(0, len(texts), batch_size):
47
+ batch = texts[start : start + batch_size]
48
+ enc = self._tokenizer(
49
+ batch, truncation=True, max_length=self._max_length, padding=True, return_tensors="pt"
50
+ ).to(self._device)
51
+ logits = self._model(**enc).logits
52
+ out.extend(str(id2label[int(i)]) for i in logits.argmax(dim=-1).tolist())
53
+ return out
54
+
55
+ @torch.no_grad()
56
+ def classify_topk(self, texts: list[str], *, k: int = 2, batch_size: int = 32) -> list[list[str]]:
57
+ """The top-`k` labels per span (highest logit first). Feeds the hybrid classifier's routing decision
58
+ (route to the LLM when the top-2 are confusable siblings). Empty input -> empty output."""
59
+ if not texts:
60
+ return []
61
+ id2label = self._model.config.id2label
62
+ out: list[list[str]] = []
63
+ for start in range(0, len(texts), batch_size):
64
+ batch = texts[start : start + batch_size]
65
+ enc = self._tokenizer(
66
+ batch, truncation=True, max_length=self._max_length, padding=True, return_tensors="pt"
67
+ ).to(self._device)
68
+ logits = self._model(**enc).logits
69
+ topk = logits.topk(min(k, logits.shape[-1]), dim=-1).indices.tolist()
70
+ out.extend([str(id2label[int(i)]) for i in row] for row in topk)
71
+ return out
72
+
73
+
74
+ def register_clause_function_classification(registry) -> None:
75
+ """CAP-REG-2: register `clause_function_classification` (model; fine-tuned LegalBERT classifier)."""
76
+ from rag_wright.contracts.function import FunctionClassification
77
+
78
+ registry.register(
79
+ "clause_function_classification",
80
+ contract=FunctionClassification,
81
+ kind="model",
82
+ display_name="Clause function classification (LegalBERT)",
83
+ )
@@ -0,0 +1,107 @@
1
+ """EP-RT-7 (ADR-0117): the SINGLE binding of model-capability name -> implementation.
2
+
3
+ This is the ONE place the engine's model capabilities (`clause_function_classification`, `clause_property_classification`)
4
+ are bound to their fleets and dispatched. It lives at the `spans/` layer -- below BOTH the engine API invoker
5
+ (`api/invoke.py`) and the ingestion pipeline (`subgraphs/`) -- so every consumer routes through it by name. No
6
+ capability's fleet is constructed anywhere else: production goes THROUGH the capability layer, never around it
7
+ (see memory route-production-through-capability-layer). Subgraph capabilities stay bound in `api/invoke.py` because
8
+ they are workspace-bound (need the handle's store/models); model capabilities are store-independent, so they bind
9
+ here where both the API and the pipeline can reach them.
10
+
11
+ Usage/cost is captured by the caller's ambient `api.measure_usage()` scope (EP-API-5); this wraps each dispatch in a
12
+ trace span only."""
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any, Callable
17
+
18
+ from rag_wright.capabilities.invoke import capability_impl
19
+ from rag_wright.models.tracing import traced_step
20
+
21
+ # --- cached fleets: loaded ONCE per process (heavy) ---
22
+
23
+ _DIM_REGISTRY: Any = None
24
+
25
+
26
+ def _dim_registry() -> Any:
27
+ """The 29-dim property-classifier fleet (shared Laya group agents + SetFit + abstain heads)."""
28
+ global _DIM_REGISTRY
29
+ if _DIM_REGISTRY is None:
30
+ from rag_wright.spans.dim_classifier import load_dim_registry
31
+
32
+ _DIM_REGISTRY = load_dim_registry()
33
+ return _DIM_REGISTRY
34
+
35
+
36
+ _SETFIT_CLAUSE: Any = None
37
+
38
+
39
+ def _setfit_clause_classifier() -> Any:
40
+ """The trained SetFit/LegalBERT clause-function soft-tagger."""
41
+ global _SETFIT_CLAUSE
42
+ if _SETFIT_CLAUSE is None:
43
+ from rag_wright.spans.clause_function_classifier import production_setfit_clause_classifier
44
+
45
+ _SETFIT_CLAUSE = production_setfit_clause_classifier()
46
+ return _SETFIT_CLAUSE
47
+
48
+
49
+ # --- the model-capability adapters: name -> (inputs -> output). Store-independent. ---
50
+
51
+ # The model-capability invoke factories: `(resources, inputs) -> result`, uniform with the subgraph factories
52
+ # (EP-CORE-2). Model capabilities are store-independent, so `resources` is ignored. These are the targets of the
53
+ # manifests' `impl_ref` ("rag_wright.spans.model_capabilities:clause_function_classification", etc.).
54
+
55
+ def clause_function_classification(resources: Any, inputs: dict) -> Any: # noqa: ARG001 - store-independent
56
+ return _setfit_clause_classifier().classify_spans(inputs["chunk_text"], inputs["span_texts"])
57
+
58
+
59
+ def clause_property_classification(resources: Any, inputs: dict) -> Any: # noqa: ARG001 - store-independent
60
+ from rag_wright.spans.property_extractor import HybridPropertyExtractor
61
+
62
+ ext = HybridPropertyExtractor(_dim_registry(), runnable=object()) # classifier lane only; runnable unused
63
+ return ext.classify_properties(inputs["text"], functions=tuple(inputs.get("functions", ())))
64
+
65
+
66
+ def dispatch_model(name: str, inputs: dict) -> Any:
67
+ """Dispatch a model-capability by name, inside a trace span -- resolving the implementation via the manifest
68
+ `impl_ref` (the SAME adapter-free path the engine API invoker uses, EP-CORE-2). This is the ONE production path
69
+ the ingestion pipeline also routes through. Model factories are store-independent, so resources is `None`."""
70
+ factory = capability_impl(name) # resolves impl_ref; raises KeyError/NotImplementedError on an unknown/unwired name
71
+ with traced_step(f"invoke:{name}"):
72
+ return factory(None, inputs)
73
+
74
+
75
+ async def adispatch_model(name: str, inputs: dict, *, sem: asyncio.Semaphore | None = None) -> Any:
76
+ """Async dispatch: run the (CPU-bound, in-process) model capability off the event loop; `sem` bounds in-flight
77
+ work across concurrent callers (the ingestion segment stage passes one shared semaphore)."""
78
+ if sem is None:
79
+ return await asyncio.to_thread(dispatch_model, name, inputs)
80
+ async with sem:
81
+ return await asyncio.to_thread(dispatch_model, name, inputs)
82
+
83
+
84
+ # --- thin shims the ingestion pipeline wires its classify stages to (so production routes through the capability) ---
85
+
86
+ class CapabilityFunctionClassifier:
87
+ """The ingestion segment stage's clause-function classifier, routed through the `clause_function_classification`
88
+ capability. Same `(classify_spans / aclassify_spans(sem=))` shape the segment stage expects."""
89
+
90
+ def classify_spans(self, chunk_text: str, span_texts: list[str]) -> Any:
91
+ return dispatch_model("clause_function_classification",
92
+ {"chunk_text": chunk_text, "span_texts": span_texts})
93
+
94
+ async def aclassify_spans(self, chunk_text: str, span_texts: list[str],
95
+ *, sem: asyncio.Semaphore | None = None) -> Any:
96
+ return await adispatch_model("clause_function_classification",
97
+ {"chunk_text": chunk_text, "span_texts": span_texts}, sem=sem)
98
+
99
+
100
+ def capability_property_classifier_fn() -> Callable[[str, tuple], list[dict]]:
101
+ """The classifier LANE for the ingestion extract stage, routed through the `clause_property_classification`
102
+ capability. Returns `(text, functions) -> [{dimension, value, confidence}]`; the pipeline composes the residual
103
+ LLM call + the semantic judge AROUND it (those are not part of this capability)."""
104
+ def _classify(text: str, functions: tuple = ()) -> list[dict]:
105
+ return dispatch_model("clause_property_classification", {"text": text, "functions": tuple(functions)})
106
+
107
+ return _classify
@@ -0,0 +1,111 @@
1
+ """T60 (FR-C.3, ADR-0026): source training labels for the 3 extended function classes.
2
+
3
+ CUAD annotates only its 41 `ClauseCategory` types, so it has no spans for the ACORD families the
4
+ function taxonomy adds (Indemnification, the indirect/consequential damages waiver, the warranty
5
+ disclaimer). We bootstrap them from the SAME CUAD contracts (ACORD stays pure eval -- no leakage): the
6
+ operative spans CUAD leaves unlabeled (NONE) are candidates, narrowed by a cheap high-recall KEYWORD
7
+ pre-filter and then confirmed by an LLM (DeepSeek via the model-profile seam).
8
+
9
+ The keyword pre-filter is unit-testable and free; the LLM confirm is the same structured-output seam
10
+ pattern the OKF classifier uses (stub-injectable, so the plumbing is tested without a model call).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from enum import Enum
16
+ from typing import Optional, Protocol, runtime_checkable
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from rag_wright.contracts.function import ExtendedFunction
21
+ from rag_wright.models.profiles import ModelRole, model_for
22
+ from rag_wright.models.seam import build_structured
23
+
24
+ NONE_LABEL = "NONE" # matches the T56 classifier's off-taxonomy sentinel
25
+
26
+ # High-recall keyword pre-filter (lowercased substring match). Precision is the LLM's job; this only
27
+ # has to avoid dropping true positives, so it is deliberately broad.
28
+ NEW_FUNCTION_KEYWORDS: dict[str, tuple[str, ...]] = {
29
+ ExtendedFunction.INDEMNIFICATION.value: (
30
+ "indemnif", "hold harmless", "harmless from", "defend, indemnify",
31
+ ),
32
+ ExtendedFunction.INDIRECT_DAMAGES_WAIVER.value: (
33
+ "consequential", "indirect damage", "incidental damage", "punitive damage",
34
+ "special damage", "in no event", "lost profits", "loss of profit",
35
+ ),
36
+ ExtendedFunction.WARRANTY_DISCLAIMER.value: (
37
+ "disclaim", "as is", "as-is", "merchantability", "fitness for a particular purpose",
38
+ "implied warrant", "no warrant",
39
+ ),
40
+ }
41
+
42
+
43
+ class NewFunctionTag(str, Enum):
44
+ """The LLM confirm's closed output: one of the 3 extended functions, or NONE (none of them)."""
45
+
46
+ INDEMNIFICATION = ExtendedFunction.INDEMNIFICATION.value
47
+ INDIRECT_DAMAGES_WAIVER = ExtendedFunction.INDIRECT_DAMAGES_WAIVER.value
48
+ WARRANTY_DISCLAIMER = ExtendedFunction.WARRANTY_DISCLAIMER.value
49
+ NONE = NONE_LABEL
50
+
51
+
52
+ class NewFunctionLabel(BaseModel):
53
+ """Structured LLM output: which extended-function class the span is (or NONE)."""
54
+
55
+ label: NewFunctionTag
56
+
57
+
58
+ def keyword_candidates(text: str) -> frozenset[str]:
59
+ """The extended-function labels a span MIGHT be, by keyword (the pre-filter). Empty => not a
60
+ candidate for any new class (skip the LLM call entirely)."""
61
+ low = text.lower()
62
+ hits = {
63
+ label
64
+ for label, keywords in NEW_FUNCTION_KEYWORDS.items()
65
+ if any(kw in low for kw in keywords)
66
+ }
67
+ return frozenset(hits)
68
+
69
+
70
+ def label_prompt(text: str, candidates: frozenset[str]) -> str:
71
+ """The confirm prompt: given the pre-filter's candidate classes, decide the one true class or NONE."""
72
+ options = "\n- ".join(sorted(candidates))
73
+ return (
74
+ "You are labeling a contract clause span for a function classifier. A keyword filter flagged it "
75
+ "as possibly one of these clause types:\n- " + options + "\n\n"
76
+ "Decide which ONE it actually is, or NONE if it is none of them (the keyword was incidental). "
77
+ "Judge by the span's operative meaning, not a passing mention.\n\n"
78
+ "Span:\n" + text[:2000]
79
+ )
80
+
81
+
82
+ @runtime_checkable
83
+ class NewFunctionConfirmer(Protocol):
84
+ """Span text + candidate classes -> a confirmed label. The seam a test stubs (no model call)."""
85
+
86
+ def __call__(self, text: str, candidates: frozenset[str]) -> NewFunctionTag: ...
87
+
88
+
89
+ class SeamNewFunctionConfirmer:
90
+ """The real confirmer: structured output through the model-profile seam on STRUCTURED_REASONING
91
+ (DeepSeek V4 Pro by default; quality-sensitive labeling). Retries a bare `None` (a transient miss
92
+ that `with_structured_output` returns instead of raising), and raises only when it persists."""
93
+
94
+ def __init__(self, model_id: Optional[str] = None, *, retries: int = 3) -> None:
95
+ self._runnable = build_structured(
96
+ model_id or model_for(ModelRole.STRUCTURED_REASONING), NewFunctionLabel
97
+ )
98
+ self._retries = retries
99
+
100
+ def __call__(self, text: str, candidates: frozenset[str]) -> NewFunctionTag:
101
+ prompt = label_prompt(text, candidates)
102
+ last_error: Exception | None = None
103
+ for _ in range(self._retries):
104
+ try:
105
+ v = self._runnable.invoke(prompt)
106
+ except Exception as e: # noqa: BLE001 - transient provider/parse error; retry
107
+ last_error = e
108
+ continue
109
+ if v is not None:
110
+ return v.label
111
+ raise last_error or ValueError("no structured output after retries")
@@ -0,0 +1,68 @@
1
+ """issue 0032 / CU-B5: map a span's canonical-text character range to its source page(s).
2
+
3
+ Docling records each parsed item's `prov[0].page_no` (+ a best-effort bbox); `content_items` now carries it
4
+ (`ContentItem.page`/`.bbox`). The chunker builds a canonical document text (`_SEP`-join of the chunk texts,
5
+ each a `_SEP`-join of the stripped item texts) that span `doc_start`/`doc_end` offsets index into. This module
6
+ reconstructs a `[char_range) -> page` map over that canonical text by locating each item's text in reading
7
+ order, so a span's pages fall out of the char range it already has -- correctly handling a clause that crosses
8
+ a page boundary (the pages come back as a LIST). Page-only is enough for the honest scanned-PDF fallback
9
+ ("page 7 -- exact location unavailable"); bbox is carried best-effort where the parser produced one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any, Optional
16
+
17
+ BBox = tuple[float, float, float, float]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PageRange:
22
+ """One content item's [start, end) character range in the canonical document text, its 1-based source
23
+ page, and a best-effort bbox (l, t, r, b on that page) when the parser produced one."""
24
+
25
+ start: int
26
+ end: int
27
+ page: int
28
+ bbox: Optional[BBox]
29
+
30
+
31
+ def build_page_offset_map(items: list[Any], canonical_text: str) -> list[PageRange]:
32
+ """Reconstruct the page map for `canonical_text` by locating each content item's STRIPPED text in reading
33
+ order with a forward cursor (the canonical text is the `_SEP`-join of exactly those stripped item texts).
34
+
35
+ Robust to the chunker's below-floor merges and bare-heading folds -- they regroup which items land in
36
+ which chunk but never change the item-text SEQUENCE, so the cursor still finds each in order. The only
37
+ lossy case is an over-cap item hard-split across chunks (rare): its text is not contiguous, so it is left
38
+ unlocated (best-effort). Items with no page or empty text contribute nothing."""
39
+ out: list[PageRange] = []
40
+ cursor = 0
41
+ for it in items:
42
+ page = getattr(it, "page", None)
43
+ text = (getattr(it, "text", "") or "").strip()
44
+ if page is None or not text:
45
+ continue
46
+ idx = canonical_text.find(text, cursor)
47
+ if idx == -1: # a merged/reordered edge: fall back to a global search before giving up
48
+ idx = canonical_text.find(text)
49
+ if idx == -1:
50
+ continue
51
+ out.append(PageRange(idx, idx + len(text), int(page), getattr(it, "bbox", None)))
52
+ cursor = idx + len(text)
53
+ return out
54
+
55
+
56
+ def pages_for(
57
+ page_map: list[PageRange], doc_start: Optional[int], doc_end: Optional[int]
58
+ ) -> tuple[list[int], Optional[BBox]]:
59
+ """The distinct source pages a `[doc_start, doc_end)` span overlaps (sorted ascending -- a LIST, because a
60
+ clause can cross a page boundary), plus a best-effort bbox: the box of the SINGLE overlapping item when
61
+ exactly one overlaps and it carries a box, else `None` (a multi-item/multi-page span has no single box).
62
+ An empty map, missing offsets, or no overlap -> `([], None)`."""
63
+ if doc_start is None or doc_end is None:
64
+ return [], None
65
+ overlapping = [pr for pr in page_map if pr.start < doc_end and pr.end > doc_start]
66
+ pages = sorted({pr.page for pr in overlapping})
67
+ bbox = overlapping[0].bbox if len(overlapping) == 1 else None
68
+ return pages, bbox