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,585 @@
1
+ """GP-1(B): docling-graph-based entity extraction from contract text.
2
+
3
+ docling-graph (IBM/docling-project) is a schema-driven LLM knowledge-graph extractor: you pass a Pydantic
4
+ *template* (entities via `model_config=ConfigDict(graph_id_fields=[...])`, relationships via the `edge()`
5
+ helper), and `run_pipeline(config, mode="api")` returns `context.extracted_models` (your Pydantic instances)
6
+ + `context.knowledge_graph` (a networkx.DiGraph). This module holds our contract template; the extracted
7
+ parties feed our existing `parties_to_extraction -> disambiguate -> resolve(EDGAR CIK) -> write_graph`
8
+ pipeline (GP-1B.2), so docling-graph replaces only the LLM extraction step. Model routing (Granite / Gemma /
9
+ DeepSeek) is docling-graph's own LiteLLM config seam, driven from our env (GP-1B.3).
10
+
11
+ `edge()` is intentionally defined here, not imported: the shipped docling-graph example templates each define
12
+ this ~15-line helper locally (it is not exported from the package); it only writes `json_schema_extra` keys the
13
+ GraphConverter reads (`edge_label`, `graph_reference`, `reference_closed_catalog`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextvars
20
+ import logging
21
+ import os
22
+ import tempfile
23
+ import threading
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from contextlib import contextmanager
26
+ from dataclasses import dataclass
27
+ from functools import lru_cache, partial
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field
32
+
33
+ from rag_wright.capabilities.disambiguation import disambiguate
34
+ from rag_wright.capabilities.entity_resolution import ResolutionResult, resolve_entities
35
+ from rag_wright.capabilities.graph_extraction import parties_to_extraction
36
+ from rag_wright.contracts.identifiers import ChunkId, canonical_source_doc_id
37
+ from rag_wright.corpus.edgar import normalize_cik, normalize_name
38
+ from rag_wright.ontology.registry import EntityRegistry, RegistryRecord
39
+
40
+ _DOCLING_LOGGER = "docling_graph" # the package-root logger; children propagate their ERROR records up to it
41
+
42
+
43
+ class ExtractionFailed(Exception):
44
+ """PROD-3 lossless invariant (ADR-0050): a docling-graph extraction call ERRORED (e.g. a truncated / invalid
45
+ LLM JSON response) rather than returning a clean result. docling-graph LOGS such a failure and then SWALLOWS it,
46
+ returning an empty result -- indistinguishable from a genuine no-content extraction unless we watch the log. We
47
+ RAISE this so the ingest graph can retry and, on exhaustion, dead-letter / flag the document, instead of
48
+ silently writing empty extractions. `stage` = 'party' | 'clause'; `reason` = the captured error message."""
49
+
50
+ def __init__(self, stage: str, reason: str) -> None:
51
+ super().__init__(f"{stage} extraction failed: {reason}")
52
+ self.stage = stage
53
+ self.reason = reason
54
+
55
+
56
+ @contextmanager
57
+ def capture_docling_errors():
58
+ """Capture ERROR-level log records emitted by docling-graph (the package logger `docling_graph`; children
59
+ propagate up) during an extraction call. The captured messages are appended to the yielded list -- a non-empty
60
+ list after the call means the extraction FAILED (vs a genuine clean-empty result)."""
61
+ captured: list[str] = []
62
+
63
+ class _Capture(logging.Handler):
64
+ def emit(self, record: logging.LogRecord) -> None:
65
+ if record.levelno >= logging.ERROR:
66
+ captured.append(record.getMessage())
67
+
68
+ logger = logging.getLogger(_DOCLING_LOGGER)
69
+ handler = _Capture()
70
+ handler.setLevel(logging.ERROR)
71
+ logger.addHandler(handler)
72
+ try:
73
+ yield captured
74
+ finally:
75
+ logger.removeHandler(handler)
76
+
77
+ _PRIVATE_RESOLUTIONS = {"PRIVATE", "SKIP"}
78
+
79
+
80
+ def edge(
81
+ label: str,
82
+ default: Any = None,
83
+ *,
84
+ reference: bool = False,
85
+ closed_catalog: bool = False,
86
+ default_factory: Any = None,
87
+ **kwargs: Any,
88
+ ) -> Any:
89
+ """Declare a field as a docling-graph edge via `json_schema_extra` (local helper, mirrors the shipped
90
+ example templates). `label` -> `edge_label`; `reference` -> id-only link; `closed_catalog` -> closed
91
+ reference catalog. A `list[Entity]` edge should pass `default_factory=list`."""
92
+ json_schema_extra: dict[str, Any] = dict(kwargs.pop("json_schema_extra", {}) or {})
93
+ json_schema_extra["edge_label"] = label
94
+ if reference:
95
+ json_schema_extra["graph_reference"] = True
96
+ if closed_catalog:
97
+ json_schema_extra["reference_closed_catalog"] = True
98
+ if default_factory is not None:
99
+ return Field(default_factory=default_factory, json_schema_extra=json_schema_extra, **kwargs)
100
+ return Field(default, json_schema_extra=json_schema_extra, **kwargs)
101
+
102
+
103
+ class Party(BaseModel):
104
+ """A signing party (organization) to the agreement. Its `name` is the stable, document-derived identity
105
+ (docling-graph hashes `graph_id_fields` into the node id; we resolve `name` -> EDGAR CIK downstream)."""
106
+
107
+ model_config = ConfigDict(graph_id_fields=["name"], extra="ignore", populate_by_name=True)
108
+
109
+ name: str = Field(description="Exact legal name of an organization that is a signing party to the agreement")
110
+
111
+
112
+ class ContractParties(BaseModel):
113
+ """The contract, identified by its title, with the organizations that are its signing parties. Minimal
114
+ schema targeting 1-hop CONTRACTS_WITH (the parties per contract); richer clause/relationship edges can be
115
+ added later. Designed for docling-graph's `direct`/`dense` extraction contracts."""
116
+
117
+ model_config = ConfigDict(graph_id_fields=["title"], extra="ignore", populate_by_name=True)
118
+
119
+ title: str = Field(description="The contract or agreement title / document name")
120
+ parties: list[Party] = edge(
121
+ "PARTY_TO", default_factory=list,
122
+ description="The organizations that are the signing parties to this agreement (usually two)",
123
+ )
124
+
125
+
126
+ def build_verified_registry(vset: dict) -> EntityRegistry:
127
+ """An `EntityRegistry` from the human-verified set: each CIK-resolved entity becomes a
128
+ `RegistryRecord(CIK, representative, aliases=variants)`, so an extracted party surface form that matches
129
+ a verified variant resolves to the CIK. PRIVATE/SKIP entities are not in the closed CIK registry
130
+ (`resolve -> None -> unlinked`). This keeps resolution recall high for verified filers, so in the A/B a
131
+ miss reflects EXTRACTION quality (the model didn't produce a matching name), not resolution weakness."""
132
+ registry = EntityRegistry()
133
+ for entity in vset["entities"]:
134
+ resolution = entity["resolution"]
135
+ if resolution in _PRIVATE_RESOLUTIONS:
136
+ continue
137
+ registry.add(RegistryRecord(
138
+ entity_id=normalize_cik(resolution), canonical_name=entity["representative"],
139
+ aliases=list(entity.get("variants", [])),
140
+ ))
141
+ return registry
142
+
143
+
144
+ def resolve_extracted(
145
+ items: list[tuple[str, ContractParties]], *, registry: EntityRegistry,
146
+ private_map: dict[str, str] | None = None,
147
+ ) -> ResolutionResult:
148
+ """Bridge docling-graph extractions into our resolution pipeline: each contract's extracted parties ->
149
+ `parties_to_extraction` (CONTRACTS_WITH between them, no LLM) -> `disambiguate` -> `resolve_entities`
150
+ (-> EDGAR CIK). `items` = (contract_id, extracted `ContractParties`). The `ResolutionResult` feeds
151
+ `to_graph` -> `store.write_graph` (GP-1B.5). This is the only new glue vs the gold-anchored GP-1(A):
152
+ the parties now come from docling-graph's LLM extraction instead of the verified `coparty_keys`.
153
+
154
+ `private_map` (GP-1B.5a): assign verified-PRIVATE parties their golden `PRIVATE:<key>` id (which the CIK
155
+ registry can't produce) so private anchors/answers are recoverable in the relational eval."""
156
+ results = []
157
+ for contract_id, cp in items:
158
+ names = [p.name for p in cp.parties]
159
+ chunk_id = ChunkId.of(canonical_source_doc_id(contract_id), 0, "|".join(names) or contract_id)
160
+ results.append(parties_to_extraction(chunk_id, names))
161
+ resolution = resolve_entities(disambiguate(results), results, resolver=registry)
162
+ if private_map:
163
+ resolution = _apply_private_identities(resolution, private_map)
164
+ return resolution
165
+
166
+
167
+ def build_private_map(vset: dict) -> dict[str, str]:
168
+ """`{normalize_name(surface) -> 'PRIVATE:<entity_key>'}` for verified-PRIVATE entities. Lets an extracted
169
+ private party (a non-filer the CIK registry can't resolve) take the golden `PRIVATE:<key>` node id
170
+ (matching `eval.multihop._identity`), instead of an `UNLINKED:<surface>` that misses the golden answer.
171
+ SKIP entities are excluded (they are excluded from the golden set)."""
172
+ private_map: dict[str, str] = {}
173
+ for entity in vset["entities"]:
174
+ if entity["resolution"] != "PRIVATE":
175
+ continue
176
+ pid = f"PRIVATE:{entity['entity_key']}"
177
+ for surface in (entity["representative"], entity["entity_key"], *entity.get("variants", [])):
178
+ key = normalize_name(surface)
179
+ if key:
180
+ private_map.setdefault(key, pid)
181
+ return private_map
182
+
183
+
184
+ def _apply_private_identities(resolution: ResolutionResult, private_map: dict[str, str]) -> ResolutionResult:
185
+ """Fill in `PRIVATE:<key>` ids for still-unlinked (entity_id None) entities/relationship endpoints whose
186
+ surface matches a verified-private entity; drop any edge that becomes a self-loop after the remap."""
187
+ entities = [
188
+ e.model_copy(update={"entity_id": e.entity_id or private_map.get(normalize_name(e.representative))})
189
+ for e in resolution.entities
190
+ ]
191
+ relationships = []
192
+ for rel in resolution.relationships:
193
+ source_id = rel.source_id or private_map.get(normalize_name(rel.source_ref))
194
+ target_id = rel.target_id or private_map.get(normalize_name(rel.target_ref))
195
+ if source_id is not None and source_id == target_id:
196
+ continue
197
+ relationships.append(rel.model_copy(update={"source_id": source_id, "target_id": target_id}))
198
+ return resolution.model_copy(update={"entities": entities, "relationships": relationships})
199
+
200
+
201
+ # --- GP-1B.3: the extraction-model seam (Granite / Gemma / DeepSeek via docling-graph's LiteLLM config) ---
202
+ #
203
+ # docling-graph's model routing is its own config seam (provider/model/connection), which we drive from our
204
+ # env -- compatible with the "no provider flag in node code" rule (the flags live in config + a dated ADR).
205
+ # Two reliability fixes are baked in (GP-1B.1/.2 findings): structured_output=False (json_object -- the strict
206
+ # nested json_schema trips DeepSeek and mis-formats others; json_object is reliable for all) and a max_tokens
207
+ # cap (the unknown-provider generic 8192-token context window else makes docling-graph SKIP the LLM).
208
+
209
+ _DEFAULT_MAX_TOKENS = 1500
210
+ _DEFAULT_PREAMBLE_CHARS = 8000 # parties are named in the preamble; keeps `direct` within the context window
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class ExtractionModel:
215
+ """One extraction-model choice for the A/B: a label + docling-graph provider + model id + connection.
216
+ `provider` is 'openrouter' (Gemma/DeepSeek) or 'ollama' (Granite, local or Modal-hosted)."""
217
+
218
+ label: str
219
+ provider: str
220
+ model: str
221
+ base_url: str
222
+ api_key: str | None = None
223
+ inference: str = "remote"
224
+ # OpenRouter provider routing from the model's PROFILE (ADR-0100), e.g. a hard pin
225
+ # {"only": ["deepinfra/bf16"], "allow_fallbacks": False}. Threaded into the litellm extraction call so the
226
+ # extraction surface honors the same provider pin as the seam. None -> the env/sort default in `_call_api`.
227
+ provider_routing: dict[str, Any] | None = None
228
+
229
+
230
+ def openrouter_model(label: str, model: str) -> ExtractionModel:
231
+ """A Gemma/DeepSeek model via OpenRouter (our seam's provider), keyed from the OPENROUTER_* env."""
232
+ return ExtractionModel(
233
+ label=label, provider="openrouter", model=model,
234
+ base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
235
+ api_key=os.environ.get("OPENROUTER_API_KEY"), inference="remote",
236
+ )
237
+
238
+
239
+ def ollama_model(label: str, model: str, base_url: str | None = None) -> ExtractionModel:
240
+ """A local (or Modal-hosted) Granite model via Ollama -- no API key. `base_url` overrides OLLAMA_BASE_URL
241
+ (used for the Modal-hosted fallback when local memory exceeds the threshold)."""
242
+ return ExtractionModel(
243
+ label=label, provider="ollama", model=model,
244
+ base_url=base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
245
+ api_key=None, inference="local",
246
+ )
247
+
248
+
249
+ def vllm_model(label: str, model: str) -> ExtractionModel:
250
+ """A self-hosted Granite model via the vLLM OpenAI-compatible server (litellm `hosted_vllm` provider);
251
+ base_url/key from the `VLLM_*` env (MS1-3, ADR-0039). This is the product-substrate extraction path."""
252
+ return ExtractionModel(
253
+ label=label, provider="hosted_vllm", model=model,
254
+ base_url=os.environ["VLLM_BASE_URL"], api_key=os.getenv("VLLM_API_KEY", "rw-vllm-dev-key"),
255
+ inference="remote",
256
+ )
257
+
258
+
259
+ _PRODUCT_EXTRACT_DEFAULT = "qwen3.8-27b-modal-or" # the built-in extraction default (matches _PRODUCT_LLM)
260
+
261
+
262
+ def default_extraction_model(label: str = "clause-extract", model: str | None = None) -> ExtractionModel:
263
+ """The clause/party/claim extraction model for the SELECTED serving backend (MS1-3, ADR-0039). The
264
+ docling-graph extraction is a SEPARATE model surface from the profile seam, so it reads the same `RAG_SERVING`
265
+ switch here (vLLM when `RAG_SERVING=vllm`, else OpenRouter). One env flips chunk + extract + judge together.
266
+
267
+ DEFAULT resolution (parallels `models.profiles.model_for`): an EXPLICIT `model` (the caller's `extract_model`,
268
+ passed through) always wins; otherwise `RAG_MODEL_ALL` (the point-every-role-at-one-model knob) is honored,
269
+ then the built-in default (`_PRODUCT_EXTRACT_DEFAULT`). So `RAG_MODEL_ALL=<id>` now genuinely covers the two
270
+ extraction surfaces too (clause + claim), not just the `model_for` roles -- and a caller-supplied model
271
+ argument is unaffected.
272
+
273
+ ADR-0100: the backend/base_url/served-id come from the model string's PROFILE (`resolve_connection`), so a
274
+ string can pin OpenRouter or a self-hosted vLLM/Modal server -- mix per stage. An un-pinned string falls back
275
+ to `RAG_SERVING`, unchanged from before."""
276
+ from rag_wright.models.profiles import profile_for
277
+ from rag_wright.models.seam import resolve_connection
278
+
279
+ model = model or os.getenv("RAG_MODEL_ALL") or _PRODUCT_EXTRACT_DEFAULT
280
+ conn = resolve_connection(model)
281
+ # ADR-0100: carry the model's PROFILE provider routing (e.g. the deepinfra/bf16 pin) onto the extraction
282
+ # surface too, so a pin set once in the profile holds engine-wide (seam AND extraction), not just the seam.
283
+ routing = (profile_for(model).extra_body or {}).get("provider")
284
+ return ExtractionModel(label=label, provider=conn.provider, model=conn.served_model_id,
285
+ base_url=conn.base_url, api_key=conn.api_key,
286
+ inference="local" if conn.backend == "ollama" else "remote",
287
+ provider_routing=routing)
288
+
289
+
290
+ # INGEST-GRAPH-LATENCY: docling-graph's default per-call timeout is 300s (ReliabilityDefaults.timeout_s), which
291
+ # let one stuck extract_parties call block a document for ~5 min. A single granite call is ~10s, so cap it far
292
+ # lower and bound the retry exposure -- a hang now fails fast and the caller's per-item tolerance skips it.
293
+ _DEFAULT_TIMEOUT_S = 90
294
+ _DEFAULT_MAX_RETRIES = 1
295
+
296
+
297
+ def build_pipeline_config(source_path: str, model: ExtractionModel, *, template: type = ContractParties,
298
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
299
+ timeout_s: int = _DEFAULT_TIMEOUT_S,
300
+ max_retries: int = _DEFAULT_MAX_RETRIES,
301
+ temperature: float | None = None,
302
+ structured_output: bool = False,
303
+ extraction_contract: str = "direct", stage_label: str | None = None,
304
+ gleaning: bool = True) -> Any:
305
+ """The docling-graph `PipelineConfig` for a model choice, with the reliability fixes baked in
306
+ (structured_output=False + max_tokens cap + a sane per-call `timeout_s`/`max_retries`, NOT docling-graph's
307
+ 300s default). Kept import-light so hermetic tests need no LLM.
308
+
309
+ `extraction_contract` defaults to "direct" (one full-document call -- right for CONTRACTS: the parties
310
+ live in the 8k preamble). LONG documents (regulations) must pass "auto"/"dense": on a doc that dwarfs the
311
+ output budget, "direct" SILENTLY self-rations (measured: FTC §255.5 -> 6 rules direct vs 31 dense), whereas
312
+ "dense" is skeleton-then-fill over chunks and auto-retries truncation by splitting. See
313
+ [[docling-graph-extraction-contract]].
314
+
315
+ `gleaning` (issue 0019): docling-graph's `gleaning_enabled` defaults to True, adding a SECOND full-document
316
+ LLM call after the extraction -- "extract any ADDITIONAL information not already extracted" -- a completeness
317
+ pass. It is right where there is more to find (ingestion of a full clause) but pure waste on the QUERY leg,
318
+ where a short question has nothing to glean (measured: the second call returned empty and doubled query cost +
319
+ latency). Default True preserves ingestion behavior; the query-leg constraint extraction passes gleaning=False."""
320
+ from docling_graph import PipelineConfig
321
+ from docling_graph.llm_clients.config import (
322
+ ConnectionOverrides,
323
+ GenerationOverrides,
324
+ LlmRuntimeOverrides,
325
+ ReliabilityOverrides,
326
+ resolve_effective_model_config,
327
+ )
328
+ from pydantic import SecretStr
329
+
330
+ connection = ConnectionOverrides(
331
+ base_url=model.base_url,
332
+ api_key=SecretStr(model.api_key) if model.api_key else None,
333
+ )
334
+ overrides = LlmRuntimeOverrides(
335
+ generation=GenerationOverrides(max_tokens=max_tokens, temperature=temperature),
336
+ reliability=ReliabilityOverrides(timeout_s=timeout_s, max_retries=max_retries),
337
+ connection=connection,
338
+ )
339
+ # ASYNC-A4 (ADR-0057): inject OUR deadline-bounded client via PipelineConfig.llm_client (a seam docling-graph
340
+ # honors at pipeline/stages.py:559). Its LLM call runs `litellm.acompletion` under a TRUE asyncio.timeout, so
341
+ # a slow-drip extraction is cancelled (socket torn down) at the deadline instead of running for minutes --
342
+ # docling-graph's own request-building and response-parsing are reused unchanged; NO fork.
343
+ effective = resolve_effective_model_config(model.provider, model.model, overrides=overrides)
344
+ llm_client = _deadline_bounded_client_class()(model_config=effective)
345
+ llm_client._stage_label = stage_label # ADR-0058/issue 0005: name the stage in the deadline timeout message
346
+ llm_client._base_url = getattr(model, "base_url", "") or "" # for OpenRouter provider routing (sort=latency)
347
+ llm_client._provider_routing = getattr(model, "provider_routing", None) # profile pin, e.g. deepinfra/bf16
348
+ return PipelineConfig(
349
+ source=source_path, template=template, backend="llm", inference=model.inference,
350
+ extraction_contract=extraction_contract, processing_mode="many-to-one",
351
+ # default json_object (OpenRouter's strict json_schema returns nothing, GP-1B.2); but vLLM's guided
352
+ # decoding (xgrammar) CONSTRAINS the decoder to the schema, so structured_output=True works + is stricter.
353
+ structured_output=structured_output,
354
+ provider_override=model.provider, model_override=model.model,
355
+ llm_overrides=overrides,
356
+ llm_client=llm_client,
357
+ gleaning_enabled=gleaning, # issue 0019: off on the query leg (nothing to glean from a short question)
358
+ )
359
+
360
+
361
+ @lru_cache(maxsize=1)
362
+ def _deadline_bounded_client_class() -> type:
363
+ """The docling-graph LLM client that runs its call ASYNC under our true wall-clock deadline (ADR-0057,
364
+ ASYNC-A4). Defined lazily (docling-graph imported only on first real extraction) so the module stays
365
+ import-light. Subclasses `LiteLLMClient` and overrides ONLY `_call_api` -- the single point that calls
366
+ litellm -- so all of docling-graph's message building, request building, response parsing, and diagnostics
367
+ are reused unchanged. `litellm.acompletion` is truly cancellable, and `run_pipeline` is synchronous with no
368
+ running event loop (it is called directly, or off the loop via `asyncio.to_thread` in `aextract_*`), so
369
+ `asyncio.run` creates a fresh loop and the socket is torn down at the deadline."""
370
+ from docling_graph.exceptions import ClientError
371
+ from docling_graph.llm_clients.litellm import LiteLLMClient
372
+
373
+ from rag_wright.models import seam
374
+
375
+ class _DeadlineBoundedLiteLLMClient(LiteLLMClient):
376
+ def _call_api(self, messages: list[dict[str, str]], **params: Any) -> tuple[str, dict[str, Any]]:
377
+ import litellm
378
+
379
+ request = self._build_request(messages, **params)
380
+ # For an OpenRouter extraction call: (1) route the provider -- pin an explicit provider order
381
+ # (env OPENROUTER_PROVIDER_ORDER, comma-separated, no fallbacks) for determinism, else prefer the
382
+ # lowest-latency provider (env OPENROUTER_SORT, default 'latency'); (2) DISABLE reasoning. The product
383
+ # default granite-4.2-8b is a reasoning model: on a forced structured call it returns empty `content`
384
+ # unless reasoning is disabled (ADR-0079). The seam applies both via the model profile; the
385
+ # docling-graph path uses litellm, so it is added here. Skipped for a non-OpenRouter (vLLM / local) base.
386
+ from rag_wright.models import tracing
387
+ from rag_wright.models import usage as usage_acct
388
+ traced = tracing.tracing_on()
389
+ capture = traced or usage_acct.usage_capturing() # issue 0042: also capture into an active usage scope
390
+ if "openrouter" in (getattr(self, "_base_url", "") or "").lower():
391
+ # provider routing precedence: OPENROUTER_PROVIDER_ORDER env (measurement override) > the model's
392
+ # PROFILE pin (ADR-0100, e.g. deepinfra/bf16 for Qwen3.8-27b) > the lowest-latency sort default.
393
+ _order = os.getenv("OPENROUTER_PROVIDER_ORDER", "").strip()
394
+ _profile_prov = getattr(self, "_provider_routing", None)
395
+ if _order:
396
+ _prov = {"order": [p.strip() for p in _order.split(",") if p.strip()], "allow_fallbacks": False}
397
+ elif _profile_prov:
398
+ _prov = _profile_prov
399
+ else:
400
+ _prov = {"sort": os.getenv("OPENROUTER_SORT", "latency")}
401
+ _reason_on = os.getenv("RAG_EXTRACT_REASONING", "0") == "1" # A/B toggle (default OFF)
402
+ _eb = {**(request.get("extra_body") or {}), "provider": _prov, "reasoning": {"enabled": _reason_on}}
403
+ if capture:
404
+ _eb["usage"] = {"include": True} # ask OpenRouter to return the actual per-call cost (issue 0017)
405
+ request["extra_body"] = _eb
406
+
407
+ async def _go() -> Any:
408
+ async with asyncio.timeout(seam._MODEL_DEADLINE_S):
409
+ return await litellm.acompletion(**request)
410
+
411
+ import time as _time
412
+ # 0048: open the generation BEFORE the call so Langfuse's own latency is the real duration (not ~0);
413
+ # ended on success AND on the error paths below so no span is left dangling.
414
+ _gen = tracing.start_generation(
415
+ model=self.model, input=messages,
416
+ label=getattr(self, "_stage_label", None) or "docling-graph-extract", stage="litellm") \
417
+ if traced else None
418
+ _t0 = _time.monotonic()
419
+ try:
420
+ response = asyncio.run(_go())
421
+ except TimeoutError as exc:
422
+ tracing.finish_generation(_gen, latency_ms=(_time.monotonic() - _t0) * 1000.0,
423
+ metadata={"error": "timeout"})
424
+ raise seam.ModelCallTimeout(
425
+ f"docling-graph extraction on {seam._call_desc(self.model, getattr(self, '_stage_label', None))} "
426
+ f"exceeded the {seam._MODEL_DEADLINE_S}s deadline") from exc
427
+ except Exception as exc: # noqa: BLE001 - wrap like the base's _call_api (docling-graph ClientError)
428
+ tracing.finish_generation(_gen, latency_ms=(_time.monotonic() - _t0) * 1000.0,
429
+ metadata={"error": type(exc).__name__})
430
+ raise ClientError(f"LiteLLM async call failed: {type(exc).__name__}",
431
+ details={"model": self.model, "error": str(exc)}, cause=exc) from exc
432
+
433
+ choices = response.get("choices", [])
434
+ if not choices:
435
+ tracing.finish_generation(_gen, latency_ms=(_time.monotonic() - _t0) * 1000.0,
436
+ metadata={"error": "no_choices"})
437
+ raise ClientError("LiteLLM returned no choices", details={"model": self.model})
438
+ content = choices[0].get("message", {}).get("content")
439
+ if not content:
440
+ tracing.finish_generation(_gen, latency_ms=(_time.monotonic() - _t0) * 1000.0,
441
+ metadata={"error": "empty_content"})
442
+ raise ClientError("LiteLLM returned empty content", details={"model": self.model})
443
+ _usage_obj = response.get("usage")
444
+ _latency_ms = (_time.monotonic() - _t0) * 1000.0
445
+ if capture:
446
+ # docling-graph party/clause extraction -- the litellm path (invisible to the seam). Read token
447
+ # usage AND OpenRouter's ACTUAL cost (usage.cost / litellm response_cost); record into any active
448
+ # usage scope (issue 0042) and, when tracing, end the generation (issue 0017/0048).
449
+ _in = int(getattr(_usage_obj, "prompt_tokens", 0) or 0) if _usage_obj else 0
450
+ _out = int(getattr(_usage_obj, "completion_tokens", 0) or 0) if _usage_obj else 0
451
+ cost = getattr(_usage_obj, "cost", None) if _usage_obj else None
452
+ if cost is None:
453
+ cost = (getattr(response, "_hidden_params", {}) or {}).get("response_cost")
454
+ usage_acct.record_usage(self.model, input_tokens=_in, output_tokens=_out, cost=cost,
455
+ latency_ms=_latency_ms)
456
+ _gid = response.get("id") # 0048: OpenRouter generation id for queue-vs-gen attribution
457
+ tracing.finish_generation(
458
+ _gen, output=str(content),
459
+ usage=({"input": _in, "output": _out} if _usage_obj else None), cost=cost,
460
+ latency_ms=_latency_ms,
461
+ metadata=({"openrouter_generation_id": _gid} if _gid else None))
462
+ else:
463
+ tracing.finish_generation(_gen, latency_ms=_latency_ms) # end the span even when not capturing usage
464
+ metadata = {"finish_reason": choices[0].get("finish_reason"),
465
+ "model": response.get("model", self.model), "usage": _usage_obj}
466
+ return str(content), metadata
467
+
468
+ return _DeadlineBoundedLiteLLMClient
469
+
470
+
471
+ def extract_parties(text: str, model: ExtractionModel, *, template: type = ContractParties,
472
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
473
+ preamble_chars: int = _DEFAULT_PREAMBLE_CHARS,
474
+ timeout_s: int = _DEFAULT_TIMEOUT_S,
475
+ temperature: float | None = None,
476
+ structured_output: bool = False,
477
+ extraction_contract: str = "direct", stage: str = "party",
478
+ gleaning: bool = True) -> Any | None:
479
+ """Extract from `text` with `model` via docling-graph (API mode). Writes the preamble to a temp .md
480
+ (docling-graph needs a path, not a raw string), runs `run_pipeline`, returns the first extracted model or
481
+ None. `extraction_contract` defaults to "direct" (contracts); pass "auto"/"dense" for long docs
482
+ (regulations) so a single call does not silently self-ration -- see build_pipeline_config.
483
+
484
+ PROD-3 lossless invariant (ADR-0050): docling-graph LOGS an LLM/parse failure and then SWALLOWS it, returning
485
+ an empty result. We capture the ERROR log during the call and RAISE `ExtractionFailed(stage, reason)` on a
486
+ failure, so the ingest graph retries and (on exhaustion) dead-letters / flags the document instead of silently
487
+ writing empty extractions. A genuine clean-empty result (no error logged) still returns None."""
488
+ from docling_graph import run_pipeline
489
+
490
+ md = Path(tempfile.mkdtemp(prefix="dg_extract_")) / "contract.md"
491
+ md.write_text(text[:preamble_chars], encoding="utf-8")
492
+ with capture_docling_errors() as errors:
493
+ ctx = run_pipeline(build_pipeline_config(str(md), model, template=template, max_tokens=max_tokens,
494
+ timeout_s=timeout_s, temperature=temperature,
495
+ structured_output=structured_output,
496
+ extraction_contract=extraction_contract,
497
+ stage_label=f"dg_extraction.{stage}", # issue 0005
498
+ gleaning=gleaning),
499
+ mode="api")
500
+ if errors: # docling logged an error then swallowed it -> a failure, NOT a clean-empty result -> raise
501
+ raise ExtractionFailed(stage, errors[-1][:300])
502
+ return ctx.extracted_models[0] if ctx.extracted_models else None
503
+
504
+
505
+ _EXTRACTION_EXECUTOR: ThreadPoolExecutor | None = None
506
+ _EXTRACTION_EXECUTOR_LOCK = threading.Lock()
507
+
508
+
509
+ def extraction_executor() -> ThreadPoolExecutor:
510
+ """EXEC-1: the DEDICATED thread pool for the (network-bound) docling-graph extraction offload
511
+ (clause / party / claim / requirement all funnel through `aextract_parties`). Sized by `RAG_EXTRACT_WORKERS`
512
+ (default 32), so extraction concurrency is bounded by OUR semaphores (`CLAUSE_CONCURRENCY`, the cross-doc
513
+ `max_concurrency`) + the deployment -- NOT asyncio's default `min(32, cpu+4)` executor, which is CPU-derived,
514
+ machine-dependent, and would conflate extraction with the genuine CPU work (parse / embed / resolve / DB
515
+ writes) that stays on the default pool. Lazy singleton, reused for the process lifetime."""
516
+ global _EXTRACTION_EXECUTOR
517
+ if _EXTRACTION_EXECUTOR is None:
518
+ with _EXTRACTION_EXECUTOR_LOCK:
519
+ if _EXTRACTION_EXECUTOR is None:
520
+ workers = max(1, int(os.environ.get("RAG_EXTRACT_WORKERS", "32")))
521
+ _EXTRACTION_EXECUTOR = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="rag-extract")
522
+ return _EXTRACTION_EXECUTOR
523
+
524
+
525
+ async def aextract_parties(text: str, model: ExtractionModel, **kwargs: Any) -> Any | None:
526
+ """ASYNC-A4 (ADR-0057): `extract_parties` off the event loop. `run_pipeline` is synchronous (docling-graph
527
+ has no async variant), so it runs in a worker thread -- keeping the loop non-blocking -- while the injected
528
+ deadline-bounded client makes the docling-graph LLM socket truly cancellable at `_MODEL_DEADLINE_S` inside
529
+ that worker thread. Same contract as `extract_parties`.
530
+
531
+ EXEC-1: it runs on the DEDICATED extraction executor (`extraction_executor()`), NOT the default `to_thread`
532
+ pool -- this is network-bound work, so its concurrency should be bounded by our semaphores + the deployment,
533
+ not the CPU-derived default that also serves parse/embed/resolve/writes.
534
+
535
+ ISSUE-0018: a ThreadPoolExecutor worker starts with an EMPTY context, so the OTel ambient context that
536
+ `traced_run` sets (langfuse correlation, stored in contextvars) would NOT reach the docling-graph LLM call --
537
+ its generation would land in a root trace with `sessionId: null`. Capture the CURRENT context at submit time
538
+ (`copy_context()`, per-call so concurrent extractions each carry their own session) and run the worker inside
539
+ it (`ctx.run`), so every generation the extraction emits stays attributed to its document/query."""
540
+ loop = asyncio.get_running_loop()
541
+ ctx = contextvars.copy_context()
542
+ call = partial(extract_parties, text, model, **kwargs)
543
+ return await loop.run_in_executor(extraction_executor(), lambda: ctx.run(call))
544
+
545
+
546
+ # --- KG-2: per-clause typed property extraction (the same seam, the KG-1 clause template) ---
547
+
548
+ # PROD-1 finding: a rich clause (esp. after ONT-2 grew the Clause template to ~36 typed dims) can exceed 2000 and
549
+ # truncate its structured JSON (a real NDA clause hit max_tokens=2000 -> unterminated string -> that clause's
550
+ # properties lost). This is a MAX for INGESTION extraction only (extract_clause), not a per-call cost -- a
551
+ # well-constrained extraction still terminates well under it -- so 4000 is a safe headroom bump, not a spend.
552
+ _CLAUSE_MAX_TOKENS = 4000
553
+ # (INGEST-REFACTOR: truncation was NOT a size problem -- unconstrained free-text fields like `document_reference`
554
+ # were dumping verbatim clause prose and ballooning the JSON; the fix is field constraints, not a higher cap.)
555
+ _CLAUSE_TEXT_CHARS = 12000 # one operative span is short; a generous cap that never truncates a real clause
556
+
557
+
558
+ def extract_clause(text: str, model: ExtractionModel, *, max_tokens: int = _CLAUSE_MAX_TOKENS,
559
+ temperature: float | None = None, structured_output: bool = False,
560
+ gleaning: bool = True) -> Any | None:
561
+ """Extract one clause's typed properties from span `text` with `model`, using the KG-1 bridge template
562
+ (`ontology.clause_template.Clause`). Same docling-graph API-mode seam + reliability fixes as
563
+ `extract_parties`; returns the extracted `Clause` (typed properties) or None. The Clause -> our
564
+ `ClausePropertyRecord` contract mapping + the grounding-judge gate live in `spans.clause_kg_extractor`.
565
+ `gleaning` (issue 0019): pass False on the QUERY leg (constraint extraction from a short question), where the
566
+ completeness pass has nothing to find; leave True for ingestion of a full clause."""
567
+ from rag_wright.ontology.clause_template import Clause
568
+
569
+ return extract_parties(
570
+ text, model, template=Clause, max_tokens=max_tokens, preamble_chars=_CLAUSE_TEXT_CHARS,
571
+ temperature=temperature, structured_output=structured_output, stage="clause", gleaning=gleaning,
572
+ )
573
+
574
+
575
+ async def aextract_clause(text: str, model: ExtractionModel, *, max_tokens: int = _CLAUSE_MAX_TOKENS,
576
+ temperature: float | None = None, structured_output: bool = False,
577
+ gleaning: bool = True) -> Any | None:
578
+ """ASYNC-A4 (ADR-0057): `extract_clause` off the event loop (via `asyncio.to_thread`), the injected
579
+ deadline-bounded client truly cancelling the docling-graph LLM socket at the deadline. Same contract.
580
+ `gleaning` (issue 0019): False on the query leg -- no second completeness call for a short question."""
581
+ from rag_wright.ontology.clause_template import Clause
582
+
583
+ return await aextract_parties(
584
+ text, model, template=Clause, max_tokens=max_tokens, preamble_chars=_CLAUSE_TEXT_CHARS,
585
+ temperature=temperature, structured_output=structured_output, stage="clause", gleaning=gleaning)