repository methods, module-level executor, read-only VFS

This commit is contained in:
Yiorgis Gozadinos 2026-04-20 11:57:39 +03:00
parent 27c5defdbb
commit 167c837514
No known key found for this signature in database
3 changed files with 49 additions and 25 deletions

View file

@ -26,10 +26,12 @@ class SandboxResult:
success: bool success: bool
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
def _run_async(coro: Any) -> Any: def _run_async(coro: Any) -> Any:
"""Run an async coroutine from a sync context (CallbackFile read).""" """Run an async coroutine from a sync context (CallbackFile read)."""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: return _executor.submit(asyncio.run, coro).result()
return pool.submit(asyncio.run, coro).result()
class Sandbox: class Sandbox:
@ -168,20 +170,12 @@ class Sandbox:
def read_content(_path: "PurePosixPath") -> str: def read_content(_path: "PurePosixPath") -> str:
async def _fetch() -> str: async def _fetch() -> str:
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.utils import escape_sql_string
async with HaikuRAG( async with HaikuRAG(
db_path, config=config, read_only=True db_path, config=config, read_only=True
) as rag: ) as rag:
safe_id = escape_sql_string(did) content = await rag.document_repository.get_content(did)
rows = list( return content or ""
rag.store.documents_table.search()
.select(["content"])
.where(f"id = '{safe_id}'")
.limit(1)
.to_list()
)
return rows[0]["content"] if rows else ""
return _run_async(_fetch()) return _run_async(_fetch())
@ -197,10 +191,8 @@ class Sandbox:
async with HaikuRAG( async with HaikuRAG(
db_path, config=config, read_only=True db_path, config=config, read_only=True
) as rag: ) as rag:
items = ( items = await rag.document_item_repository.get_all_items(
await rag.document_item_repository.get_items_in_range( did
did, 0, 999999
)
) )
lines = [] lines = []
for item in items: for item in items:
@ -222,24 +214,27 @@ class Sandbox:
return read_items return read_items
def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None:
raise PermissionError(f"Document files are read-only: {_path}")
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/content.txt", f"{doc_dir}/content.txt",
read=_make_content_reader(doc_id), read=_make_content_reader(doc_id),
write=lambda _p, _c: None, write=_deny_write,
) )
) )
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/items.jsonl", f"{doc_dir}/items.jsonl",
read=_make_items_reader(doc_id), read=_make_items_reader(doc_id),
write=lambda _p, _c: None, write=_deny_write,
) )
) )
return OSAccess(files) return OSAccess(files)
async def _ensure_initialized(self) -> None: async def _ensure_initialized(self) -> tuple[MontyRepl, OSAccess]:
"""Initialize the REPL session and VFS on first use.""" """Initialize the REPL session and VFS on first use."""
if self._repl is None: if self._repl is None:
self._vfs = await self._build_vfs() self._vfs = await self._build_vfs()
@ -266,16 +261,19 @@ class Sandbox:
external_functions=self._build_external_functions(), external_functions=self._build_external_functions(),
os=self._vfs, os=self._vfs,
) )
# Both are guaranteed non-None after initialization
repl = self._repl
vfs = self._vfs
if repl is None or vfs is None:
raise RuntimeError("Sandbox initialization failed")
return repl, vfs
async def execute(self, code: str) -> SandboxResult: async def execute(self, code: str) -> SandboxResult:
"""Execute Python code in the Monty REPL. """Execute Python code in the Monty REPL.
Variables persist across calls within the same Sandbox instance. Variables persist across calls within the same Sandbox instance.
""" """
await self._ensure_initialized() repl, vfs = await self._ensure_initialized()
assert self._repl is not None
assert self._vfs is not None
external_fns = self._build_external_functions() external_fns = self._build_external_functions()
stdout_lines: list[str] = [] stdout_lines: list[str] = []
@ -287,11 +285,11 @@ class Sandbox:
try: try:
output = await pydantic_monty.run_repl_async( output = await pydantic_monty.run_repl_async(
self._repl, repl,
code, code,
external_functions=external_fns, external_functions=external_fns,
print_callback=print_callback, print_callback=print_callback,
os=self._vfs, os=vfs,
) )
except ( except (
pydantic_monty.MontySyntaxError, pydantic_monty.MontySyntaxError,

View file

@ -100,6 +100,20 @@ class DocumentRepository:
return self._record_to_document(results[0]) return self._record_to_document(results[0])
async def get_content(self, entity_id: str) -> str | None:
"""Get only the text content of a document (skips docling blobs)."""
safe_id = escape_sql_string(entity_id)
results = list(
self.store.documents_table.search()
.select(["content"])
.where(f"id = '{safe_id}'")
.limit(1)
.to_list()
)
if not results:
return None
return results[0]["content"]
_DOCLING_COLUMNS = ["id", "docling_document", "docling_version"] _DOCLING_COLUMNS = ["id", "docling_document", "docling_version"]
async def get_docling_data(self, entity_id: str) -> Document | None: async def get_docling_data(self, entity_id: str) -> Document | None:

View file

@ -40,6 +40,18 @@ class DocumentItemRepository:
] ]
self.store.document_items_table.add(records) self.store.document_items_table.add(records)
async def get_all_items(self, document_id: str) -> list[DocumentItem]:
"""Get all items for a document, sorted by position."""
safe_id = escape_sql_string(document_id)
rows = (
self.store.document_items_table.search()
.where(f"document_id = '{safe_id}'")
.to_list()
)
items = [self._record_to_item(row) for row in rows]
items.sort(key=lambda x: x.position)
return items
async def get_items_in_range( async def get_items_in_range(
self, document_id: str, start: int, end: int self, document_id: str, start: int, end: int
) -> list[DocumentItem]: ) -> list[DocumentItem]: