mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 20:16:48 +00:00
standalone OCR
This commit is contained in:
1 parent
d4e92be0ab
commit
96b878217d
27 files changed
+1995
-152
No files matched your search
+12
-9
@@ -42,10 +42,11 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir tiktoken && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Optional docling parser engine (DOC_PARSER_ENGINE=docling, OCR for scanned
|
||||
# PDFs/images, read_document's structured output) — OFF by default: it pulls
|
||||
# the layout/OCR model stack and adds gigabytes to the image. anydoc (in
|
||||
# requirements.txt) is the default parser and needs none of it.
|
||||
# Optional docling parser engine (DOC_PARSER_ENGINE=docling, the docling OCR
|
||||
# backend, read_document's structured output) — OFF by default: it pulls the
|
||||
# layout/OCR model stack and adds gigabytes to the image. anydoc (in
|
||||
# requirements.txt) is the default parser and needs none of it, and OCR runs
|
||||
# natively on tesseract (below) without it.
|
||||
ARG INSTALL_DOCLING=false
|
||||
RUN if [ "$INSTALL_DOCLING" = "true" ]; then \
|
||||
pip install --no-cache-dir -r requirements-docling.txt; \
|
||||
@@ -66,11 +67,13 @@ RUN apt-get update && \
|
||||
ln -s /usr/bin/python3.12 /usr/bin/python && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The recommended OCR engine (OCR_ENGINE=tesseract) shells out to the system
|
||||
# tesseract binary, so ship it alongside the optional docling engine. Extra
|
||||
# language packs are a deployment concern (apt: tesseract-ocr-<lang>).
|
||||
ARG INSTALL_DOCLING=false
|
||||
RUN if [ "$INSTALL_DOCLING" = "true" ]; then \
|
||||
# OCR (OCR_ENABLED=true) runs on the system tesseract binary by default,
|
||||
# with or without the docling extra, so ship it in every image (~35 MB).
|
||||
# Opt out with --build-arg INSTALL_TESSERACT=false (e.g. a DeepSeek-OCR-only
|
||||
# deployment). Extra language packs are a deployment concern
|
||||
# (apt: tesseract-ocr-<lang>, then list them in OCR_LANGS).
|
||||
ARG INSTALL_TESSERACT=true
|
||||
RUN if [ "$INSTALL_TESSERACT" = "true" ]; then \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -110,13 +110,32 @@ class Settings(BaseSettings):
|
||||
# read_document tool. "anydoc" (default): firecrawl-anydoc, a Rust
|
||||
# converter with no ML models — milliseconds per file, ~100 MB peak RSS.
|
||||
# "docling": the layout/table-model pipeline (optional install; needed
|
||||
# for OCR and for read_document's structured output). Files anydoc
|
||||
# cannot convert (scanned PDFs, malformed input) fall back to docling
|
||||
# when it is installed, otherwise to the legacy parsers. Rollback to the
|
||||
# previous behaviour is this one variable.
|
||||
# for read_document's structured output and the docling OCR backend).
|
||||
# Files anydoc cannot convert (scanned PDFs, malformed input) fall back to
|
||||
# docling when it is installed, otherwise to the native OCR parsers (OCR
|
||||
# on) or the legacy parsers. Rollback to the previous behaviour is this
|
||||
# one variable.
|
||||
DOC_PARSER_ENGINE: str = "anydoc"
|
||||
DOCLING_OCR_ENABLED: bool = False # Enable OCR for docling parsers (PDF, images)
|
||||
DOCLING_OCR_ATTACHMENTS_ENABLED: bool = False # Enable OCR for docling when parsing attachments
|
||||
# OCR for scanned PDFs and images. OCR_ENABLED covers source ingestion,
|
||||
# OCR_ATTACHMENTS_ENABLED chat attachments. Which stack performs it is
|
||||
# OCR_BACKEND; which engine, OCR_ENGINE. The DOCLING_OCR_* names are the
|
||||
# pre-2026-09 spellings and stay accepted as aliases.
|
||||
OCR_ENABLED: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("OCR_ENABLED", "DOCLING_OCR_ENABLED")
|
||||
)
|
||||
OCR_ATTACHMENTS_ENABLED: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("OCR_ATTACHMENTS_ENABLED", "DOCLING_OCR_ATTACHMENTS_ENABLED"),
|
||||
)
|
||||
# Which stack runs OCR when it is on:
|
||||
# auto — docling when installed, otherwise native.
|
||||
# docling — the layout-model pipeline (hybrid region OCR, reading order,
|
||||
# table structure); needs the optional docling extra.
|
||||
# native — pypdfium2/Pillow page rendering straight into tesseract or a
|
||||
# DeepSeek-OCR endpoint (application/parser/file/ocr_parser.py).
|
||||
# No ML models in the worker; tables come out as text lines
|
||||
# under tesseract.
|
||||
OCR_BACKEND: str = "auto"
|
||||
# Pages docling's threaded pipeline buffers in flight; the library
|
||||
# default (100) drives worker RSS to ~3 GB on a mid-size PDF.
|
||||
DOCLING_PIPELINE_QUEUE_MAX_SIZE: int = 2
|
||||
@@ -141,34 +160,44 @@ class Settings(BaseSettings):
|
||||
# default: it rewrites content on a heuristic (>=3 uniform label+numbers
|
||||
# lines) validated only on a small corpus so far.
|
||||
ANYDOC_TABLEIZE: bool = False
|
||||
# OCR engine used by the docling parsers when OCR is on
|
||||
# (DOCLING_OCR_ENABLED / DOCLING_OCR_ATTACHMENTS_ENABLED). Benched 2026-08
|
||||
# on EN/ZH/table/degraded scans (docs/Guides/ocr has the menu):
|
||||
# OCR engine used when OCR is on (OCR_ENABLED / OCR_ATTACHMENTS_ENABLED).
|
||||
# Benched 2026-08 on EN/ZH/table/degraded scans (docs/Guides/ocr has the
|
||||
# menu):
|
||||
# tesseract — recommended: best classic-engine accuracy (perfect EN word
|
||||
# recall, 0.000 bilingual CER, 100% table cells), ~35 MB, CPU-only.
|
||||
# Needs the system binary + language packs (the Docker image installs
|
||||
# them when built with INSTALL_DOCLING=true).
|
||||
# Needs the system binary + language packs (the Docker image ships
|
||||
# them unless built with INSTALL_TESSERACT=false). Both backends.
|
||||
# deepseek — DeepSeek-OCR against an Ollama/vLLM endpoint
|
||||
# (OCR_DEEPSEEK_*). Best table/CJK quality; the worker stays light
|
||||
# (no layout models) but each page costs seconds on the model server.
|
||||
# Both backends.
|
||||
# auto — docling's pick: ocrmac on macOS (excellent), rapidocr on Linux
|
||||
# (silently shreds some long text lines — avoid as a server default).
|
||||
# ocrmac | rapidocr — force one of those.
|
||||
# deepseek — DeepSeek-OCR through docling's VLM pipeline against an
|
||||
# Ollama/vLLM endpoint (OCR_DEEPSEEK_*). Best table/CJK quality; the
|
||||
# worker stays light (~370 MB, no layout models) but each page costs
|
||||
# seconds on the model server.
|
||||
# An engine that is not installed degrades to "auto" with a warning
|
||||
# instead of failing the parse.
|
||||
# auto/ocrmac/rapidocr exist only inside docling; the native backend runs
|
||||
# tesseract for them. An engine that is not installed degrades (docling:
|
||||
# to "auto") with a warning instead of failing the parse.
|
||||
OCR_ENGINE: str = "tesseract"
|
||||
# Tesseract language packs, "+"-separated (e.g. "eng+chi_sim+deu"). Other
|
||||
# engines keep their own defaults — their language codes differ.
|
||||
OCR_LANGS: str = "eng"
|
||||
OCR_DEEPSEEK_URL: str = "http://localhost:11434/v1/chat/completions"
|
||||
OCR_DEEPSEEK_MODEL: str = "deepseek-ocr:3b"
|
||||
# Chars-per-page floor below which an OCR'd PDF/image parse is treated as a docling
|
||||
# pipeline dropout (long-running workers were observed returning zero characters for
|
||||
# Native backend only: seconds allowed per page request to the DeepSeek
|
||||
# endpoint (pages are sent one at a time). A 3B model on a laptop needs
|
||||
# minutes; a vLLM GPU deployment, seconds.
|
||||
OCR_DEEPSEEK_TIMEOUT: float = 300.0
|
||||
# Native backend only: resolution at which pages without a text layer are
|
||||
# rendered before OCR. 200 suits tesseract; clamped to 72-600.
|
||||
OCR_RENDER_DPI: int = 200
|
||||
# Chars-per-page floor below which an OCR'd PDF/image parse is treated as an OCR
|
||||
# dropout (long-running docling workers were observed returning zero characters for
|
||||
# every scanned page after a long scanned PDF, with no error) rather than as content.
|
||||
# Such a parse is retried once on a fresh full-page-OCR converter and then fails
|
||||
# docling retries once on a fresh full-page-OCR converter; both backends then fail
|
||||
# loudly instead of indexing an empty document. 0 disables the guard.
|
||||
DOCLING_OCR_MIN_CHARS_PER_PAGE: int = 20
|
||||
OCR_MIN_CHARS_PER_PAGE: int = Field(
|
||||
default=20, validation_alias=AliasChoices("OCR_MIN_CHARS_PER_PAGE", "DOCLING_OCR_MIN_CHARS_PER_PAGE")
|
||||
)
|
||||
# Read PDF *attachments* via their embedded text layer (pypdfium2) instead
|
||||
# of docling, falling back to docling when there is no text layer to read.
|
||||
# Attachments go into a prompt, so docling's structural markdown earns far
|
||||
|
||||
@@ -169,7 +169,7 @@ def _resolve_ocr_enabled(ocr: str) -> bool:
|
||||
return True
|
||||
if ocr == "off":
|
||||
return False
|
||||
return bool(getattr(settings, "DOCLING_OCR_ENABLED", False))
|
||||
return bool(getattr(settings, "OCR_ENABLED", False))
|
||||
|
||||
|
||||
def _effective_engine(engine: str) -> str:
|
||||
|
||||
@@ -10,9 +10,10 @@ PDFs.
|
||||
|
||||
anydoc never OCRs. It *detects* a scanned or image-only PDF and raises
|
||||
``UnsupportedError`` ("OCR is required"), which is the routing point: a
|
||||
parser given a ``fallback_parser`` (docling when installed, otherwise the
|
||||
legacy parser for the suffix) delegates there; without one the file fails
|
||||
loudly as ``DocumentParseError`` rather than being stored empty.
|
||||
parser given a ``fallback_parser`` (docling when installed, the native OCR
|
||||
parser when OCR is on without docling, otherwise the legacy parser for the
|
||||
suffix) delegates there; without one the file fails loudly as
|
||||
``DocumentParseError`` rather than being stored empty.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -85,14 +86,21 @@ def _result_chars(result: Union[str, List[str]]) -> int:
|
||||
|
||||
|
||||
def _is_docling_backed(parser: Optional[BaseParser]) -> bool:
|
||||
"""True when ``parser`` is a docling parser (worth a trust-check re-parse)."""
|
||||
"""True when ``parser`` is a docling parser (worth a trust-check re-parse).
|
||||
|
||||
Looks through ``NativeOcrPdfParser``, which under ``OCR_BACKEND=native``
|
||||
wraps the docling PDF parser as its ``text_parser`` and hands text-layer
|
||||
PDFs — the only kind the trust check flags — straight to it.
|
||||
"""
|
||||
if parser is None:
|
||||
return False
|
||||
try:
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
except ImportError:
|
||||
return False
|
||||
return isinstance(parser, DoclingParser)
|
||||
if isinstance(parser, DoclingParser):
|
||||
return True
|
||||
return isinstance(getattr(parser, "text_parser", None), DoclingParser)
|
||||
|
||||
|
||||
class AnydocParser(BaseParser):
|
||||
@@ -120,6 +128,8 @@ class AnydocParser(BaseParser):
|
||||
# check flagged but kept — surfaced via ``get_file_metadata`` as
|
||||
# ``parse_warnings`` so the document carries its own caveat.
|
||||
self._last_warnings: Optional[Tuple[Path, List[str]]] = None
|
||||
# (path, count) of scanned pages OCR'd into the most recent parse.
|
||||
self._last_ocr_pages: Optional[Tuple[Path, int]] = None
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
# Import for real rather than trusting ``find_spec``: a wheel whose
|
||||
@@ -156,6 +166,7 @@ class AnydocParser(BaseParser):
|
||||
|
||||
path = Path(file)
|
||||
self._last_warnings = None
|
||||
self._last_ocr_pages = None
|
||||
try:
|
||||
content = anydoc.to_markdown(str(path))
|
||||
except anydoc.ResourceLimitError as exc:
|
||||
@@ -196,7 +207,7 @@ class AnydocParser(BaseParser):
|
||||
def _finish_pdf(self, path: Path, content: str, errors: str) -> Union[str, List[str]]:
|
||||
"""Post-process a successful anydoc PDF conversion.
|
||||
|
||||
Two PDF-only steps:
|
||||
Three PDF-only steps:
|
||||
|
||||
1. Trust check (``PDF_TRUST_CHECK``): the two classes where anydoc
|
||||
drops text *silently* — Type0 fonts without ToUnicode, and
|
||||
@@ -204,7 +215,12 @@ class AnydocParser(BaseParser):
|
||||
on the docling fallback when one is wired; otherwise the anydoc
|
||||
output is kept and the problems ride along as ``parse_warnings``
|
||||
metadata.
|
||||
2. ``ANYDOC_TABLEIZE`` (off by default): rewrite dot-leader /
|
||||
2. Mixed documents: anydoc only refuses a PDF when *every* page lacks
|
||||
text, so a text document with scanned pages in it converts fine
|
||||
and silently loses those pages. When the fallback parser can OCR
|
||||
(it is wired with OCR on), the pages with no text layer are OCR'd
|
||||
through it and appended (``_ocr_scanned_pages``).
|
||||
3. ``ANYDOC_TABLEIZE`` (off by default): rewrite dot-leader /
|
||||
whitespace table runs as GFM tables. Never applied to a docling
|
||||
re-parse — docling emits real tables already.
|
||||
"""
|
||||
@@ -218,6 +234,7 @@ class AnydocParser(BaseParser):
|
||||
f"anydoc output for {path.name} failed the PDF trust check "
|
||||
f"({'; '.join(problems)}); keeping it with parse_warnings"
|
||||
)
|
||||
content = self._ocr_scanned_pages(path, content)
|
||||
if settings.ANYDOC_TABLEIZE:
|
||||
from application.parser.file.tableize import tableize
|
||||
|
||||
@@ -281,12 +298,58 @@ class AnydocParser(BaseParser):
|
||||
self.last_engine = getattr(fallback, "last_engine", None) or type(fallback).__name__
|
||||
return result
|
||||
|
||||
def _ocr_scanned_pages(self, path: Path, content: str) -> str:
|
||||
"""Append OCR text for the pages of ``path`` that have no text layer.
|
||||
|
||||
Runs only when the fallback parser both has OCR on and exposes
|
||||
``ocr_pages`` (the native OCR PDF parser, or the docling PDF parser).
|
||||
With OCR off the fallback is the plain pypdf parser and mixed
|
||||
documents keep today's behaviour: text pages only. An OCR failure is
|
||||
logged and the anydoc text kept — a partial document beats a
|
||||
rejected upload, and the loud-failure path stays reserved for fully
|
||||
scanned files.
|
||||
"""
|
||||
fallback = self.fallback_parser
|
||||
if not (getattr(fallback, "ocr_enabled", False) and hasattr(fallback, "ocr_pages")):
|
||||
return content
|
||||
from application.parser.file.ocr_parser import scanned_page_indices
|
||||
|
||||
indices = scanned_page_indices(path)
|
||||
if not indices:
|
||||
return content
|
||||
logger.info(
|
||||
"%s has %d page(s) without a text layer among its text pages; OCR-ing them with %s",
|
||||
path.name,
|
||||
len(indices),
|
||||
type(fallback).__name__,
|
||||
)
|
||||
try:
|
||||
if not fallback.parser_config_set:
|
||||
fallback.init_parser()
|
||||
texts = fallback.ocr_pages(path, indices)
|
||||
except Exception: # noqa: BLE001 - keep the text pages rather than fail the file
|
||||
logger.warning(
|
||||
"OCR of the scanned pages of %s failed; indexing the text pages only",
|
||||
path.name,
|
||||
exc_info=True,
|
||||
)
|
||||
return content
|
||||
extra = [texts[index] for index in indices if texts.get(index, "").strip()]
|
||||
if not extra:
|
||||
return content
|
||||
self._last_ocr_pages = (path, len(extra))
|
||||
return content.rstrip() + "\n\n" + "\n\n".join(extra) + "\n"
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
"""Surface trust-check findings for the file just parsed, if any."""
|
||||
"""Surface trust-check findings and OCR'd page counts for the file just parsed."""
|
||||
metadata: Dict = {}
|
||||
last = self._last_warnings
|
||||
if last is not None and last[0] == Path(file):
|
||||
return {"parse_warnings": list(last[1])}
|
||||
return {}
|
||||
metadata["parse_warnings"] = list(last[1])
|
||||
ocr = self._last_ocr_pages
|
||||
if ocr is not None and ocr[0] == Path(file):
|
||||
metadata["ocr_pages"] = ocr[1]
|
||||
return metadata
|
||||
|
||||
def _delegate(
|
||||
self, path: Path, errors: str, reason: str, ocr_needed: bool = False
|
||||
@@ -315,9 +378,10 @@ class AnydocParser(BaseParser):
|
||||
hint = " even with OCR enabled."
|
||||
else:
|
||||
hint = (
|
||||
". Enable OCR to ingest scans: set DOCLING_OCR_ENABLED=true "
|
||||
"(and DOCLING_OCR_ATTACHMENTS_ENABLED for attachments) with "
|
||||
"the docling extra installed."
|
||||
". Enable OCR to ingest scans: set OCR_ENABLED=true (and "
|
||||
"OCR_ATTACHMENTS_ENABLED for attachments). OCR runs on the "
|
||||
"tesseract binary or a DeepSeek-OCR endpoint (OCR_ENGINE), or "
|
||||
"through the optional docling extra when installed."
|
||||
)
|
||||
raise DocumentParseError(
|
||||
f"{path.name} appears to be a scanned PDF (no text layer), and "
|
||||
|
||||
@@ -68,21 +68,70 @@ def _gained_format_entries() -> Dict[str, BaseParser]:
|
||||
return {suffix: AnydocParser() for suffix in ANYDOC_GAINED_SUFFIXES}
|
||||
|
||||
|
||||
def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BaseParser]:
|
||||
def _native_ocr_parsers(
|
||||
text_parser: Optional[BaseParser] = None,
|
||||
) -> Optional[Tuple[BaseParser, Callable[[], BaseParser]]]:
|
||||
"""PDF parser and image-parser factory for the native OCR backend.
|
||||
|
||||
Args:
|
||||
text_parser: Parser for PDFs whose every page has a text layer (the
|
||||
docling PDF parser under the docling engine); None reads text
|
||||
layers with pypdfium2.
|
||||
|
||||
Returns:
|
||||
``(pdf_parser, image_parser_factory)``, or None when pypdfium2 or
|
||||
Pillow is missing — both are core dependencies, so that is a broken
|
||||
install, logged as such.
|
||||
"""
|
||||
from application.parser.file.ocr_parser import (
|
||||
NativeOcrImageParser,
|
||||
NativeOcrPdfParser,
|
||||
native_ocr_available,
|
||||
)
|
||||
|
||||
if not native_ocr_available():
|
||||
logging.error(
|
||||
"OCR is enabled but pypdfium2/Pillow are not importable, so the native "
|
||||
"OCR backend cannot run; scanned PDFs and images will not be OCR'd"
|
||||
)
|
||||
return None
|
||||
return NativeOcrPdfParser(text_parser=text_parser), NativeOcrImageParser
|
||||
|
||||
|
||||
def _image_entries(factory: Callable[[], BaseParser], suffixes) -> Dict[str, BaseParser]:
|
||||
"""One parser instance per image suffix."""
|
||||
return {suffix: factory() for suffix in suffixes}
|
||||
|
||||
|
||||
_LEGACY_IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg")
|
||||
|
||||
|
||||
def _legacy_file_extractor(pdf_text_fast_path: bool = False, ocr_enabled: bool = False) -> Dict[str, BaseParser]:
|
||||
"""Parser map that needs neither docling nor anydoc.
|
||||
|
||||
Args:
|
||||
pdf_text_fast_path: Put the pypdfium2 text-layer parser in front of
|
||||
the legacy PDF parser.
|
||||
the PDF parser.
|
||||
ocr_enabled: Use the native OCR parsers (tesseract / DeepSeek-OCR via
|
||||
pypdfium2 + Pillow) for PDFs and images instead of the plain
|
||||
pypdf / remote-service parsers.
|
||||
|
||||
Returns:
|
||||
Dict[str, BaseParser]: Parser keyed by lower-case file suffix.
|
||||
"""
|
||||
legacy_pdf: BaseParser = PDFParser()
|
||||
from application.parser.file.ocr_parser import IMAGE_SUFFIXES
|
||||
|
||||
pdf_parser: BaseParser = PDFParser()
|
||||
images: Dict[str, BaseParser] = _image_entries(ImageParser, _LEGACY_IMAGE_SUFFIXES)
|
||||
if ocr_enabled:
|
||||
native = _native_ocr_parsers()
|
||||
if native is not None:
|
||||
pdf_parser, image_factory = native
|
||||
images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES))
|
||||
if pdf_text_fast_path:
|
||||
legacy_pdf = _wrap_pdf_fast_path(legacy_pdf)
|
||||
pdf_parser = _wrap_pdf_fast_path(pdf_parser)
|
||||
return {
|
||||
".pdf": legacy_pdf,
|
||||
".pdf": pdf_parser,
|
||||
".docx": DocxParser(),
|
||||
".csv": PandasCSVParser(),
|
||||
".xlsx": ExcelParser(),
|
||||
@@ -94,9 +143,7 @@ def _legacy_file_extractor(pdf_text_fast_path: bool = False) -> Dict[str, BasePa
|
||||
".mdx": MarkdownParser(),
|
||||
".json": JSONParser(),
|
||||
".pptx": PPTXParser(),
|
||||
".png": ImageParser(),
|
||||
".jpg": ImageParser(),
|
||||
".jpeg": ImageParser(),
|
||||
**images,
|
||||
**_build_audio_parser_mapping(),
|
||||
**_gained_format_entries(),
|
||||
}
|
||||
@@ -110,6 +157,12 @@ def _docling_file_extractor(
|
||||
) -> Dict[str, BaseParser]:
|
||||
"""docling parser map, or the legacy map when docling is not installed.
|
||||
|
||||
With OCR on, ``OCR_BACKEND`` decides who OCRs: docling's own hybrid
|
||||
pipeline, or the native parsers (``ocr_parser.py``) with the docling PDF
|
||||
parser kept — OCR off — for PDFs that have a text layer on every page.
|
||||
Without docling installed the legacy map takes over, itself using the
|
||||
native OCR parsers when OCR is on.
|
||||
|
||||
The availability check has to happen here, not at parse time:
|
||||
``DoclingParser`` only imports docling inside ``_init_parser``, and the
|
||||
``ImportError`` that raises is not a ``DocumentParseError``, so a map
|
||||
@@ -144,12 +197,27 @@ def _docling_file_extractor(
|
||||
except ImportError:
|
||||
logging.log(
|
||||
missing_log_level,
|
||||
"docling is not installed. Using standard parsers. "
|
||||
"For OCR and layout-model parsing, install with: pip install docling",
|
||||
"docling is not installed. Using standard parsers%s. For layout-model "
|
||||
"parsing, install with: pip install -r requirements-docling.txt",
|
||||
" with native OCR" if ocr_enabled else "",
|
||||
)
|
||||
return _legacy_file_extractor(pdf_text_fast_path)
|
||||
return _legacy_file_extractor(pdf_text_fast_path, ocr_enabled=ocr_enabled)
|
||||
|
||||
pdf_parser: BaseParser = DoclingPDFParser(ocr_enabled=ocr_enabled)
|
||||
from application.parser.file.ocr_parser import IMAGE_SUFFIXES, resolve_ocr_backend
|
||||
|
||||
native = None
|
||||
if ocr_enabled and resolve_ocr_backend() == "native":
|
||||
native = _native_ocr_parsers(text_parser=DoclingPDFParser(ocr_enabled=False))
|
||||
if native is not None:
|
||||
pdf_parser, image_factory = native
|
||||
images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES))
|
||||
else:
|
||||
pdf_parser = DoclingPDFParser(ocr_enabled=ocr_enabled)
|
||||
# Images (with OCR) - only use Docling when OCR is enabled
|
||||
image_factory = (
|
||||
(lambda: DoclingImageParser(ocr_enabled=True)) if ocr_enabled else ImageParser
|
||||
)
|
||||
images = _image_entries(image_factory, sorted(IMAGE_SUFFIXES))
|
||||
if pdf_text_fast_path:
|
||||
pdf_parser = _wrap_pdf_fast_path(pdf_parser)
|
||||
return {
|
||||
@@ -170,14 +238,8 @@ def _docling_file_extractor(
|
||||
".rst": RstParser(),
|
||||
".adoc": DoclingAsciiDocParser(),
|
||||
".asciidoc": DoclingAsciiDocParser(),
|
||||
# Images (with OCR) - only use Docling when OCR is enabled
|
||||
".png": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".jpg": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".jpeg": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".tiff": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".tif": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".bmp": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
".webp": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
# Images: OCR'd by the selected backend when OCR is on, else ImageParser
|
||||
**images,
|
||||
# Media/subtitles
|
||||
".vtt": DoclingVTTParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
@@ -195,10 +257,11 @@ def _anydoc_file_extractor(ocr_enabled: bool, pdf_text_fast_path: bool = False)
|
||||
|
||||
anydoc takes every format it converts; each of those parsers gets the
|
||||
base map's parser for the same suffix as ``fallback_parser``, so a
|
||||
scanned PDF still reaches docling+OCR when docling is installed and the
|
||||
legacy parser otherwise. docling, when installed, also keeps serving
|
||||
what anydoc cannot: image OCR and ``.adoc``/``.vtt``/``.xml``. HTML goes
|
||||
to the markdownify-based ``HTMLMarkdownParser``.
|
||||
scanned PDF still reaches OCR — docling's when it is installed and
|
||||
selected, the native OCR parser otherwise — and the legacy parser when
|
||||
OCR is off. docling, when installed, also keeps serving what anydoc
|
||||
cannot: ``.adoc``/``.vtt``/``.xml``. Images go to whichever OCR backend
|
||||
is active. HTML goes to the markdownify-based ``HTMLMarkdownParser``.
|
||||
|
||||
``pdf_text_fast_path`` is deliberately ignored here: anydoc already reads
|
||||
the text layer in milliseconds *and* keeps headings/tables, and does its
|
||||
@@ -255,14 +318,16 @@ def get_default_file_extractor(
|
||||
The engine is ``settings.DOC_PARSER_ENGINE`` unless overridden:
|
||||
|
||||
* ``anydoc`` (default): firecrawl-anydoc for office/PDF/tabular formats,
|
||||
markdownify for HTML, docling (if installed) for image OCR and as the
|
||||
fallback for files anydoc rejects; legacy parsers otherwise.
|
||||
markdownify for HTML, the active OCR backend for images and as the
|
||||
fallback for scanned PDFs; docling (if installed) for the rest anydoc
|
||||
rejects, legacy parsers otherwise.
|
||||
* ``docling``: the docling map, with the legacy parsers when docling is
|
||||
not installed.
|
||||
|
||||
Args:
|
||||
ocr_enabled: Enable OCR in the docling parsers. Defaults to
|
||||
``settings.DOCLING_OCR_ENABLED``.
|
||||
ocr_enabled: Enable OCR for scanned PDFs and images, through the
|
||||
backend ``OCR_BACKEND`` resolves to. Defaults to
|
||||
``settings.OCR_ENABLED``.
|
||||
pdf_text_fast_path: Read PDFs via their embedded text layer
|
||||
(pypdfium2) instead of docling, falling back to docling for scans.
|
||||
Off by default: it trades docling's structural markdown for speed,
|
||||
@@ -275,7 +340,7 @@ def get_default_file_extractor(
|
||||
Dict[str, BaseParser]: Parser keyed by lower-case file suffix.
|
||||
"""
|
||||
if ocr_enabled is None:
|
||||
ocr_enabled = settings.DOCLING_OCR_ENABLED
|
||||
ocr_enabled = settings.OCR_ENABLED
|
||||
selected = (engine or getattr(settings, "DOC_PARSER_ENGINE", None) or "anydoc")
|
||||
selected = str(selected).strip().lower()
|
||||
if selected == "docling":
|
||||
|
||||
@@ -22,6 +22,8 @@ from application.parser.file.base_parser import (
|
||||
DocumentParseError,
|
||||
delegate_parse as _delegate,
|
||||
)
|
||||
from application.parser.file.ocr_parser import VALID_OCR_ENGINES as _VALID_OCR_ENGINES
|
||||
from application.parser.file.ocr_parser import collapse_cjk_spaces
|
||||
from application.utils import truncate_to_line_boundary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,7 +75,6 @@ def _apply_inference_settings() -> None:
|
||||
inference.compile_torch_models = settings.DOCLING_COMPILE_TORCH_MODELS
|
||||
|
||||
|
||||
_VALID_OCR_ENGINES = ("tesseract", "auto", "ocrmac", "rapidocr", "deepseek")
|
||||
|
||||
|
||||
def _resolve_ocr_engine(requested: Optional[str]) -> str:
|
||||
@@ -102,8 +103,8 @@ def _resolve_ocr_engine(requested: Optional[str]) -> str:
|
||||
if engine == "tesseract" and shutil.which("tesseract") is None:
|
||||
logger.warning(
|
||||
"OCR_ENGINE=tesseract but no tesseract binary is on PATH (install "
|
||||
"tesseract-ocr plus language packs, or build the Docker image with "
|
||||
"INSTALL_DOCLING=true); using docling auto-selection"
|
||||
"tesseract-ocr plus language packs; the Docker image ships them unless "
|
||||
"built with INSTALL_TESSERACT=false); using docling auto-selection"
|
||||
)
|
||||
return "auto"
|
||||
if engine == "ocrmac" and (
|
||||
@@ -314,7 +315,7 @@ def _ocr_min_chars_per_page() -> int:
|
||||
return int(
|
||||
getattr(
|
||||
settings,
|
||||
"DOCLING_OCR_MIN_CHARS_PER_PAGE",
|
||||
"OCR_MIN_CHARS_PER_PAGE",
|
||||
_DEFAULT_OCR_MIN_CHARS_PER_PAGE,
|
||||
)
|
||||
)
|
||||
@@ -451,6 +452,9 @@ class DoclingParser(BaseParser):
|
||||
self.ocr_engine = ocr_engine
|
||||
self.ocr_languages = ocr_languages
|
||||
self.force_full_page_ocr = force_full_page_ocr
|
||||
# Engine the current converter was built for (None with OCR off);
|
||||
# drives tesseract-specific post-processing of the export.
|
||||
self._active_ocr_engine: Optional[str] = None
|
||||
self._converter = None
|
||||
|
||||
def _create_converter(self):
|
||||
@@ -477,6 +481,7 @@ class DoclingParser(BaseParser):
|
||||
_apply_inference_settings()
|
||||
|
||||
engine = _resolve_ocr_engine(self.ocr_engine) if self.ocr_enabled else None
|
||||
self._active_ocr_engine = engine
|
||||
if engine == "deepseek":
|
||||
return self._create_vlm_converter()
|
||||
|
||||
@@ -607,8 +612,20 @@ class DoclingParser(BaseParser):
|
||||
f"Standard export minimal ({len(stripped_content)} chars), "
|
||||
f"extracting {len(extracted_texts)} texts directly"
|
||||
)
|
||||
return "\n\n".join(extracted_texts)
|
||||
content = "\n\n".join(extracted_texts)
|
||||
|
||||
return self._postprocess_ocr_text(content)
|
||||
|
||||
def _postprocess_ocr_text(self, content: str) -> str:
|
||||
"""Engine-specific cleanup of OCR'd text.
|
||||
|
||||
tesseract's CJK models space every glyph ("互相 保密 协议"); the native
|
||||
backend collapses that in ``TesseractEngine`` and the docling path
|
||||
needs the same treatment or Chinese scans index worse here than there.
|
||||
Other engines (and OCR off) return the export untouched.
|
||||
"""
|
||||
if self.ocr_enabled and self._active_ocr_engine == "tesseract":
|
||||
return collapse_cjk_spaces(content)
|
||||
return content
|
||||
|
||||
def _ocr_guard_applies(self, file: Path) -> bool:
|
||||
@@ -816,6 +833,35 @@ class DoclingPDFParser(DoclingParser):
|
||||
)
|
||||
|
||||
|
||||
def ocr_pages(self, file: Path, indices: List[int]) -> Dict[int, str]:
|
||||
"""Convert only the given 0-based pages of ``file`` and return their text.
|
||||
|
||||
The counterpart of ``NativeOcrPdfParser.ocr_pages`` for the docling
|
||||
backend: ``AnydocParser`` calls it for the scanned pages of a mixed
|
||||
document. Each page is a separate ``convert`` with ``page_range`` so
|
||||
the layout model sees one page at a time; a fully scanned page is one
|
||||
bitmap region, which hybrid OCR reads in full.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: docling failed on a page.
|
||||
"""
|
||||
if self._converter is None:
|
||||
self._init_parser()
|
||||
path = Path(file)
|
||||
texts: Dict[int, str] = {}
|
||||
for index in indices:
|
||||
if index < 0:
|
||||
continue
|
||||
try:
|
||||
result = self._converter.convert(str(path), page_range=(index + 1, index + 1))
|
||||
texts[index] = self._export_content(result.document).strip()
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(
|
||||
f"Failed to OCR page {index + 1} of {path.name} with docling: {exc}"
|
||||
) from exc
|
||||
return texts
|
||||
|
||||
|
||||
class DoclingDocxParser(DoclingParser):
|
||||
"""Docling-based DOCX parser."""
|
||||
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
"""Native OCR parsers: scanned PDFs and images without docling.
|
||||
|
||||
OCR in DocsGPT has two backends, selected by ``OCR_BACKEND``:
|
||||
|
||||
* ``docling`` — the layout-model pipeline in ``docling_parser.py``. Hybrid
|
||||
OCR (only the bitmap regions of a page), reading-order and table
|
||||
structure recovery, and five engines (tesseract / auto / ocrmac /
|
||||
rapidocr / deepseek). Costs the optional docling install (torch, ONNX
|
||||
models, gigabytes of image) and seconds to minutes per file.
|
||||
* ``native`` (this module) — page rendering with pypdfium2 and Pillow, both
|
||||
core dependencies, feeding one of two engines directly: the system
|
||||
``tesseract`` binary, or a DeepSeek-OCR model behind an OpenAI-compatible
|
||||
endpoint (Ollama / vLLM). No ML models load in the worker. Pages that
|
||||
carry a text layer are read through pypdfium2 and never OCR'd; pages
|
||||
without one are rendered and OCR'd. What it lacks against docling is the
|
||||
layout model: multi-column scans rely on tesseract's own page
|
||||
segmentation, and tesseract yields tables as plain lines (DeepSeek-OCR
|
||||
emits Markdown tables itself).
|
||||
|
||||
``auto`` picks docling when it is installed and native otherwise, so a
|
||||
deployment that never installs the docling extra gets working OCR from the
|
||||
tesseract binary alone, and one that does install it keeps today's
|
||||
behaviour unchanged.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Protocol, Tuple, Union
|
||||
|
||||
from application.parser.file.base_parser import (
|
||||
BaseParser,
|
||||
DocumentParseError,
|
||||
delegate_parse,
|
||||
module_available,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Every engine ``OCR_ENGINE`` accepts. ``auto``, ``ocrmac`` and ``rapidocr``
|
||||
# exist only inside docling; the native backend maps them to tesseract.
|
||||
VALID_OCR_ENGINES: Tuple[str, ...] = ("tesseract", "auto", "ocrmac", "rapidocr", "deepseek")
|
||||
NATIVE_OCR_ENGINES: Tuple[str, ...] = ("tesseract", "deepseek")
|
||||
VALID_OCR_BACKENDS: Tuple[str, ...] = ("auto", "docling", "native")
|
||||
|
||||
IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".webp"})
|
||||
|
||||
# Characters in a page's text layer at or above which the page is read from
|
||||
# that layer instead of being OCR'd. Same separation ``PdfiumTextParser``
|
||||
# relies on: scanned pages sit at 0-17 chars, text pages in the hundreds+.
|
||||
DEFAULT_TEXT_LAYER_MIN_CHARS = 32
|
||||
# Chars-per-page floor for the near-empty guard when the setting is unusable.
|
||||
_DEFAULT_MIN_CHARS_PER_PAGE = 20
|
||||
_DEFAULT_RENDER_DPI = 200
|
||||
_MIN_RENDER_DPI, _MAX_RENDER_DPI = 72, 600
|
||||
_TESSERACT_TIMEOUT_SECONDS = 300
|
||||
# docling's DeepSeek-OCR prompt minus its ``<|grounding|>`` prefix: grounding
|
||||
# makes the model wrap every element in ref/det tags with bounding boxes,
|
||||
# which docling parses back into a layout tree. Plain Markdown is what the
|
||||
# ingestion pipeline stores, so ask for that directly.
|
||||
DEEPSEEK_PROMPT = "Convert the document to markdown."
|
||||
_DEEPSEEK_MAX_TOKENS = 4096
|
||||
# Defensive cleanup should a served model still emit grounding markup.
|
||||
_DEEPSEEK_DET_BLOCK_RE = re.compile(r"<\|det\|>.*?<\|/det\|>", re.DOTALL)
|
||||
_DEEPSEEK_TAG_RE = re.compile(r"<\|/?ref\|>|<\|grounding\|>|<\|end▁of▁sentence\|>")
|
||||
# tesseract's CJK models emit one space between every character ("互相 保密 协议")
|
||||
# because they segment by glyph. Chinese and Japanese are written without
|
||||
# word spaces, so collapse whitespace between two CJK characters (or CJK
|
||||
# punctuation); spaces next to Latin text, digits and line breaks stay.
|
||||
_CJK_CHAR = r"[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]"
|
||||
_CJK_SPACE_RE = re.compile(rf"(?<={_CJK_CHAR})[ \t]+(?={_CJK_CHAR})")
|
||||
|
||||
|
||||
class OcrUnavailableError(DocumentParseError):
|
||||
"""The selected OCR engine cannot run on this host (missing binary or endpoint)."""
|
||||
|
||||
|
||||
class OcrEngine(Protocol):
|
||||
"""Anything that turns one page image into text."""
|
||||
|
||||
name: str
|
||||
|
||||
def ocr_image(self, image) -> str: # pragma: no cover - protocol
|
||||
"""Return the text (or Markdown) recognised in ``image`` (a PIL image)."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def native_ocr_available() -> bool:
|
||||
"""Whether the native backend's rendering stack (pypdfium2 + Pillow) is importable."""
|
||||
return module_available("pypdfium2") and module_available("PIL")
|
||||
|
||||
|
||||
def resolve_ocr_backend(requested: Optional[str] = None) -> str:
|
||||
"""Resolve ``OCR_BACKEND`` to the backend that will actually run.
|
||||
|
||||
Args:
|
||||
requested: ``auto`` | ``docling`` | ``native``, or None to read the setting.
|
||||
|
||||
Returns:
|
||||
``"docling"`` or ``"native"``. ``auto`` prefers docling when it is
|
||||
installed; ``docling`` without the install degrades to native with a
|
||||
warning rather than leaving OCR off.
|
||||
"""
|
||||
from application.core.settings import settings
|
||||
|
||||
backend = str(requested or getattr(settings, "OCR_BACKEND", None) or "auto").strip().lower()
|
||||
if backend not in VALID_OCR_BACKENDS:
|
||||
logger.warning(f"Unknown OCR_BACKEND {backend!r}; using auto")
|
||||
backend = "auto"
|
||||
docling_installed = module_available("docling")
|
||||
if backend == "docling" and not docling_installed:
|
||||
logger.warning(
|
||||
"OCR_BACKEND=docling but docling is not installed (pip install -r "
|
||||
"requirements-docling.txt); using the native OCR backend"
|
||||
)
|
||||
return "native"
|
||||
if backend == "auto":
|
||||
return "docling" if docling_installed else "native"
|
||||
return backend
|
||||
|
||||
|
||||
def resolve_native_ocr_engine(requested: Optional[str] = None) -> str:
|
||||
"""Map ``OCR_ENGINE`` onto an engine the native backend implements.
|
||||
|
||||
Args:
|
||||
requested: Engine name, or None to read ``settings.OCR_ENGINE``.
|
||||
|
||||
Returns:
|
||||
``"tesseract"`` or ``"deepseek"``. docling-only engines (``auto``,
|
||||
``ocrmac``, ``rapidocr``) and unknown names become tesseract with a
|
||||
warning, so switching a deployment off docling never silently
|
||||
disables OCR.
|
||||
"""
|
||||
from application.core.settings import settings
|
||||
|
||||
engine = str(requested or getattr(settings, "OCR_ENGINE", None) or "tesseract").strip().lower()
|
||||
if engine in NATIVE_OCR_ENGINES:
|
||||
return engine
|
||||
if engine in VALID_OCR_ENGINES:
|
||||
logger.warning(
|
||||
f"OCR_ENGINE={engine!r} is only available through the docling backend; "
|
||||
"the native backend is using tesseract"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown OCR_ENGINE {engine!r}; the native backend is using tesseract")
|
||||
return "tesseract"
|
||||
|
||||
|
||||
def ocr_min_chars_per_page() -> int:
|
||||
"""Chars-per-page floor for the near-empty OCR guard (``OCR_MIN_CHARS_PER_PAGE``); 0 disables it."""
|
||||
from application.core.settings import settings
|
||||
|
||||
try:
|
||||
return int(getattr(settings, "OCR_MIN_CHARS_PER_PAGE", _DEFAULT_MIN_CHARS_PER_PAGE))
|
||||
except (TypeError, ValueError):
|
||||
return _DEFAULT_MIN_CHARS_PER_PAGE
|
||||
|
||||
|
||||
def render_dpi() -> int:
|
||||
"""Rendering resolution for pages that need OCR (``OCR_RENDER_DPI``), clamped to a sane range."""
|
||||
from application.core.settings import settings
|
||||
|
||||
try:
|
||||
dpi = int(getattr(settings, "OCR_RENDER_DPI", _DEFAULT_RENDER_DPI))
|
||||
except (TypeError, ValueError):
|
||||
dpi = _DEFAULT_RENDER_DPI
|
||||
return max(_MIN_RENDER_DPI, min(_MAX_RENDER_DPI, dpi))
|
||||
|
||||
|
||||
def _png_bytes(image) -> bytes:
|
||||
"""Encode a PIL image as PNG, flattening modes the engines cannot take."""
|
||||
if image.mode not in ("RGB", "L"):
|
||||
image = image.convert("RGB")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TesseractEngine:
|
||||
"""OCR through the system ``tesseract`` binary (stdin -> stdout, no Python wrapper).
|
||||
|
||||
Attributes:
|
||||
languages: Tesseract language codes; None reads ``OCR_LANGS`` per call.
|
||||
psm: Page segmentation mode (3 = fully automatic, tesseract's default).
|
||||
timeout: Seconds allowed per page.
|
||||
"""
|
||||
|
||||
name = "tesseract"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
languages: Optional[List[str]] = None,
|
||||
psm: int = 3,
|
||||
timeout: float = _TESSERACT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self.languages = languages
|
||||
self.psm = psm
|
||||
self.timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
"""Whether a ``tesseract`` binary is on PATH."""
|
||||
return shutil.which("tesseract") is not None
|
||||
|
||||
def _language_arg(self) -> str:
|
||||
if self.languages:
|
||||
langs = [lang.strip() for lang in self.languages if lang and lang.strip()]
|
||||
else:
|
||||
from application.core.settings import settings
|
||||
|
||||
configured = str(getattr(settings, "OCR_LANGS", "") or "eng")
|
||||
langs = [lang.strip() for lang in configured.split("+") if lang.strip()]
|
||||
return "+".join(langs) or "eng"
|
||||
|
||||
def command(self) -> List[str]:
|
||||
"""The tesseract command line, for logging and tests."""
|
||||
return ["tesseract", "stdin", "stdout", "-l", self._language_arg(), "--psm", str(self.psm)]
|
||||
|
||||
def ocr_image(self, image) -> str:
|
||||
"""OCR one PIL image.
|
||||
|
||||
Raises:
|
||||
OcrUnavailableError: No tesseract binary on PATH.
|
||||
DocumentParseError: tesseract failed or timed out.
|
||||
"""
|
||||
if not self.available():
|
||||
raise OcrUnavailableError(
|
||||
"OCR_ENGINE=tesseract but no tesseract binary is on PATH. Install "
|
||||
"tesseract-ocr plus language packs (the Docker image ships them "
|
||||
"unless built with INSTALL_TESSERACT=false), or set "
|
||||
"OCR_ENGINE=deepseek / install the docling extra."
|
||||
)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
self.command(),
|
||||
input=_png_bytes(image),
|
||||
capture_output=True,
|
||||
timeout=self.timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise DocumentParseError(f"tesseract timed out after {self.timeout:.0f}s on a page") from exc
|
||||
except OSError as exc:
|
||||
raise DocumentParseError(f"tesseract could not be started: {exc}") from exc
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.decode("utf-8", "replace").strip()[:300]
|
||||
raise DocumentParseError(f"tesseract failed (exit {completed.returncode}): {detail}")
|
||||
return collapse_cjk_spaces(completed.stdout.decode("utf-8", "replace")).strip()
|
||||
|
||||
|
||||
def collapse_cjk_spaces(text: str) -> str:
|
||||
"""Remove the per-glyph spaces tesseract inserts inside CJK runs."""
|
||||
return _CJK_SPACE_RE.sub("", text)
|
||||
|
||||
|
||||
def clean_deepseek_output(text: str) -> str:
|
||||
"""Strip DeepSeek-OCR grounding markup, keeping the referenced text."""
|
||||
text = _DEEPSEEK_DET_BLOCK_RE.sub("", text)
|
||||
return _DEEPSEEK_TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
class DeepseekOcrEngine:
|
||||
"""DeepSeek-OCR over an OpenAI-compatible chat-completions endpoint.
|
||||
|
||||
One request per page, sequentially. The image travels as a base64 data
|
||||
URL, which both Ollama and vLLM accept. Pages are sent one at a time
|
||||
because the model server, not the worker, is the bottleneck; docling's
|
||||
default of four concurrent requests and a 90 s timeout is what broke on
|
||||
a laptop-hosted Ollama in testing.
|
||||
|
||||
Attributes:
|
||||
url: Chat-completions URL (``OCR_DEEPSEEK_URL``).
|
||||
model: Model name at that endpoint (``OCR_DEEPSEEK_MODEL``).
|
||||
timeout: Seconds per page (``OCR_DEEPSEEK_TIMEOUT``).
|
||||
prompt: Instruction sent with every page image.
|
||||
"""
|
||||
|
||||
name = "deepseek"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
prompt: str = DEEPSEEK_PROMPT,
|
||||
max_tokens: int = _DEEPSEEK_MAX_TOKENS,
|
||||
) -> None:
|
||||
from application.core.settings import settings
|
||||
|
||||
self.url = url or settings.OCR_DEEPSEEK_URL
|
||||
self.model = model or settings.OCR_DEEPSEEK_MODEL
|
||||
self.timeout = float(timeout if timeout is not None else getattr(settings, "OCR_DEEPSEEK_TIMEOUT", 300))
|
||||
self.prompt = prompt
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
def payload(self, image) -> Dict:
|
||||
"""The chat-completions request body for one page image."""
|
||||
data_url = "data:image/png;base64," + base64.b64encode(_png_bytes(image)).decode("ascii")
|
||||
return {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
{"type": "text", "text": self.prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
def ocr_image(self, image) -> str:
|
||||
"""OCR one PIL image through the endpoint.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: The request failed, timed out, or the
|
||||
response was not a chat completion.
|
||||
"""
|
||||
import requests
|
||||
|
||||
try:
|
||||
response = requests.post(self.url, json=self.payload(image), timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
except requests.RequestException as exc:
|
||||
raise DocumentParseError(
|
||||
f"DeepSeek-OCR request to {self.url} failed: {exc}. Check OCR_DEEPSEEK_URL "
|
||||
f"and that model {self.model!r} is served (e.g. `ollama pull {self.model}`)."
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise DocumentParseError(f"DeepSeek-OCR endpoint {self.url} returned a non-JSON body") from exc
|
||||
try:
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise DocumentParseError(
|
||||
f"DeepSeek-OCR endpoint {self.url} returned no chat completion: {str(body)[:200]}"
|
||||
) from exc
|
||||
if isinstance(content, list): # some servers return content parts
|
||||
content = "".join(part.get("text", "") for part in content if isinstance(part, dict))
|
||||
return clean_deepseek_output(str(content or ""))
|
||||
|
||||
|
||||
def build_native_ocr_engine(engine: Optional[str] = None, languages: Optional[List[str]] = None) -> OcrEngine:
|
||||
"""Instantiate the engine ``OCR_ENGINE`` (or ``engine``) resolves to for the native backend."""
|
||||
resolved = resolve_native_ocr_engine(engine)
|
||||
if resolved == "deepseek":
|
||||
return DeepseekOcrEngine()
|
||||
return TesseractEngine(languages=languages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_page(page, dpi: int):
|
||||
"""Render a pypdfium2 page to a PIL image at ``dpi``."""
|
||||
return page.render(scale=dpi / 72.0).to_pil()
|
||||
|
||||
|
||||
def text_layer_counts(path: Path) -> List[int]:
|
||||
"""Characters in each page's text layer (pypdfium2), in page order.
|
||||
|
||||
Raises:
|
||||
Exception: Propagated from pypdfium2 when the file cannot be opened.
|
||||
"""
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
pdf = pdfium.PdfDocument(str(path))
|
||||
counts: List[int] = []
|
||||
try:
|
||||
for index in range(len(pdf)):
|
||||
page = pdf[index]
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
try:
|
||||
counts.append(max(0, textpage.count_chars()))
|
||||
finally:
|
||||
textpage.close()
|
||||
finally:
|
||||
page.close()
|
||||
finally:
|
||||
pdf.close()
|
||||
return counts
|
||||
|
||||
|
||||
def scanned_page_indices(path: Path, min_chars: int = DEFAULT_TEXT_LAYER_MIN_CHARS) -> List[int]:
|
||||
"""0-based indices of the pages whose text layer is too thin to be real.
|
||||
|
||||
The probe behind mixed-document handling: a text-layer converter (anydoc)
|
||||
reads the text pages and silently skips these, so whoever owns OCR has to
|
||||
fill them in. Returns [] when the file cannot be probed — the caller then
|
||||
keeps what it has rather than failing a successful parse.
|
||||
"""
|
||||
try:
|
||||
counts = text_layer_counts(Path(path))
|
||||
except Exception: # noqa: BLE001 - a probe must never fail the parse
|
||||
logger.warning("Could not probe %s for scanned pages", Path(path).name, exc_info=True)
|
||||
return []
|
||||
return [index for index, count in enumerate(counts) if count < max(1, int(min_chars))]
|
||||
|
||||
|
||||
def _check_near_empty(name: str, engine: str, content: str, pages: int, ocr_pages: int) -> None:
|
||||
"""Loud failure for OCR that produced nothing, a warning for text-sparse documents.
|
||||
|
||||
Mirrors the docling parser's dropout guard: failing only on positive
|
||||
evidence — several pages that OCR'd to literally nothing — because a
|
||||
photo, logo or chart is genuinely text-poor, and ``DocumentParseError``
|
||||
rejects the upload permanently.
|
||||
"""
|
||||
if ocr_pages == 0:
|
||||
return
|
||||
floor = ocr_min_chars_per_page()
|
||||
chars = len(content.strip())
|
||||
if floor <= 0 or chars >= floor * pages:
|
||||
return
|
||||
if chars == 0 and pages > 1:
|
||||
raise DocumentParseError(
|
||||
f"OCR ({engine}) produced no text over {pages} pages of {name}; "
|
||||
"document not indexed. Check the engine's languages (OCR_LANGS) and "
|
||||
"that the scan is legible."
|
||||
)
|
||||
logger.warning(
|
||||
"%s OCR'd (%s) to only %d chars over %d page(s); indexing it as text-sparse",
|
||||
name,
|
||||
engine,
|
||||
chars,
|
||||
pages,
|
||||
)
|
||||
|
||||
|
||||
class NativeOcrPdfParser(BaseParser):
|
||||
"""PDF parser that OCRs pages without a text layer and reads the rest directly.
|
||||
|
||||
Per page: a text layer of at least ``min_text_chars`` characters is read
|
||||
with pypdfium2; anything thinner is rendered and sent to the engine. So
|
||||
a scan, a born-digital PDF and a mix of both all come out right without
|
||||
any prior classification.
|
||||
|
||||
Attributes:
|
||||
engine: The ``OcrEngine``; built from ``OCR_ENGINE`` on first use when None.
|
||||
text_parser: Optional parser for documents whose *every* page has a
|
||||
text layer — used under ``DOC_PARSER_ENGINE=docling`` to keep
|
||||
docling's structured Markdown for text PDFs while scans take the
|
||||
native OCR path. None reads text layers with pypdfium2.
|
||||
min_text_chars: Text-layer characters per page below which the page is OCR'd.
|
||||
ocr_enabled: Always True; lets callers that inspect their fallback
|
||||
parser (``AnydocParser``) phrase their errors correctly.
|
||||
last_engine: Engine name behind the most recent parse.
|
||||
"""
|
||||
|
||||
ocr_enabled = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Optional[OcrEngine] = None,
|
||||
text_parser: Optional[BaseParser] = None,
|
||||
min_text_chars: int = DEFAULT_TEXT_LAYER_MIN_CHARS,
|
||||
parser_config: Optional[Dict] = None,
|
||||
) -> None:
|
||||
super().__init__(parser_config)
|
||||
self.engine = engine
|
||||
self.text_parser = text_parser
|
||||
self.min_text_chars = max(1, int(min_text_chars))
|
||||
self.last_engine: Optional[str] = None
|
||||
self._last_metadata: Dict = {}
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
try:
|
||||
import pypdfium2 # noqa: F401
|
||||
from PIL import Image # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError("pypdfium2 and Pillow are required for NativeOcrPdfParser") from exc
|
||||
if self.engine is None:
|
||||
self.engine = build_native_ocr_engine()
|
||||
return {
|
||||
"engine": self.engine.name,
|
||||
"text_parser": type(self.text_parser).__name__ if self.text_parser else None,
|
||||
"min_text_chars": self.min_text_chars,
|
||||
}
|
||||
|
||||
def _ensure_engine(self) -> OcrEngine:
|
||||
if self.engine is None:
|
||||
self.engine = build_native_ocr_engine()
|
||||
return self.engine
|
||||
|
||||
def _text_layer_counts(self, pdf) -> List[int]:
|
||||
counts: List[int] = []
|
||||
for index in range(len(pdf)):
|
||||
page = pdf[index]
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
try:
|
||||
counts.append(max(0, textpage.count_chars()))
|
||||
finally:
|
||||
textpage.close()
|
||||
finally:
|
||||
page.close()
|
||||
return counts
|
||||
|
||||
def ocr_pages(self, file: Path, indices: List[int]) -> Dict[int, str]:
|
||||
"""OCR only the given 0-based pages of ``file``, ignoring their text layers.
|
||||
|
||||
Used by ``AnydocParser`` for mixed documents: anydoc has already read
|
||||
the text pages, so only the scanned ones come here.
|
||||
|
||||
Returns:
|
||||
Page index -> recognised text, in the order requested.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: The file cannot be opened or the engine failed.
|
||||
"""
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
path = Path(file)
|
||||
engine = self._ensure_engine()
|
||||
try:
|
||||
pdf = pdfium.PdfDocument(str(path))
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(f"Failed to open {path.name} with pypdfium2: {exc}") from exc
|
||||
texts: Dict[int, str] = {}
|
||||
try:
|
||||
dpi = render_dpi()
|
||||
for index in indices:
|
||||
if index < 0 or index >= len(pdf):
|
||||
continue
|
||||
page = pdf[index]
|
||||
try:
|
||||
texts[index] = (engine.ocr_image(_render_page(page, dpi)) or "").strip()
|
||||
finally:
|
||||
page.close()
|
||||
except DocumentParseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(f"Failed to OCR pages of {path.name} ({engine.name}): {exc}") from exc
|
||||
finally:
|
||||
pdf.close()
|
||||
self.last_engine = engine.name
|
||||
return texts
|
||||
|
||||
def _delegate_text(self, path: Path, errors: str) -> Union[str, List[str]]:
|
||||
text_parser = self.text_parser
|
||||
assert text_parser is not None
|
||||
logger.info(
|
||||
"%s has a text layer on every page; parsing with %s instead of OCR",
|
||||
path.name,
|
||||
type(text_parser).__name__,
|
||||
)
|
||||
result = delegate_parse(text_parser, path, errors)
|
||||
self.last_engine = getattr(text_parser, "last_engine", None) or type(text_parser).__name__
|
||||
self._last_metadata = {**text_parser.get_file_metadata(path), "parse_engine": self.last_engine}
|
||||
return result
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse a PDF, OCR-ing only the pages that need it.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: The file cannot be opened, the engine failed,
|
||||
or several pages OCR'd to nothing.
|
||||
"""
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
path = Path(file)
|
||||
engine = self._ensure_engine()
|
||||
try:
|
||||
pdf = pdfium.PdfDocument(str(path))
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(f"Failed to open {path.name} with pypdfium2: {exc}") from exc
|
||||
|
||||
pages_text: List[str] = []
|
||||
ocr_pages = 0
|
||||
try:
|
||||
page_count = len(pdf)
|
||||
if page_count == 0:
|
||||
raise DocumentParseError(f"{path.name} has no pages")
|
||||
counts = self._text_layer_counts(pdf)
|
||||
if self.text_parser is not None and all(count >= self.min_text_chars for count in counts):
|
||||
pdf.close()
|
||||
pdf = None
|
||||
return self._delegate_text(path, errors)
|
||||
dpi = render_dpi()
|
||||
for index in range(page_count):
|
||||
page = pdf[index]
|
||||
try:
|
||||
if counts[index] >= self.min_text_chars:
|
||||
textpage = page.get_textpage()
|
||||
try:
|
||||
text = textpage.get_text_range()
|
||||
finally:
|
||||
textpage.close()
|
||||
else:
|
||||
text = engine.ocr_image(_render_page(page, dpi))
|
||||
ocr_pages += 1
|
||||
finally:
|
||||
page.close()
|
||||
pages_text.append((text or "").strip())
|
||||
except DocumentParseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(f"Failed to parse {path.name} with native OCR ({engine.name}): {exc}") from exc
|
||||
finally:
|
||||
if pdf is not None:
|
||||
pdf.close()
|
||||
|
||||
content = "\n\n".join(pages_text)
|
||||
_check_near_empty(path.name, engine.name, content, page_count, ocr_pages)
|
||||
self.last_engine = engine.name
|
||||
self._last_metadata = {"parse_engine": engine.name, "pdf_pages": page_count, "ocr_pages": ocr_pages}
|
||||
logger.info(
|
||||
"Parsed %s with native OCR (%s): %d/%d page(s) OCR'd, %d chars",
|
||||
path.name,
|
||||
engine.name,
|
||||
ocr_pages,
|
||||
page_count,
|
||||
len(content),
|
||||
)
|
||||
return content
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
"""Engine and page counts for the most recently parsed file."""
|
||||
_ = file
|
||||
return dict(self._last_metadata)
|
||||
|
||||
|
||||
class NativeOcrImageParser(BaseParser):
|
||||
"""Image parser that OCRs every frame (multi-page TIFFs included) with the native engine."""
|
||||
|
||||
ocr_enabled = True
|
||||
|
||||
def __init__(self, engine: Optional[OcrEngine] = None, parser_config: Optional[Dict] = None) -> None:
|
||||
super().__init__(parser_config)
|
||||
self.engine = engine
|
||||
self.last_engine: Optional[str] = None
|
||||
self._last_metadata: Dict = {}
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
try:
|
||||
from PIL import Image # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError("Pillow is required for NativeOcrImageParser") from exc
|
||||
if self.engine is None:
|
||||
self.engine = build_native_ocr_engine()
|
||||
return {"engine": self.engine.name}
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""OCR an image file.
|
||||
|
||||
Raises:
|
||||
DocumentParseError: The image cannot be decoded, the engine failed,
|
||||
or a multi-frame image OCR'd to nothing.
|
||||
"""
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
_ = errors
|
||||
path = Path(file)
|
||||
if self.engine is None:
|
||||
self.engine = build_native_ocr_engine()
|
||||
engine = self.engine
|
||||
texts: List[str] = []
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
texts.append((engine.ocr_image(frame.convert("RGB")) or "").strip())
|
||||
except DocumentParseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DocumentParseError(f"Failed to OCR {path.name} ({engine.name}): {exc}") from exc
|
||||
|
||||
content = "\n\n".join(texts)
|
||||
_check_near_empty(path.name, engine.name, content, max(1, len(texts)), len(texts))
|
||||
self.last_engine = engine.name
|
||||
self._last_metadata = {"parse_engine": engine.name, "ocr_pages": len(texts)}
|
||||
return content
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
"""Engine and frame count for the most recently parsed file."""
|
||||
_ = file
|
||||
return dict(self._last_metadata)
|
||||
@@ -69,7 +69,9 @@ def tableize(markdown: str, min_rows: int = 3) -> str:
|
||||
if len(run) >= min_rows and len(widths) == 1:
|
||||
ncols = widths.pop()
|
||||
out.append("")
|
||||
out.append("| " + " | ".join([" "] + [f"col{i + 1}" for i in range(ncols)]) + " |")
|
||||
# GFM needs a header row; an empty one keeps invented labels out
|
||||
# of the indexed text (they would otherwise be embedded as content).
|
||||
out.append("|" + " |" * (ncols + 1))
|
||||
out.append("|" + " --- |" * (ncols + 1))
|
||||
for label, values, _ in run:
|
||||
# Only the label can carry a '|' (values are numeric tokens);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Optional docling parser engine.
|
||||
#
|
||||
# Needed only for: DOC_PARSER_ENGINE=docling, OCR of scanned PDFs and images
|
||||
# (DOCLING_OCR_ENABLED / DOCLING_OCR_ATTACHMENTS_ENABLED), .adoc/.vtt/.xml
|
||||
# source parsing, and the read_document tool's `structured` output. The
|
||||
# default anydoc engine needs none of this.
|
||||
# Needed only for: DOC_PARSER_ENGINE=docling, the docling OCR backend
|
||||
# (OCR_BACKEND=docling: layout-model hybrid OCR, ocrmac/rapidocr engines),
|
||||
# .adoc/.vtt/.xml source parsing, and the read_document tool's `structured`
|
||||
# output. The default anydoc engine needs none of this, and OCR itself does
|
||||
# not either: OCR_ENABLED=true with the tesseract binary (or a DeepSeek-OCR
|
||||
# endpoint) runs through application/parser/file/ocr_parser.py.
|
||||
#
|
||||
# Install ON TOP of requirements.txt (torch/transformers stay pinned there,
|
||||
# shared with sentence-transformers):
|
||||
|
||||
@@ -1745,7 +1745,7 @@ def attachment_worker(self, file_info, user):
|
||||
# calls SimpleDirectoryReader without a ``file_extractor`` and so keeps
|
||||
# the ``DOC_PARSER_ENGINE`` default map, which retrieval quality depends on.
|
||||
file_extractor = get_default_file_extractor(
|
||||
ocr_enabled=settings.DOCLING_OCR_ATTACHMENTS_ENABLED,
|
||||
ocr_enabled=settings.OCR_ATTACHMENTS_ENABLED,
|
||||
pdf_text_fast_path=settings.ATTACHMENT_PDF_TEXT_FAST_PATH,
|
||||
)
|
||||
_parser = file_extractor.get(os.path.splitext(filename)[1].lower())
|
||||
|
||||
Reference in new issue
Block a user