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
rnsr/agent/repl_env.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REPL Environment - Recursive Language Model Execution Engine
|
|
3
|
+
|
|
4
|
+
Implements Section 2 of the research paper:
|
|
5
|
+
"The Prompt-as-Environment Abstraction" and "The Recursive Loop"
|
|
6
|
+
|
|
7
|
+
Key Concepts:
|
|
8
|
+
1. DOC_VAR: The document is loaded as a persistent variable, NOT passed to LLM context
|
|
9
|
+
2. Code Execution: LLM generates Python code to interact with DOC_VAR
|
|
10
|
+
3. Variable Stitching: Intermediate results frozen into variables to prevent Context Rot
|
|
11
|
+
4. Recursive Calls: Agent can invoke sub-LLM instances for sub-tasks
|
|
12
|
+
|
|
13
|
+
Per Zhang et al. (2025):
|
|
14
|
+
"The RLM initializes a computational environment where the massive input context
|
|
15
|
+
(the 'Long Prompt') is loaded into a variable, typically denoted as P or DOC_VAR.
|
|
16
|
+
The Neural Network itself does not ingest P. Instead, it ingests a system instruction
|
|
17
|
+
that informs it of P's existence and provides a set of tools to interact with it."
|
|
18
|
+
|
|
19
|
+
This decouples Memory (REPL state) from Processing (LLM inference), enabling
|
|
20
|
+
effectively unbounded context lengths.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import hashlib
|
|
27
|
+
import re
|
|
28
|
+
import traceback
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
import structlog
|
|
35
|
+
|
|
36
|
+
from rnsr.agent.variable_store import VariableStore, generate_pointer_name
|
|
37
|
+
from rnsr.indexing.kv_store import KVStore
|
|
38
|
+
from rnsr.models import SkeletonNode
|
|
39
|
+
|
|
40
|
+
logger = structlog.get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# =============================================================================
|
|
44
|
+
# RLM System Prompt - Informs LLM of DOC_VAR and available tools
|
|
45
|
+
# =============================================================================
|
|
46
|
+
|
|
47
|
+
RLM_SYSTEM_PROMPT = """You are a Recursive Language Model (RLM) operating in a Python REPL environment.
|
|
48
|
+
|
|
49
|
+
CRITICAL: You do NOT have the document in your context. The document is stored in the variable DOC_VAR.
|
|
50
|
+
You interact with the document by writing Python code that will be executed in the REPL.
|
|
51
|
+
|
|
52
|
+
## Available Variables:
|
|
53
|
+
- DOC_VAR: The full document text (string). Use slicing to access portions.
|
|
54
|
+
- DOC_TREE: Hierarchical tree structure of the document (dict).
|
|
55
|
+
- VARIABLES: Dictionary storing your findings (use store_variable() to add).
|
|
56
|
+
|
|
57
|
+
## Available Functions:
|
|
58
|
+
- len(DOC_VAR): Get document length in characters
|
|
59
|
+
- DOC_VAR[i:j]: Slice document to get characters from i to j
|
|
60
|
+
- search_text(pattern): Search DOC_VAR for regex pattern, returns list of (start, end, match)
|
|
61
|
+
- split_by_regex(pattern): Split DOC_VAR by pattern, returns list of segments
|
|
62
|
+
- list_children(node_id): Get summaries of child nodes in DOC_TREE
|
|
63
|
+
- read_node(node_id): Get full text content of a node
|
|
64
|
+
- store_variable(name, content): Store content as $NAME for later synthesis
|
|
65
|
+
- get_variable(name): Retrieve stored variable content
|
|
66
|
+
- sub_llm(prompt, context): Invoke sub-LLM to process context with prompt (for decomposition)
|
|
67
|
+
- batch_sub_llm(prompts, contexts): Batch process multiple sub-tasks in parallel
|
|
68
|
+
|
|
69
|
+
## Workflow for Complex Queries:
|
|
70
|
+
1. First, explore the document structure: `list_children('root')`
|
|
71
|
+
2. Navigate to relevant sections based on query
|
|
72
|
+
3. For multi-part queries, decompose and use sub_llm():
|
|
73
|
+
```python
|
|
74
|
+
# Example: Compare clauses across documents
|
|
75
|
+
clause_2023 = sub_llm("Extract the indemnification clause", read_node('2023_legal'))
|
|
76
|
+
clause_2024 = sub_llm("Extract the indemnification clause", read_node('2024_legal'))
|
|
77
|
+
store_variable("CLAUSE_2023", clause_2023)
|
|
78
|
+
store_variable("CLAUSE_2024", clause_2024)
|
|
79
|
+
```
|
|
80
|
+
4. For bulk processing, use batch_sub_llm():
|
|
81
|
+
```python
|
|
82
|
+
# Process 50 contracts in parallel batches
|
|
83
|
+
clauses = batch_sub_llm(
|
|
84
|
+
prompts=["Extract liability clause"] * 50,
|
|
85
|
+
contexts=[read_node(f'contract_{i}') for i in range(50)]
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Output:
|
|
90
|
+
Write Python code blocks to execute. The REPL will run them and return results.
|
|
91
|
+
When ready to synthesize, call: synthesize_answer(query, variable_names)
|
|
92
|
+
|
|
93
|
+
IMPORTANT:
|
|
94
|
+
- Always use store_variable() to save findings - this prevents Context Rot
|
|
95
|
+
- Prefer batch_sub_llm() over sequential sub_llm() calls for efficiency
|
|
96
|
+
- Target ~200k characters per batch for optimal token density
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# =============================================================================
|
|
101
|
+
# REPL Environment Class
|
|
102
|
+
# =============================================================================
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class REPLEnvironment:
|
|
107
|
+
"""
|
|
108
|
+
Python REPL Environment for Recursive Language Model execution.
|
|
109
|
+
|
|
110
|
+
Implements the "Prompt-as-Environment" abstraction from Section 2.1:
|
|
111
|
+
- DOC_VAR: Document loaded as persistent variable
|
|
112
|
+
- Code execution sandbox for LLM-generated code
|
|
113
|
+
- Variable stitching for intermediate results
|
|
114
|
+
- Recursive sub-LLM invocation
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
env = REPLEnvironment(
|
|
118
|
+
document_text=full_text,
|
|
119
|
+
skeleton=skeleton_index,
|
|
120
|
+
kv_store=kv_store,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# LLM generates code, REPL executes it
|
|
124
|
+
result = env.execute("len(DOC_VAR)")
|
|
125
|
+
# -> 152347
|
|
126
|
+
|
|
127
|
+
result = env.execute("list_children('root')")
|
|
128
|
+
# -> [{'id': 'sec_001', 'header': 'Introduction', ...}, ...]
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# The document as persistent variable (NOT in LLM context)
|
|
132
|
+
document_text: str
|
|
133
|
+
skeleton: dict[str, SkeletonNode]
|
|
134
|
+
kv_store: KVStore
|
|
135
|
+
|
|
136
|
+
# Variable store for findings
|
|
137
|
+
variable_store: VariableStore = field(default_factory=VariableStore)
|
|
138
|
+
|
|
139
|
+
# Execution state
|
|
140
|
+
execution_history: list[dict[str, Any]] = field(default_factory=list)
|
|
141
|
+
max_output_length: int = 10000 # Truncate long outputs
|
|
142
|
+
|
|
143
|
+
# Batching configuration
|
|
144
|
+
batch_size: int = 5 # Process N items per batch
|
|
145
|
+
max_parallel_batches: int = 4 # Max concurrent batch calls
|
|
146
|
+
optimal_chars_per_call: int = 200_000 # Target ~200k chars per LLM call
|
|
147
|
+
|
|
148
|
+
# LLM function for sub-calls (injected)
|
|
149
|
+
_llm_fn: Callable[[str], str] | None = None
|
|
150
|
+
_async_llm_fn: Callable[[str], Any] | None = None
|
|
151
|
+
|
|
152
|
+
def __post_init__(self):
|
|
153
|
+
"""Build the execution namespace."""
|
|
154
|
+
self._namespace = self._build_namespace()
|
|
155
|
+
logger.info(
|
|
156
|
+
"repl_initialized",
|
|
157
|
+
doc_length=len(self.document_text),
|
|
158
|
+
num_nodes=len(self.skeleton),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _build_namespace(self) -> dict[str, Any]:
|
|
162
|
+
"""Build the Python namespace for code execution."""
|
|
163
|
+
return {
|
|
164
|
+
# Core variables (the "environment")
|
|
165
|
+
"DOC_VAR": self.document_text,
|
|
166
|
+
"DOC_TREE": self._build_tree_dict(),
|
|
167
|
+
"VARIABLES": {},
|
|
168
|
+
|
|
169
|
+
# Built-in functions available in namespace
|
|
170
|
+
"len": len,
|
|
171
|
+
"print": print,
|
|
172
|
+
"str": str,
|
|
173
|
+
"int": int,
|
|
174
|
+
"float": float,
|
|
175
|
+
"list": list,
|
|
176
|
+
"dict": dict,
|
|
177
|
+
"range": range,
|
|
178
|
+
"enumerate": enumerate,
|
|
179
|
+
"zip": zip,
|
|
180
|
+
"sorted": sorted,
|
|
181
|
+
"min": min,
|
|
182
|
+
"max": max,
|
|
183
|
+
"sum": sum,
|
|
184
|
+
"abs": abs,
|
|
185
|
+
"round": round,
|
|
186
|
+
"re": re, # For regex operations
|
|
187
|
+
|
|
188
|
+
# Navigator functions
|
|
189
|
+
"search_text": self._search_text,
|
|
190
|
+
"split_by_regex": self._split_by_regex,
|
|
191
|
+
"list_children": self._list_children,
|
|
192
|
+
"read_node": self._read_node,
|
|
193
|
+
"get_node_path": self._get_node_path,
|
|
194
|
+
|
|
195
|
+
# Variable stitching functions
|
|
196
|
+
"store_variable": self._store_variable,
|
|
197
|
+
"get_variable": self._get_variable,
|
|
198
|
+
"list_variables": self._list_variables,
|
|
199
|
+
|
|
200
|
+
# Recursive LLM functions
|
|
201
|
+
"sub_llm": self._sub_llm,
|
|
202
|
+
"batch_sub_llm": self._batch_sub_llm,
|
|
203
|
+
|
|
204
|
+
# Synthesis
|
|
205
|
+
"synthesize_answer": self._synthesize_answer,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _build_tree_dict(self) -> dict[str, Any]:
|
|
209
|
+
"""Convert skeleton to navigable dict structure."""
|
|
210
|
+
tree = {}
|
|
211
|
+
for node_id, node in self.skeleton.items():
|
|
212
|
+
tree[node_id] = {
|
|
213
|
+
"id": node.node_id,
|
|
214
|
+
"header": node.header,
|
|
215
|
+
"summary": node.summary,
|
|
216
|
+
"level": node.level,
|
|
217
|
+
"children": node.child_ids,
|
|
218
|
+
"parent": node.parent_id,
|
|
219
|
+
}
|
|
220
|
+
return tree
|
|
221
|
+
|
|
222
|
+
# =========================================================================
|
|
223
|
+
# Core Execution
|
|
224
|
+
# =========================================================================
|
|
225
|
+
|
|
226
|
+
def execute(self, code: str) -> dict[str, Any]:
|
|
227
|
+
"""
|
|
228
|
+
Execute Python code in the REPL environment.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
code: Python code to execute.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Dict with 'success', 'output', 'error', and 'variables'.
|
|
235
|
+
"""
|
|
236
|
+
start_time = datetime.now(timezone.utc)
|
|
237
|
+
|
|
238
|
+
# Clean code (remove markdown code blocks if present)
|
|
239
|
+
code = self._clean_code(code)
|
|
240
|
+
|
|
241
|
+
result = {
|
|
242
|
+
"success": False,
|
|
243
|
+
"output": None,
|
|
244
|
+
"error": None,
|
|
245
|
+
"variables": list(self._namespace.get("VARIABLES", {}).keys()),
|
|
246
|
+
"execution_time_ms": 0,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# Capture output
|
|
251
|
+
import io
|
|
252
|
+
import sys
|
|
253
|
+
|
|
254
|
+
old_stdout = sys.stdout
|
|
255
|
+
sys.stdout = captured_output = io.StringIO()
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Try as expression first (returns value)
|
|
259
|
+
compiled = compile(code, "<repl>", "eval")
|
|
260
|
+
output = eval(compiled, self._namespace)
|
|
261
|
+
result["output"] = self._format_output(output)
|
|
262
|
+
result["success"] = True
|
|
263
|
+
except SyntaxError:
|
|
264
|
+
# Execute as statements
|
|
265
|
+
exec(code, self._namespace)
|
|
266
|
+
printed = captured_output.getvalue()
|
|
267
|
+
result["output"] = printed if printed else "Executed successfully"
|
|
268
|
+
result["success"] = True
|
|
269
|
+
finally:
|
|
270
|
+
sys.stdout = old_stdout
|
|
271
|
+
|
|
272
|
+
# Update VARIABLES reference
|
|
273
|
+
result["variables"] = list(self._namespace.get("VARIABLES", {}).keys())
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
result["error"] = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
|
277
|
+
logger.warning("repl_execution_error", error=str(e), code=code[:200])
|
|
278
|
+
|
|
279
|
+
# Record timing
|
|
280
|
+
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
|
|
281
|
+
result["execution_time_ms"] = round(elapsed, 2)
|
|
282
|
+
|
|
283
|
+
# Record in history
|
|
284
|
+
self.execution_history.append({
|
|
285
|
+
"code": code,
|
|
286
|
+
"result": result,
|
|
287
|
+
"timestamp": start_time.isoformat(),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
logger.debug(
|
|
291
|
+
"repl_executed",
|
|
292
|
+
success=result["success"],
|
|
293
|
+
output_length=len(str(result["output"])) if result["output"] else 0,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
def _clean_code(self, code: str) -> str:
|
|
299
|
+
"""Remove markdown code blocks and clean whitespace."""
|
|
300
|
+
# Remove ```python ... ``` blocks
|
|
301
|
+
code = re.sub(r"```python\s*", "", code)
|
|
302
|
+
code = re.sub(r"```\s*", "", code)
|
|
303
|
+
return code.strip()
|
|
304
|
+
|
|
305
|
+
def _format_output(self, output: Any) -> str:
|
|
306
|
+
"""Format output for display, truncating if needed."""
|
|
307
|
+
if output is None:
|
|
308
|
+
return "None"
|
|
309
|
+
|
|
310
|
+
output_str = str(output)
|
|
311
|
+
|
|
312
|
+
if len(output_str) > self.max_output_length:
|
|
313
|
+
truncated = output_str[:self.max_output_length]
|
|
314
|
+
return f"{truncated}\n... [truncated, {len(output_str)} total chars]"
|
|
315
|
+
|
|
316
|
+
return output_str
|
|
317
|
+
|
|
318
|
+
# =========================================================================
|
|
319
|
+
# Navigator Functions (exposed to REPL)
|
|
320
|
+
# =========================================================================
|
|
321
|
+
|
|
322
|
+
def _search_text(self, pattern: str) -> list[tuple[int, int, str]]:
|
|
323
|
+
"""
|
|
324
|
+
Search DOC_VAR for regex pattern.
|
|
325
|
+
|
|
326
|
+
Returns list of (start_pos, end_pos, matched_text).
|
|
327
|
+
"""
|
|
328
|
+
matches = []
|
|
329
|
+
for match in re.finditer(pattern, self.document_text, re.IGNORECASE):
|
|
330
|
+
matches.append((match.start(), match.end(), match.group()))
|
|
331
|
+
return matches[:100] # Limit results
|
|
332
|
+
|
|
333
|
+
def _split_by_regex(self, pattern: str) -> list[str]:
|
|
334
|
+
"""Split DOC_VAR by regex pattern."""
|
|
335
|
+
return re.split(pattern, self.document_text)
|
|
336
|
+
|
|
337
|
+
def _list_children(self, node_id: str) -> list[dict[str, Any]]:
|
|
338
|
+
"""Get summaries of child nodes."""
|
|
339
|
+
node = self.skeleton.get(node_id)
|
|
340
|
+
if not node:
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
children = []
|
|
344
|
+
for child_id in node.child_ids:
|
|
345
|
+
child = self.skeleton.get(child_id)
|
|
346
|
+
if child:
|
|
347
|
+
children.append({
|
|
348
|
+
"id": child.node_id,
|
|
349
|
+
"header": child.header,
|
|
350
|
+
"summary": child.summary[:200] if child.summary else "",
|
|
351
|
+
"has_children": len(child.child_ids) > 0,
|
|
352
|
+
})
|
|
353
|
+
return children
|
|
354
|
+
|
|
355
|
+
def _read_node(self, node_id: str) -> str:
|
|
356
|
+
"""Get full text content of a node."""
|
|
357
|
+
content = self.kv_store.get(node_id)
|
|
358
|
+
if content:
|
|
359
|
+
return content
|
|
360
|
+
|
|
361
|
+
# Fallback to skeleton summary
|
|
362
|
+
node = self.skeleton.get(node_id)
|
|
363
|
+
return node.summary if node else ""
|
|
364
|
+
|
|
365
|
+
def _get_node_path(self, node_id: str) -> list[str]:
|
|
366
|
+
"""Get path from root to node."""
|
|
367
|
+
path = []
|
|
368
|
+
current = node_id
|
|
369
|
+
while current:
|
|
370
|
+
node = self.skeleton.get(current)
|
|
371
|
+
if node:
|
|
372
|
+
path.insert(0, node.header)
|
|
373
|
+
current = node.parent_id
|
|
374
|
+
else:
|
|
375
|
+
break
|
|
376
|
+
return path
|
|
377
|
+
|
|
378
|
+
# =========================================================================
|
|
379
|
+
# Variable Stitching Functions (Section 2.2)
|
|
380
|
+
# =========================================================================
|
|
381
|
+
|
|
382
|
+
def _store_variable(self, name: str, content: str) -> str:
|
|
383
|
+
"""
|
|
384
|
+
Store content as a named variable.
|
|
385
|
+
|
|
386
|
+
This implements Variable Stitching to prevent Context Rot.
|
|
387
|
+
Intermediate results are "frozen" into variables.
|
|
388
|
+
"""
|
|
389
|
+
if not name.startswith("$"):
|
|
390
|
+
name = "$" + name.upper().replace(" ", "_")
|
|
391
|
+
|
|
392
|
+
self.variable_store.assign(name, content, source_node_id="repl")
|
|
393
|
+
self._namespace["VARIABLES"][name] = content
|
|
394
|
+
|
|
395
|
+
logger.info("variable_stored", name=name, length=len(content))
|
|
396
|
+
return name
|
|
397
|
+
|
|
398
|
+
def _get_variable(self, name: str) -> str | None:
|
|
399
|
+
"""Retrieve a stored variable."""
|
|
400
|
+
if not name.startswith("$"):
|
|
401
|
+
name = "$" + name.upper()
|
|
402
|
+
|
|
403
|
+
return self.variable_store.resolve(name)
|
|
404
|
+
|
|
405
|
+
def _list_variables(self) -> list[str]:
|
|
406
|
+
"""List all stored variables."""
|
|
407
|
+
return [v.pointer for v in self.variable_store.list_variables()]
|
|
408
|
+
|
|
409
|
+
# =========================================================================
|
|
410
|
+
# Recursive LLM Functions (Section 2.2)
|
|
411
|
+
# =========================================================================
|
|
412
|
+
|
|
413
|
+
def _sub_llm(self, prompt: str, context: str = "") -> str:
|
|
414
|
+
"""
|
|
415
|
+
Invoke a sub-LLM call for a specific sub-task.
|
|
416
|
+
|
|
417
|
+
This is the recursive mechanism: the model can call itself
|
|
418
|
+
with a specific sub-prompt to solve sub-problems.
|
|
419
|
+
|
|
420
|
+
Example:
|
|
421
|
+
liability_1 = sub_llm("Extract the liability clause", contract_1_text)
|
|
422
|
+
"""
|
|
423
|
+
if self._llm_fn is None:
|
|
424
|
+
return "[ERROR: LLM function not configured]"
|
|
425
|
+
|
|
426
|
+
full_prompt = f"{prompt}\n\nContext:\n{context}" if context else prompt
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
result = self._llm_fn(full_prompt)
|
|
430
|
+
logger.debug("sub_llm_called", prompt_length=len(prompt), result_length=len(result))
|
|
431
|
+
return result
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error("sub_llm_error", error=str(e))
|
|
434
|
+
return f"[ERROR: {str(e)}]"
|
|
435
|
+
|
|
436
|
+
def _batch_sub_llm(
|
|
437
|
+
self,
|
|
438
|
+
prompts: list[str],
|
|
439
|
+
contexts: list[str],
|
|
440
|
+
) -> list[str]:
|
|
441
|
+
"""
|
|
442
|
+
Batch process multiple sub-tasks in parallel.
|
|
443
|
+
|
|
444
|
+
Implements Section 2.3 Optimization via Batching:
|
|
445
|
+
"Instead of making 1,000 individual calls to summarize 1,000 paragraphs,
|
|
446
|
+
the RLM writes code to group paragraphs into chunks of 5 and processes
|
|
447
|
+
them in parallel threads (or batched API calls)."
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
prompts: List of prompts for each sub-task.
|
|
451
|
+
contexts: List of context strings for each sub-task.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
List of results from sub-LLM calls.
|
|
455
|
+
"""
|
|
456
|
+
if len(prompts) != len(contexts):
|
|
457
|
+
raise ValueError("prompts and contexts must have same length")
|
|
458
|
+
|
|
459
|
+
if not prompts:
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
logger.info(
|
|
463
|
+
"batch_sub_llm_start",
|
|
464
|
+
num_tasks=len(prompts),
|
|
465
|
+
batch_size=self.batch_size,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Group into batches for optimal token density
|
|
469
|
+
batches = self._create_optimal_batches(prompts, contexts)
|
|
470
|
+
|
|
471
|
+
# Process batches
|
|
472
|
+
results = []
|
|
473
|
+
for batch in batches:
|
|
474
|
+
batch_results = self._process_batch(batch)
|
|
475
|
+
results.extend(batch_results)
|
|
476
|
+
|
|
477
|
+
logger.info("batch_sub_llm_complete", num_results=len(results))
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
def _create_optimal_batches(
|
|
481
|
+
self,
|
|
482
|
+
prompts: list[str],
|
|
483
|
+
contexts: list[str],
|
|
484
|
+
) -> list[list[tuple[str, str]]]:
|
|
485
|
+
"""
|
|
486
|
+
Create batches targeting optimal token density (~200k chars).
|
|
487
|
+
"""
|
|
488
|
+
batches = []
|
|
489
|
+
current_batch = []
|
|
490
|
+
current_chars = 0
|
|
491
|
+
|
|
492
|
+
for prompt, context in zip(prompts, contexts):
|
|
493
|
+
item_chars = len(prompt) + len(context)
|
|
494
|
+
|
|
495
|
+
if current_chars + item_chars > self.optimal_chars_per_call and current_batch:
|
|
496
|
+
batches.append(current_batch)
|
|
497
|
+
current_batch = []
|
|
498
|
+
current_chars = 0
|
|
499
|
+
|
|
500
|
+
current_batch.append((prompt, context))
|
|
501
|
+
current_chars += item_chars
|
|
502
|
+
|
|
503
|
+
if current_batch:
|
|
504
|
+
batches.append(current_batch)
|
|
505
|
+
|
|
506
|
+
return batches
|
|
507
|
+
|
|
508
|
+
def _process_batch(self, batch: list[tuple[str, str]]) -> list[str]:
|
|
509
|
+
"""Process a batch of (prompt, context) pairs."""
|
|
510
|
+
if self._llm_fn is None:
|
|
511
|
+
return ["[ERROR: LLM not configured]"] * len(batch)
|
|
512
|
+
|
|
513
|
+
results = []
|
|
514
|
+
|
|
515
|
+
# Try parallel execution with ThreadPoolExecutor
|
|
516
|
+
try:
|
|
517
|
+
with ThreadPoolExecutor(max_workers=self.max_parallel_batches) as executor:
|
|
518
|
+
futures = []
|
|
519
|
+
for prompt, context in batch:
|
|
520
|
+
full_prompt = f"{prompt}\n\nContext:\n{context}" if context else prompt
|
|
521
|
+
futures.append(executor.submit(self._llm_fn, full_prompt))
|
|
522
|
+
|
|
523
|
+
for future in futures:
|
|
524
|
+
try:
|
|
525
|
+
results.append(future.result(timeout=60))
|
|
526
|
+
except Exception as e:
|
|
527
|
+
results.append(f"[ERROR: {str(e)}]")
|
|
528
|
+
except Exception as e:
|
|
529
|
+
logger.error("batch_processing_error", error=str(e))
|
|
530
|
+
# Fallback to sequential
|
|
531
|
+
for prompt, context in batch:
|
|
532
|
+
results.append(self._sub_llm(prompt, context))
|
|
533
|
+
|
|
534
|
+
return results
|
|
535
|
+
|
|
536
|
+
# =========================================================================
|
|
537
|
+
# Synthesis
|
|
538
|
+
# =========================================================================
|
|
539
|
+
|
|
540
|
+
def _synthesize_answer(
|
|
541
|
+
self,
|
|
542
|
+
query: str,
|
|
543
|
+
variable_names: list[str] | None = None,
|
|
544
|
+
) -> str:
|
|
545
|
+
"""
|
|
546
|
+
Synthesize final answer from stored variables.
|
|
547
|
+
|
|
548
|
+
This is the final step where all accumulated variables
|
|
549
|
+
are resolved and compared to generate the answer.
|
|
550
|
+
"""
|
|
551
|
+
if variable_names is None:
|
|
552
|
+
variable_names = self._list_variables()
|
|
553
|
+
|
|
554
|
+
# Resolve all variables
|
|
555
|
+
context_parts = []
|
|
556
|
+
for name in variable_names:
|
|
557
|
+
content = self._get_variable(name)
|
|
558
|
+
if content:
|
|
559
|
+
context_parts.append(f"=== {name} ===\n{content}")
|
|
560
|
+
|
|
561
|
+
if not context_parts:
|
|
562
|
+
return "No relevant information found."
|
|
563
|
+
|
|
564
|
+
context = "\n\n".join(context_parts)
|
|
565
|
+
|
|
566
|
+
# Use LLM to synthesize
|
|
567
|
+
if self._llm_fn:
|
|
568
|
+
prompt = f"""Based on the following collected information, answer the query.
|
|
569
|
+
|
|
570
|
+
Query: {query}
|
|
571
|
+
|
|
572
|
+
Collected Information:
|
|
573
|
+
{context}
|
|
574
|
+
|
|
575
|
+
Provide a comprehensive answer based on the above information."""
|
|
576
|
+
|
|
577
|
+
return self._llm_fn(prompt)
|
|
578
|
+
|
|
579
|
+
return f"Found {len(variable_names)} relevant sections. Variables: {variable_names}"
|
|
580
|
+
|
|
581
|
+
# =========================================================================
|
|
582
|
+
# Configuration
|
|
583
|
+
# =========================================================================
|
|
584
|
+
|
|
585
|
+
def set_llm_function(self, llm_fn: Callable[[str], str]) -> None:
|
|
586
|
+
"""Set the LLM function for sub-calls."""
|
|
587
|
+
self._llm_fn = llm_fn
|
|
588
|
+
logger.info("llm_function_configured")
|
|
589
|
+
|
|
590
|
+
def get_system_prompt(self) -> str:
|
|
591
|
+
"""Get the RLM system prompt for the LLM."""
|
|
592
|
+
return RLM_SYSTEM_PROMPT
|
|
593
|
+
|
|
594
|
+
def get_state_summary(self) -> dict[str, Any]:
|
|
595
|
+
"""Get current REPL state summary."""
|
|
596
|
+
return {
|
|
597
|
+
"doc_length": len(self.document_text),
|
|
598
|
+
"num_nodes": len(self.skeleton),
|
|
599
|
+
"variables": self._list_variables(),
|
|
600
|
+
"execution_count": len(self.execution_history),
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# =============================================================================
|
|
605
|
+
# Factory Function
|
|
606
|
+
# =============================================================================
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def create_repl_environment(
|
|
610
|
+
document_text: str | None = None,
|
|
611
|
+
skeleton: dict[str, SkeletonNode] | None = None,
|
|
612
|
+
kv_store: KVStore | None = None,
|
|
613
|
+
llm_provider: str | None = None,
|
|
614
|
+
) -> REPLEnvironment:
|
|
615
|
+
"""
|
|
616
|
+
Create a configured REPL environment.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
document_text: Full document text (DOC_VAR).
|
|
620
|
+
skeleton: Skeleton index for tree navigation.
|
|
621
|
+
kv_store: KV store for full node content.
|
|
622
|
+
llm_provider: LLM provider for sub-calls ('openai', 'anthropic', 'gemini').
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Configured REPLEnvironment.
|
|
626
|
+
|
|
627
|
+
Example:
|
|
628
|
+
from rnsr import ingest_document, build_skeleton_index
|
|
629
|
+
from rnsr.agent.repl_env import create_repl_environment
|
|
630
|
+
|
|
631
|
+
result = ingest_document("contract.pdf")
|
|
632
|
+
skeleton, kv_store = build_skeleton_index(result.tree)
|
|
633
|
+
|
|
634
|
+
# Get full text
|
|
635
|
+
full_text = kv_store.get("root") or ""
|
|
636
|
+
|
|
637
|
+
env = create_repl_environment(
|
|
638
|
+
document_text=full_text,
|
|
639
|
+
skeleton=skeleton,
|
|
640
|
+
kv_store=kv_store,
|
|
641
|
+
llm_provider="gemini",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# Execute code
|
|
645
|
+
env.execute("len(DOC_VAR)") # -> 152347
|
|
646
|
+
env.execute("list_children('root')") # -> [...]
|
|
647
|
+
"""
|
|
648
|
+
from rnsr.indexing.kv_store import InMemoryKVStore
|
|
649
|
+
|
|
650
|
+
env = REPLEnvironment(
|
|
651
|
+
document_text=document_text or "",
|
|
652
|
+
skeleton=skeleton or {},
|
|
653
|
+
kv_store=kv_store or InMemoryKVStore(),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Configure LLM if provider specified
|
|
657
|
+
if llm_provider:
|
|
658
|
+
try:
|
|
659
|
+
from rnsr.llm import get_llm, LLMProvider
|
|
660
|
+
|
|
661
|
+
# Convert string to LLMProvider enum if needed
|
|
662
|
+
provider_enum = LLMProvider(llm_provider) if isinstance(llm_provider, str) else llm_provider
|
|
663
|
+
llm = get_llm(provider=provider_enum)
|
|
664
|
+
|
|
665
|
+
def llm_fn(prompt: str) -> str:
|
|
666
|
+
response = llm.complete(prompt)
|
|
667
|
+
return str(response)
|
|
668
|
+
|
|
669
|
+
env.set_llm_function(llm_fn)
|
|
670
|
+
except Exception as e:
|
|
671
|
+
logger.warning("llm_config_failed", error=str(e))
|
|
672
|
+
|
|
673
|
+
return env
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# =============================================================================
|
|
677
|
+
# Async Batch Processing
|
|
678
|
+
# =============================================================================
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
async def batch_process_async(
|
|
682
|
+
env: REPLEnvironment,
|
|
683
|
+
prompts: list[str],
|
|
684
|
+
contexts: list[str],
|
|
685
|
+
) -> list[str]:
|
|
686
|
+
"""
|
|
687
|
+
Async batch processing for maximum throughput.
|
|
688
|
+
|
|
689
|
+
Uses asyncio for concurrent LLM calls when available.
|
|
690
|
+
"""
|
|
691
|
+
if env._async_llm_fn is None:
|
|
692
|
+
# Fallback to sync batch
|
|
693
|
+
return env._batch_sub_llm(prompts, contexts)
|
|
694
|
+
|
|
695
|
+
async_fn = env._async_llm_fn # Capture for type narrowing
|
|
696
|
+
|
|
697
|
+
async def process_one(prompt: str, context: str) -> str:
|
|
698
|
+
full_prompt = f"{prompt}\n\nContext:\n{context}" if context else prompt
|
|
699
|
+
try:
|
|
700
|
+
return await async_fn(full_prompt)
|
|
701
|
+
except Exception as e:
|
|
702
|
+
return f"[ERROR: {str(e)}]"
|
|
703
|
+
|
|
704
|
+
# Process all concurrently
|
|
705
|
+
tasks = [
|
|
706
|
+
process_one(p, c) for p, c in zip(prompts, contexts)
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
return await asyncio.gather(*tasks)
|