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,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)
|