feat(ocr): add self-hosted OCR engine support (Tesseract, EasyOCR) with multi-provider cross-checking

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-23 20:59:43 +00:00
parent 9bba1f025f
commit 1f843fe460
7 changed files with 892 additions and 9 deletions
+34
View File
@@ -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" # Tesseract language code(s), e.g. "eng" or "eng+deu"
# EasyOCR settings (used when "easyocr" is in OCR_PROVIDERS)
easyocr_languages: str = "en" # 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
+9 -8
View File
@@ -17,6 +17,7 @@ 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 +314,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 +412,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 +437,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 +447,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 +463,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}
+139
View File
@@ -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
+545
View File
@@ -0,0 +1,545 @@
#!/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:
import azure.core.exceptions
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
+117
View File
@@ -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__)
@@ -280,6 +281,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.",
"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.",
"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",
+43
View File
@@ -139,6 +139,27 @@
Enable {{ setting.key.replace('_', ' ').title() }}
</label>
</div>
{% 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.options %}
<!-- Dropdown Select for fields with a fixed list of values -->
<select
@@ -280,6 +301,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
View File
@@ -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)