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,365 @@
|
|
|
1
|
+
"""T57b (FR-C.6, ADR-0025/0026): the targeted PROPERTY extractor.
|
|
2
|
+
|
|
3
|
+
Given an operative span, its FUNCTION (from the T56 classifier), and its clause provenance, extract the
|
|
4
|
+
queryable PROPERTIES the span states -> a `ClausePropertyRecord` (T57a). Structured output through the
|
|
5
|
+
model-profile seam (DeepSeek V4 Pro by default; quality-sensitive extraction). The extractor is
|
|
6
|
+
FUNCTION-AWARE: it only asks for the dimensions that apply to that clause type (`FUNCTION_DIMENSIONS`),
|
|
7
|
+
which keeps the prompt tight, the output on-vocabulary, and out-of-scope dimensions from being invented.
|
|
8
|
+
|
|
9
|
+
The LLM->contract mapping (`build_record`) is a pure, unit-tested function: it scope-filters to the
|
|
10
|
+
function's dimensions, and coerces an out-of-vocabulary value to an AMBIGUOUS assertion (the schema's
|
|
11
|
+
`other` escape) rather than dropping or crashing. At ingestion (T57c) this runs concurrently via
|
|
12
|
+
`util.map_concurrent`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ValidationError
|
|
20
|
+
|
|
21
|
+
from rag_wright.contracts.function import canonical_function
|
|
22
|
+
from rag_wright.contracts.identifiers import ChunkId
|
|
23
|
+
from rag_wright.contracts.property import (
|
|
24
|
+
CLOSED_VOCAB,
|
|
25
|
+
FOLIO_CLAUSE_IRI,
|
|
26
|
+
ClausePropertyRecord,
|
|
27
|
+
PropertyAssertion,
|
|
28
|
+
PropertyDimension,
|
|
29
|
+
)
|
|
30
|
+
from rag_wright.contracts.provenance import ConfidenceTag, Provenance
|
|
31
|
+
from rag_wright.models.profiles import ModelRole, model_for
|
|
32
|
+
from rag_wright.models.seam import build_structured
|
|
33
|
+
|
|
34
|
+
_D = PropertyDimension
|
|
35
|
+
|
|
36
|
+
# Which property dimensions apply to which FUNCTION (the two-tier schema, operationalized). A clause type
|
|
37
|
+
# not listed falls back to the cross-cutting pair; extraction stays focused on what the ACORD queries
|
|
38
|
+
# actually filter that clause type on.
|
|
39
|
+
FUNCTION_DIMENSIONS: dict[str, tuple[PropertyDimension, ...]] = {
|
|
40
|
+
"Cap On Liability": (_D.MUTUALITY, _D.FAVORABILITY, _D.CARVE_OUT, _D.CAP_BASIS, _D.CAP_QUANTUM, _D.PARTY_ASYMMETRY),
|
|
41
|
+
"Uncapped Liability": (_D.MUTUALITY, _D.FAVORABILITY, _D.CARVE_OUT, _D.PARTY_ASYMMETRY),
|
|
42
|
+
"Indirect/Consequential Damages Waiver": (_D.MUTUALITY, _D.FAVORABILITY, _D.CARVE_OUT, _D.DAMAGE_TYPE),
|
|
43
|
+
"Warranty Disclaimer": (_D.FAVORABILITY, _D.WARRANTY_SCOPE),
|
|
44
|
+
"Indemnification": (_D.MUTUALITY, _D.FAVORABILITY, _D.CLAIM_SCOPE, _D.COVERED_SUBJECT, _D.COVERED_PARTIES, _D.PROCEDURAL),
|
|
45
|
+
"Governing Law": (_D.JURISDICTION, _D.LAW_MULTIPLICITY, _D.DISPUTE_METHOD),
|
|
46
|
+
"No-Solicit Of Employees": (_D.NONSOLICIT_TARGET, _D.TEMPORAL_BOUND),
|
|
47
|
+
"No-Solicit Of Customers": (_D.NONSOLICIT_TARGET, _D.TEMPORAL_BOUND),
|
|
48
|
+
"Renewal Term": (_D.RENEWAL_MECHANISM, _D.NOTICE_PERIOD),
|
|
49
|
+
"Notice Period To Terminate Renewal": (_D.RENEWAL_MECHANISM, _D.NOTICE_PERIOD),
|
|
50
|
+
"IP Ownership Assignment": (_D.IP_OWNERSHIP, _D.COVERED_PARTIES),
|
|
51
|
+
"Joint IP Ownership": (_D.IP_OWNERSHIP, _D.COVERED_PARTIES),
|
|
52
|
+
"License Grant": (_D.COVERED_PARTIES,),
|
|
53
|
+
"Affiliate License-Licensor": (_D.COVERED_PARTIES,),
|
|
54
|
+
"Affiliate License-Licensee": (_D.COVERED_PARTIES,),
|
|
55
|
+
# CLS-F: the 8 corpus-sourced dims mapped to their functions (canonical 52-label taxonomy names)
|
|
56
|
+
"Dispute Resolution": (_D.DISPUTE_METHOD,),
|
|
57
|
+
"Confidentiality": (_D.CONFIDENTIALITY_EXCEPTION,),
|
|
58
|
+
"Royalties": (_D.ROYALTY_BASIS,),
|
|
59
|
+
"Security Interest": (_D.COLLATERAL_TYPE,),
|
|
60
|
+
"Condition Precedent": (_D.CONDITION_TYPE,),
|
|
61
|
+
"Force Majeure": (_D.FORCE_MAJEURE_EVENT,),
|
|
62
|
+
"Source Code Escrow": (_D.ESCROW_RELEASE_TRIGGER,),
|
|
63
|
+
"Rofr": (_D.RIGHT_OF_FIRST_TYPE,),
|
|
64
|
+
"Rofo": (_D.RIGHT_OF_FIRST_TYPE,),
|
|
65
|
+
"Rofn": (_D.RIGHT_OF_FIRST_TYPE,),
|
|
66
|
+
}
|
|
67
|
+
_DEFAULT_DIMENSIONS: tuple[PropertyDimension, ...] = (_D.MUTUALITY, _D.FAVORABILITY)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def dimensions_for(function: str) -> tuple[PropertyDimension, ...]:
|
|
71
|
+
return FUNCTION_DIMENSIONS.get(function, _DEFAULT_DIMENSIONS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def scoped_dims(functions: tuple[str, ...]) -> Optional[set[PropertyDimension]]:
|
|
75
|
+
"""CLS-D SOFT function-scoping: the UNION of applicable dims over the clause's (top-k) function soft-tags.
|
|
76
|
+
A classifier cannot say 'not present', so without scoping every dim fires on every span (over-emission on the
|
|
77
|
+
subjective dims the grounding gate can't prune). Scoping to the top-k functions' dims fixes that while staying
|
|
78
|
+
tolerant of the ~0.5 function accuracy (the union over top-k catches the right function even when top-1 is
|
|
79
|
+
wrong). An EMPTY `functions` (a genuinely untagged provision) returns None -> no scoping (run every dim)."""
|
|
80
|
+
if not functions:
|
|
81
|
+
return None
|
|
82
|
+
out: set[PropertyDimension] = set()
|
|
83
|
+
for f in functions:
|
|
84
|
+
out.update(dimensions_for(f))
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# CLS-B/C rework (ADR-0066 candidate for ontology migration, like FUNCTION_DIMENSIONS): the FUNCTION-INDEPENDENT
|
|
89
|
+
# Step-3a routing. The classifier lane fills every dim the registry covers. These 7 numeric/open dims are the ONLY
|
|
90
|
+
# ones the LLM extracts -- a classifier cannot emit a number/place/duration -- and that is PERMANENT. The 8
|
|
91
|
+
# corpus-starved closed-vocab dims (dispute_method, royalty_basis, condition_type, collateral_type,
|
|
92
|
+
# force_majeure_event, confidentiality_exception, right_of_first_type, escrow_release_trigger) are NOT extracted
|
|
93
|
+
# here until CLS-F sources data, after which they join the CLASSIFIER lane -- never the LLM.
|
|
94
|
+
RESIDUAL_LLM_DIMS: tuple[PropertyDimension, ...] = (
|
|
95
|
+
_D.CAP_QUANTUM, _D.JURISDICTION, _D.TEMPORAL_BOUND, _D.NOTICE_PERIOD,
|
|
96
|
+
_D.AUDIT_FREQUENCY, _D.COMMITMENT_QUANTUM, _D.LD_TRIGGER,
|
|
97
|
+
)
|
|
98
|
+
# Classifier dims that do not clear the confidence bar (cap_basis numeric, renewal_mechanism subjective): served
|
|
99
|
+
# locally like the rest, but emitted AMBIGUOUS so the grounding gate + query side treat them as low-confidence.
|
|
100
|
+
# They flip to EXTRACTED if a better model/more data later clears the bar; they are NEVER routed to the LLM.
|
|
101
|
+
ACCEPT_WEAK_DIMS: frozenset[PropertyDimension] = frozenset({_D.CAP_BASIS, _D.RENEWAL_MECHANISM})
|
|
102
|
+
|
|
103
|
+
# The residual numeric/open dims are EXTRACTIVE (a number, amount, place, duration) -- not label selection -- so
|
|
104
|
+
# they need a value-verbatim prompt, not the closed-vocab "one of {...}" or "lowercase_snake" phrasing.
|
|
105
|
+
_RESIDUAL_DIM_HINTS: dict[PropertyDimension, str] = {
|
|
106
|
+
_D.CAP_QUANTUM: "the monetary amount or formula of a liability cap (e.g. '$1,000,000', '12 months of fees')",
|
|
107
|
+
_D.JURISDICTION: "the governing-law jurisdiction (e.g. 'Delaware', 'England and Wales')",
|
|
108
|
+
_D.TEMPORAL_BOUND: "the clause's time bound or duration (e.g. '3 years', 'the Term')",
|
|
109
|
+
_D.NOTICE_PERIOD: "the required notice period (e.g. '60 days', 'ninety (90) days')",
|
|
110
|
+
_D.AUDIT_FREQUENCY: "how often an audit is permitted (e.g. 'annually', 'twice per year')",
|
|
111
|
+
_D.COMMITMENT_QUANTUM: "a committed quantity or minimum (e.g. '10,000 units', '$500,000')",
|
|
112
|
+
_D.LD_TRIGGER: "the event that triggers liquidated damages (a short phrase)",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def residual_extraction_prompt(text: str) -> str:
|
|
117
|
+
"""The residual LLM prompt: the 7 numeric/open dims only, extracted VERBATIM (not categorized). One call."""
|
|
118
|
+
lines = "\n".join(f"- {d.value}: {_RESIDUAL_DIM_HINTS[d]}" for d in RESIDUAL_LLM_DIMS)
|
|
119
|
+
return (
|
|
120
|
+
"Extract these NUMERIC/OPEN properties from the contract clause span, ONLY when the span explicitly states "
|
|
121
|
+
"them; omit any not present (do not guess). Report each value VERBATIM from the text -- a number, amount, "
|
|
122
|
+
"place, or duration, NOT a category label. Confidence EXTRACTED (stated) or INFERRED (clearly implied).\n\n"
|
|
123
|
+
f"Properties:\n{lines}\n\nSpan:\n{text[:2000]}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ExtractedProperty(BaseModel):
|
|
128
|
+
"""One property as the LLM emits it (pre-provenance): a dimension, a value, and a confidence."""
|
|
129
|
+
|
|
130
|
+
dimension: PropertyDimension
|
|
131
|
+
value: str
|
|
132
|
+
confidence: ConfidenceTag
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class PropertyExtraction(BaseModel):
|
|
136
|
+
"""The LLM's structured output for one span: the properties it states (empty if none)."""
|
|
137
|
+
|
|
138
|
+
properties: list[ExtractedProperty] = []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def extraction_prompt(function: str, dimensions: tuple[PropertyDimension, ...], text: str) -> str:
|
|
142
|
+
"""The extraction prompt: only the dimensions that apply to this function, with their vocabularies."""
|
|
143
|
+
lines = []
|
|
144
|
+
for d in dimensions:
|
|
145
|
+
vocab = CLOSED_VOCAB.get(d)
|
|
146
|
+
if vocab:
|
|
147
|
+
lines.append(f"- {d.value}: one or more of {sorted(vocab)}; a genuinely novel value must be AMBIGUOUS")
|
|
148
|
+
else:
|
|
149
|
+
lines.append(f"- {d.value}: a short lowercase_snake value (open-valued)")
|
|
150
|
+
dims = "\n".join(lines)
|
|
151
|
+
return (
|
|
152
|
+
f"You extract queryable PROPERTIES from a '{function}' contract clause span for a legal search index.\n"
|
|
153
|
+
"Report ONLY properties the span actually states; omit any dimension the span does not address "
|
|
154
|
+
"(do not guess). Confidence: EXTRACTED (stated), INFERRED (clearly implied), AMBIGUOUS (a novel "
|
|
155
|
+
"value outside the list, or competing readings). Multi-valued dimensions (e.g. carve_out) may have "
|
|
156
|
+
"several entries.\n\n"
|
|
157
|
+
f"Dimensions for a '{function}' clause:\n{dims}\n\n"
|
|
158
|
+
f"Span:\n{text[:2000]}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_record(
|
|
163
|
+
*, chunk_id: ChunkId, function: str, span_id: str, extraction: PropertyExtraction
|
|
164
|
+
) -> ClausePropertyRecord:
|
|
165
|
+
"""Map an LLM `PropertyExtraction` to a validated `ClausePropertyRecord` (pure; no model call).
|
|
166
|
+
|
|
167
|
+
Scope-filters to the function's dimensions (drops any invented out-of-scope dimension), and coerces an
|
|
168
|
+
out-of-vocabulary value to an AMBIGUOUS assertion (the schema's `other` escape) instead of dropping it.
|
|
169
|
+
"""
|
|
170
|
+
function = canonical_function(function) or function # normalize classifier casing (e.g. 'Ip' -> 'IP')
|
|
171
|
+
prov = Provenance.of(chunk_id)
|
|
172
|
+
applicable = set(dimensions_for(function))
|
|
173
|
+
assertions: list[PropertyAssertion] = []
|
|
174
|
+
for p in extraction.properties:
|
|
175
|
+
if p.dimension not in applicable:
|
|
176
|
+
continue # function-aware: ignore a dimension that does not belong to this clause type
|
|
177
|
+
try:
|
|
178
|
+
assertions.append(
|
|
179
|
+
PropertyAssertion(
|
|
180
|
+
provenance=prov, confidence=p.confidence, dimension=p.dimension, value=p.value, span_id=span_id
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except ValidationError:
|
|
184
|
+
# out-of-vocabulary value with a non-AMBIGUOUS confidence -> retain it as the AMBIGUOUS escape
|
|
185
|
+
try:
|
|
186
|
+
assertions.append(
|
|
187
|
+
PropertyAssertion(
|
|
188
|
+
provenance=prov, confidence=ConfidenceTag.AMBIGUOUS, dimension=p.dimension,
|
|
189
|
+
value=p.value, span_id=span_id,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
except ValidationError:
|
|
193
|
+
continue # empty value or otherwise unsalvageable -> drop
|
|
194
|
+
return ClausePropertyRecord(
|
|
195
|
+
clause_id=str(chunk_id), function=function, folio_iri=FOLIO_CLAUSE_IRI.get(function, ""),
|
|
196
|
+
assertions=assertions,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@runtime_checkable
|
|
201
|
+
class PropertyExtractor(Protocol):
|
|
202
|
+
"""Span -> `ClausePropertyRecord`. The seam a test stubs and T57c drives via `map_concurrent`."""
|
|
203
|
+
|
|
204
|
+
def __call__(self, *, chunk_id: ChunkId, function: str, text: str, span_id: str) -> ClausePropertyRecord: ...
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SeamPropertyExtractor:
|
|
208
|
+
"""The real extractor: structured output through the model-profile seam on STRUCTURED_REASONING
|
|
209
|
+
(DeepSeek V4 Pro by default). Retries a bare `None` (a transient miss `with_structured_output`
|
|
210
|
+
returns), returning an empty record only if extraction persistently fails (the span keeps its
|
|
211
|
+
function; it simply carries no properties)."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, model_id: Optional[str] = None, *, runnable=None, retries: int = 3) -> None:
|
|
214
|
+
self._runnable = runnable or build_structured(
|
|
215
|
+
model_id or model_for(ModelRole.STRUCTURED_REASONING), PropertyExtraction
|
|
216
|
+
)
|
|
217
|
+
self._retries = retries
|
|
218
|
+
|
|
219
|
+
def __call__(self, *, chunk_id: ChunkId, function: str, text: str, span_id: str = "") -> ClausePropertyRecord:
|
|
220
|
+
prompt = extraction_prompt(function, dimensions_for(function), text)
|
|
221
|
+
extraction: Optional[PropertyExtraction] = None
|
|
222
|
+
for _ in range(self._retries):
|
|
223
|
+
try:
|
|
224
|
+
extraction = self._runnable.invoke(prompt)
|
|
225
|
+
except Exception: # noqa: BLE001 - transient provider/parse error; retry
|
|
226
|
+
continue
|
|
227
|
+
if extraction is not None:
|
|
228
|
+
break
|
|
229
|
+
return build_record(
|
|
230
|
+
chunk_id=chunk_id, function=function, span_id=span_id,
|
|
231
|
+
extraction=extraction or PropertyExtraction(),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class HybridPropertyExtractor:
|
|
236
|
+
"""CLS-B/C (FR-C.6/FR-I.4): the FUNCTION-INDEPENDENT Step-3a property extractor. This is THE extractor for
|
|
237
|
+
these dimensions -- not a toggle over an LLM fallback (ADR: the classifier lane is the decided path).
|
|
238
|
+
|
|
239
|
+
Two lanes, function-independent (the clause function is a soft tag on the record, never a gate):
|
|
240
|
+
* CLASSIFIER lane -- every dim the registry covers is answered by its trained `DimClassifier` (top-k soft
|
|
241
|
+
tags: rank-0 EXTRACTED, lower ranks INFERRED). `ACCEPT_WEAK_DIMS` are emitted AMBIGUOUS (low-confidence).
|
|
242
|
+
* RESIDUAL LLM lane -- ONE consolidated call for `RESIDUAL_LLM_DIMS` ONLY (the 7 numeric/open dims a
|
|
243
|
+
classifier cannot emit). The LLM is NEVER asked for a classifier dim, and any starved/other dim it
|
|
244
|
+
volunteers is filtered out.
|
|
245
|
+
The 8 corpus-starved dims are not extracted here (they join the classifier lane after CLS-F). Same
|
|
246
|
+
`PropertyExtractor` Protocol, so the ADR-0028 grounding + ADR-0040 symbolic gates downstream apply unchanged.
|
|
247
|
+
Classifiers honor the device-agnostic serving seam (GPU-if-available-else-CPU); the LLM stays on the seam."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, registry=None, *, runnable=None, model_id: Optional[str] = None, retries: int = 3,
|
|
250
|
+
classifier_fn=None) -> None:
|
|
251
|
+
self._registry = registry
|
|
252
|
+
self._runnable = runnable or build_structured(
|
|
253
|
+
model_id or model_for(ModelRole.STRUCTURED_REASONING), PropertyExtraction)
|
|
254
|
+
self._retries = retries
|
|
255
|
+
# EP-RT-7: when set, the classifier LANE is obtained through the `clause_property_classification` capability
|
|
256
|
+
# (the single production path) instead of this instance's local fleet -- `(text, functions) -> [{dimension,
|
|
257
|
+
# value, confidence}]`. The capability impl (`classify_properties`) itself always uses the local fleet, so
|
|
258
|
+
# there is no recursion. `None` -> this instance owns the fleet (the capability adapter's own extractor).
|
|
259
|
+
self._classifier_fn = classifier_fn
|
|
260
|
+
|
|
261
|
+
def _classifier_lane(self, prov: Provenance, text: str, span_id: str,
|
|
262
|
+
functions: tuple[str, ...]) -> list[PropertyAssertion]:
|
|
263
|
+
"""The classifier-lane assertions: via the capability (`_classifier_fn`) when injected, else the local fleet."""
|
|
264
|
+
if self._classifier_fn is None:
|
|
265
|
+
return self._classifier_assertions(prov, text, span_id, scoped_dims(functions))
|
|
266
|
+
out: list[PropertyAssertion] = []
|
|
267
|
+
for tag in self._classifier_fn(text, tuple(functions)):
|
|
268
|
+
try:
|
|
269
|
+
out.append(PropertyAssertion(
|
|
270
|
+
provenance=prov, confidence=ConfidenceTag(tag["confidence"]),
|
|
271
|
+
dimension=PropertyDimension(tag["dimension"]), value=tag["value"], span_id=span_id))
|
|
272
|
+
except (ValidationError, ValueError):
|
|
273
|
+
continue # a value/dim/confidence the contract rejects -> drop (same as the local lane)
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
def _classifier_assertions(self, prov: Provenance, text: str, span_id: str,
|
|
277
|
+
applicable: Optional[set[PropertyDimension]]) -> list[PropertyAssertion]:
|
|
278
|
+
"""Classifier lane: soft-scoped to the function's applicable dims (`applicable=None` -> every covered dim),
|
|
279
|
+
top-k soft tags (accept-weak -> AMBIGUOUS)."""
|
|
280
|
+
out: list[PropertyAssertion] = []
|
|
281
|
+
for d in self._registry.dims:
|
|
282
|
+
if applicable is not None and d not in applicable:
|
|
283
|
+
continue # out of the clause function's scope -> don't emit (over-emission fix)
|
|
284
|
+
clf = self._registry.get(d)
|
|
285
|
+
if clf is None:
|
|
286
|
+
continue
|
|
287
|
+
for rank, (value, _prob) in enumerate(clf.classify(text)):
|
|
288
|
+
if str(value).lower() == "none":
|
|
289
|
+
continue # ABSTAIN sentinel (ADR-0116): the classifier says "dim not present" -> emit nothing
|
|
290
|
+
if d in ACCEPT_WEAK_DIMS:
|
|
291
|
+
conf = ConfidenceTag.AMBIGUOUS
|
|
292
|
+
else:
|
|
293
|
+
conf = ConfidenceTag.EXTRACTED if rank == 0 else ConfidenceTag.INFERRED
|
|
294
|
+
try:
|
|
295
|
+
out.append(PropertyAssertion(provenance=prov, confidence=conf, dimension=d,
|
|
296
|
+
value=value, span_id=span_id))
|
|
297
|
+
except ValidationError:
|
|
298
|
+
continue # a value outside the dim vocab (shouldn't happen from a trained head) -> drop
|
|
299
|
+
return out
|
|
300
|
+
|
|
301
|
+
def classify_properties(self, text: str, *, functions: tuple[str, ...] = ()) -> list[dict]:
|
|
302
|
+
"""EP-RT-1 (ADR-0117): the classifier LANE as a standalone `model`-kind capability -- closed-vocab property
|
|
303
|
+
assertions for one provision, soft-scoped to the clause `functions` (no LLM, no gates). Returns
|
|
304
|
+
`[{dimension, value, confidence}]` (primary-first within each dim). `functions=()` -> every covered dim."""
|
|
305
|
+
prov = Provenance.of(ChunkId(source_doc_id="classify", chunk_index=0, content_hash="0" * 64))
|
|
306
|
+
asserts = self._classifier_assertions(prov, text, "", scoped_dims(functions))
|
|
307
|
+
return [{"dimension": a.dimension.value, "value": a.value, "confidence": a.confidence.value}
|
|
308
|
+
for a in asserts]
|
|
309
|
+
|
|
310
|
+
def _residual_assertions(self, prov: Provenance, extraction: Optional[PropertyExtraction],
|
|
311
|
+
span_id: str) -> list[PropertyAssertion]:
|
|
312
|
+
"""Keep ONLY the 7 residual dims from the LLM's answer -- never a classifier dim or a starved dim."""
|
|
313
|
+
out: list[PropertyAssertion] = []
|
|
314
|
+
residual = set(RESIDUAL_LLM_DIMS)
|
|
315
|
+
for p in (extraction.properties if extraction else []):
|
|
316
|
+
if p.dimension not in residual:
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
out.append(PropertyAssertion(provenance=prov, confidence=p.confidence, dimension=p.dimension,
|
|
320
|
+
value=p.value, span_id=span_id))
|
|
321
|
+
except ValidationError:
|
|
322
|
+
continue
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
def _record(self, chunk_id: ChunkId, function: str, assertions: list[PropertyAssertion],
|
|
326
|
+
span_id: str = "") -> ClausePropertyRecord:
|
|
327
|
+
# Carry the record-level operative-span anchor (ADR-0025) EVEN when property-less: Leg A rehydrates a
|
|
328
|
+
# property-less clause from its own `span_id` (else it has no citable text and the serve step abstains).
|
|
329
|
+
return ClausePropertyRecord(clause_id=str(chunk_id), function=function,
|
|
330
|
+
folio_iri=FOLIO_CLAUSE_IRI.get(function, ""),
|
|
331
|
+
span_id=span_id, assertions=assertions)
|
|
332
|
+
|
|
333
|
+
def __call__(self, *, chunk_id: ChunkId, function: str, text: str, span_id: str = "",
|
|
334
|
+
functions: tuple[str, ...] = ()) -> ClausePropertyRecord:
|
|
335
|
+
function = canonical_function(function) or function
|
|
336
|
+
prov = Provenance.of(chunk_id)
|
|
337
|
+
assertions = self._classifier_lane(prov, text, span_id, functions)
|
|
338
|
+
extraction: Optional[PropertyExtraction] = None # ONE residual call (the 7 numeric dims), always fires
|
|
339
|
+
for _ in range(self._retries):
|
|
340
|
+
try:
|
|
341
|
+
extraction = self._runnable.invoke(residual_extraction_prompt(text))
|
|
342
|
+
except Exception: # noqa: BLE001 - transient provider/parse error; retry
|
|
343
|
+
continue
|
|
344
|
+
if extraction is not None:
|
|
345
|
+
break
|
|
346
|
+
assertions += self._residual_assertions(prov, extraction, span_id)
|
|
347
|
+
return self._record(chunk_id, function, assertions, span_id)
|
|
348
|
+
|
|
349
|
+
async def aextract(self, *, chunk_id: ChunkId, function: str, text: str,
|
|
350
|
+
span_id: str = "", functions: tuple[str, ...] = ()) -> ClausePropertyRecord:
|
|
351
|
+
"""ASYNC-B2b (ADR-0057): async twin -- classifiers run in-process (ms), the ONE residual call is awaited.
|
|
352
|
+
`functions` = the clause's top-k function soft-tags for soft-scoping (empty -> every covered dim runs)."""
|
|
353
|
+
function = canonical_function(function) or function
|
|
354
|
+
prov = Provenance.of(chunk_id)
|
|
355
|
+
assertions = self._classifier_lane(prov, text, span_id, functions)
|
|
356
|
+
extraction: Optional[PropertyExtraction] = None
|
|
357
|
+
for _ in range(self._retries):
|
|
358
|
+
try:
|
|
359
|
+
extraction = await self._runnable.ainvoke(residual_extraction_prompt(text))
|
|
360
|
+
except Exception: # noqa: BLE001 - transient provider/parse error; retry
|
|
361
|
+
continue
|
|
362
|
+
if extraction is not None:
|
|
363
|
+
break
|
|
364
|
+
assertions += self._residual_assertions(prov, extraction, span_id)
|
|
365
|
+
return self._record(chunk_id, function, assertions, span_id)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""T61 (FR-C.6, ADR-0028): the deterministic property-grounding judge.
|
|
2
|
+
|
|
3
|
+
Most of the property vocabulary maps to legal TERMS OF ART that must physically appear in the clause. This
|
|
4
|
+
judge checks, locally and deterministically (no model, no network), whether an `EXTRACTED` value on a
|
|
5
|
+
lexically-anchored dimension actually has its surface cue in the text. If not, the value is UNGROUNDED --
|
|
6
|
+
almost certainly hallucinated (e.g. Flash emitting `carve_out=fraud` on a clause with no "fraud").
|
|
7
|
+
|
|
8
|
+
Two uses (double duty):
|
|
9
|
+
1. **Escalation trigger** (T58b cascade): run the fast model (Flash) for the bulk, and re-extract only the
|
|
10
|
+
clauses the judge flags with the precise model (Pro). Concentrates the throttled Pro calls on the
|
|
11
|
+
deterministically-suspect cases; a false flag costs one extra Pro call, never correctness.
|
|
12
|
+
2. **Permanent quality gate** on the final graph: `reground` downgrades an ungrounded `EXTRACTED` assertion
|
|
13
|
+
to `AMBIGUOUS` (kept, but marked unverified so soft-boost down-weights it) regardless of which model
|
|
14
|
+
produced it -- so the shared property-value nodes stay clean.
|
|
15
|
+
|
|
16
|
+
Coverage: lexically-anchored closed values (cue check), plus open-valued scalars (token overlap,
|
|
17
|
+
GROUNDING-OPENVALUED). LIMITATION: the closed SEMANTIC dimensions (mutuality, favorability, party_asymmetry,
|
|
18
|
+
cap_basis, the consent regimes) carry no surface form and are NOT checkable here; their errors pass through
|
|
19
|
+
unflagged and are the target of the Layer-3 semantic judge (ADR-0040, JUDGE-SEMANTIC).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from rag_wright.contracts.property import (
|
|
27
|
+
CLOSED_VOCAB,
|
|
28
|
+
ClausePropertyRecord,
|
|
29
|
+
PropertyAssertion,
|
|
30
|
+
PropertyDimension,
|
|
31
|
+
)
|
|
32
|
+
from rag_wright.contracts.provenance import ConfidenceTag
|
|
33
|
+
|
|
34
|
+
_D = PropertyDimension
|
|
35
|
+
|
|
36
|
+
# Open-valued dimensions carry a free-text scalar (jurisdiction, cap_quantum, temporal_bound, notice_period,
|
|
37
|
+
# audit_frequency, commitment_quantum, ld_trigger) rather than a closed vocabulary. GROUNDING-OPENVALUED
|
|
38
|
+
# (ADR-0040): unlike a closed SEMANTIC dim (mutuality/etc., which stays Layer-3's job), an open value SHOULD
|
|
39
|
+
# have a textual anchor -- an EXTRACTED `jurisdiction=Delaware` on a clause that never mentions Delaware is a
|
|
40
|
+
# fabrication. Checked by TOKEN OVERLAP (lenient: grounded if ANY significant token of the value appears), so
|
|
41
|
+
# normalized forms survive (`12_months` grounded by "months" in "twelve (12) months") while pure inventions
|
|
42
|
+
# with no overlapping token are flagged.
|
|
43
|
+
OPEN_VALUED_DIMENSIONS: frozenset[PropertyDimension] = frozenset(
|
|
44
|
+
d for d in PropertyDimension if d not in CLOSED_VOCAB
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# dimension -> value -> surface cues (lowercased substrings). A value ABSENT from this map is NOT
|
|
48
|
+
# lexically anchored (semantic/open-valued) and is treated as grounded (the judge cannot disprove it).
|
|
49
|
+
GROUNDING_CUES: dict[PropertyDimension, dict[str, tuple[str, ...]]] = {
|
|
50
|
+
_D.CARVE_OUT: {
|
|
51
|
+
"indemnification": ("indemnif", "hold harmless"),
|
|
52
|
+
"confidentiality": ("confidential",),
|
|
53
|
+
"third_party_ip_infringement": ("infring",),
|
|
54
|
+
"fraud": ("fraud",),
|
|
55
|
+
"gross_negligence": ("gross negligence",),
|
|
56
|
+
"willful_misconduct": ("willful misconduct", "wilful misconduct"),
|
|
57
|
+
"bodily_injury": ("bodily injury", "personal injury", "death"),
|
|
58
|
+
"applicable_law": ("law",),
|
|
59
|
+
},
|
|
60
|
+
_D.COVERED_SUBJECT: {
|
|
61
|
+
"ip_infringement": ("infring",),
|
|
62
|
+
"trademark": ("trademark",),
|
|
63
|
+
"copyright": ("copyright",),
|
|
64
|
+
"violation_of_law": ("violation of law", "breach of law", "applicable law", "law"),
|
|
65
|
+
"fraud": ("fraud",),
|
|
66
|
+
"gross_negligence": ("gross negligence",),
|
|
67
|
+
},
|
|
68
|
+
_D.DAMAGE_TYPE: {
|
|
69
|
+
"indirect": ("indirect",),
|
|
70
|
+
"consequential": ("consequential",),
|
|
71
|
+
"incidental": ("incidental",),
|
|
72
|
+
"punitive": ("punitive", "exemplary"),
|
|
73
|
+
"special": ("special",),
|
|
74
|
+
},
|
|
75
|
+
_D.WARRANTY_SCOPE: {
|
|
76
|
+
"implied": ("implied", "merchantability", "fitness for"),
|
|
77
|
+
"express": ("express",),
|
|
78
|
+
"as_is": ("as is", "as-is", "with all faults"),
|
|
79
|
+
"non_reliance": ("non-reliance", "nonreliance", "no reliance", "not relied"),
|
|
80
|
+
},
|
|
81
|
+
_D.PROCEDURAL: {
|
|
82
|
+
"duty_to_defend": ("defend", "defense", "defence"),
|
|
83
|
+
"control_of_defense": ("control of the defense", "control of the defence", "control of defense",
|
|
84
|
+
"sole control", "conduct of the defense", "conduct the defense"),
|
|
85
|
+
},
|
|
86
|
+
_D.COVERED_PARTIES: {
|
|
87
|
+
"affiliates": ("affiliate",),
|
|
88
|
+
"licensor_affiliates": ("affiliate",),
|
|
89
|
+
"licensee_affiliates": ("affiliate",),
|
|
90
|
+
},
|
|
91
|
+
_D.CLAIM_SCOPE: {
|
|
92
|
+
"third_party": ("third party", "third-party"),
|
|
93
|
+
},
|
|
94
|
+
# tier 3 -- CUAD-family extensions (KG-4). Only the strongly lexically-anchored values; the semantic
|
|
95
|
+
# ones (consent regimes, mfn_scope, termination_right) are not checkable and pass through.
|
|
96
|
+
_D.EXCLUSIVITY_TYPE: {
|
|
97
|
+
"exclusive": ("exclusive",),
|
|
98
|
+
"sole": ("sole",),
|
|
99
|
+
"non_exclusive": ("non-exclusive", "nonexclusive", "non exclusive"),
|
|
100
|
+
},
|
|
101
|
+
_D.RIGHT_OF_FIRST_TYPE: {
|
|
102
|
+
"rofr": ("first refusal",),
|
|
103
|
+
"rofo": ("first offer",),
|
|
104
|
+
"rofn": ("first negotiation",),
|
|
105
|
+
},
|
|
106
|
+
_D.ESCROW_RELEASE_TRIGGER: {
|
|
107
|
+
"bankruptcy": ("bankrupt", "insolven"),
|
|
108
|
+
"breach": ("breach", "default"),
|
|
109
|
+
"discontinuance": ("discontinu", "cease", "no longer"),
|
|
110
|
+
},
|
|
111
|
+
_D.RESTRICTION_SCOPE: {
|
|
112
|
+
"geographic": ("geographic", "territor", "worldwide", "region"),
|
|
113
|
+
"activity": ("activit", "business", "compet"),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_lexically_anchored(dimension: PropertyDimension, value: str) -> bool:
|
|
119
|
+
"""Whether this (dimension, value) has a defined surface cue the judge can check at all."""
|
|
120
|
+
return value in GROUNDING_CUES.get(dimension, {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _open_value_grounded(value: str, text: str) -> bool:
|
|
124
|
+
"""An open-valued scalar is grounded if ANY significant token of its (normalized) value appears in the
|
|
125
|
+
text -- lenient so normalized forms survive, strict enough to flag a value with no textual anchor at all."""
|
|
126
|
+
low = text.lower()
|
|
127
|
+
tokens = [t for t in re.split(r"[^a-z0-9]+", value.lower()) if len(t) >= 2]
|
|
128
|
+
if not tokens: # nothing checkable (e.g. a single-char value) -> cannot disprove
|
|
129
|
+
return True
|
|
130
|
+
return any(t in low for t in tokens)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_grounded(dimension: PropertyDimension, value: str, text: str) -> bool:
|
|
134
|
+
"""True if the value is supported by the text. Lexically-anchored (closed-vocab) values require their cue
|
|
135
|
+
to appear; open-valued scalars require a token overlap (GROUNDING-OPENVALUED); closed SEMANTIC values
|
|
136
|
+
(mutuality/etc.) carry no surface form and are treated as grounded -- the judge cannot disprove them."""
|
|
137
|
+
cues = GROUNDING_CUES.get(dimension, {}).get(value)
|
|
138
|
+
if cues is not None:
|
|
139
|
+
low = text.lower()
|
|
140
|
+
return any(cue in low for cue in cues)
|
|
141
|
+
if dimension in OPEN_VALUED_DIMENSIONS:
|
|
142
|
+
return _open_value_grounded(value, text)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ungrounded_assertions(record: ClausePropertyRecord, text: str) -> list[PropertyAssertion]:
|
|
147
|
+
"""The record's `EXTRACTED` assertions on a lexically-anchored dimension whose cue is ABSENT from the
|
|
148
|
+
text -- the likely hallucinations. `INFERRED`/`AMBIGUOUS` are not flagged (they do not claim to be
|
|
149
|
+
stated verbatim)."""
|
|
150
|
+
return [
|
|
151
|
+
a for a in record.assertions
|
|
152
|
+
if a.confidence == ConfidenceTag.EXTRACTED and not is_grounded(a.dimension, a.value, text)
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def needs_escalation(record: ClausePropertyRecord, text: str) -> bool:
|
|
157
|
+
"""Escalation trigger: True if any EXTRACTED value is ungrounded -> re-extract this clause with the
|
|
158
|
+
precise model (the T58b Flash->Pro cascade)."""
|
|
159
|
+
return bool(ungrounded_assertions(record, text))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def reground(record: ClausePropertyRecord, text: str) -> ClausePropertyRecord:
|
|
163
|
+
"""Quality gate (double duty): downgrade every ungrounded `EXTRACTED` assertion to `AMBIGUOUS` (kept but
|
|
164
|
+
flagged unverified, so soft-boost down-weights it) -- a model-agnostic gate on the final graph."""
|
|
165
|
+
flagged = {id(a) for a in ungrounded_assertions(record, text)}
|
|
166
|
+
if not flagged:
|
|
167
|
+
return record
|
|
168
|
+
new = [
|
|
169
|
+
a.model_copy(update={"confidence": ConfidenceTag.AMBIGUOUS}) if id(a) in flagged else a
|
|
170
|
+
for a in record.assertions
|
|
171
|
+
]
|
|
172
|
+
return record.model_copy(update={"assertions": new})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def register_extraction_grounding_judge(registry) -> None:
|
|
176
|
+
"""CAP-REG-2: register `extraction_grounding_judge` (function; ADR-0028 lexical grounding gate)."""
|
|
177
|
+
registry.register(
|
|
178
|
+
"extraction_grounding_judge",
|
|
179
|
+
contract=ClausePropertyRecord,
|
|
180
|
+
kind="function",
|
|
181
|
+
display_name="Extraction grounding judge",
|
|
182
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""INGEST-LLM-CLASSIFIER (ADR-0048) Phase A: the non-destructive reclassify logic.
|
|
2
|
+
|
|
3
|
+
Pure, testable core (no store, no LLM): given a chunk's spans (full context) + the existing Clause labels + a
|
|
4
|
+
batched classifier, compute the NEW primary + multi-label per existing clause, and detect PRIMARY FLIPS (the only
|
|
5
|
+
thing that triggers Phase B re-extraction). The store driver (`scripts/reclassify_kg.py`) reads/writes the KG and
|
|
6
|
+
runs this concurrently with X/N progress."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rag_wright.contracts.function import NO_FUNCTION, FunctionScore, primary_function
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ClauseReclass:
|
|
19
|
+
"""The reclassification of one existing Clause: its id/span, old primary, and the new scores + primary."""
|
|
20
|
+
|
|
21
|
+
clause_id: str
|
|
22
|
+
span_id: str
|
|
23
|
+
old_function: str
|
|
24
|
+
new_scores: list[FunctionScore]
|
|
25
|
+
new_primary: str # primary_function(new_scores) or NO_FUNCTION
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def primary_flipped(self) -> bool:
|
|
29
|
+
return self.old_function != self.new_primary
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reclassify_chunk(
|
|
33
|
+
chunk_text: str,
|
|
34
|
+
ordered_spans: list[tuple[str, str]], # [(span_id, span_text)] in span order (full chunk = context)
|
|
35
|
+
existing_by_span: dict[str, tuple[str, str]], # span_id -> (clause_id, old_function) for existing Clauses
|
|
36
|
+
classify_fn: Any,
|
|
37
|
+
) -> list[ClauseReclass]:
|
|
38
|
+
"""Batched-classify the chunk's spans (one call, chunk as context), then map the result back to the chunk's
|
|
39
|
+
EXISTING Clause nodes only (non-destructive: spans without an existing Clause are left to Phase B)."""
|
|
40
|
+
if not ordered_spans:
|
|
41
|
+
return []
|
|
42
|
+
scores_per_span = classify_fn.classify_spans(chunk_text, [t for _, t in ordered_spans])
|
|
43
|
+
out: list[ClauseReclass] = []
|
|
44
|
+
for (span_id, _), scores in zip(ordered_spans, scores_per_span):
|
|
45
|
+
if span_id in existing_by_span:
|
|
46
|
+
clause_id, old_fn = existing_by_span[span_id]
|
|
47
|
+
out.append(ClauseReclass(
|
|
48
|
+
clause_id=clause_id, span_id=span_id, old_function=old_fn,
|
|
49
|
+
new_scores=list(scores), new_primary=primary_function(scores) or NO_FUNCTION))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ReclassDelta:
|
|
55
|
+
"""The aggregate reclassification delta (for the dry-run report + gating Phase B)."""
|
|
56
|
+
|
|
57
|
+
total: int = 0
|
|
58
|
+
unchanged: int = 0
|
|
59
|
+
flipped: int = 0 # primary changed -> Phase B re-extraction candidates
|
|
60
|
+
to_none: int = 0 # primary flipped to NO_FUNCTION (clause should be retired)
|
|
61
|
+
from_none: int = 0 # was NONE-ish, now a real function (should not happen for EXISTING clauses)
|
|
62
|
+
transitions: Counter = field(default_factory=Counter) # (old -> new) -> count, flips only
|
|
63
|
+
|
|
64
|
+
def add(self, rc: ClauseReclass) -> None:
|
|
65
|
+
self.total += 1
|
|
66
|
+
if rc.primary_flipped:
|
|
67
|
+
self.flipped += 1
|
|
68
|
+
self.transitions[(rc.old_function, rc.new_primary)] += 1
|
|
69
|
+
if rc.new_primary == NO_FUNCTION:
|
|
70
|
+
self.to_none += 1
|
|
71
|
+
if rc.old_function == NO_FUNCTION:
|
|
72
|
+
self.from_none += 1
|
|
73
|
+
else:
|
|
74
|
+
self.unchanged += 1
|
|
75
|
+
|
|
76
|
+
def top_transitions(self, n: int = 15) -> list[tuple[tuple[str, str], int]]:
|
|
77
|
+
return self.transitions.most_common(n)
|