Merge pull request #376 from christianlouis/copilot/add-self-hosted-ocr-support
fix(tests): update stale mock targets after Azure OCR → unified OCR task rename
This commit is contained in:
@@ -72,6 +72,40 @@ class Settings(BaseSettings):
|
||||
azure_region: str
|
||||
azure_endpoint: str
|
||||
gotenberg_url: str
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCR provider settings
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comma-separated list of OCR engines to use.
|
||||
# Supported values: azure, tesseract, easyocr, mistral, google_docai, aws_textract
|
||||
# When multiple engines are listed all are run and results are merged.
|
||||
# Example: OCR_PROVIDERS=azure,tesseract
|
||||
ocr_providers: str = "azure"
|
||||
|
||||
# Strategy for merging results from multiple OCR providers.
|
||||
# - ai_merge : Ask the AI model to produce the best merged text (default).
|
||||
# - longest : Return the result with the most characters.
|
||||
# - primary : Return only the first provider's result (no merging).
|
||||
ocr_merge_strategy: str = "ai_merge"
|
||||
|
||||
# Tesseract OCR settings (used when "tesseract" is in OCR_PROVIDERS)
|
||||
tesseract_cmd: Optional[str] = None # Path to tesseract binary (e.g. /usr/bin/tesseract)
|
||||
tesseract_language: str = "eng+deu" # Tesseract language code(s), e.g. "eng" or "eng+deu"
|
||||
|
||||
# EasyOCR settings (used when "easyocr" is in OCR_PROVIDERS)
|
||||
easyocr_languages: str = "en,de" # Comma-separated language codes, e.g. "en,de,fr"
|
||||
easyocr_gpu: bool = False # Enable GPU acceleration for EasyOCR
|
||||
|
||||
# Mistral OCR settings (used when "mistral" is in OCR_PROVIDERS)
|
||||
mistral_api_key: Optional[str] = None
|
||||
mistral_ocr_model: str = "mistral-ocr-latest"
|
||||
|
||||
# Google Cloud Document AI settings (used when "google_docai" is in OCR_PROVIDERS)
|
||||
# Falls back to google_drive_credentials_json for service account credentials.
|
||||
google_docai_credentials_json: Optional[str] = None
|
||||
google_docai_project_id: Optional[str] = None
|
||||
google_docai_processor_id: Optional[str] = None
|
||||
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
||||
external_hostname: str = "localhost" # Default to localhost
|
||||
|
||||
# Authentication settings
|
||||
|
||||
@@ -14,9 +14,7 @@ from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord
|
||||
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
||||
from app.tasks.process_with_azure_document_intelligence import (
|
||||
process_with_azure_document_intelligence,
|
||||
)
|
||||
from app.tasks.process_with_ocr import process_with_ocr
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.utils import get_unique_filepath_with_counter, hash_file, log_task_progress
|
||||
|
||||
@@ -313,7 +311,7 @@ def process_document(
|
||||
"Queued for forced OCR processing",
|
||||
file_id=file_id,
|
||||
)
|
||||
process_with_azure_document_intelligence.delay(new_filename, file_id)
|
||||
process_with_ocr.delay(new_filename, file_id)
|
||||
return {"file": new_local_path, "status": "Queued for forced OCR", "file_id": file_id}
|
||||
|
||||
# If the file is not a PDF, skip embedded text check and convert to PDF first
|
||||
@@ -411,12 +409,12 @@ def process_document(
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# Mark Azure OCR as skipped since we extracted text locally
|
||||
# Mark OCR as skipped since we extracted text locally
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"process_with_azure_document_intelligence",
|
||||
"skipped",
|
||||
"Local text extraction succeeded, Azure OCR not needed",
|
||||
"Local text extraction succeeded, OCR not needed",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
@@ -436,8 +434,8 @@ def process_document(
|
||||
"file_id": file_id,
|
||||
}
|
||||
|
||||
# 3. If no embedded text, queue Azure Document Intelligence processing
|
||||
logger.info(f"[{task_id}] No embedded text found. Queueing Azure Document Intelligence processing")
|
||||
# 3. If no embedded text, queue OCR processing
|
||||
logger.info(f"[{task_id}] No embedded text found. Queueing OCR processing")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"check_text",
|
||||
@@ -446,12 +444,12 @@ def process_document(
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# Mark local text extraction as skipped since we're using Azure OCR
|
||||
# Mark local text extraction as skipped since we're using cloud OCR
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_text",
|
||||
"skipped",
|
||||
"No embedded text, using Azure OCR instead",
|
||||
"No embedded text, using OCR instead",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
@@ -462,5 +460,5 @@ def process_document(
|
||||
"Queued for OCR processing",
|
||||
file_id=file_id,
|
||||
)
|
||||
process_with_azure_document_intelligence.delay(new_filename, file_id)
|
||||
process_with_ocr.delay(new_filename, file_id)
|
||||
return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unified OCR processing task for DocuElevate.
|
||||
|
||||
This task replaces the single-provider ``process_with_azure_document_intelligence``
|
||||
task with a multi-engine OCR pipeline that:
|
||||
|
||||
1. Runs every OCR provider listed in ``OCR_PROVIDERS`` (default: ``azure``).
|
||||
2. Merges/cross-checks the results using the configured AI model when more
|
||||
than one provider is active (see ``OCR_MERGE_STRATEGY``).
|
||||
3. Writes the best searchable PDF back to the working directory.
|
||||
4. Hands off to the page-rotation and metadata-extraction pipeline exactly as
|
||||
the legacy Azure task did.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
|
||||
from app.utils import log_task_progress
|
||||
from app.utils.ocr_provider import OCRResult, get_ocr_providers, merge_ocr_results
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||
def process_with_ocr(self, filename: str, file_id: Optional[int] = None):
|
||||
"""Run the configured OCR providers on *filename* and continue the pipeline.
|
||||
|
||||
When multiple OCR providers are configured the results are merged using the
|
||||
AI model (or a simpler strategy controlled by ``OCR_MERGE_STRATEGY``).
|
||||
|
||||
Args:
|
||||
filename: Base name of the file inside ``<workdir>/tmp/``.
|
||||
file_id: Optional database record ID passed through to downstream tasks.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"process_with_ocr",
|
||||
"in_progress",
|
||||
f"Starting OCR for {filename}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
try:
|
||||
tmp_file_path = os.path.join(settings.workdir, "tmp", filename)
|
||||
if not os.path.exists(tmp_file_path):
|
||||
raise FileNotFoundError(f"Local file not found: {tmp_file_path}")
|
||||
|
||||
providers = get_ocr_providers()
|
||||
provider_names = [p.name for p in providers]
|
||||
logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}")
|
||||
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"run_ocr_providers",
|
||||
"in_progress",
|
||||
f"Running OCR providers: {', '.join(provider_names)}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
for provider in providers:
|
||||
pname = provider.__class__.__name__
|
||||
try:
|
||||
logger.info(f"[{task_id}] Running {pname} on {filename}")
|
||||
result: OCRResult = provider.process(tmp_file_path)
|
||||
results.append(result)
|
||||
logger.info(f"[{task_id}] {pname} extracted {len(result.text)} chars")
|
||||
except Exception as exc:
|
||||
logger.error(f"[{task_id}] {pname} failed for {filename}: {exc}")
|
||||
errors.append(f"{pname}: {exc}")
|
||||
|
||||
if not results:
|
||||
error_summary = "; ".join(errors)
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"run_ocr_providers",
|
||||
"failure",
|
||||
"All OCR providers failed",
|
||||
file_id=file_id,
|
||||
detail=error_summary,
|
||||
)
|
||||
raise RuntimeError(f"All OCR providers failed for {filename}: {error_summary}")
|
||||
|
||||
if errors:
|
||||
logger.warning(f"[{task_id}] Some OCR providers failed: {'; '.join(errors)}")
|
||||
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"run_ocr_providers",
|
||||
"success",
|
||||
f"{len(results)} of {len(providers)} OCR provider(s) succeeded",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# Merge results (no-op when only one provider succeeded)
|
||||
extracted_text, searchable_pdf_path, rotation_data = merge_ocr_results(results, filename)
|
||||
logger.info(
|
||||
f"[{task_id}] Merged OCR text: {len(extracted_text)} chars, "
|
||||
f"pdf={'yes' if searchable_pdf_path else 'no'}, "
|
||||
f"rotations={len(rotation_data)}"
|
||||
)
|
||||
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"process_with_ocr",
|
||||
"success",
|
||||
f"OCR complete for {filename}",
|
||||
file_id=file_id,
|
||||
detail=f"Extracted {len(extracted_text)} chars using {len(results)} provider(s)",
|
||||
)
|
||||
|
||||
# Continue pipeline: rotate pages (if needed), then extract metadata
|
||||
rotate_pdf_pages.delay(filename, extracted_text, rotation_data, file_id)
|
||||
|
||||
return {
|
||||
"file": filename,
|
||||
"searchable_pdf": searchable_pdf_path or tmp_file_path,
|
||||
"cleaned_text": extracted_text,
|
||||
"providers_used": [r.provider for r in results],
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[{task_id}] OCR failed for {filename}: {exc}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"process_with_ocr",
|
||||
"failure",
|
||||
f"OCR failed for {filename}",
|
||||
file_id=file_id,
|
||||
detail=str(exc),
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCR provider abstraction layer for DocuElevate.
|
||||
|
||||
This module provides a pluggable abstraction for various OCR engines, allowing
|
||||
the platform to work with Azure Document Intelligence, Tesseract, EasyOCR,
|
||||
Mistral OCR, Google Cloud Document AI, and AWS Textract without being locked to
|
||||
a single vendor.
|
||||
|
||||
Provider selection is controlled by the ``OCR_PROVIDERS`` environment variable
|
||||
(comma-separated list, e.g. ``azure,tesseract``). When multiple providers are
|
||||
specified, all enabled providers run in parallel and the results are
|
||||
cross-checked by the configured AI model to produce the best final output.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OCRResult:
|
||||
"""Container for a single OCR provider's output.
|
||||
|
||||
Attributes:
|
||||
provider: Name of the OCR provider (e.g. ``"azure"``, ``"tesseract"``).
|
||||
text: The extracted plain text.
|
||||
searchable_pdf_path: Optional path to a searchable PDF produced by the
|
||||
provider. ``None`` when the provider does not produce PDFs.
|
||||
rotation_data: Optional dict mapping page indices to detected rotation
|
||||
angles (same format used by the Azure task).
|
||||
metadata: Provider-specific metadata dict (e.g. confidence scores).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
text: str,
|
||||
searchable_pdf_path: Optional[str] = None,
|
||||
rotation_data: Optional[Dict[int, float]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.text = text
|
||||
self.searchable_pdf_path = searchable_pdf_path
|
||||
self.rotation_data = rotation_data or {}
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"OCRResult(provider={self.provider!r}, "
|
||||
f"chars={len(self.text)}, "
|
||||
f"has_pdf={self.searchable_pdf_path is not None})"
|
||||
)
|
||||
|
||||
|
||||
class OCRProvider(ABC):
|
||||
"""Abstract base class for OCR providers.
|
||||
|
||||
Concrete providers must implement :meth:`process`, which accepts a path to a
|
||||
PDF file and returns an :class:`OCRResult`.
|
||||
|
||||
Subclasses should also set :attr:`name` to a short, stable identifier
|
||||
(e.g. ``"azure"``, ``"tesseract"``).
|
||||
"""
|
||||
|
||||
#: Short, stable identifier for this provider. Must match the key used in
|
||||
#: :data:`_PROVIDER_MAP` and in the ``OCR_PROVIDERS`` setting.
|
||||
name: str = "unknown"
|
||||
|
||||
@abstractmethod
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
"""Run OCR on *file_path* and return an :class:`OCRResult`.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the input PDF file.
|
||||
|
||||
Returns:
|
||||
An :class:`OCRResult` with the extracted text and optional
|
||||
searchable-PDF path.
|
||||
|
||||
Raises:
|
||||
Exception: If OCR processing fails.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AzureOCRProvider(OCRProvider):
|
||||
"""OCR via Azure Document Intelligence (the existing provider).
|
||||
|
||||
Credentials are read from ``settings.azure_ai_key`` and
|
||||
``settings.azure_endpoint``.
|
||||
"""
|
||||
|
||||
name = "azure"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
||||
from azure.ai.documentintelligence.models import AnalyzeOutputOption
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
if not settings.azure_ai_key:
|
||||
raise ValueError("AZURE_AI_KEY must be set when using the Azure OCR provider.")
|
||||
if not settings.azure_endpoint:
|
||||
raise ValueError("AZURE_ENDPOINT must be set when using the Azure OCR provider.")
|
||||
|
||||
client = DocumentIntelligenceClient(
|
||||
endpoint=settings.azure_endpoint,
|
||||
credential=AzureKeyCredential(settings.azure_ai_key),
|
||||
)
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
poller = client.begin_analyze_document("prebuilt-read", body=f, output=[AnalyzeOutputOption.PDF])
|
||||
result = poller.result()
|
||||
operation_id = poller.details["operation_id"]
|
||||
|
||||
# Extract rotation data
|
||||
rotation_data: Dict[int, float] = {}
|
||||
if hasattr(result, "pages") and result.pages:
|
||||
for i, page in enumerate(result.pages):
|
||||
if hasattr(page, "angle") and page.angle is not None and page.angle != 0:
|
||||
rotation_data[i] = page.angle
|
||||
|
||||
# Retrieve searchable PDF
|
||||
response = client.get_analyze_result_pdf(model_id=result.model_id, result_id=operation_id)
|
||||
searchable_pdf_path = file_path # overwrite in place
|
||||
with open(searchable_pdf_path, "wb") as writer:
|
||||
writer.writelines(response)
|
||||
|
||||
extracted_text = result.content if result.content else ""
|
||||
logger.info(f"[AzureOCR] Extracted {len(extracted_text)} chars from {os.path.basename(file_path)}")
|
||||
|
||||
return OCRResult(
|
||||
provider="azure",
|
||||
text=extracted_text,
|
||||
searchable_pdf_path=searchable_pdf_path,
|
||||
rotation_data=rotation_data,
|
||||
)
|
||||
|
||||
|
||||
class TesseractOCRProvider(OCRProvider):
|
||||
"""OCR via Tesseract (self-hosted, open-source).
|
||||
|
||||
Requires ``pytesseract`` and ``Pillow`` to be installed, plus the
|
||||
Tesseract binary on the system.
|
||||
|
||||
Config knobs (from :class:`~app.config.Settings`):
|
||||
- ``tesseract_cmd`` – path to the ``tesseract`` binary (optional).
|
||||
- ``tesseract_language`` – Tesseract language code(s), e.g. ``"eng"`` or
|
||||
``"eng+deu"`` (default: ``"eng"``).
|
||||
"""
|
||||
|
||||
name = "tesseract"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"pytesseract and pdf2image are required for the Tesseract OCR provider. "
|
||||
"Install them with: pip install pytesseract pdf2image"
|
||||
) from exc
|
||||
|
||||
tesseract_cmd = getattr(settings, "tesseract_cmd", None)
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
|
||||
lang = getattr(settings, "tesseract_language", None) or "eng"
|
||||
|
||||
logger.info(f"[TesseractOCR] Processing {os.path.basename(file_path)} (lang={lang})")
|
||||
pages = convert_from_path(file_path, dpi=300)
|
||||
texts: List[str] = []
|
||||
for i, page_img in enumerate(pages):
|
||||
page_text = pytesseract.image_to_string(page_img, lang=lang)
|
||||
texts.append(page_text)
|
||||
logger.debug(f"[TesseractOCR] Page {i + 1}: {len(page_text)} chars")
|
||||
|
||||
extracted_text = "\n".join(texts)
|
||||
logger.info(f"[TesseractOCR] Extracted {len(extracted_text)} chars total")
|
||||
|
||||
return OCRResult(
|
||||
provider="tesseract",
|
||||
text=extracted_text,
|
||||
)
|
||||
|
||||
|
||||
class EasyOCRProvider(OCRProvider):
|
||||
"""OCR via EasyOCR (self-hosted, deep-learning based).
|
||||
|
||||
Requires the ``easyocr`` package to be installed.
|
||||
|
||||
Config knobs (from :class:`~app.config.Settings`):
|
||||
- ``easyocr_languages`` – comma-separated list of language codes
|
||||
(default: ``"en"``).
|
||||
- ``easyocr_gpu`` – whether to use GPU acceleration (default: ``False``).
|
||||
"""
|
||||
|
||||
name = "easyocr"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
import easyocr
|
||||
from pdf2image import convert_from_path
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"easyocr and pdf2image are required for the EasyOCR provider. "
|
||||
"Install them with: pip install easyocr pdf2image"
|
||||
) from exc
|
||||
|
||||
lang_str = getattr(settings, "easyocr_languages", None) or "en"
|
||||
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
|
||||
gpu = getattr(settings, "easyocr_gpu", False)
|
||||
|
||||
logger.info(f"[EasyOCR] Processing {os.path.basename(file_path)} (langs={langs}, gpu={gpu})")
|
||||
reader = easyocr.Reader(langs, gpu=gpu)
|
||||
pages = convert_from_path(file_path, dpi=300)
|
||||
texts: List[str] = []
|
||||
for i, page_img in enumerate(pages):
|
||||
result = reader.readtext(page_img, detail=0, paragraph=True)
|
||||
page_text = "\n".join(result)
|
||||
texts.append(page_text)
|
||||
logger.debug(f"[EasyOCR] Page {i + 1}: {len(page_text)} chars")
|
||||
|
||||
extracted_text = "\n".join(texts)
|
||||
logger.info(f"[EasyOCR] Extracted {len(extracted_text)} chars total")
|
||||
|
||||
return OCRResult(
|
||||
provider="easyocr",
|
||||
text=extracted_text,
|
||||
)
|
||||
|
||||
|
||||
class MistralOCRProvider(OCRProvider):
|
||||
"""OCR via Mistral's document understanding API.
|
||||
|
||||
Uses the ``mistral-ocr-latest`` model (or ``settings.mistral_ocr_model``)
|
||||
via the OpenAI-compatible messages API.
|
||||
|
||||
Config knobs (from :class:`~app.config.Settings`):
|
||||
- ``mistral_api_key`` – Mistral API key.
|
||||
- ``mistral_ocr_model`` – model name (default: ``"mistral-ocr-latest"``).
|
||||
"""
|
||||
|
||||
name = "mistral"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
import base64
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("openai package is required for the Mistral OCR provider.") from exc
|
||||
|
||||
api_key = getattr(settings, "mistral_api_key", None)
|
||||
if not api_key:
|
||||
raise ValueError("MISTRAL_API_KEY must be set when using the Mistral OCR provider.")
|
||||
|
||||
model = getattr(settings, "mistral_ocr_model", None) or "mistral-ocr-latest"
|
||||
base_url = "https://api.mistral.ai/v1"
|
||||
|
||||
logger.info(f"[MistralOCR] Processing {os.path.basename(file_path)} with {model}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
pdf_b64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
client = openai.OpenAI(api_key=api_key, base_url=base_url)
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:application/pdf;base64,{pdf_b64}"},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Extract all text from this document. Return only the extracted text, preserving structure.",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
extracted_text = response.choices[0].message.content or ""
|
||||
logger.info(f"[MistralOCR] Extracted {len(extracted_text)} chars")
|
||||
|
||||
return OCRResult(provider="mistral", text=extracted_text)
|
||||
|
||||
|
||||
class GoogleDocAIOCRProvider(OCRProvider):
|
||||
"""OCR via Google Cloud Document AI.
|
||||
|
||||
Config knobs (from :class:`~app.config.Settings`):
|
||||
- ``google_docai_credentials_json`` – Service account JSON (optional;
|
||||
falls back to ``google_drive_credentials_json`` or ADC).
|
||||
- ``google_docai_project_id`` – GCP project ID (required).
|
||||
- ``google_docai_processor_id`` – Document AI processor ID (required).
|
||||
- ``google_docai_location`` – processor location, e.g. ``"us"`` (default:
|
||||
``"us"``).
|
||||
"""
|
||||
|
||||
name = "google_docai"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
from google.cloud import documentai
|
||||
from google.oauth2 import service_account
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"google-cloud-documentai is required for the Google Document AI OCR provider. "
|
||||
"Install it with: pip install google-cloud-documentai"
|
||||
) from exc
|
||||
|
||||
import json
|
||||
|
||||
project_id = getattr(settings, "google_docai_project_id", None)
|
||||
processor_id = getattr(settings, "google_docai_processor_id", None)
|
||||
location = getattr(settings, "google_docai_location", None) or "us"
|
||||
|
||||
if not project_id or not processor_id:
|
||||
raise ValueError(
|
||||
"GOOGLE_DOCAI_PROJECT_ID and GOOGLE_DOCAI_PROCESSOR_ID must be set "
|
||||
"when using the Google Document AI OCR provider."
|
||||
)
|
||||
|
||||
# Credentials: prefer dedicated docai key, then fall back to gdrive SA key
|
||||
creds_json = getattr(settings, "google_docai_credentials_json", None) or getattr(
|
||||
settings, "google_drive_credentials_json", None
|
||||
)
|
||||
|
||||
creds = None
|
||||
if creds_json:
|
||||
try:
|
||||
creds_info = json.loads(creds_json)
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
creds_info,
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[GoogleDocAIOCR] Failed to parse credentials JSON: {e}; using ADC")
|
||||
|
||||
client_options = {"api_endpoint": f"{location}-documentai.googleapis.com"}
|
||||
client = documentai.DocumentProcessorServiceClient(
|
||||
credentials=creds,
|
||||
client_options=client_options,
|
||||
)
|
||||
|
||||
processor_name = client.processor_path(project_id, location, processor_id)
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
raw_document = documentai.RawDocument(content=f.read(), mime_type="application/pdf")
|
||||
|
||||
request = documentai.ProcessRequest(name=processor_name, raw_document=raw_document)
|
||||
result = client.process_document(request=request)
|
||||
document = result.document
|
||||
extracted_text = document.text or ""
|
||||
logger.info(f"[GoogleDocAIOCR] Extracted {len(extracted_text)} chars")
|
||||
|
||||
return OCRResult(provider="google_docai", text=extracted_text)
|
||||
|
||||
|
||||
class AWSTextractOCRProvider(OCRProvider):
|
||||
"""OCR via AWS Textract.
|
||||
|
||||
Reuses existing AWS credentials from settings (``aws_access_key_id``,
|
||||
``aws_secret_access_key``, ``aws_region``).
|
||||
"""
|
||||
|
||||
name = "aws_textract"
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
import boto3
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("boto3 is required for the AWS Textract OCR provider.") from exc
|
||||
|
||||
aws_access_key_id = getattr(settings, "aws_access_key_id", None)
|
||||
aws_secret_access_key = getattr(settings, "aws_secret_access_key", None)
|
||||
region = getattr(settings, "aws_region", None) or "us-east-1"
|
||||
|
||||
if not aws_access_key_id or not aws_secret_access_key:
|
||||
raise ValueError(
|
||||
"AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set when using the AWS Textract OCR provider."
|
||||
)
|
||||
|
||||
logger.info(f"[AWSTextract] Processing {os.path.basename(file_path)} (region={region})")
|
||||
|
||||
client = boto3.client(
|
||||
"textract",
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
region_name=region,
|
||||
)
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
document_bytes = f.read()
|
||||
|
||||
response = client.detect_document_text(Document={"Bytes": document_bytes})
|
||||
|
||||
lines: List[str] = []
|
||||
for block in response.get("Blocks", []):
|
||||
if block.get("BlockType") == "LINE":
|
||||
text = block.get("Text", "")
|
||||
if text:
|
||||
lines.append(text)
|
||||
|
||||
extracted_text = "\n".join(lines)
|
||||
logger.info(f"[AWSTextract] Extracted {len(extracted_text)} chars")
|
||||
|
||||
return OCRResult(provider="aws_textract", text=extracted_text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory & multi-provider orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROVIDER_MAP: Dict[str, type] = {
|
||||
"azure": AzureOCRProvider,
|
||||
"tesseract": TesseractOCRProvider,
|
||||
"easyocr": EasyOCRProvider,
|
||||
"mistral": MistralOCRProvider,
|
||||
"google_docai": GoogleDocAIOCRProvider,
|
||||
"aws_textract": AWSTextractOCRProvider,
|
||||
}
|
||||
|
||||
# Sorted list of known provider names (kept in sync with _PROVIDER_MAP)
|
||||
KNOWN_OCR_PROVIDERS: List[str] = sorted(_PROVIDER_MAP.keys())
|
||||
|
||||
# Maximum characters per OCR result sent to the AI for merging.
|
||||
# Keeping this bounded prevents excessively large prompts that would exhaust
|
||||
# the model's context window or incur high token costs.
|
||||
MAX_OCR_TEXT_FOR_AI_MERGE = 4000
|
||||
|
||||
|
||||
def get_ocr_providers() -> List[OCRProvider]:
|
||||
"""Return a list of configured OCR provider instances.
|
||||
|
||||
Reads ``settings.ocr_providers`` (comma-separated provider names) and
|
||||
returns one instantiated provider per entry. Falls back to ``["azure"]``
|
||||
when the setting is absent.
|
||||
"""
|
||||
raw = getattr(settings, "ocr_providers", None) or "azure"
|
||||
provider_names = [name.strip().lower() for name in raw.split(",") if name.strip()]
|
||||
|
||||
providers: List[OCRProvider] = []
|
||||
for name in provider_names:
|
||||
cls = _PROVIDER_MAP.get(name)
|
||||
if cls is None:
|
||||
logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS – skipping.")
|
||||
continue
|
||||
providers.append(cls())
|
||||
logger.debug(f"Registered OCR provider: {name}")
|
||||
|
||||
if not providers:
|
||||
logger.warning("No valid OCR providers configured, falling back to Azure.")
|
||||
providers.append(AzureOCRProvider())
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def merge_ocr_results(results: List[OCRResult], filename: str) -> Tuple[str, Optional[str], Dict[int, float]]:
|
||||
"""Select the best text from multiple OCR results.
|
||||
|
||||
When only one result is available the text is returned as-is. When
|
||||
multiple results exist the AI model is consulted to pick or merge the best
|
||||
version (controlled by ``settings.ocr_merge_strategy``).
|
||||
|
||||
Args:
|
||||
results: Non-empty list of :class:`OCRResult` objects.
|
||||
filename: Document filename used for logging.
|
||||
|
||||
Returns:
|
||||
A 3-tuple of ``(best_text, searchable_pdf_path, rotation_data)`` where
|
||||
*searchable_pdf_path* and *rotation_data* come from the first result
|
||||
that provides them.
|
||||
"""
|
||||
if not results:
|
||||
return "", None, {}
|
||||
|
||||
if len(results) == 1:
|
||||
r = results[0]
|
||||
return r.text, r.searchable_pdf_path, r.rotation_data
|
||||
|
||||
strategy = getattr(settings, "ocr_merge_strategy", None) or "ai_merge"
|
||||
logger.info(f"Merging {len(results)} OCR results for {filename} (strategy={strategy})")
|
||||
|
||||
# Best searchable PDF comes from the first provider that produced one
|
||||
searchable_pdf_path = next((r.searchable_pdf_path for r in results if r.searchable_pdf_path), None)
|
||||
# Best rotation data comes from the first provider that detected any
|
||||
rotation_data = next((r.rotation_data for r in results if r.rotation_data), {})
|
||||
|
||||
if strategy == "primary":
|
||||
# Simply return the first result's text
|
||||
return results[0].text, searchable_pdf_path, rotation_data
|
||||
|
||||
if strategy == "longest":
|
||||
best = max(results, key=lambda r: len(r.text))
|
||||
return best.text, searchable_pdf_path, rotation_data
|
||||
|
||||
# Default: ai_merge – ask AI to pick/merge the best text
|
||||
try:
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
|
||||
extracts_block = "\n\n".join(
|
||||
f"--- OCR Engine: {r.provider} ---\n{r.text[:MAX_OCR_TEXT_FOR_AI_MERGE]}" for r in results
|
||||
)
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an expert document editor. You will receive OCR extracts of the same document "
|
||||
"produced by different OCR engines. Your task is to produce a single, clean, accurate "
|
||||
"version of the text by cross-referencing all inputs. "
|
||||
"Fix obvious OCR errors, preserve document structure, and return ONLY the final text."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Document: {filename}\n\n"
|
||||
f"The following are OCR extracts from different engines:\n\n{extracts_block}\n\n"
|
||||
"Please merge these into the most accurate version of the document text."
|
||||
),
|
||||
},
|
||||
]
|
||||
merged_text = provider.chat_completion(messages, model=model, temperature=0)
|
||||
logger.info(f"AI-merged OCR text: {len(merged_text)} chars for {filename}")
|
||||
return merged_text, searchable_pdf_path, rotation_data
|
||||
except Exception as exc:
|
||||
logger.error(f"AI merge failed for {filename}: {exc}; falling back to longest result")
|
||||
best = max(results, key=lambda r: len(r.text))
|
||||
return best.text, searchable_pdf_path, rotation_data
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ApplicationSettings, SettingsAuditLog
|
||||
from app.utils.ocr_provider import KNOWN_OCR_PROVIDERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -326,6 +327,122 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# OCR Engine Configuration
|
||||
"ocr_providers": {
|
||||
"category": "OCR Engines",
|
||||
"description": (
|
||||
"Active OCR engines. Select one or more engines to enable. "
|
||||
"When multiple engines are selected all run in parallel and results are merged. "
|
||||
"Stored as a comma-separated list (e.g. azure,tesseract)."
|
||||
),
|
||||
"type": "multiselect",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
"options": KNOWN_OCR_PROVIDERS,
|
||||
},
|
||||
"ocr_merge_strategy": {
|
||||
"category": "OCR Engines",
|
||||
"description": (
|
||||
"Strategy for merging results from multiple OCR providers. "
|
||||
"ai_merge: use AI to produce best merged text (default); "
|
||||
"longest: return the result with most characters; "
|
||||
"primary: return only the first provider's result."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
"options": ["ai_merge", "longest", "primary"],
|
||||
},
|
||||
# OCR – Tesseract
|
||||
"tesseract_cmd": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Path to the Tesseract binary (e.g. /usr/bin/tesseract). Leave blank to use system PATH.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"tesseract_language": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. Default: eng+deu (English + German).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# OCR – EasyOCR
|
||||
"easyocr_languages": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"easyocr_gpu": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Enable GPU acceleration for EasyOCR (requires CUDA). Default: False.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# OCR – Mistral
|
||||
"mistral_api_key": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Mistral API key (required when 'mistral' is in OCR_PROVIDERS).",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"mistral_ocr_model": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Mistral OCR model name. Default: mistral-ocr-latest.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# OCR – Google Cloud Document AI
|
||||
"google_docai_credentials_json": {
|
||||
"category": "OCR Engines",
|
||||
"description": (
|
||||
"Google Cloud service account credentials JSON for Document AI "
|
||||
"(optional; falls back to google_drive_credentials_json or Application Default Credentials)."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"google_docai_project_id": {
|
||||
"category": "OCR Engines",
|
||||
"description": "GCP project ID for Google Cloud Document AI (required when 'google_docai' is in OCR_PROVIDERS).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"google_docai_processor_id": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Document AI processor ID (required when 'google_docai' is in OCR_PROVIDERS).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"google_docai_location": {
|
||||
"category": "OCR Engines",
|
||||
"description": "Document AI processor location (e.g. 'us' or 'eu'). Default: us.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
"options": ["us", "eu"],
|
||||
},
|
||||
# Storage Providers - Dropbox
|
||||
"dropbox_app_key": {
|
||||
"category": "Storage Providers",
|
||||
|
||||
@@ -139,6 +139,29 @@
|
||||
Enable {{ setting.key.replace('_', ' ').title() }}
|
||||
</label>
|
||||
</div>
|
||||
<<<<<<< copilot/add-self-hosted-ocr-support
|
||||
{% elif setting.metadata.type == 'multiselect' and setting.metadata.options %}
|
||||
<!-- Multi-select checkboxes: stored as comma-separated string -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
{% for opt in setting.metadata.options %}
|
||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border cursor-pointer select-none
|
||||
transition-colors
|
||||
bg-white border-gray-300 hover:border-blue-400"
|
||||
:class="isSelected('{{ setting.key }}', '{{ opt }}')
|
||||
? 'bg-blue-100 border-blue-500 text-blue-800 font-medium'
|
||||
: 'text-gray-700'">
|
||||
<input type="checkbox"
|
||||
class="sr-only"
|
||||
:checked="isSelected('{{ setting.key }}', '{{ opt }}')"
|
||||
@change="toggleMultiselect('{{ setting.key }}', '{{ opt }}', $event.target.checked)"
|
||||
/>
|
||||
<i :class="isSelected('{{ setting.key }}', '{{ opt }}') ? 'fas fa-check-circle text-blue-600' : 'far fa-circle text-gray-400'"></i>
|
||||
<span class="text-sm">{{ opt }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
|
||||
=======
|
||||
{% elif setting.metadata.type == 'model_picker' %}
|
||||
<!-- Model Picker: free-text input with datalist of common models -->
|
||||
<div class="relative">
|
||||
@@ -162,6 +185,7 @@
|
||||
Pick from the list or type any model name supported by your provider.
|
||||
</p>
|
||||
</div>
|
||||
>>>>>>> main
|
||||
{% elif setting.metadata.options %}
|
||||
<!-- Dropdown Select for fields with a fixed list of values -->
|
||||
<select
|
||||
@@ -303,6 +327,28 @@ function settingsApp() {
|
||||
{% endfor %}
|
||||
},
|
||||
|
||||
/** Parse a comma-separated multiselect value into a trimmed array. */
|
||||
parseMultiselect(key) {
|
||||
return (this.formData[key] || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
},
|
||||
|
||||
/** Return true when *option* is present in the comma-separated *key* value. */
|
||||
isSelected(key, option) {
|
||||
return this.parseMultiselect(key).includes(option);
|
||||
},
|
||||
|
||||
/** Add or remove *option* from the comma-separated *key* value. */
|
||||
toggleMultiselect(key, option, checked) {
|
||||
const current = this.parseMultiselect(key);
|
||||
if (checked) {
|
||||
if (!current.includes(option)) current.push(option);
|
||||
} else {
|
||||
const idx = current.indexOf(option);
|
||||
if (idx !== -1) current.splice(idx, 1);
|
||||
}
|
||||
this.formData[key] = current.join(',');
|
||||
},
|
||||
|
||||
togglePassword(key) {
|
||||
this.showPassword[key] = !this.showPassword[key];
|
||||
},
|
||||
|
||||
+5
-1
@@ -37,4 +37,8 @@ paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license)
|
||||
apprise>=1.4.0
|
||||
|
||||
# AI provider aggregator - enables Anthropic, Gemini, Ollama, and 100+ LLM providers
|
||||
litellm>=1.0.0,<2.0.0
|
||||
litellm>=1.0.0,<2.0.0
|
||||
|
||||
# Self-hosted OCR engines (optional – only required when the provider is enabled)
|
||||
pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
|
||||
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
|
||||
|
||||
@@ -220,7 +220,7 @@ startxref
|
||||
patch("app.tasks.process_document.SessionLocal") as mock_session_local,
|
||||
patch("app.tasks.process_document.settings") as mock_settings,
|
||||
patch("app.tasks.process_document.log_task_progress"),
|
||||
patch("app.tasks.process_document.process_with_azure_document_intelligence") as mock_azure,
|
||||
patch("app.tasks.process_document.process_with_ocr") as mock_azure,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
@@ -235,7 +235,7 @@ startxref
|
||||
assert result["status"] == "Queued for OCR"
|
||||
assert "file_id" in result
|
||||
|
||||
# Verify that process_with_azure_document_intelligence was called with file_id
|
||||
# Verify that process_with_ocr was called with file_id
|
||||
mock_azure.delay.assert_called_once()
|
||||
call_args = mock_azure.delay.call_args
|
||||
|
||||
@@ -618,7 +618,7 @@ startxref
|
||||
patch("app.tasks.process_document.SessionLocal") as mock_session_local,
|
||||
patch("app.tasks.process_document.settings") as mock_settings,
|
||||
patch("app.tasks.process_document.log_task_progress"),
|
||||
patch("app.tasks.process_document.process_with_azure_document_intelligence") as mock_azure,
|
||||
patch("app.tasks.process_document.process_with_ocr") as mock_azure,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
@@ -633,7 +633,7 @@ startxref
|
||||
assert result["status"] == "Queued for forced OCR"
|
||||
assert "file_id" in result
|
||||
|
||||
# Verify that process_with_azure_document_intelligence was called
|
||||
# Verify that process_with_ocr was called
|
||||
mock_azure.delay.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ startxref
|
||||
patch("app.tasks.process_document.SessionLocal") as mock_session_local,
|
||||
patch("app.tasks.process_document.settings") as mock_settings,
|
||||
patch("app.tasks.process_document.log_task_progress"),
|
||||
patch("app.tasks.process_document.process_with_azure_document_intelligence") as mock_azure,
|
||||
patch("app.tasks.process_document.process_with_ocr") as mock_azure,
|
||||
):
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_session_local.return_value.__enter__.return_value = db_session
|
||||
@@ -362,6 +362,6 @@ startxref
|
||||
# Process with force_cloud_ocr=True
|
||||
result = process_document(str(test_pdf), force_cloud_ocr=True)
|
||||
|
||||
# Should queue Azure OCR, not local extraction
|
||||
# Should queue OCR, not local extraction
|
||||
mock_azure.delay.assert_called_once()
|
||||
assert result["status"] == "Queued for forced OCR"
|
||||
|
||||
Reference in New Issue
Block a user