diff --git a/app/config.py b/app/config.py index b331d8a1..aaf0c067 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/tasks/process_document.py b/app/tasks/process_document.py index 9030b736..0c994d38 100644 --- a/app/tasks/process_document.py +++ b/app/tasks/process_document.py @@ -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} diff --git a/app/tasks/process_with_ocr.py b/app/tasks/process_with_ocr.py new file mode 100644 index 00000000..846de711 --- /dev/null +++ b/app/tasks/process_with_ocr.py @@ -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 ``/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 diff --git a/app/utils/ocr_provider.py b/app/utils/ocr_provider.py new file mode 100644 index 00000000..7c61192b --- /dev/null +++ b/app/utils/ocr_provider.py @@ -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 diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 49c8b14b..bfd53acb 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -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", diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index cf2335e2..80990ad1 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -139,6 +139,29 @@ Enable {{ setting.key.replace('_', ' ').title() }} +<<<<<<< copilot/add-self-hosted-ocr-support + {% elif setting.metadata.type == 'multiselect' and setting.metadata.options %} + +
+ {% for opt in setting.metadata.options %} + + {% endfor %} +
+

Effective value:

+======= {% elif setting.metadata.type == 'model_picker' %}
@@ -162,6 +185,7 @@ Pick from the list or type any model name supported by your provider.

+>>>>>>> main {% elif setting.metadata.options %}