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