-
Notifications
You must be signed in to change notification settings - Fork 281
feat(core): ingest docx, pptx, and csv into document sidecar notes #1529
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
phernandez
wants to merge
10
commits into
main
Choose a base branch
from
feat/1512-document-ingestion-office
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
1e055ab
feat(core): ingest docx, pptx, and csv into document sidecar notes
phernandez 58b425a
fix(core): address review findings for document ingestion
phernandez ebd87bf
fix(core): guard raw sidecars before rebuilding them
phernandez f904444
fix(core): record raw projection checksums independent of indexer edits
phernandez e2b702c
fix(core): separate file and projection checksums for document writes
phernandez 1a8ad71
fix(core): verify raw provenance before reusing document sidecars
phernandez a865e17
fix(core): honor persisted projections and ingestion byte bounds
phernandez af3ffea
fix(core): preserve generated document identity during indexing
phernandez 7245d5c
fix(core): refuse conflicting document source ownership
phernandez 134abc8
fix(core): preserve post-index text in document provenance
phernandez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| """Import command: extract one document file in a project into a sidecar Markdown note.""" | ||
|
|
||
| # PEP 563 lazy annotations keep the ingestion stack out of module import (#886). | ||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Annotated, Optional | ||
|
|
||
| import typer | ||
| from loguru import logger | ||
| from rich.console import Console | ||
| from rich.panel import Panel | ||
|
|
||
| from basic_memory.cli.app import import_app | ||
| from basic_memory.cli.commands.command_utils import run_with_cleanup | ||
| from basic_memory.cli.commands.routing import force_routing | ||
| from basic_memory.config import ConfigManager | ||
|
|
||
| if TYPE_CHECKING: | ||
| from basic_memory.document_ingestion.raw_document import RawDocumentWriteResult | ||
|
|
||
| console = Console() | ||
|
|
||
|
|
||
| async def import_document(path: Path, project: str | None) -> tuple[str, RawDocumentWriteResult]: | ||
| """Index the source, extract it, and write its sidecar note into the project.""" | ||
| # Deferred: the ingestion stack and API client load only when the command runs (#886). | ||
| from basic_memory.document_ingestion.local_runtime import ( | ||
| ApiDocumentSourceEntityResolver, | ||
| LocalDocumentSourceReader, | ||
| LocalRawDocumentWriter, | ||
| default_document_extractors, | ||
| ) | ||
| from basic_memory.document_ingestion.raw_document import RawDocumentRuntime | ||
| from basic_memory.markdown import EntityParser, MarkdownProcessor | ||
| from basic_memory.mcp.async_client import get_client | ||
| from basic_memory.mcp.clients import KnowledgeClient, ProjectClient | ||
| from basic_memory.mcp.project_context import get_active_project | ||
| from basic_memory.services.file_service import FileService | ||
|
|
||
| config_manager = ConfigManager() | ||
| project_name = project or config_manager.default_project | ||
| async with get_client(project_name=project_name) as client: | ||
| project_item = await get_active_project(client, project_name, None) | ||
| project_home = Path(project_item.path).expanduser().resolve() | ||
| source = path.expanduser().resolve() | ||
| if not source.is_file(): | ||
| raise typer.BadParameter(f"File not found: {path}") | ||
| if not source.is_relative_to(project_home): | ||
| raise typer.BadParameter( | ||
| f"{path} is not inside project {project_item.name!r} ({project_home})" | ||
| ) | ||
| relative_path = source.relative_to(project_home).as_posix() | ||
|
|
||
| # The source must be an indexed file entity before it can own a document | ||
| # note, and a fresh drop may not have been seen by the watcher yet. | ||
| await ProjectClient(client).index( | ||
| project_item.external_id, force_full=False, run_in_background=False | ||
| ) | ||
|
|
||
| knowledge = KnowledgeClient(client, project_item.external_id) | ||
| app_config = config_manager.config | ||
| markdown_processor = MarkdownProcessor(EntityParser(project_home), app_config=app_config) | ||
| file_service = FileService(project_home, markdown_processor, app_config=app_config) | ||
| runtime = RawDocumentRuntime( | ||
| source_resolver=ApiDocumentSourceEntityResolver(knowledge), | ||
| source_reader=LocalDocumentSourceReader(project_home), | ||
| extractors=default_document_extractors(), | ||
| writer=LocalRawDocumentWriter(file_service, knowledge), | ||
| ) | ||
| result = await runtime.ingest(file_path=relative_path, observed_etag=None) | ||
| return project_item.name, result | ||
|
|
||
|
|
||
| @import_app.command( | ||
| name="document", | ||
| help="Extract a PDF, docx, pptx, or csv file in a project into a sidecar Markdown note.", | ||
| ) | ||
| def document( | ||
| path: Annotated[Path, typer.Argument(help="Path to a file inside the project directory")], | ||
| project: Annotated[ | ||
| Optional[str], | ||
| typer.Option("--project", "-p", help="Project name (defaults to the default project)"), | ||
| ] = None, | ||
| ) -> None: | ||
| """Write ``<file>.md`` next to the source and a run note under document-ingestion-runs/.""" | ||
| try: | ||
| # Trigger: the project may be configured for cloud routing. | ||
| # Why: this runtime reads the source and writes the sidecar in the local | ||
| # project directory, so the API it indexes through must be the local | ||
| # one; a cloud client would be asked to index files it cannot see. | ||
| # Outcome: local ASGI routing for the whole command, whatever the project mode. | ||
| with force_routing(local=True): | ||
| project_name, result = run_with_cleanup(import_document(path, project)) | ||
| except typer.BadParameter as error: | ||
| typer.echo(f"Error: {error}", err=True) | ||
| raise typer.Exit(1) | ||
| except Exception as error: | ||
| logger.error("Document import failed") | ||
| typer.echo(f"Error during import: {error}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| document_state = "created" if result.document_created else "already current" | ||
| run_state = "created" if result.run_created else "already recorded" | ||
| console.print( | ||
| Panel( | ||
| f"[green]Document import complete![/green]\n\n" | ||
| f"Project: {project_name}\n" | ||
| f"Document note: {result.document_file_path} ({document_state})\n" | ||
| f"Run note: {result.run_file_path} ({run_state})\n" | ||
| f"Run id: {result.run_id}", | ||
| expand=False, | ||
| ) | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| """Run one extractor over untrusted bytes in a killable, byte-capped child process. | ||
|
|
||
| Every native or third-party document parser Basic Memory runs is synchronous and | ||
| parses attacker-controlled input. The parent therefore never calls a parser | ||
| in-process: it feeds the bytes to ``python -m <worker>`` on stdin, drains both | ||
| pipes with a hard ceiling, and kills the child at the deadline. The worker | ||
| applies POSIX rlimits to itself before reading any input. | ||
|
|
||
| This module owns only the process mechanics. Each extractor owns its argv, | ||
| its output contract, and the error types its callers see. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from collections.abc import Sequence | ||
|
|
||
|
|
||
| class BoundedProcessError(RuntimeError): | ||
| """Base failure for one bounded child process run.""" | ||
|
|
||
|
|
||
| class BoundedProcessSpawnError(BoundedProcessError): | ||
| """The running event loop cannot spawn subprocesses.""" | ||
|
|
||
|
|
||
| class BoundedProcessTimeoutError(BoundedProcessError): | ||
| """The child was killed after exceeding its wall-clock deadline.""" | ||
|
|
||
|
|
||
| class BoundedProcessOutputLimitError(BoundedProcessError): | ||
| """A child pipe crossed the output byte ceiling while streaming.""" | ||
|
|
||
| def __init__(self, stream_name: str) -> None: | ||
| super().__init__(f"child process exceeded the output byte limit on {stream_name}") | ||
| self.stream_name = stream_name | ||
|
|
||
|
|
||
| class BoundedProcessExitError(BoundedProcessError): | ||
| """The child exited with a non-zero status.""" | ||
|
|
||
| def __init__(self, returncode: int, detail: str) -> None: | ||
| super().__init__(f"child process failed with exit code {returncode}: {detail}") | ||
| self.returncode = returncode | ||
| self.detail = detail | ||
|
|
||
|
|
||
| async def run_bounded_process( | ||
| argv: Sequence[str], | ||
| stdin_bytes: bytes, | ||
| *, | ||
| timeout_seconds: float, | ||
| max_output_bytes: int, | ||
| ) -> bytes: | ||
| """Run ``argv`` with ``stdin_bytes`` on stdin and return its capped stdout. | ||
|
|
||
| The caller bounds concurrency; this function bounds one run. It raises a | ||
| :class:`BoundedProcessError` subclass for every failure mode so the caller | ||
| can name the failure in its own domain terms. | ||
| """ | ||
| try: | ||
| process = await asyncio.create_subprocess_exec( | ||
| *argv, | ||
| stdin=asyncio.subprocess.PIPE, | ||
| stdout=asyncio.subprocess.PIPE, | ||
| stderr=asyncio.subprocess.PIPE, | ||
| ) | ||
| except NotImplementedError as error: | ||
| # Trigger: the running loop cannot spawn subprocesses. Basic Memory | ||
| # installs the selector loop on Windows for aiosqlite (see db.py), | ||
| # and that loop has no subprocess transport. | ||
| # Outcome: a named error instead of a bare NotImplementedError. | ||
| raise BoundedProcessSpawnError( | ||
| "child process needs an event loop with subprocess support; " | ||
| "Basic Memory runs the selector loop on Windows" | ||
| ) from error | ||
| try: | ||
| async with asyncio.timeout(timeout_seconds): | ||
| stdout, stderr = await _communicate_capped(process, stdin_bytes, cap=max_output_bytes) | ||
| except asyncio.CancelledError: | ||
| await kill_and_wait(process) | ||
| raise | ||
| except TimeoutError as error: | ||
| await kill_and_wait(process) | ||
| raise BoundedProcessTimeoutError( | ||
| "child process exceeded the configured deadline" | ||
| ) from error | ||
| except BoundedProcessOutputLimitError: | ||
| # A pipe crossed its ceiling while the child may still be writing. | ||
| await kill_and_wait(process) | ||
| raise | ||
|
|
||
| if process.returncode != 0: | ||
| detail = stderr.decode("utf-8", errors="replace")[-2000:].strip() | ||
| raise BoundedProcessExitError( | ||
| process.returncode if process.returncode is not None else -1, | ||
| detail or "no error detail", | ||
| ) | ||
| return stdout | ||
|
|
||
|
|
||
| async def kill_and_wait(process: asyncio.subprocess.Process) -> None: | ||
| """Terminate and reap one child before releasing worker capacity.""" | ||
| if process.returncode is None: | ||
| try: | ||
| process.kill() | ||
| except ProcessLookupError: | ||
| # The child can exit between the returncode check and the signal. | ||
| # Waiting still reaps it and preserves the caller's original outcome. | ||
| pass | ||
| await process.wait() | ||
|
|
||
|
|
||
| # Read pipes in modest chunks so the ceiling applies as bytes arrive rather than | ||
| # after an entire stream has already been buffered. | ||
| _PIPE_CHUNK_BYTES = 64 * 1024 | ||
|
|
||
|
|
||
| async def _communicate_capped( | ||
| process: asyncio.subprocess.Process, | ||
| stdin_bytes: bytes, | ||
| *, | ||
| cap: int, | ||
| ) -> tuple[bytes, bytes]: | ||
| """Feed stdin and drain both pipes with a hard byte ceiling on each. | ||
|
|
||
| ``Process.communicate()`` buffers stdout and stderr without limit before the | ||
| caller can look at their size, so a hostile document that makes the worker | ||
| emit a huge field, or a runaway error stream, would land in parent memory. | ||
| This variant raises as soon as either pipe crosses ``cap``; the caller kills | ||
| the child. | ||
| """ | ||
| stdin, stdout, stderr = process.stdin, process.stdout, process.stderr | ||
| if stdin is None or stdout is None or stderr is None: # pragma: no cover - PIPE at spawn | ||
| raise RuntimeError("child process was started without pipes") | ||
| try: | ||
| async with asyncio.TaskGroup() as group: | ||
| stdout_task = group.create_task(_read_capped(stdout, cap=cap, stream_name="stdout")) | ||
| stderr_task = group.create_task(_read_capped(stderr, cap=cap, stream_name="stderr")) | ||
| group.create_task(_feed_stdin(stdin, stdin_bytes)) | ||
| except* BoundedProcessOutputLimitError as overflow: | ||
| raise overflow.exceptions[0] from None | ||
| await process.wait() | ||
| return stdout_task.result(), stderr_task.result() | ||
|
|
||
|
|
||
| async def _read_capped(stream: asyncio.StreamReader, *, cap: int, stream_name: str) -> bytes: | ||
| buffer = bytearray() | ||
| while chunk := await stream.read(_PIPE_CHUNK_BYTES): | ||
| buffer.extend(chunk) | ||
| if len(buffer) > cap: | ||
| raise BoundedProcessOutputLimitError(stream_name) | ||
| return bytes(buffer) | ||
|
|
||
|
|
||
| async def _feed_stdin(stdin: asyncio.StreamWriter, stdin_bytes: bytes) -> None: | ||
| try: | ||
| stdin.write(stdin_bytes) | ||
| await stdin.drain() | ||
| except (BrokenPipeError, ConnectionResetError): | ||
| # The child exited before consuming its input. Its exit status and | ||
| # stderr carry the failure; ``Process.communicate()`` ignores the same | ||
| # pair for the same reason. | ||
| pass | ||
| finally: | ||
| stdin.close() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.