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,163 @@
|
|
|
1
|
+
"""Entity disambiguation and canonicalization (FR-C.7, T23b): normalize, reject, cluster.
|
|
2
|
+
|
|
3
|
+
The stage between recognition (T23) and closed-world linking (T24). It takes the raw `EntityMention`
|
|
4
|
+
stream from graph extraction and turns it into canonical mention clusters, each a proposal a human
|
|
5
|
+
verifies before T24 links it to an EDGAR CIK. Three deterministic stages (ADR-0004): normalize surface
|
|
6
|
+
variants to one key, reject non-entities, cluster survivors by key. Two things this capability adds on
|
|
7
|
+
top of the T10 rules it reuses (`corpus.canonicalize`): it carries `chunk_id` provenance and confidence
|
|
8
|
+
onto each cluster, and it applies the conservative-merge bias (ADR-0004 C3/C4) by FLAGGING ambiguous
|
|
9
|
+
near-duplicates (parent/subsidiary or shared-token pairs) for a human decision rather than merging them
|
|
10
|
+
— a false merge is a silent, invisible error, worse than a false split the human can see.
|
|
11
|
+
|
|
12
|
+
Full coreference (pronouns, definite descriptions like "the Company" bound to a party) is deferred
|
|
13
|
+
behind the `CoreferenceResolver` seam, the same discipline as OpenIE at T5: a stable interface
|
|
14
|
+
additional resolvers bind later, with nothing here reopened.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from rag_wright.capabilities.registry import CapabilityRegistry
|
|
25
|
+
from rag_wright.contracts.extraction import ExtractionResult
|
|
26
|
+
from rag_wright.contracts.provenance import ConfidenceTag
|
|
27
|
+
from rag_wright.corpus.canonicalize import is_entity, normalize_entity_name
|
|
28
|
+
|
|
29
|
+
# Confidence weakness rank: a cluster carries the WEAKEST tag among its mentions (a cluster is only as
|
|
30
|
+
# trustworthy as its least-certain member).
|
|
31
|
+
_CONFIDENCE_RANK = {ConfidenceTag.EXTRACTED: 0, ConfidenceTag.INFERRED: 1, ConfidenceTag.AMBIGUOUS: 2}
|
|
32
|
+
_RANK_TO_CONFIDENCE = {rank: tag for tag, rank in _CONFIDENCE_RANK.items()}
|
|
33
|
+
|
|
34
|
+
# Stopwords dropped only for the near-duplicate token comparison (not for the clustering key), so
|
|
35
|
+
# "Bank of America" vs "Bank of England" are not flagged on the shared "bank"/"of".
|
|
36
|
+
_NEAR_DUP_STOPWORDS = frozenset({"of", "the", "and", "for", "a", "an"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MentionCluster(BaseModel):
|
|
40
|
+
"""One candidate real-world entity: a human-verifiable proposal, never an auto-committed merge.
|
|
41
|
+
|
|
42
|
+
Carries `chunk_id` provenance and confidence (FR-S.4). `ambiguous_with` lists the keys of near-
|
|
43
|
+
duplicate clusters flagged for a human to decide off the contract language (ADR-0004 C4); it is the
|
|
44
|
+
conservative-merge bias made visible — these are NOT merged automatically.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
key: str # the normalized clustering key
|
|
48
|
+
representative: str # the longest surface form, for display + registry matching
|
|
49
|
+
variants: list[str]
|
|
50
|
+
entity_type: str # opaque domain entity type (DD-5); carried through from the mention
|
|
51
|
+
confidence: ConfidenceTag # weakest over the clustered mentions
|
|
52
|
+
chunk_ids: list[str] # provenance: the chunks the mentions came from
|
|
53
|
+
ambiguous_with: list[str] = [] # keys of flagged near-duplicate clusters (human decides)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DisambiguationResult(BaseModel):
|
|
57
|
+
"""The capability's output: canonical clusters (proposals) plus the rejected surface forms (audit)."""
|
|
58
|
+
|
|
59
|
+
clusters: list[MentionCluster]
|
|
60
|
+
rejected: list[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class CoreferenceResolver(Protocol):
|
|
65
|
+
"""The deferred full-coreference seam (ADR-0004): additional resolvers (pronoun / definite-
|
|
66
|
+
description coreference) bind this later and rewrite the cluster set; nothing here changes. A
|
|
67
|
+
resolver returns the (possibly merged) clusters. Default: no resolvers — clustering is by surface
|
|
68
|
+
form only."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
|
|
72
|
+
def resolve(self, clusters: list[MentionCluster]) -> list[MentionCluster]: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _weakest(confidences: list[ConfidenceTag]) -> ConfidenceTag:
|
|
76
|
+
return _RANK_TO_CONFIDENCE[max(_CONFIDENCE_RANK[c] for c in confidences)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _near_duplicate(key_a: str, key_b: str) -> bool:
|
|
80
|
+
"""Two cluster keys are ambiguous near-duplicates (flag, do not merge): one key's significant
|
|
81
|
+
tokens are a proper subset of the other's (parent/subsidiary, extra-qualifier), or they share a
|
|
82
|
+
first token and overlap substantially (shared-token pair). Biased to over-flag (human decides)."""
|
|
83
|
+
tokens_a = [t for t in key_a.split() if t not in _NEAR_DUP_STOPWORDS]
|
|
84
|
+
tokens_b = [t for t in key_b.split() if t not in _NEAR_DUP_STOPWORDS]
|
|
85
|
+
set_a, set_b = set(tokens_a), set(tokens_b)
|
|
86
|
+
if not set_a or not set_b or set_a == set_b:
|
|
87
|
+
return False
|
|
88
|
+
if set_a < set_b or set_b < set_a: # proper token subset
|
|
89
|
+
return True
|
|
90
|
+
if tokens_a[0] == tokens_b[0]: # shared first token + substantial overlap
|
|
91
|
+
return len(set_a & set_b) / len(set_a | set_b) >= 0.5
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def disambiguate(
|
|
96
|
+
results: Sequence[ExtractionResult],
|
|
97
|
+
*,
|
|
98
|
+
coreference_resolvers: Sequence[CoreferenceResolver] = (),
|
|
99
|
+
) -> DisambiguationResult:
|
|
100
|
+
"""Normalize, reject, and cluster the extracted entity mentions into human-verifiable proposals.
|
|
101
|
+
|
|
102
|
+
Mentions are collected across the extraction results (their `chunk_id` is provenance), non-entities
|
|
103
|
+
are rejected (never reach T24), survivors are clustered by (normalized key, type), each cluster
|
|
104
|
+
carries its provenance and weakest confidence, deferred coreference resolvers (if any) rewrite the
|
|
105
|
+
clusters, and ambiguous near-duplicates are flagged for human decision (never merged).
|
|
106
|
+
"""
|
|
107
|
+
groups: dict[tuple[str, str], dict] = {} # (normalized key, entity_type) -> cluster accumulator
|
|
108
|
+
rejected: list[str] = []
|
|
109
|
+
for result in results:
|
|
110
|
+
chunk_id = result.chunk_id.value
|
|
111
|
+
for mention in result.entity_mentions:
|
|
112
|
+
if not is_entity(mention.text):
|
|
113
|
+
rejected.append(mention.text)
|
|
114
|
+
continue
|
|
115
|
+
key = normalize_entity_name(mention.text)
|
|
116
|
+
group = groups.setdefault(
|
|
117
|
+
(key, mention.entity_type),
|
|
118
|
+
{"variants": [], "confidences": [], "chunk_ids": set()},
|
|
119
|
+
)
|
|
120
|
+
if mention.text not in group["variants"]:
|
|
121
|
+
group["variants"].append(mention.text)
|
|
122
|
+
group["confidences"].append(mention.confidence)
|
|
123
|
+
group["chunk_ids"].add(chunk_id)
|
|
124
|
+
|
|
125
|
+
clusters = [
|
|
126
|
+
MentionCluster(
|
|
127
|
+
key=key,
|
|
128
|
+
representative=max(group["variants"], key=len),
|
|
129
|
+
variants=sorted(group["variants"]),
|
|
130
|
+
entity_type=entity_type,
|
|
131
|
+
confidence=_weakest(group["confidences"]),
|
|
132
|
+
chunk_ids=sorted(group["chunk_ids"]),
|
|
133
|
+
)
|
|
134
|
+
for (key, entity_type), group in groups.items()
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
for resolver in coreference_resolvers: # deferred seam (default: none)
|
|
138
|
+
clusters = resolver.resolve(clusters)
|
|
139
|
+
|
|
140
|
+
_flag_near_duplicates(clusters)
|
|
141
|
+
clusters.sort(key=lambda c: c.key)
|
|
142
|
+
return DisambiguationResult(clusters=clusters, rejected=rejected)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _flag_near_duplicates(clusters: list[MentionCluster]) -> None:
|
|
146
|
+
"""Set each cluster's `ambiguous_with` (symmetric) for same-type near-duplicate pairs (ADR-0004 C4)."""
|
|
147
|
+
for i, a in enumerate(clusters):
|
|
148
|
+
for b in clusters[i + 1 :]:
|
|
149
|
+
if a.entity_type == b.entity_type and _near_duplicate(a.key, b.key):
|
|
150
|
+
a.ambiguous_with.append(b.key)
|
|
151
|
+
b.ambiguous_with.append(a.key)
|
|
152
|
+
for cluster in clusters:
|
|
153
|
+
cluster.ambiguous_with.sort()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def register_entity_disambiguation(registry: CapabilityRegistry) -> None:
|
|
157
|
+
"""Register under FR-C.7 (`entity_disambiguation`, an in-process `function`)."""
|
|
158
|
+
registry.register(
|
|
159
|
+
"entity_disambiguation",
|
|
160
|
+
contract=DisambiguationResult,
|
|
161
|
+
kind="function",
|
|
162
|
+
display_name="Entity disambiguation (normalize / reject / cluster)",
|
|
163
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Generic document-parse surface (EP-API-6b): the corpus-seam `SourceDocument` contract + the docling-parse
|
|
2
|
+
helpers that build one from raw bytes.
|
|
3
|
+
|
|
4
|
+
DOMAIN-FREE on purpose. It uses only the generic docling parser (`capabilities.parsing`) and the generic text
|
|
5
|
+
extraction (`corpus.document_parser`) -- nothing contract/clause/edgar -- so the engine's public parse API
|
|
6
|
+
(`api.parse_document`) depends on THIS, not on the contract ingestion pipeline (which would drag the whole
|
|
7
|
+
contract reference pack into any caller just to parse a PDF). A byte-source corpus adapter for ANY domain builds
|
|
8
|
+
a structure-bearing `SourceDocument` the same way. (Relocated out of `subgraphs/contract_ingestion_pipeline`,
|
|
9
|
+
which now re-exports these for its existing importers.)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from rag_wright.capabilities.parsing import ParsedDocument
|
|
19
|
+
|
|
20
|
+
_INGEST_PARSE_DEADLINE_S = 600.0 # per-document parse ceiling (a degraded multi-page doc escalated to the VLM)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SourceDocument(BaseModel):
|
|
24
|
+
"""One document to ingest: its canonical `source_doc_id` (HYG-1), its already-parsed text, and optional
|
|
25
|
+
per-corpus metadata (e.g. CUAD annotated parties, ACORD pre-segmented spans) the stages may consult."""
|
|
26
|
+
|
|
27
|
+
source_doc_id: str
|
|
28
|
+
text: str
|
|
29
|
+
metadata: dict = {}
|
|
30
|
+
# CHUNK-7 (ADR-0058, issue 0004): the REAL docling parse (structure preserved), set by a BYTE-source adapter
|
|
31
|
+
# (or `parsed_source_document`). When present, the chunk stage uses it so the structural pass fires on the
|
|
32
|
+
# document's actual headings; when None (genuinely text-only input) the chunker falls back to a text parse.
|
|
33
|
+
parsed: Optional[ParsedDocument] = None
|
|
34
|
+
# 0009-WIRE2: pages the tiered OCR could not read even after VLM escalation (a degraded scan) -- surfaced as an
|
|
35
|
+
# `ocr` PARTIAL in the IngestionReport, never silently ingested as gibberish.
|
|
36
|
+
ocr_unreadable_pages: list[int] = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parsed_source_document(
|
|
40
|
+
source_doc_id: str, name: str, data: bytes, *, cache_dir: Any, metadata: Optional[dict] = None
|
|
41
|
+
) -> SourceDocument:
|
|
42
|
+
"""Build a STRUCTURE-BEARING `SourceDocument` from raw document BYTES (PDF/DOCX/HTML/MD): docling-parse ONCE
|
|
43
|
+
(content-hash gated + cached), carry the `DoclingDocument` on `.parsed` (so the chunker's structural pass
|
|
44
|
+
fires on real headings), and set `.text` to the flattened text (for the text-consuming stages). This is how a
|
|
45
|
+
byte-source corpus adapter -- or the product (RuleWright), which hand-builds its ingest -- feeds a real
|
|
46
|
+
document to the engine; a plain-text `SourceDocument` (no `.parsed`) still uses the text fallback."""
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
from rag_wright.capabilities.parsing import TieredOCRParser, load_document
|
|
52
|
+
from rag_wright.corpus.document_parser import document_to_text, parse_document_bytes
|
|
53
|
+
|
|
54
|
+
cache_dir = Path(cache_dir)
|
|
55
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
content_hash = hashlib.sha256(data).hexdigest()
|
|
57
|
+
manifest_path = cache_dir / f"{source_doc_id}.{content_hash[:16]}.json"
|
|
58
|
+
ocr_sidecar = cache_dir / f"{source_doc_id}.{content_hash[:16]}.ocr.json" # the OCR verdict, cached alongside
|
|
59
|
+
parsed = ParsedDocument(
|
|
60
|
+
source_doc_id=source_doc_id, content_hash=content_hash, manifest_path=str(manifest_path))
|
|
61
|
+
if manifest_path.exists(): # content-hash gate: parse once (restore the OCR verdict from the sidecar)
|
|
62
|
+
document = load_document(parsed)
|
|
63
|
+
unreadable = json.loads(ocr_sidecar.read_text()) if ocr_sidecar.exists() else []
|
|
64
|
+
else: # 0009-WIRE2: tiered OCR -- capture which pages stayed unreadable even after VLM, so they surface PARTIAL
|
|
65
|
+
tiered = TieredOCRParser()
|
|
66
|
+
document = parse_document_bytes(name, data, parser=tiered)
|
|
67
|
+
document.save_as_json(manifest_path)
|
|
68
|
+
unreadable = list(tiered.report.unreadable_pages)
|
|
69
|
+
ocr_sidecar.write_text(json.dumps(unreadable))
|
|
70
|
+
return SourceDocument(
|
|
71
|
+
source_doc_id=source_doc_id, text=document_to_text(document), parsed=parsed, metadata=metadata or {},
|
|
72
|
+
ocr_unreadable_pages=unreadable)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def aparsed_source_document(
|
|
76
|
+
source_doc_id: str, name: str, data: bytes, *, cache_dir: Any, metadata: Optional[dict] = None,
|
|
77
|
+
deadline_s: float = _INGEST_PARSE_DEADLINE_S,
|
|
78
|
+
) -> SourceDocument:
|
|
79
|
+
"""The ASYNC, deadline-bounded twin of `parsed_source_document` (ADR-0057) -- STABLE PUBLIC API. Runs the sync
|
|
80
|
+
build (docling parse + the tiered OCR/VLM escalation, the slowest call in the pipeline) OFF the event loop
|
|
81
|
+
(`to_thread`) under an `asyncio.timeout`, so a hand-built async ingest can parse a document into the
|
|
82
|
+
structure-bearing `SourceDocument` the chunker needs WITHOUT reimplementing the wrapper (or blocking the loop).
|
|
83
|
+
Same caveat as every `to_thread` bound: the deadline unblocks the CALLER; the docling worker thread finishes in
|
|
84
|
+
the background (true cancellation would route the vision call through the async model seam)."""
|
|
85
|
+
async with asyncio.timeout(deadline_s):
|
|
86
|
+
return await asyncio.to_thread(
|
|
87
|
+
parsed_source_document, source_doc_id, name, data, cache_dir=cache_dir, metadata=metadata)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Issue 0031: a workspace document scope for corpus-wide retrieval.
|
|
2
|
+
|
|
3
|
+
A `documents` list narrows a corpus-wide query (span retrieval, graph traversal) to a subset of the store's
|
|
4
|
+
source documents, applied IN THE STORE so out-of-scope content is never pooled, embedded against, judged, or
|
|
5
|
+
cited (the same argument issue 0007 settled for compliance `sources`). This module holds the shared
|
|
6
|
+
validation both consumers use: an unknown document id RAISES rather than silently matching nothing (a filter
|
|
7
|
+
that quietly matches nothing is indistinguishable from an empty workspace -- the exact failure this scope
|
|
8
|
+
exists to prevent).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownDocumentError(ValueError):
|
|
17
|
+
"""A `documents` scope named a document id that was never ingested into the store (issue 0031). Mirrors
|
|
18
|
+
compliance's `UnknownComplianceSourceError` (issue 0007): naming a document that does not exist is a
|
|
19
|
+
caller error, surfaced explicitly rather than silently returning empty. Carries `.unknown` (the offending
|
|
20
|
+
ids) and `.present` (the FULL known-document set) as structured attributes; the message names every
|
|
21
|
+
unknown id but samples `.present`, which can run to thousands of documents in a real store."""
|
|
22
|
+
|
|
23
|
+
_PRESENT_SAMPLE = 20
|
|
24
|
+
|
|
25
|
+
def __init__(self, unknown: list[str], present: list[str]) -> None:
|
|
26
|
+
self.unknown = unknown
|
|
27
|
+
self.present = present # full set, for programmatic inspection
|
|
28
|
+
if len(present) > self._PRESENT_SAMPLE:
|
|
29
|
+
shown = f"{present[:self._PRESENT_SAMPLE]} ... (+{len(present) - self._PRESENT_SAMPLE} more)"
|
|
30
|
+
else:
|
|
31
|
+
shown = str(present)
|
|
32
|
+
super().__init__(f"unknown document id(s): {unknown}; present in store: {shown}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_documents(store: Any, documents: Optional[list[str]]) -> None:
|
|
36
|
+
"""Validate a `documents` scope against `store.known_document_ids()` BEFORE any retrieval spends (mirrors
|
|
37
|
+
issue 0007's `_validate_sources`). `None` (whole store) is not validated. `[]` (scope-to-nothing) is a
|
|
38
|
+
valid empty scope, not an error -- the retrieval surfaces short-circuit it. A non-empty list with any id
|
|
39
|
+
absent from the store raises `UnknownDocumentError`. A store without `known_document_ids` (a minimal fake)
|
|
40
|
+
is treated as un-validatable and passes through."""
|
|
41
|
+
if not documents: # None or [] -> nothing to validate
|
|
42
|
+
return
|
|
43
|
+
known = getattr(store, "known_document_ids", None)
|
|
44
|
+
if known is None:
|
|
45
|
+
return
|
|
46
|
+
present = known()
|
|
47
|
+
unknown = sorted(set(documents) - present)
|
|
48
|
+
if unknown:
|
|
49
|
+
raise UnknownDocumentError(unknown, sorted(present))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Embedding capability (FR-C.2, FR-I.3): dense over the summary, native sparse over the full text.
|
|
2
|
+
|
|
3
|
+
From one BGE-M3 model, produce a dense vector over the chunk summary and a native sparse vector over
|
|
4
|
+
the full chunk text. The dense-over-summary + sparse-over-full-text split is the summary-miss
|
|
5
|
+
mitigation (a detail dropped from the summary is still recoverable through the sparse leg). Output
|
|
6
|
+
shapes match the T3 chunk-record contract (dense length `BGE_M3_DENSE_DIM`; sparse `dict[int, float]`,
|
|
7
|
+
BGE-M3's string token-id keys converted to ints).
|
|
8
|
+
|
|
9
|
+
FR-I.6 decoupling: embedding is a GPU-calling capability, so it is built to be called concurrently
|
|
10
|
+
and non-blocking. `embed_chunks` is async; each encode goes through `asyncio.to_thread` (a poolable
|
|
11
|
+
inference boundary, not a hardcoded synchronous call), and a semaphore applies backpressure so bulk
|
|
12
|
+
mode can saturate the GPU without unbounded in-flight work. Grounded on `FlagEmbedding.BGEM3FlagModel`
|
|
13
|
+
(the public BGE-M3 class; its inference engine is `M3Embedder`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import math
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, field_validator
|
|
24
|
+
|
|
25
|
+
from rag_wright.capabilities.rlm_chunking import Chunk
|
|
26
|
+
from rag_wright.contracts.chunk import BGE_M3_DENSE_DIM
|
|
27
|
+
|
|
28
|
+
DEFAULT_MAX_CONCURRENCY = 4 # in-flight encode budget (backpressure); tune per GPU at deploy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Embedder(Protocol):
|
|
33
|
+
"""The BGE-M3 inference seam: dense and native-sparse encoders."""
|
|
34
|
+
|
|
35
|
+
def encode_dense(self, text: str) -> list[float]: ...
|
|
36
|
+
def encode_sparse(self, text: str) -> dict[int, float]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChunkEmbedding(BaseModel):
|
|
40
|
+
"""The embedding capability's output: one chunk's dense and sparse vectors."""
|
|
41
|
+
|
|
42
|
+
model_config = {"frozen": True}
|
|
43
|
+
|
|
44
|
+
chunk_id: str
|
|
45
|
+
dense_vector: list[float] # over the summary; length BGE_M3_DENSE_DIM
|
|
46
|
+
sparse_vector: dict[int, float] # over the full text; token-id -> non-negative weight
|
|
47
|
+
|
|
48
|
+
@field_validator("dense_vector")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _check_dense(cls, v: list[float]) -> list[float]:
|
|
51
|
+
if len(v) != BGE_M3_DENSE_DIM:
|
|
52
|
+
raise ValueError(f"dense_vector must have length {BGE_M3_DENSE_DIM}, got {len(v)}")
|
|
53
|
+
if not all(math.isfinite(x) for x in v):
|
|
54
|
+
raise ValueError("dense_vector must contain only finite values")
|
|
55
|
+
return v
|
|
56
|
+
|
|
57
|
+
@field_validator("sparse_vector")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _check_sparse(cls, v: dict[int, float]) -> dict[int, float]:
|
|
60
|
+
if any(weight < 0 for weight in v.values()):
|
|
61
|
+
raise ValueError("sparse_vector weights must be non-negative")
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_device(device: str | None) -> str:
|
|
66
|
+
"""The device to run BGE-M3 on: explicit arg, else `EMBED_DEVICE`, else auto (Metal `mps` when available,
|
|
67
|
+
else `cpu`). Auto-MPS offloads embedding from the CPU (freeing it for LegalBERT + extraction during ingest)
|
|
68
|
+
and is ~2x faster; the vectors are bit-for-bit equivalent to CPU (verified cosine 1.0), so it is a pure
|
|
69
|
+
speed choice, not a semantic one."""
|
|
70
|
+
import os
|
|
71
|
+
|
|
72
|
+
chosen = device or os.environ.get("EMBED_DEVICE")
|
|
73
|
+
if chosen:
|
|
74
|
+
return chosen
|
|
75
|
+
try:
|
|
76
|
+
import torch
|
|
77
|
+
|
|
78
|
+
if torch.backends.mps.is_available():
|
|
79
|
+
return "mps"
|
|
80
|
+
if torch.cuda.is_available(): # a GPU box (e.g. the bulk-ingestion VM) -> use the GPU
|
|
81
|
+
return "cuda"
|
|
82
|
+
except Exception: # noqa: BLE001 - torch/device probing must never break model construction
|
|
83
|
+
pass
|
|
84
|
+
return "cpu"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BGEM3Embedder:
|
|
88
|
+
"""The real embedder: BGE-M3 via `FlagEmbedding.BGEM3FlagModel` (model loaded lazily).
|
|
89
|
+
|
|
90
|
+
THREAD-SAFE (engine issue 0016): FlagEmbedding mutates the model IN PLACE on every `encode`
|
|
91
|
+
(`self.model.float()` / `.to(device)` / `.eval()`), and those C++ conversions release the GIL, so two threads
|
|
92
|
+
driving one shared model swap the same parameter-tensor storage under each other and SEGFAULT the interpreter
|
|
93
|
+
(not a catchable exception). The engine SHARES one `Embedder` across concurrent-document ingestion
|
|
94
|
+
(`run_job(max_concurrency=...)` -> per-document `encode_batch`) and across `embed_chunks`' per-chunk thread
|
|
95
|
+
fan-out. So every model call is serialized by an instance lock. The lock guards ONLY `self._model.encode`
|
|
96
|
+
(the numpy results are per-call, thread-local); the serialized section is the CPU/GPU encode, which is a small
|
|
97
|
+
slice of ingest time -- the concurrency worth overlapping is the network-bound extraction, not the encode."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, model_name: str = "BAAI/bge-m3", *, use_fp16: bool = False,
|
|
100
|
+
device: str | None = None, batch_size: int = 64, model: object | None = None) -> None:
|
|
101
|
+
self._batch_size = batch_size # cross-item independent, so batching never changes a vector, only speed
|
|
102
|
+
self._lock = threading.Lock() # issue 0016: serialize the in-place-mutating BGE-M3 encode across threads
|
|
103
|
+
if model is not None: # injected (hermetic tests) -- avoids loading the real model
|
|
104
|
+
self._model = model
|
|
105
|
+
else:
|
|
106
|
+
from FlagEmbedding import BGEM3FlagModel
|
|
107
|
+
|
|
108
|
+
self._model = BGEM3FlagModel(model_name, use_fp16=use_fp16, devices=_resolve_device(device))
|
|
109
|
+
|
|
110
|
+
def encode_dense(self, text: str) -> list[float]:
|
|
111
|
+
with self._lock: # issue 0016
|
|
112
|
+
out = self._model.encode([text], return_dense=True, return_sparse=False)
|
|
113
|
+
return out["dense_vecs"][0].tolist()
|
|
114
|
+
|
|
115
|
+
def encode_sparse(self, text: str) -> dict[int, float]:
|
|
116
|
+
with self._lock: # issue 0016
|
|
117
|
+
out = self._model.encode([text], return_dense=False, return_sparse=True)
|
|
118
|
+
# lexical_weights is a Dict[str, float] keyed by string token ids; convert to int keys (T3).
|
|
119
|
+
return {int(k): float(v) for k, v in out["lexical_weights"][0].items()}
|
|
120
|
+
|
|
121
|
+
def encode_batch(self, texts: list[str]) -> tuple[list[list[float]], list[dict[int, float]]]:
|
|
122
|
+
"""Batched dense+sparse over many texts in ONE model call (the ingestion/population path):
|
|
123
|
+
same per-text format as `encode_dense`/`encode_sparse`, amortizing the model overhead."""
|
|
124
|
+
if not texts:
|
|
125
|
+
return [], []
|
|
126
|
+
with self._lock: # issue 0016: serialize the shared-model encode across concurrent-document threads
|
|
127
|
+
out = self._model.encode(texts, return_dense=True, return_sparse=True, batch_size=self._batch_size)
|
|
128
|
+
dense = [v.tolist() for v in out["dense_vecs"]]
|
|
129
|
+
sparse = [{int(k): float(v) for k, v in lw.items()} for lw in out["lexical_weights"]]
|
|
130
|
+
return dense, sparse
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def embed_chunks(
|
|
134
|
+
chunks: list[Chunk],
|
|
135
|
+
*,
|
|
136
|
+
embedder: Embedder,
|
|
137
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
138
|
+
) -> list[ChunkEmbedding]:
|
|
139
|
+
"""Embed chunks concurrently (dense over summary, sparse over full text), backpressured.
|
|
140
|
+
|
|
141
|
+
Each chunk's encodes run in a thread (the poolable inference boundary); a semaphore bounds the
|
|
142
|
+
in-flight work so bulk mode saturates the GPU without unbounded concurrency.
|
|
143
|
+
"""
|
|
144
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
145
|
+
|
|
146
|
+
async def _embed(chunk: Chunk) -> ChunkEmbedding:
|
|
147
|
+
async with semaphore: # backpressure
|
|
148
|
+
dense = await asyncio.to_thread(embedder.encode_dense, chunk.summary)
|
|
149
|
+
sparse = await asyncio.to_thread(embedder.encode_sparse, chunk.text)
|
|
150
|
+
return ChunkEmbedding(chunk_id=chunk.chunk_id, dense_vector=dense, sparse_vector=sparse)
|
|
151
|
+
|
|
152
|
+
return list(await asyncio.gather(*(_embed(chunk) for chunk in chunks)))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def embed_chunks_sync(
|
|
156
|
+
chunks: list[Chunk],
|
|
157
|
+
*,
|
|
158
|
+
embedder: Embedder,
|
|
159
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
160
|
+
) -> list[ChunkEmbedding]:
|
|
161
|
+
"""Synchronous convenience for callers not already in an event loop."""
|
|
162
|
+
return asyncio.run(embed_chunks(chunks, embedder=embedder, max_concurrency=max_concurrency))
|
|
163
|
+
|
|
164
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""EP-API-4b (ADR-0117): embedding profiles -- the single place an embedding profile alias maps to its embedders.
|
|
2
|
+
|
|
3
|
+
A product selects a profile via `EngineConfig.embeddings["text"]`; the engine resolves it to the right QUERY-side
|
|
4
|
+
encoder (retrieval) and INGEST-side SPAN encoder (the index's `encode_batch`). `bge-m3` is the default BGE-M3
|
|
5
|
+
family (the query encoder + the batch SPAN encoder). Adding a profile is one entry in each registry here, invisible
|
|
6
|
+
to the product -- so BGE-M3 is no longer hardcoded in the workspace / the ingestion pipeline. Builders import their
|
|
7
|
+
heavy impls LAZILY, so importing this module (and passing a profile string around) stays cheap."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _bge_m3_query() -> Any:
|
|
14
|
+
from rag_wright.capabilities.remote_encoders import query_embedder
|
|
15
|
+
|
|
16
|
+
return query_embedder()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bge_m3_ingest() -> Any:
|
|
20
|
+
from rag_wright.capabilities.embedding import BGEM3Embedder
|
|
21
|
+
|
|
22
|
+
return BGEM3Embedder()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# profile alias -> builder. A new embedder family = one entry in each (same alias).
|
|
26
|
+
_QUERY_BUILDERS: dict[str, Callable[[], Any]] = {"bge-m3": _bge_m3_query}
|
|
27
|
+
_INGEST_BUILDERS: dict[str, Callable[[], Any]] = {"bge-m3": _bge_m3_ingest}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_query_embedder(profile: str = "bge-m3") -> Any:
|
|
31
|
+
"""The retrieval-side embedder for `profile` (default BGE-M3). Raises `ValueError` for an unknown profile."""
|
|
32
|
+
builder = _QUERY_BUILDERS.get(profile)
|
|
33
|
+
if builder is None:
|
|
34
|
+
raise ValueError(f"unsupported embedding profile {profile!r} (known: {sorted(_QUERY_BUILDERS)})")
|
|
35
|
+
return builder()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_ingest_embedder(profile: str = "bge-m3") -> Any:
|
|
39
|
+
"""The ingest-side SPAN embedder for `profile` (default BGE-M3; `encode_batch`). Raises on an unknown profile."""
|
|
40
|
+
builder = _INGEST_BUILDERS.get(profile)
|
|
41
|
+
if builder is None:
|
|
42
|
+
raise ValueError(f"unsupported embedding profile {profile!r} (known: {sorted(_INGEST_BUILDERS)})")
|
|
43
|
+
return builder()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Entity resolution (FR-C.7, T24): link canonical mention clusters to a canonical-id registry (closed-world).
|
|
2
|
+
|
|
3
|
+
The closed-world linking stage after canonicalization (T23b). It resolves each `MentionCluster` to its
|
|
4
|
+
canonical `entity_id` (the registry's canonical id) against the injected registry, and resolves the relationship endpoints
|
|
5
|
+
(the `RelationshipFact` `source_ref`/`target_ref` refs) as the SAME stream, so an entity that appears
|
|
6
|
+
both as a standalone mention and as a relationship endpoint lands on one node, not two (FR-C.7, ADR-0004).
|
|
7
|
+
|
|
8
|
+
Matching strategy (§16.3, ADR-0013): exact normalized-surface-form match against the registry,
|
|
9
|
+
closed-world — an unknown surface form resolves to `None` (unlinked), never a fabricated id. No fuzzy /
|
|
10
|
+
embedding / LLM matching: a wrong fuzzy link is a silent false merge onto a canonical entity, worse than
|
|
11
|
+
leaving a cluster unlinked (which the human sees), and T23b already collapsed the surface variants, so
|
|
12
|
+
exact-normalized is high-recall for entities the registry knows. Two post-resolution invariants live
|
|
13
|
+
here, not in the T4 contract, because both need resolved ids: a relationship whose two refs resolve to
|
|
14
|
+
the SAME entity_id is dropped as a self-loop, and the two mention channels are deduped across each other.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from rag_wright.capabilities.disambiguation import DisambiguationResult, MentionCluster
|
|
26
|
+
from rag_wright.capabilities.registry import CapabilityRegistry
|
|
27
|
+
from rag_wright.contracts.extraction import ExtractionResult
|
|
28
|
+
from rag_wright.contracts.provenance import ConfidenceTag
|
|
29
|
+
from rag_wright.corpus.canonicalize import is_entity, normalize_entity_name
|
|
30
|
+
from rag_wright.ontology.registry import EntityResolver
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResolvedEntity(BaseModel):
|
|
34
|
+
"""A mention cluster linked (or not) to a canonical registry id. `entity_id is None` = unlinked
|
|
35
|
+
(closed-world: not in the registry — expected for private/unknown entities — never fabricated)."""
|
|
36
|
+
|
|
37
|
+
key: str
|
|
38
|
+
representative: str
|
|
39
|
+
entity_type: str # opaque domain entity type (DD-5); carried through from the cluster
|
|
40
|
+
entity_id: Optional[str] # the registry's canonical id, or None (unlinked)
|
|
41
|
+
confidence: ConfidenceTag
|
|
42
|
+
chunk_ids: list[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ResolvedRelationship(BaseModel):
|
|
46
|
+
"""A relationship whose endpoints are resolved to canonical ids (or None). Self-loops are dropped
|
|
47
|
+
before this is emitted (a relationship whose refs resolve to the same entity_id)."""
|
|
48
|
+
|
|
49
|
+
source_ref: str
|
|
50
|
+
target_ref: str
|
|
51
|
+
source_id: Optional[str]
|
|
52
|
+
target_id: Optional[str]
|
|
53
|
+
relationship_type: str # opaque domain edge type (DD-5); carried through from the fact
|
|
54
|
+
confidence: ConfidenceTag
|
|
55
|
+
chunk_id: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ResolutionResult(BaseModel):
|
|
59
|
+
"""The capability's output: resolved entities and relationships (self-loops removed)."""
|
|
60
|
+
|
|
61
|
+
entities: list[ResolvedEntity]
|
|
62
|
+
relationships: list[ResolvedRelationship]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_cluster(cluster: MentionCluster, resolver: EntityResolver) -> Optional[str]:
|
|
66
|
+
"""Resolve a cluster to a canonical id: the first of its surface forms the resolver knows (closed-world)."""
|
|
67
|
+
for surface in (cluster.representative, *cluster.variants):
|
|
68
|
+
entity_id = resolver.resolve(surface)
|
|
69
|
+
if entity_id is not None:
|
|
70
|
+
return entity_id.value
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_entities(
|
|
75
|
+
clusters: DisambiguationResult,
|
|
76
|
+
results: Sequence[ExtractionResult],
|
|
77
|
+
*,
|
|
78
|
+
resolver: EntityResolver,
|
|
79
|
+
) -> ResolutionResult:
|
|
80
|
+
"""Link clusters to canonical ids and resolve relationship endpoints as one stream (self-loops dropped).
|
|
81
|
+
|
|
82
|
+
Each cluster resolves to a canonical id (or None) via the injected `EntityResolver` seam (DD-3) — the
|
|
83
|
+
resolution STRATEGY is the domain's concern, not this capability's. A relationship ref resolves by matching
|
|
84
|
+
a cluster key first — so a ref that is the same entity as a standalone mention takes that cluster's id (the
|
|
85
|
+
two-channel dedup, ADR-0004) — falling back to a direct resolver lookup only for a ref with no cluster. A
|
|
86
|
+
relationship whose two refs resolve to the same non-None id is dropped (self-loop). These invariants are
|
|
87
|
+
domain-neutral and stay here; only the surface-form lookup is delegated to the resolver.
|
|
88
|
+
"""
|
|
89
|
+
entities: list[ResolvedEntity] = []
|
|
90
|
+
key_to_id: dict[str, Optional[str]] = {}
|
|
91
|
+
for cluster in clusters.clusters:
|
|
92
|
+
entity_id = _resolve_cluster(cluster, resolver)
|
|
93
|
+
key_to_id[cluster.key] = entity_id # channel-unification map (may be None: same unlinked entity)
|
|
94
|
+
entities.append(
|
|
95
|
+
ResolvedEntity(
|
|
96
|
+
key=cluster.key, representative=cluster.representative,
|
|
97
|
+
entity_type=cluster.entity_type, entity_id=entity_id,
|
|
98
|
+
confidence=cluster.confidence, chunk_ids=cluster.chunk_ids,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _resolve_ref(ref: str) -> Optional[str]:
|
|
103
|
+
if not is_entity(ref):
|
|
104
|
+
return None
|
|
105
|
+
key = normalize_entity_name(ref)
|
|
106
|
+
if key in key_to_id: # same entity as a standalone mention -> its id (even if None)
|
|
107
|
+
return key_to_id[key]
|
|
108
|
+
resolved = resolver.resolve(ref)
|
|
109
|
+
return resolved.value if resolved is not None else None
|
|
110
|
+
|
|
111
|
+
relationships: list[ResolvedRelationship] = []
|
|
112
|
+
for result in results:
|
|
113
|
+
chunk_id = result.chunk_id.value
|
|
114
|
+
for fact in result.relationship_facts:
|
|
115
|
+
source_id = _resolve_ref(fact.source_ref)
|
|
116
|
+
target_id = _resolve_ref(fact.target_ref)
|
|
117
|
+
if source_id is not None and source_id == target_id:
|
|
118
|
+
continue # post-resolution self-loop: two distinct mentions, one entity -> dropped
|
|
119
|
+
relationships.append(
|
|
120
|
+
ResolvedRelationship(
|
|
121
|
+
source_ref=fact.source_ref, target_ref=fact.target_ref,
|
|
122
|
+
source_id=source_id, target_id=target_id,
|
|
123
|
+
relationship_type=fact.relationship_type, confidence=fact.confidence,
|
|
124
|
+
chunk_id=chunk_id,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return ResolutionResult(entities=entities, relationships=relationships)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fragmentation_rate(result: ResolutionResult, gold_by_key: dict[str, str]) -> float:
|
|
131
|
+
"""Residual fragmentation after resolution (risk 5), measured against gold entity labels: the
|
|
132
|
+
fraction of true entities that end as more than one node. Resolution reduces fragmentation when the
|
|
133
|
+
registry links surface variants T23b's exact-key clustering left separate (e.g. an acronym alias) to
|
|
134
|
+
the same canonical id. A cluster is a node by its `entity_id` if linked, else by its own key (unlinked)."""
|
|
135
|
+
nodes_per_entity: dict[str, set[str]] = defaultdict(set)
|
|
136
|
+
for entity in result.entities:
|
|
137
|
+
gold = gold_by_key.get(entity.key)
|
|
138
|
+
if gold is None:
|
|
139
|
+
continue
|
|
140
|
+
nodes_per_entity[gold].add(entity.entity_id or f"unlinked:{entity.key}")
|
|
141
|
+
if not nodes_per_entity:
|
|
142
|
+
return 0.0
|
|
143
|
+
fragmented = sum(1 for nodes in nodes_per_entity.values() if len(nodes) > 1)
|
|
144
|
+
return fragmented / len(nodes_per_entity)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def register_entity_resolution(registry: CapabilityRegistry) -> None:
|
|
148
|
+
"""Register under FR-C.7 (`entity_resolution`, an in-process `function`)."""
|
|
149
|
+
registry.register(
|
|
150
|
+
"entity_resolution",
|
|
151
|
+
contract=ResolutionResult,
|
|
152
|
+
kind="function",
|
|
153
|
+
display_name="Entity resolution (closed-world to a canonical-id registry)",
|
|
154
|
+
)
|