Skip to content

Phase 5: Rich Filesystem Adapter #89

Description

@tinkermonkey

Implement the RichFilesystemAdapter that converts non-markdown files (PDF, DOCX, XLSX, PPTX, HTML, images, audio) to markdown using MarkItDown with Pandoc fallback, yielding NormalizedContent with rich structural metadata.

Requirements

  • Convert non-markdown files to markdown using MarkItDown as primary conversion library (FR-4.1)
  • Fall back to Pandoc subprocess for formats not supported by MarkItDown (FR-4.2)
  • Emit NormalizedContent with StructuralHints populated with MIME type, file size in bytes, creation timestamp, modification timestamp, and directory hierarchy (FR-4.3)
  • Use _watching.py FileSystemWatcher when configured for push-based ingestion (FR-4.4)
  • adapter_id must be f"filesystem_rich:{resolved_path}" — distinct from FilesystemAdapter's f"filesystem:{absolute_path}" (FR-4.5)
  • Set domain = Domain.NOTES (FR-4.6)
  • Include original MIME type or file extension in metadata on each NormalizedContent item (FR-4.7)
  • Skip .md files — those are handled by the existing FilesystemAdapter
  • Add markitdown to optional dependencies under rich-fs group in pyproject.toml

Design Guidance

File to create: src/context_library/adapters/filesystem_rich.py

Class signature:

class RichFilesystemAdapter(BaseAdapter):
    def __init__(
        self,
        directory: Path,
        poll_strategy: PollStrategy = PollStrategy.PULL,
        extensions: set[str] | None = None,  # None = all supported
    ) -> None: ...

    @property
    def adapter_id(self) -> str:
        return f"filesystem_rich:{self._directory.resolve()}"

    @property
    def domain(self) -> Domain:
        return Domain.NOTES

    @property
    def normalizer_version(self) -> str:
        return "1.0.0"

    def fetch(self, source_ref: str) -> Iterator[NormalizedContent]: ...

MarkItDown conversion:

from markitdown import MarkItDown

def _convert_with_markitdown(file_path: Path) -> str | None:
    md = MarkItDown()
    try:
        result = md.convert(str(file_path))
        return result.text_content
    except Exception:
        return None  # signal fallback needed

Pandoc fallback:

import subprocess

def _convert_with_pandoc(file_path: Path) -> str | None:
    try:
        result = subprocess.run(
            ["pandoc", "-t", "markdown", str(file_path)],
            capture_output=True, text=True, timeout=30
        )
        if result.returncode == 0:
            return result.stdout
        return None
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None

StructuralHints population: The existing StructuralHints model has file_path, modified_at, file_size_bytes. To pass MIME type and creation timestamp, use the extra_metadata field (if added in Phase 1) or encode them in the file_path field comment. Recommended: use extra_metadata: dict on StructuralHints.

MIME type detection:

import mimetypes
mime_type, _ = mimetypes.guess_type(str(file_path))

pyproject.toml change:

[project.optional-dependencies]
rich-fs = ["markitdown", "watchdog"]

Supported formats (MarkItDown handles): PDF, DOCX, PPTX, XLSX, HTML, images (with alt text), audio (with transcription if configured).

Pandoc handles: LaTeX (.tex), EPUB, RST, ODT, and many others.

Directory walking: Use Path.rglob("*") and filter by suffix, skipping .md files.

Push mode: When poll_strategy == PollStrategy.PUSH, instantiate FileSystemWatcher and wire its callback to trigger re-ingestion of the changed file's source_ref.

Acceptance Criteria

  • RichFilesystemAdapter(directory=path).adapter_id returns f"filesystem_rich:{path.resolve()}"
  • FilesystemAdapter and RichFilesystemAdapter registered on the same DocumentStore have distinct adapter_id values
  • fetch("") on a directory containing a .pdf file yields a NormalizedContent with non-empty markdown
  • fetch("") on a directory containing a .docx file converts it to markdown
  • fetch("") skips .md files (those are handled by FilesystemAdapter)
  • NormalizedContent.structural_hints includes MIME type of the original file
  • NormalizedContent.structural_hints.file_size_bytes is populated
  • NormalizedContent.structural_hints.modified_at is populated as ISO 8601
  • When MarkItDown cannot convert a format, Pandoc is attempted
  • When both converters fail for a file, the file is skipped with a logged warning (no exception propagation)
  • domain property returns Domain.NOTES
  • When poll_strategy=PollStrategy.PUSH, a FileSystemWatcher instance is created (not started until start() is called)
  • RichFilesystemAdapter is importable from context_library.adapters.filesystem_rich

Dependencies

Phase 1: Model Additions (PollStrategy + MessageMetadata), Phase 3: Shared Filesystem Watcher (_watching.py)

Parent Issue

Part of #83

Discussion

This work is detailed in discussion 84

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions