275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""
|
||
PP-Structure-V3 文档解析器
|
||
|
||
使用 PP-Structure-V3 进行文档版面解析和内容提取
|
||
"""
|
||
|
||
import base64
|
||
import os
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import requests
|
||
|
||
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
|
||
from yuxi.utils import logger
|
||
|
||
|
||
class PPStructureV3Parser(BaseDocumentProcessor):
|
||
"""PP-Structure-V3 文档解析器 - 使用 PP-Structure-V3 进行版面解析"""
|
||
|
||
def __init__(self, server_url: str | None = None):
|
||
self.server_url = server_url or os.getenv("PADDLEX_URI") or "http://localhost:8080"
|
||
self.base_url = self.server_url.rstrip("/")
|
||
self.endpoint = f"{self.base_url}/layout-parsing"
|
||
|
||
def get_service_name(self) -> str:
|
||
return "pp_structure_v3_ocr"
|
||
|
||
def get_supported_extensions(self) -> list[str]:
|
||
"""PP-Structure-V3 支持 PDF 和多种图像格式"""
|
||
return [".pdf", ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]
|
||
|
||
def _encode_file_to_base64(self, file_path: str) -> str:
|
||
"""将文件编码为Base64"""
|
||
with open(file_path, "rb") as file:
|
||
encoded = base64.b64encode(file.read()).decode("utf-8")
|
||
return encoded
|
||
|
||
def _process_file_input(self, file_input: str) -> str:
|
||
"""处理文件输入:本地文件路径、URL或Base64内容"""
|
||
# 检查是否为本地文件路径
|
||
if os.path.exists(file_input):
|
||
logger.info(f"📁 检测到本地文件: {file_input}")
|
||
logger.info(f"📏 文件大小: {os.path.getsize(file_input) / 1024 / 1024:.2f} MB")
|
||
return self._encode_file_to_base64(file_input)
|
||
|
||
# 检查是否为URL
|
||
elif file_input.startswith(("http://", "https://")):
|
||
logger.info(f"🌐 检测到URL: {file_input}")
|
||
return file_input
|
||
|
||
# 否则假设为Base64编码内容
|
||
else:
|
||
logger.info(f"📝 假设为Base64编码内容,长度: {len(file_input)} 字符")
|
||
return file_input
|
||
|
||
def _call_layout_api(
|
||
self,
|
||
file_input: str,
|
||
file_type: int | None = None,
|
||
use_table_recognition: bool = True,
|
||
use_formula_recognition: bool = True,
|
||
use_seal_recognition: bool = False,
|
||
**kwargs,
|
||
) -> dict[str, Any]:
|
||
"""调用PP-Structure-V3版面解析API"""
|
||
# 处理文件输入
|
||
processed_file_input = self._process_file_input(file_input)
|
||
payload = {"file": processed_file_input}
|
||
|
||
# 添加核心参数
|
||
optional_params = {
|
||
"fileType": file_type,
|
||
"useTableRecognition": use_table_recognition,
|
||
"useFormulaRecognition": use_formula_recognition,
|
||
"useSealRecognition": use_seal_recognition,
|
||
}
|
||
|
||
# 添加非空参数
|
||
for key, value in optional_params.items():
|
||
if value is not None:
|
||
payload[key] = value
|
||
|
||
# 添加其他kwargs参数
|
||
for key, value in kwargs.items():
|
||
if value is not None:
|
||
payload[key] = value
|
||
|
||
response = requests.post(self.endpoint, json=payload, headers={"Content-Type": "application/json"}, timeout=300)
|
||
|
||
if response.status_code == 200:
|
||
return response.json()
|
||
else:
|
||
error_msg = f"PP-Structure-V3 API请求失败: {response.status_code}"
|
||
try:
|
||
error_result = response.json()
|
||
raise DocumentParserException(f"{error_msg}: {error_result}", self.get_service_name(), "api_error")
|
||
except Exception:
|
||
raise DocumentParserException(f"{error_msg}: {response.text}", self.get_service_name(), "api_error")
|
||
|
||
def _parse_api_result(self, api_result: dict[str, Any], file_path: str) -> dict[str, Any]:
|
||
"""解析API返回结果"""
|
||
# 基本信息
|
||
parsed_result = {
|
||
"success": True,
|
||
"file_path": file_path,
|
||
"file_name": os.path.basename(file_path),
|
||
"log_id": api_result.get("logId"),
|
||
"total_pages": 0,
|
||
"pages": [],
|
||
"full_text": "",
|
||
"summary": {},
|
||
}
|
||
|
||
result_data = api_result.get("result", {})
|
||
layout_results = result_data.get("layoutParsingResults", [])
|
||
|
||
# 数据信息
|
||
parsed_result["total_pages"] = len(layout_results)
|
||
|
||
# 统计信息
|
||
total_tables = 0
|
||
total_formulas = 0
|
||
all_text_content = []
|
||
|
||
# 解析每页结果
|
||
for page_result in layout_results:
|
||
# Markdown内容
|
||
if "markdown" in page_result:
|
||
markdown = page_result["markdown"]
|
||
if markdown.get("text"):
|
||
all_text_content.append(markdown["text"])
|
||
|
||
# 详细识别结果
|
||
if "prunedResult" in page_result:
|
||
pruned = page_result["prunedResult"]
|
||
|
||
# 表格识别
|
||
table_result = pruned.get("table_result", [])
|
||
total_tables += len(table_result)
|
||
|
||
# 公式识别
|
||
formula_result = pruned.get("formula_result", [])
|
||
total_formulas += len(formula_result)
|
||
|
||
# 汇总全文内容
|
||
parsed_result["full_text"] = "\n\n".join(all_text_content)
|
||
|
||
# 汇总统计信息
|
||
parsed_result["summary"] = {
|
||
"total_tables": total_tables,
|
||
"total_formulas": total_formulas,
|
||
"total_characters": len(parsed_result["full_text"]),
|
||
}
|
||
|
||
return parsed_result
|
||
|
||
def check_health(self) -> dict:
|
||
"""检查 PP-Structure-V3 服务健康状态"""
|
||
try:
|
||
response = requests.get(f"{self.base_url}/health", timeout=5)
|
||
|
||
if response.status_code == 200:
|
||
return {
|
||
"status": "healthy",
|
||
"message": "PP-Structure-V3 服务运行正常",
|
||
"details": {"server_url": self.server_url},
|
||
}
|
||
else:
|
||
return {
|
||
"status": "unhealthy",
|
||
"message": f"PP-Structure-V3 服务响应异常: {response.status_code}",
|
||
"details": {"server_url": self.server_url},
|
||
}
|
||
|
||
except requests.exceptions.ConnectionError:
|
||
return {
|
||
"status": "unavailable",
|
||
"message": "PP-Structure-V3 服务无法连接,请检查服务是否启动",
|
||
"details": {"server_url": self.server_url},
|
||
}
|
||
except requests.exceptions.Timeout:
|
||
return {
|
||
"status": "timeout",
|
||
"message": "PP-Structure-V3 服务连接超时",
|
||
"details": {"server_url": self.server_url},
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"status": "error",
|
||
"message": f"PP-Structure-V3 健康检查失败: {str(e)}",
|
||
"details": {"server_url": self.server_url, "error": str(e)},
|
||
}
|
||
|
||
def process_file(self, file_path: str, params: dict | None = None) -> str:
|
||
"""
|
||
使用 PP-Structure-V3 处理文档
|
||
|
||
Args:
|
||
file_path: 文件路径
|
||
params: 处理参数
|
||
- use_table_recognition: 启用表格识别 (默认: True)
|
||
- use_formula_recognition: 启用公式识别 (默认: True)
|
||
- use_seal_recognition: 启用印章识别 (默认: False)
|
||
|
||
Returns:
|
||
str: 提取的 Markdown 文本
|
||
"""
|
||
if not os.path.exists(file_path):
|
||
raise DocumentParserException(f"文件不存在: {file_path}", self.get_service_name(), "file_not_found")
|
||
|
||
file_ext = Path(file_path).suffix.lower()
|
||
if not self.supports_file_type(file_ext):
|
||
raise DocumentParserException(
|
||
f"不支持的文件类型: {file_ext}", self.get_service_name(), "unsupported_file_type"
|
||
)
|
||
|
||
# 先检查服务健康状态
|
||
health = self.check_health()
|
||
if health["status"] != "healthy":
|
||
raise DocumentParserException(
|
||
f"PP-Structure-V3 服务不可用: {health['message']}", self.get_service_name(), health["status"]
|
||
)
|
||
|
||
try:
|
||
start_time = time.time()
|
||
params = params or {}
|
||
|
||
# 判断文件类型
|
||
file_type = 0 if file_ext == ".pdf" else 1
|
||
|
||
logger.info(f"PP-Structure-V3 开始处理: {os.path.basename(file_path)}")
|
||
|
||
# 调用API
|
||
api_result = self._call_layout_api(
|
||
file_input=file_path,
|
||
file_type=file_type,
|
||
use_table_recognition=params.get("use_table_recognition", True),
|
||
use_formula_recognition=params.get("use_formula_recognition", True),
|
||
use_seal_recognition=params.get("use_seal_recognition", False),
|
||
)
|
||
|
||
# 检查API调用是否成功
|
||
if api_result.get("errorCode") != 0:
|
||
raise DocumentParserException(
|
||
f"PP-Structure-V3 API错误: {api_result.get('errorMsg', '未知错误')}",
|
||
self.get_service_name(),
|
||
"api_error",
|
||
)
|
||
|
||
# 解析结果
|
||
result = self._parse_api_result(api_result, file_path)
|
||
text = result.get("full_text", "")
|
||
|
||
processing_time = time.time() - start_time
|
||
logger.info(
|
||
f"PP-Structure-V3 处理成功: {os.path.basename(file_path)} - {len(text)} 字符 ({processing_time:.2f}s)"
|
||
)
|
||
|
||
# 记录统计信息
|
||
summary = result.get("summary", {})
|
||
if summary:
|
||
logger.info(f" 统计: {summary.get('total_tables', 0)} 表格, {summary.get('total_formulas', 0)} 公式")
|
||
|
||
return text
|
||
|
||
except DocumentParserException:
|
||
raise
|
||
except Exception as e:
|
||
processing_time = time.time() - start_time
|
||
error_msg = f"PP-Structure-V3 处理失败: {str(e)}"
|
||
logger.error(f"{error_msg} ({processing_time:.2f}s)")
|
||
raise DocumentParserException(error_msg, self.get_service_name(), "processing_failed")
|