rnsr 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.
- rnsr/__init__.py +118 -0
- rnsr/__main__.py +242 -0
- rnsr/agent/__init__.py +218 -0
- rnsr/agent/cross_doc_navigator.py +767 -0
- rnsr/agent/graph.py +1557 -0
- rnsr/agent/llm_cache.py +575 -0
- rnsr/agent/navigator_api.py +497 -0
- rnsr/agent/provenance.py +772 -0
- rnsr/agent/query_clarifier.py +617 -0
- rnsr/agent/reasoning_memory.py +736 -0
- rnsr/agent/repl_env.py +709 -0
- rnsr/agent/rlm_navigator.py +2108 -0
- rnsr/agent/self_reflection.py +602 -0
- rnsr/agent/variable_store.py +308 -0
- rnsr/benchmarks/__init__.py +118 -0
- rnsr/benchmarks/comprehensive_benchmark.py +733 -0
- rnsr/benchmarks/evaluation_suite.py +1210 -0
- rnsr/benchmarks/finance_bench.py +147 -0
- rnsr/benchmarks/pdf_merger.py +178 -0
- rnsr/benchmarks/performance.py +321 -0
- rnsr/benchmarks/quality.py +321 -0
- rnsr/benchmarks/runner.py +298 -0
- rnsr/benchmarks/standard_benchmarks.py +995 -0
- rnsr/client.py +560 -0
- rnsr/document_store.py +394 -0
- rnsr/exceptions.py +74 -0
- rnsr/extraction/__init__.py +172 -0
- rnsr/extraction/candidate_extractor.py +357 -0
- rnsr/extraction/entity_extractor.py +581 -0
- rnsr/extraction/entity_linker.py +825 -0
- rnsr/extraction/grounded_extractor.py +722 -0
- rnsr/extraction/learned_types.py +599 -0
- rnsr/extraction/models.py +232 -0
- rnsr/extraction/relationship_extractor.py +600 -0
- rnsr/extraction/relationship_patterns.py +511 -0
- rnsr/extraction/relationship_validator.py +392 -0
- rnsr/extraction/rlm_extractor.py +589 -0
- rnsr/extraction/rlm_unified_extractor.py +990 -0
- rnsr/extraction/tot_validator.py +610 -0
- rnsr/extraction/unified_extractor.py +342 -0
- rnsr/indexing/__init__.py +60 -0
- rnsr/indexing/knowledge_graph.py +1128 -0
- rnsr/indexing/kv_store.py +313 -0
- rnsr/indexing/persistence.py +323 -0
- rnsr/indexing/semantic_retriever.py +237 -0
- rnsr/indexing/semantic_search.py +320 -0
- rnsr/indexing/skeleton_index.py +395 -0
- rnsr/ingestion/__init__.py +161 -0
- rnsr/ingestion/chart_parser.py +569 -0
- rnsr/ingestion/document_boundary.py +662 -0
- rnsr/ingestion/font_histogram.py +334 -0
- rnsr/ingestion/header_classifier.py +595 -0
- rnsr/ingestion/hierarchical_cluster.py +515 -0
- rnsr/ingestion/layout_detector.py +356 -0
- rnsr/ingestion/layout_model.py +379 -0
- rnsr/ingestion/ocr_fallback.py +177 -0
- rnsr/ingestion/pipeline.py +936 -0
- rnsr/ingestion/semantic_fallback.py +417 -0
- rnsr/ingestion/table_parser.py +799 -0
- rnsr/ingestion/text_builder.py +460 -0
- rnsr/ingestion/tree_builder.py +402 -0
- rnsr/ingestion/vision_retrieval.py +965 -0
- rnsr/ingestion/xy_cut.py +555 -0
- rnsr/llm.py +733 -0
- rnsr/models.py +167 -0
- rnsr/py.typed +2 -0
- rnsr-0.1.0.dist-info/METADATA +592 -0
- rnsr-0.1.0.dist-info/RECORD +72 -0
- rnsr-0.1.0.dist-info/WHEEL +5 -0
- rnsr-0.1.0.dist-info/entry_points.txt +2 -0
- rnsr-0.1.0.dist-info/licenses/LICENSE +21 -0
- rnsr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RNSR Relationship Extractor
|
|
3
|
+
|
|
4
|
+
DEPRECATED: This extractor uses LLM-first approach which can hallucinate.
|
|
5
|
+
Use RLMUnifiedExtractor instead for grounded, accurate extraction.
|
|
6
|
+
|
|
7
|
+
LLM-based relationship extraction between entities and sections.
|
|
8
|
+
Extracts temporal, causal, semantic, and reference relationships.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import warnings
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import structlog
|
|
20
|
+
|
|
21
|
+
from rnsr.extraction.models import (
|
|
22
|
+
Entity,
|
|
23
|
+
EntityType,
|
|
24
|
+
Relationship,
|
|
25
|
+
RelationType,
|
|
26
|
+
)
|
|
27
|
+
from rnsr.llm import get_llm
|
|
28
|
+
|
|
29
|
+
logger = structlog.get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
# Deprecation warning
|
|
32
|
+
_DEPRECATION_WARNING = """
|
|
33
|
+
RelationshipExtractor is deprecated and may hallucinate relationships.
|
|
34
|
+
Use RLMUnifiedExtractor instead for grounded, accurate extraction:
|
|
35
|
+
|
|
36
|
+
from rnsr.extraction import RLMUnifiedExtractor
|
|
37
|
+
extractor = RLMUnifiedExtractor()
|
|
38
|
+
result = extractor.extract(node_id, doc_id, header, content)
|
|
39
|
+
# result.relationships contains validated relationships
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Relationship extraction prompt template
|
|
44
|
+
RELATIONSHIP_EXTRACTION_PROMPT = """You are an expert relationship extractor for legal and business documents.
|
|
45
|
+
|
|
46
|
+
Analyze the following document section and extract relationships between:
|
|
47
|
+
1. Entities (people, organizations, dates, events, legal concepts)
|
|
48
|
+
2. This section and other referenced sections/documents
|
|
49
|
+
|
|
50
|
+
Document Section:
|
|
51
|
+
---
|
|
52
|
+
{content}
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
Section ID: {node_id}
|
|
56
|
+
Document ID: {doc_id}
|
|
57
|
+
Section Header: {header}
|
|
58
|
+
|
|
59
|
+
Known entities in this section:
|
|
60
|
+
{entities_json}
|
|
61
|
+
|
|
62
|
+
Extract relationships of the following types:
|
|
63
|
+
|
|
64
|
+
ENTITY-TO-ENTITY RELATIONSHIPS:
|
|
65
|
+
- TEMPORAL_BEFORE: Event/date X occurred before Event/date Y
|
|
66
|
+
- TEMPORAL_AFTER: Event/date X occurred after Event/date Y
|
|
67
|
+
- CAUSAL: Action X caused/led to Outcome Y (e.g., breach led to damages)
|
|
68
|
+
- AFFILIATED_WITH: Person X is affiliated with Organization Y
|
|
69
|
+
- PARTY_TO: Entity X is party to Document/Event Y (e.g., signatory, defendant)
|
|
70
|
+
|
|
71
|
+
SECTION/DOCUMENT RELATIONSHIPS:
|
|
72
|
+
- SUPPORTS: This section supports a claim or finding in another section
|
|
73
|
+
- CONTRADICTS: This section contradicts another section
|
|
74
|
+
- REFERENCES: This section references another document or section (exhibit, citation)
|
|
75
|
+
- SUPERSEDES: This section supersedes/overrides another
|
|
76
|
+
- AMENDS: This section amends another section/document
|
|
77
|
+
|
|
78
|
+
For each relationship, provide:
|
|
79
|
+
1. type: One of the types above
|
|
80
|
+
2. source_id: ID of the source entity (from the known entities list) or "{node_id}" for this section
|
|
81
|
+
3. source_type: "entity" or "node"
|
|
82
|
+
4. target_id: ID of the target entity or node ID of the target section (use descriptive placeholder if referencing external doc, e.g., "exhibit_a")
|
|
83
|
+
5. target_type: "entity" or "node"
|
|
84
|
+
6. confidence: 0.0-1.0 based on how explicit the relationship is
|
|
85
|
+
7. evidence: The exact quote that establishes this relationship
|
|
86
|
+
|
|
87
|
+
Return your response as a JSON array:
|
|
88
|
+
```json
|
|
89
|
+
[
|
|
90
|
+
{{
|
|
91
|
+
"type": "CAUSAL",
|
|
92
|
+
"source_id": "ent_abc123",
|
|
93
|
+
"source_type": "entity",
|
|
94
|
+
"target_id": "ent_def456",
|
|
95
|
+
"target_type": "entity",
|
|
96
|
+
"confidence": 0.9,
|
|
97
|
+
"evidence": "The breach of contract by Defendant led to significant damages..."
|
|
98
|
+
}},
|
|
99
|
+
{{
|
|
100
|
+
"type": "REFERENCES",
|
|
101
|
+
"source_id": "{node_id}",
|
|
102
|
+
"source_type": "node",
|
|
103
|
+
"target_id": "exhibit_a",
|
|
104
|
+
"target_type": "node",
|
|
105
|
+
"confidence": 1.0,
|
|
106
|
+
"evidence": "As shown in Exhibit A..."
|
|
107
|
+
}}
|
|
108
|
+
]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If no relationships are found, return an empty array: []
|
|
112
|
+
|
|
113
|
+
Important:
|
|
114
|
+
- Only extract relationships that are explicitly stated or strongly implied
|
|
115
|
+
- Include the exact quote as evidence
|
|
116
|
+
- Use entity IDs from the provided list when possible
|
|
117
|
+
- For temporal relationships, be precise about the direction (BEFORE vs AFTER)
|
|
118
|
+
- For causal relationships, the source is the cause and target is the effect
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RelationshipExtractor:
|
|
123
|
+
"""
|
|
124
|
+
DEPRECATED: Extracts relationships from document sections using LLM-first approach.
|
|
125
|
+
|
|
126
|
+
This extractor can hallucinate relationships. Use RLMUnifiedExtractor instead.
|
|
127
|
+
|
|
128
|
+
Identifies connections between:
|
|
129
|
+
- Entities (temporal, causal, affiliation)
|
|
130
|
+
- Sections (supports, contradicts, references)
|
|
131
|
+
- Documents (cross-references, amendments)
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
llm: Any | None = None,
|
|
137
|
+
min_content_length: int = 50,
|
|
138
|
+
max_content_length: int = 8000,
|
|
139
|
+
suppress_deprecation_warning: bool = False,
|
|
140
|
+
):
|
|
141
|
+
# Emit deprecation warning
|
|
142
|
+
if not suppress_deprecation_warning:
|
|
143
|
+
warnings.warn(
|
|
144
|
+
_DEPRECATION_WARNING,
|
|
145
|
+
DeprecationWarning,
|
|
146
|
+
stacklevel=2,
|
|
147
|
+
)
|
|
148
|
+
logger.warning("deprecated_extractor_used", extractor="RelationshipExtractor")
|
|
149
|
+
"""
|
|
150
|
+
Initialize the relationship extractor.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
llm: LLM instance to use. If None, uses get_llm().
|
|
154
|
+
min_content_length: Minimum content length to process.
|
|
155
|
+
max_content_length: Maximum content length per extraction call.
|
|
156
|
+
"""
|
|
157
|
+
self.llm = llm or get_llm()
|
|
158
|
+
self.min_content_length = min_content_length
|
|
159
|
+
self.max_content_length = max_content_length
|
|
160
|
+
|
|
161
|
+
# Cache for extracted relationships (node_id -> relationships)
|
|
162
|
+
self._cache: dict[str, list[Relationship]] = {}
|
|
163
|
+
|
|
164
|
+
def extract_from_node(
|
|
165
|
+
self,
|
|
166
|
+
node_id: str,
|
|
167
|
+
doc_id: str,
|
|
168
|
+
header: str,
|
|
169
|
+
content: str,
|
|
170
|
+
entities: list[Entity],
|
|
171
|
+
) -> list[Relationship]:
|
|
172
|
+
"""
|
|
173
|
+
Extract relationships from a single document node.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
node_id: Skeleton node ID.
|
|
177
|
+
doc_id: Document ID.
|
|
178
|
+
header: Section header text.
|
|
179
|
+
content: Full section content.
|
|
180
|
+
entities: Entities already extracted from this node.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of extracted Relationship objects.
|
|
184
|
+
"""
|
|
185
|
+
start_time = time.time()
|
|
186
|
+
|
|
187
|
+
# Skip very short content
|
|
188
|
+
if len(content.strip()) < self.min_content_length:
|
|
189
|
+
logger.debug(
|
|
190
|
+
"skipping_short_content",
|
|
191
|
+
node_id=node_id,
|
|
192
|
+
content_length=len(content),
|
|
193
|
+
)
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
# Check cache
|
|
197
|
+
cache_key = f"{doc_id}:{node_id}"
|
|
198
|
+
if cache_key in self._cache:
|
|
199
|
+
logger.debug("using_cached_relationships", node_id=node_id)
|
|
200
|
+
return self._cache[cache_key]
|
|
201
|
+
|
|
202
|
+
# Truncate content if too long
|
|
203
|
+
if len(content) > self.max_content_length:
|
|
204
|
+
content = content[:self.max_content_length] + "..."
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
relationships = self._extract_with_llm(
|
|
208
|
+
node_id=node_id,
|
|
209
|
+
doc_id=doc_id,
|
|
210
|
+
header=header,
|
|
211
|
+
content=content,
|
|
212
|
+
entities=entities,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Cache results
|
|
216
|
+
self._cache[cache_key] = relationships
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(
|
|
220
|
+
"relationship_extraction_failed",
|
|
221
|
+
node_id=node_id,
|
|
222
|
+
error=str(e),
|
|
223
|
+
)
|
|
224
|
+
relationships = []
|
|
225
|
+
|
|
226
|
+
processing_time_ms = (time.time() - start_time) * 1000
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
"relationships_extracted",
|
|
230
|
+
node_id=node_id,
|
|
231
|
+
relationship_count=len(relationships),
|
|
232
|
+
processing_time_ms=processing_time_ms,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return relationships
|
|
236
|
+
|
|
237
|
+
def _extract_with_llm(
|
|
238
|
+
self,
|
|
239
|
+
node_id: str,
|
|
240
|
+
doc_id: str,
|
|
241
|
+
header: str,
|
|
242
|
+
content: str,
|
|
243
|
+
entities: list[Entity],
|
|
244
|
+
) -> list[Relationship]:
|
|
245
|
+
"""
|
|
246
|
+
Use LLM to extract relationships from content.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
node_id: Skeleton node ID.
|
|
250
|
+
doc_id: Document ID.
|
|
251
|
+
header: Section header.
|
|
252
|
+
content: Section content.
|
|
253
|
+
entities: Known entities in this section.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of extracted Relationship objects.
|
|
257
|
+
"""
|
|
258
|
+
# Format entities for prompt
|
|
259
|
+
entities_json = json.dumps([
|
|
260
|
+
{
|
|
261
|
+
"id": e.id,
|
|
262
|
+
"type": e.type.value,
|
|
263
|
+
"name": e.canonical_name,
|
|
264
|
+
"aliases": e.aliases,
|
|
265
|
+
}
|
|
266
|
+
for e in entities
|
|
267
|
+
], indent=2)
|
|
268
|
+
|
|
269
|
+
prompt = RELATIONSHIP_EXTRACTION_PROMPT.format(
|
|
270
|
+
content=content,
|
|
271
|
+
node_id=node_id,
|
|
272
|
+
doc_id=doc_id,
|
|
273
|
+
header=header,
|
|
274
|
+
entities_json=entities_json,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Call LLM
|
|
278
|
+
response = self.llm.complete(prompt)
|
|
279
|
+
response_text = str(response) if not isinstance(response, str) else response
|
|
280
|
+
|
|
281
|
+
# Parse JSON from response
|
|
282
|
+
relationships = self._parse_llm_response(
|
|
283
|
+
response_text=response_text,
|
|
284
|
+
node_id=node_id,
|
|
285
|
+
doc_id=doc_id,
|
|
286
|
+
entities=entities,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return relationships
|
|
290
|
+
|
|
291
|
+
def _parse_llm_response(
|
|
292
|
+
self,
|
|
293
|
+
response_text: str,
|
|
294
|
+
node_id: str,
|
|
295
|
+
doc_id: str,
|
|
296
|
+
entities: list[Entity],
|
|
297
|
+
) -> list[Relationship]:
|
|
298
|
+
"""
|
|
299
|
+
Parse LLM response into Relationship objects.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
response_text: Raw LLM response.
|
|
303
|
+
node_id: Source node ID.
|
|
304
|
+
doc_id: Source document ID.
|
|
305
|
+
entities: Known entities.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
List of Relationship objects.
|
|
309
|
+
"""
|
|
310
|
+
# Extract JSON from response
|
|
311
|
+
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response_text)
|
|
312
|
+
if json_match:
|
|
313
|
+
json_str = json_match.group(1)
|
|
314
|
+
else:
|
|
315
|
+
json_match = re.search(r'\[[\s\S]*\]', response_text)
|
|
316
|
+
if json_match:
|
|
317
|
+
json_str = json_match.group(0)
|
|
318
|
+
else:
|
|
319
|
+
logger.warning(
|
|
320
|
+
"no_json_found_in_response",
|
|
321
|
+
response_preview=response_text[:200],
|
|
322
|
+
)
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
raw_relationships = json.loads(json_str)
|
|
327
|
+
except json.JSONDecodeError as e:
|
|
328
|
+
logger.warning(
|
|
329
|
+
"json_parse_error",
|
|
330
|
+
error=str(e),
|
|
331
|
+
json_preview=json_str[:200],
|
|
332
|
+
)
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
if not isinstance(raw_relationships, list):
|
|
336
|
+
logger.warning("expected_list_of_relationships", got=type(raw_relationships).__name__)
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
# Create a set of valid entity IDs
|
|
340
|
+
valid_entity_ids = {e.id for e in entities}
|
|
341
|
+
|
|
342
|
+
relationships = []
|
|
343
|
+
for raw in raw_relationships:
|
|
344
|
+
try:
|
|
345
|
+
relationship = self._create_relationship_from_raw(
|
|
346
|
+
raw=raw,
|
|
347
|
+
node_id=node_id,
|
|
348
|
+
doc_id=doc_id,
|
|
349
|
+
valid_entity_ids=valid_entity_ids,
|
|
350
|
+
)
|
|
351
|
+
if relationship:
|
|
352
|
+
relationships.append(relationship)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
logger.debug(
|
|
355
|
+
"failed_to_create_relationship",
|
|
356
|
+
raw=raw,
|
|
357
|
+
error=str(e),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return relationships
|
|
361
|
+
|
|
362
|
+
def _create_relationship_from_raw(
|
|
363
|
+
self,
|
|
364
|
+
raw: dict[str, Any],
|
|
365
|
+
node_id: str,
|
|
366
|
+
doc_id: str,
|
|
367
|
+
valid_entity_ids: set[str],
|
|
368
|
+
) -> Relationship | None:
|
|
369
|
+
"""
|
|
370
|
+
Create a Relationship object from raw LLM output.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
raw: Raw relationship dict from LLM.
|
|
374
|
+
node_id: Source node ID.
|
|
375
|
+
doc_id: Source document ID.
|
|
376
|
+
valid_entity_ids: Set of valid entity IDs.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Relationship object or None if invalid.
|
|
380
|
+
"""
|
|
381
|
+
# Parse relationship type
|
|
382
|
+
type_str = raw.get("type", "").upper()
|
|
383
|
+
|
|
384
|
+
type_mapping = {
|
|
385
|
+
"TEMPORAL_BEFORE": RelationType.TEMPORAL_BEFORE,
|
|
386
|
+
"TEMPORAL_AFTER": RelationType.TEMPORAL_AFTER,
|
|
387
|
+
"BEFORE": RelationType.TEMPORAL_BEFORE,
|
|
388
|
+
"AFTER": RelationType.TEMPORAL_AFTER,
|
|
389
|
+
"CAUSAL": RelationType.CAUSAL,
|
|
390
|
+
"CAUSED": RelationType.CAUSAL,
|
|
391
|
+
"AFFILIATED_WITH": RelationType.AFFILIATED_WITH,
|
|
392
|
+
"AFFILIATION": RelationType.AFFILIATED_WITH,
|
|
393
|
+
"PARTY_TO": RelationType.PARTY_TO,
|
|
394
|
+
"PARTY": RelationType.PARTY_TO,
|
|
395
|
+
"SUPPORTS": RelationType.SUPPORTS,
|
|
396
|
+
"SUPPORT": RelationType.SUPPORTS,
|
|
397
|
+
"CONTRADICTS": RelationType.CONTRADICTS,
|
|
398
|
+
"CONTRADICT": RelationType.CONTRADICTS,
|
|
399
|
+
"REFERENCES": RelationType.REFERENCES,
|
|
400
|
+
"REFERENCE": RelationType.REFERENCES,
|
|
401
|
+
"CITES": RelationType.REFERENCES,
|
|
402
|
+
"SUPERSEDES": RelationType.SUPERSEDES,
|
|
403
|
+
"SUPERSEDE": RelationType.SUPERSEDES,
|
|
404
|
+
"AMENDS": RelationType.AMENDS,
|
|
405
|
+
"AMEND": RelationType.AMENDS,
|
|
406
|
+
"MENTIONS": RelationType.MENTIONS,
|
|
407
|
+
"DEFINED_IN": RelationType.DEFINED_IN,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
rel_type = type_mapping.get(type_str)
|
|
411
|
+
if not rel_type:
|
|
412
|
+
logger.debug("unknown_relationship_type", type=type_str)
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
# Get source and target
|
|
416
|
+
source_id = raw.get("source_id", "")
|
|
417
|
+
target_id = raw.get("target_id", "")
|
|
418
|
+
source_type = raw.get("source_type", "entity").lower()
|
|
419
|
+
target_type = raw.get("target_type", "entity").lower()
|
|
420
|
+
|
|
421
|
+
if not source_id or not target_id:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
# Validate source_type and target_type
|
|
425
|
+
if source_type not in ("entity", "node"):
|
|
426
|
+
source_type = "entity"
|
|
427
|
+
if target_type not in ("entity", "node"):
|
|
428
|
+
target_type = "entity"
|
|
429
|
+
|
|
430
|
+
# Get confidence and evidence
|
|
431
|
+
confidence = raw.get("confidence", 0.8)
|
|
432
|
+
if not isinstance(confidence, (int, float)):
|
|
433
|
+
confidence = 0.8
|
|
434
|
+
confidence = max(0.0, min(1.0, float(confidence)))
|
|
435
|
+
|
|
436
|
+
evidence = raw.get("evidence", "").strip()
|
|
437
|
+
|
|
438
|
+
# Create relationship
|
|
439
|
+
relationship = Relationship(
|
|
440
|
+
type=rel_type,
|
|
441
|
+
source_id=source_id,
|
|
442
|
+
target_id=target_id,
|
|
443
|
+
source_type=source_type,
|
|
444
|
+
target_type=target_type,
|
|
445
|
+
doc_id=doc_id,
|
|
446
|
+
confidence=confidence,
|
|
447
|
+
evidence=evidence,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
return relationship
|
|
451
|
+
|
|
452
|
+
def extract_entity_to_section_relationships(
|
|
453
|
+
self,
|
|
454
|
+
entities: list[Entity],
|
|
455
|
+
) -> list[Relationship]:
|
|
456
|
+
"""
|
|
457
|
+
Create MENTIONS relationships linking entities to their sections.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
entities: List of entities with mentions.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
List of MENTIONS relationships.
|
|
464
|
+
"""
|
|
465
|
+
relationships = []
|
|
466
|
+
|
|
467
|
+
for entity in entities:
|
|
468
|
+
for mention in entity.mentions:
|
|
469
|
+
rel = Relationship(
|
|
470
|
+
type=RelationType.MENTIONS,
|
|
471
|
+
source_id=mention.node_id,
|
|
472
|
+
target_id=entity.id,
|
|
473
|
+
source_type="node",
|
|
474
|
+
target_type="entity",
|
|
475
|
+
doc_id=mention.doc_id,
|
|
476
|
+
confidence=mention.confidence,
|
|
477
|
+
evidence=mention.context,
|
|
478
|
+
)
|
|
479
|
+
relationships.append(rel)
|
|
480
|
+
|
|
481
|
+
return relationships
|
|
482
|
+
|
|
483
|
+
def extract_batch(
|
|
484
|
+
self,
|
|
485
|
+
nodes: list[dict[str, Any]],
|
|
486
|
+
entities_by_node: dict[str, list[Entity]],
|
|
487
|
+
) -> list[Relationship]:
|
|
488
|
+
"""
|
|
489
|
+
Extract relationships from multiple nodes.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
nodes: List of node dicts with keys: node_id, doc_id, header, content
|
|
493
|
+
entities_by_node: Mapping of node_id to entities in that node.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
List of all extracted Relationship objects.
|
|
497
|
+
"""
|
|
498
|
+
all_relationships = []
|
|
499
|
+
|
|
500
|
+
for node in nodes:
|
|
501
|
+
node_id = node.get("node_id", "")
|
|
502
|
+
entities = entities_by_node.get(node_id, [])
|
|
503
|
+
|
|
504
|
+
relationships = self.extract_from_node(
|
|
505
|
+
node_id=node_id,
|
|
506
|
+
doc_id=node.get("doc_id", ""),
|
|
507
|
+
header=node.get("header", ""),
|
|
508
|
+
content=node.get("content", ""),
|
|
509
|
+
entities=entities,
|
|
510
|
+
)
|
|
511
|
+
all_relationships.extend(relationships)
|
|
512
|
+
|
|
513
|
+
return all_relationships
|
|
514
|
+
|
|
515
|
+
def clear_cache(self) -> None:
|
|
516
|
+
"""Clear the relationship cache."""
|
|
517
|
+
self._cache.clear()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def extract_implicit_relationships(
|
|
521
|
+
entities: list[Entity],
|
|
522
|
+
doc_id: str,
|
|
523
|
+
) -> list[Relationship]:
|
|
524
|
+
"""
|
|
525
|
+
Extract implicit relationships based on entity metadata.
|
|
526
|
+
|
|
527
|
+
For example:
|
|
528
|
+
- PERSON with role "defendant" -> PARTY_TO -> any legal proceeding
|
|
529
|
+
- PERSON affiliated with ORGANIZATION
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
entities: List of entities.
|
|
533
|
+
doc_id: Document ID.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
List of implicit relationships.
|
|
537
|
+
"""
|
|
538
|
+
relationships = []
|
|
539
|
+
|
|
540
|
+
# Group entities by type
|
|
541
|
+
persons = [e for e in entities if e.type == EntityType.PERSON]
|
|
542
|
+
orgs = [e for e in entities if e.type == EntityType.ORGANIZATION]
|
|
543
|
+
events = [e for e in entities if e.type == EntityType.EVENT]
|
|
544
|
+
documents = [e for e in entities if e.type == EntityType.DOCUMENT]
|
|
545
|
+
|
|
546
|
+
# Extract affiliations from person metadata
|
|
547
|
+
for person in persons:
|
|
548
|
+
# Check for organization affiliation in metadata
|
|
549
|
+
affiliated_org = person.metadata.get("organization") or person.metadata.get("employer")
|
|
550
|
+
if affiliated_org:
|
|
551
|
+
# Find matching org
|
|
552
|
+
for org in orgs:
|
|
553
|
+
if affiliated_org.lower() in org.canonical_name.lower() or any(
|
|
554
|
+
affiliated_org.lower() in alias.lower() for alias in org.aliases
|
|
555
|
+
):
|
|
556
|
+
rel = Relationship(
|
|
557
|
+
type=RelationType.AFFILIATED_WITH,
|
|
558
|
+
source_id=person.id,
|
|
559
|
+
target_id=org.id,
|
|
560
|
+
source_type="entity",
|
|
561
|
+
target_type="entity",
|
|
562
|
+
doc_id=doc_id,
|
|
563
|
+
confidence=0.9,
|
|
564
|
+
evidence=f"{person.canonical_name} affiliated with {org.canonical_name}",
|
|
565
|
+
)
|
|
566
|
+
relationships.append(rel)
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
# Check for role-based party relationships
|
|
570
|
+
role = person.metadata.get("role", "").lower()
|
|
571
|
+
if role in ("defendant", "plaintiff", "respondent", "petitioner", "applicant"):
|
|
572
|
+
for event in events:
|
|
573
|
+
if any(kw in event.canonical_name.lower() for kw in ["case", "proceeding", "trial", "hearing"]):
|
|
574
|
+
rel = Relationship(
|
|
575
|
+
type=RelationType.PARTY_TO,
|
|
576
|
+
source_id=person.id,
|
|
577
|
+
target_id=event.id,
|
|
578
|
+
source_type="entity",
|
|
579
|
+
target_type="entity",
|
|
580
|
+
doc_id=doc_id,
|
|
581
|
+
confidence=0.85,
|
|
582
|
+
evidence=f"{person.canonical_name} is {role} in {event.canonical_name}",
|
|
583
|
+
)
|
|
584
|
+
relationships.append(rel)
|
|
585
|
+
|
|
586
|
+
for doc in documents:
|
|
587
|
+
if any(kw in doc.canonical_name.lower() for kw in ["complaint", "motion", "order", "judgment"]):
|
|
588
|
+
rel = Relationship(
|
|
589
|
+
type=RelationType.PARTY_TO,
|
|
590
|
+
source_id=person.id,
|
|
591
|
+
target_id=doc.id,
|
|
592
|
+
source_type="entity",
|
|
593
|
+
target_type="entity",
|
|
594
|
+
doc_id=doc_id,
|
|
595
|
+
confidence=0.85,
|
|
596
|
+
evidence=f"{person.canonical_name} is {role} in {doc.canonical_name}",
|
|
597
|
+
)
|
|
598
|
+
relationships.append(rel)
|
|
599
|
+
|
|
600
|
+
return relationships
|