refactor: Transform document parsing into pluggable processor architecture

Refactors PR #190's hardcoded Unstructured.io integration into a flexible,
extensible plugin system supporting multiple text extraction engines.

- **`DocumentProcessor` ABC**: Abstract interface for all processors
- **`ProcessorRegistry`**: Central registry for discovery and routing
- **`ProcessingResult`**: Standardized output format across processors

- **`UnstructuredProcessor`**: Refactored from `UnstructuredClient`
- **`TesseractProcessor`**: Local OCR for images (lightweight alternative)
- **`CustomHTTPProcessor`**: Generic wrapper for custom HTTP APIs

- New `get_document_processor_config()` returns structured config
- Supports enabling/disabling individual processors
- Per-processor configuration via environment variables
- **Breaking Change**: `ENABLE_UNSTRUCTURED_PARSING` replaced with:
  - `ENABLE_DOCUMENT_PROCESSING=true/false` (master switch)
  - `ENABLE_UNSTRUCTURED=true/false` (per-processor)
  - `ENABLE_TESSERACT=true/false`
  - `ENABLE_CUSTOM_PROCESSOR=true/false`

- `parse_document()` now uses `ProcessorRegistry`
- Auto-selects appropriate processor based on MIME type
- Processor priority system (Unstructured=10, Tesseract=5, Custom=1)

- `initialize_document_processors()` registers processors at startup
- Integrated into both BasicAuth and OAuth lifespans
- Graceful degradation if processors fail to initialize

```env
ENABLE_DOCUMENT_PROCESSING=false

ENABLE_UNSTRUCTURED=false
UNSTRUCTURED_API_URL=http://unstructured:8000
UNSTRUCTURED_STRATEGY=auto  # auto|fast|hi_res
UNSTRUCTURED_LANGUAGES=eng,deu

ENABLE_TESSERACT=false
TESSERACT_LANG=eng

ENABLE_CUSTOM_PROCESSOR=false
CUSTOM_PROCESSOR_URL=http://localhost:9000/process
CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg
```

- **Removed**: `tests/test_unstructured_config.py` (legacy tests)
- **Added**: `tests/unit/test_document_processor_config.py`
  - 7 unit tests for new config system
  - Tests individual and multi-processor configurations

- **Added**:
  - `nextcloud_mcp_server/document_processors/__init__.py`
  - `nextcloud_mcp_server/document_processors/base.py`
  - `nextcloud_mcp_server/document_processors/registry.py`
  - `nextcloud_mcp_server/document_processors/unstructured.py`
  - `nextcloud_mcp_server/document_processors/tesseract.py`
  - `nextcloud_mcp_server/document_processors/custom_http.py`
  - `tests/unit/test_document_processor_config.py`

- **Modified**:
  - `nextcloud_mcp_server/config.py` - New plugin config system
  - `nextcloud_mcp_server/app.py` - Processor initialization
  - `nextcloud_mcp_server/utils/document_parser.py` - Uses registry
  - `nextcloud_mcp_server/server/webdav.py` - Import updates
  - `env.sample` - New configuration format
  - `docker-compose.yml` - (profile changes from previous work)

- **Removed**:
  - `nextcloud_mcp_server/client/unstructured_client.py` - Replaced by UnstructuredProcessor
  - `tests/test_unstructured_config.py` - Replaced with new tests

 **Extensible**: Add processors without modifying core code
 **Testable**: Mock processors for unit tests
 **Configurable**: Enable only needed processors
 **Flexible**: Choose fast (Tesseract) vs accurate (Unstructured)
 **Opt-in**: Disabled by default, no mandatory dependencies

Users upgrading from PR #190 need to update environment variables:
```bash
ENABLE_UNSTRUCTURED_PARSING=true

ENABLE_DOCUMENT_PROCESSING=true
ENABLE_UNSTRUCTURED=true
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-10-25 19:28:35 +02:00
co-authored by Claude
parent a19017c686
commit 2147fc1696
20 changed files with 2027 additions and 529 deletions
@@ -0,0 +1,146 @@
"""Generic HTTP API processor wrapper for custom document processing services."""
import logging
from typing import Any, Optional
import httpx
from .base import DocumentProcessor, ProcessingResult, ProcessorError
logger = logging.getLogger(__name__)
class CustomHTTPProcessor(DocumentProcessor):
"""Generic HTTP API processor wrapper.
Allows integration with any custom document processing API that follows
a simple request/response pattern. This makes it easy to integrate your
own text extraction services without writing a full processor.
Expected API Contract:
- POST request with file as multipart/form-data
- Response: {"text": "extracted text", "metadata": {...}}
Example:
processor = CustomHTTPProcessor(
name="my_ocr",
api_url="https://my-ocr-service.com/process",
api_key="secret",
supported_types={"application/pdf", "image/jpeg"},
)
result = await processor.process(pdf_bytes, "application/pdf")
"""
def __init__(
self,
api_url: str,
api_key: Optional[str] = None,
timeout: int = 60,
supported_types: Optional[set[str]] = None,
name: str = "custom",
):
"""Initialize custom HTTP processor.
Args:
api_url: Your API endpoint (should accept POST with multipart/form-data)
api_key: Optional API key for authentication (sent as Bearer token)
timeout: Request timeout in seconds (default: 60)
supported_types: MIME types your API supports
name: Unique name for this processor (default: "custom")
"""
self.api_url = api_url
self.api_key = api_key
self.timeout = timeout
self._name = name
self._supported_types = supported_types or set()
logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}")
@property
def name(self) -> str:
return self._name
@property
def supported_mime_types(self) -> set[str]:
return self._supported_types
async def process(
self,
content: bytes,
content_type: str,
filename: Optional[str] = None,
options: Optional[dict[str, Any]] = None,
) -> ProcessingResult:
"""Process via custom HTTP API.
Args:
content: Document bytes
content_type: MIME type
filename: Optional filename
options: Custom options (passed as form data to API)
Returns:
ProcessingResult with extracted text and metadata
Raises:
ProcessorError: If API call fails
"""
options = options or {}
# Prepare request
files = {"file": (filename or "document", content, content_type)}
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
self.api_url,
files=files,
headers=headers,
data=options, # Pass options as form data
)
response.raise_for_status()
# Parse response
result = response.json()
text = result.get("text", "")
metadata = result.get("metadata", {})
logger.debug(
f"Custom processor '{self.name}' extracted {len(text)} characters"
)
return ProcessingResult(
text=text,
metadata=metadata,
processor=self.name,
success=True,
)
except httpx.HTTPError as e:
logger.error(f"Custom processor '{self.name}' HTTP error: {e}")
raise ProcessorError(f"API call failed: {str(e)}") from e
except Exception as e:
logger.error(f"Custom processor '{self.name}' failed: {e}")
raise ProcessorError(f"Processing failed: {str(e)}") from e
async def health_check(self) -> bool:
"""Check if custom API is available.
Returns:
True if API responds with status < 500
"""
try:
async with httpx.AsyncClient(timeout=5) as client:
# Try GET request to check availability
response = await client.get(
self.api_url,
headers={"User-Agent": "nextcloud-mcp-server"},
)
return response.status_code < 500
except Exception as e:
logger.warning(f"Custom processor '{self.name}' health check failed: {e}")
return False