ai-chatbot / app /services /parsers /docling_parser.py
shuaiwang
fix: independent persist executor + reset pending_push on failure
8420e1e
Raw
History Blame
6.7 kB
"""Docling parser - 主力.
特性:
- 结构感知 (reading order / headings)
- 跨页表合并 (TableFormer)
- 内置 OCR (PaddleOCR 支持中英)
- DOCX / PPTX / 图片 / HTML 全支持
Docling API 在 2.x 期间变动较多, 本实现基于 2.30+ 兼容.
"""
from __future__ import annotations
import asyncio
import logging
import time
from pathlib import Path
from app.config import settings
from app.services.parsers.base_parser import BaseParser, PageContent, ParsedDocument
logger = logging.getLogger(__name__)
class DoclingParser(BaseParser):
name = "docling"
def supported_extensions(self) -> set[str]:
return {".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".tiff", ".html", ".xlsx"}
async def parse(self, file_path: Path) -> ParsedDocument:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._parse_sync, file_path)
def _parse_sync(self, file_path: Path) -> ParsedDocument:
"""实际解析. CPU 密集, 在线程池跑."""
# Force CPU device for Docling to prevent MPS NotImplementedError on Intel Mac
try:
import docling.utils.accelerator_utils
docling.utils.accelerator_utils.decide_device = lambda *args, **kwargs: "cpu"
except ImportError:
pass
# 延迟 import, 避免启动期未装 docling 时崩溃
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
started = time.time()
logger.info("Docling parsing: %s", file_path.name)
from docling.datamodel.accelerator_options import AcceleratorOptions, AcceleratorDevice
opts = PdfPipelineOptions()
opts.do_ocr = settings.parser_enable_ocr
opts.do_table_structure = settings.parser_table_structure
opts.images_scale = 2.0
# ⚠️ 不要设 artifacts_path: 设了但目录为空会被 Docling 拒绝 (报 "is not valid")
# 不设时, Docling 通过 huggingface_hub 走 HF_HOME 自动下载 + 缓存
# 我们的 Dockerfile 设了 HF_HOME=/data/.cache/huggingface (持久卷), 所以重启后还在
opts.accelerator_options = AcceleratorOptions(device=AcceleratorDevice.CPU)
converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=opts),
}
)
try:
result = converter.convert(str(file_path))
except Exception as e: # noqa: BLE001
logger.exception("Docling parse failed: %s", e)
raise
doc = result.document
# 全文 markdown
markdown = doc.export_to_markdown()
# 页面级 (Docling 用 iterate_items / pages 属性, 视版本略有差异)
pages: list[PageContent] = []
try:
page_count = len(doc.pages) if hasattr(doc, "pages") else 0
for idx in range(page_count):
page = doc.pages[idx]
# 提取该页文本 (Docling 2.x 没有现成 API, 用 page-level export 近似)
page_md = ""
if hasattr(page, "export_to_markdown"):
try:
page_md = page.export_to_markdown()
except Exception: # noqa: BLE001
page_md = ""
pages.append(PageContent(
page_no=idx + 1,
text=page_md,
headings=[], # Docling 不直接给页级 headings, 留空
))
except Exception as e: # noqa: BLE001
logger.warning("Docling page extraction partial: %s", e)
# 表格 (简化提取)
tables: list[dict] = []
try:
for t in (doc.tables or []):
tables.append({
"html": t.export_to_html() if hasattr(t, "export_to_html") else "",
"caption": getattr(t, "caption", None),
})
except Exception: # noqa: BLE001
pass
elapsed_ms = int((time.time() - started) * 1000)
logger.info("Docling done: %s pages, %s tables, %dms", len(pages), len(tables), elapsed_ms)
return ParsedDocument(
markdown=markdown,
pages=pages,
tables=tables,
images=[],
meta={
"parser": self.name,
"page_count": len(pages),
"elapsed_ms": elapsed_ms,
},
)
def _prewarm_docling_models() -> None:
"""预下载 Docling 需要的模型 (layout/heron, tableformer, paddleocr 等, 共 ~2GB).
在 Space 启动 lifespan 阶段跑一次, 避免首次上传时下载超时或下载失败.
模型会缓存到 settings.hf_cache_dir, 后续启动跳过.
"""
import logging
from pathlib import Path
from app.config import settings
logger_local = logging.getLogger(__name__)
logger_local.info("Docling model prewarm: pulling layout/table/ocr models...")
from huggingface_hub import snapshot_download
# Docling 模型都在 ds4sd 命名空间下
repos = [
"ds4sd/docling-models", # 主模型集 (layout, tableformer)
]
cache_dir = Path(settings.hf_cache_dir) if hasattr(settings, "hf_cache_dir") else None
if cache_dir is None:
from app.core.paths import data_dir
cache_dir = data_dir() / ".cache" / "huggingface"
for repo in repos:
try:
p = snapshot_download(
repo_id=repo,
cache_dir=str(cache_dir),
# 避免下载所有 variants, 只下必需的
allow_patterns=[
"*.json",
"*.txt",
"*.safetensors",
"tokenizer*",
],
)
logger_local.info("Docling model %s cached at %s", repo, p)
except Exception as e: # noqa: BLE001
logger_local.warning("Docling model prewarm %s failed: %s", repo, e)
# PaddleOCR 模型 (Docling 内置 OCR 用). 单独下载.
try:
from paddleocr import PaddleOCR # type: ignore
# 实例化会触发模型下载到 ~/.paddleocr
PaddleOCR(use_angle_cls=False, lang="ch", show_log=False)
logger_local.info("PaddleOCR (ch) model cached")
except Exception as e: # noqa: BLE001
# PaddleOCR 可能没装 (e.g. arm64 平台), 不阻塞
logger_local.warning("PaddleOCR prewarm skipped: %s", e)
logger_local.info("Docling model prewarm done")