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:
@@ -72,6 +72,40 @@ class Settings(BaseSettings):
|
|||||||
azure_region: str
|
azure_region: str
|
||||||
azure_endpoint: str
|
azure_endpoint: str
|
||||||
gotenberg_url: 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
|
external_hostname: str = "localhost" # Default to localhost
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
|
|||||||
@@ -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 (
|
from app.tasks.process_with_azure_document_intelligence import (
|
||||||
process_with_azure_document_intelligence,
|
process_with_azure_document_intelligence,
|
||||||
)
|
)
|
||||||
|
from app.tasks.process_with_ocr import process_with_ocr
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.utils import get_unique_filepath_with_counter, hash_file, log_task_progress
|
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",
|
"Queued for forced OCR processing",
|
||||||
file_id=file_id,
|
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}
|
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
|
# 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,
|
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(
|
log_task_progress(
|
||||||
task_id,
|
task_id,
|
||||||
"process_with_azure_document_intelligence",
|
"process_with_azure_document_intelligence",
|
||||||
"skipped",
|
"skipped",
|
||||||
"Local text extraction succeeded, Azure OCR not needed",
|
"Local text extraction succeeded, OCR not needed",
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,8 +437,8 @@ def process_document(
|
|||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. If no embedded text, queue Azure Document Intelligence processing
|
# 3. If no embedded text, queue OCR processing
|
||||||
logger.info(f"[{task_id}] No embedded text found. Queueing Azure Document Intelligence processing")
|
logger.info(f"[{task_id}] No embedded text found. Queueing OCR processing")
|
||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id,
|
task_id,
|
||||||
"check_text",
|
"check_text",
|
||||||
@@ -446,12 +447,12 @@ def process_document(
|
|||||||
file_id=file_id,
|
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(
|
log_task_progress(
|
||||||
task_id,
|
task_id,
|
||||||
"extract_text",
|
"extract_text",
|
||||||
"skipped",
|
"skipped",
|
||||||
"No embedded text, using Azure OCR instead",
|
"No embedded text, using OCR instead",
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -462,5 +463,5 @@ def process_document(
|
|||||||
"Queued for OCR processing",
|
"Queued for OCR processing",
|
||||||
file_id=file_id,
|
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}
|
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,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
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ApplicationSettings, SettingsAuditLog
|
from app.models import ApplicationSettings, SettingsAuditLog
|
||||||
|
from app.utils.ocr_provider import KNOWN_OCR_PROVIDERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -280,6 +281,122 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_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
|
# Storage Providers - Dropbox
|
||||||
"dropbox_app_key": {
|
"dropbox_app_key": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
|
|||||||
@@ -139,6 +139,27 @@
|
|||||||
Enable {{ setting.key.replace('_', ' ').title() }}
|
Enable {{ setting.key.replace('_', ' ').title() }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 %}
|
{% elif setting.metadata.options %}
|
||||||
<!-- Dropdown Select for fields with a fixed list of values -->
|
<!-- Dropdown Select for fields with a fixed list of values -->
|
||||||
<select
|
<select
|
||||||
@@ -280,6 +301,28 @@ function settingsApp() {
|
|||||||
{% endfor %}
|
{% 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) {
|
togglePassword(key) {
|
||||||
this.showPassword[key] = !this.showPassword[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
|
apprise>=1.4.0
|
||||||
|
|
||||||
# AI provider aggregator - enables Anthropic, Gemini, Ollama, and 100+ LLM providers
|
# 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user