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.
Files changed (72) hide show
  1. rnsr/__init__.py +118 -0
  2. rnsr/__main__.py +242 -0
  3. rnsr/agent/__init__.py +218 -0
  4. rnsr/agent/cross_doc_navigator.py +767 -0
  5. rnsr/agent/graph.py +1557 -0
  6. rnsr/agent/llm_cache.py +575 -0
  7. rnsr/agent/navigator_api.py +497 -0
  8. rnsr/agent/provenance.py +772 -0
  9. rnsr/agent/query_clarifier.py +617 -0
  10. rnsr/agent/reasoning_memory.py +736 -0
  11. rnsr/agent/repl_env.py +709 -0
  12. rnsr/agent/rlm_navigator.py +2108 -0
  13. rnsr/agent/self_reflection.py +602 -0
  14. rnsr/agent/variable_store.py +308 -0
  15. rnsr/benchmarks/__init__.py +118 -0
  16. rnsr/benchmarks/comprehensive_benchmark.py +733 -0
  17. rnsr/benchmarks/evaluation_suite.py +1210 -0
  18. rnsr/benchmarks/finance_bench.py +147 -0
  19. rnsr/benchmarks/pdf_merger.py +178 -0
  20. rnsr/benchmarks/performance.py +321 -0
  21. rnsr/benchmarks/quality.py +321 -0
  22. rnsr/benchmarks/runner.py +298 -0
  23. rnsr/benchmarks/standard_benchmarks.py +995 -0
  24. rnsr/client.py +560 -0
  25. rnsr/document_store.py +394 -0
  26. rnsr/exceptions.py +74 -0
  27. rnsr/extraction/__init__.py +172 -0
  28. rnsr/extraction/candidate_extractor.py +357 -0
  29. rnsr/extraction/entity_extractor.py +581 -0
  30. rnsr/extraction/entity_linker.py +825 -0
  31. rnsr/extraction/grounded_extractor.py +722 -0
  32. rnsr/extraction/learned_types.py +599 -0
  33. rnsr/extraction/models.py +232 -0
  34. rnsr/extraction/relationship_extractor.py +600 -0
  35. rnsr/extraction/relationship_patterns.py +511 -0
  36. rnsr/extraction/relationship_validator.py +392 -0
  37. rnsr/extraction/rlm_extractor.py +589 -0
  38. rnsr/extraction/rlm_unified_extractor.py +990 -0
  39. rnsr/extraction/tot_validator.py +610 -0
  40. rnsr/extraction/unified_extractor.py +342 -0
  41. rnsr/indexing/__init__.py +60 -0
  42. rnsr/indexing/knowledge_graph.py +1128 -0
  43. rnsr/indexing/kv_store.py +313 -0
  44. rnsr/indexing/persistence.py +323 -0
  45. rnsr/indexing/semantic_retriever.py +237 -0
  46. rnsr/indexing/semantic_search.py +320 -0
  47. rnsr/indexing/skeleton_index.py +395 -0
  48. rnsr/ingestion/__init__.py +161 -0
  49. rnsr/ingestion/chart_parser.py +569 -0
  50. rnsr/ingestion/document_boundary.py +662 -0
  51. rnsr/ingestion/font_histogram.py +334 -0
  52. rnsr/ingestion/header_classifier.py +595 -0
  53. rnsr/ingestion/hierarchical_cluster.py +515 -0
  54. rnsr/ingestion/layout_detector.py +356 -0
  55. rnsr/ingestion/layout_model.py +379 -0
  56. rnsr/ingestion/ocr_fallback.py +177 -0
  57. rnsr/ingestion/pipeline.py +936 -0
  58. rnsr/ingestion/semantic_fallback.py +417 -0
  59. rnsr/ingestion/table_parser.py +799 -0
  60. rnsr/ingestion/text_builder.py +460 -0
  61. rnsr/ingestion/tree_builder.py +402 -0
  62. rnsr/ingestion/vision_retrieval.py +965 -0
  63. rnsr/ingestion/xy_cut.py +555 -0
  64. rnsr/llm.py +733 -0
  65. rnsr/models.py +167 -0
  66. rnsr/py.typed +2 -0
  67. rnsr-0.1.0.dist-info/METADATA +592 -0
  68. rnsr-0.1.0.dist-info/RECORD +72 -0
  69. rnsr-0.1.0.dist-info/WHEEL +5 -0
  70. rnsr-0.1.0.dist-info/entry_points.txt +2 -0
  71. rnsr-0.1.0.dist-info/licenses/LICENSE +21 -0
  72. 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)