mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-31 11:54:57 +08:00
1245 lines
47 KiB
Python
1245 lines
47 KiB
Python
# -*- coding: utf-8 -*-
|
|
import re
|
|
import os
|
|
import asyncio
|
|
from typing import List, Dict, Any, Optional, Tuple, Union
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
import logging
|
|
import sys
|
|
import traceback
|
|
import numpy as np
|
|
import time
|
|
import io
|
|
import json
|
|
from .ocr_engine import OCREngine
|
|
from .image_utils import image_to_base64
|
|
from .config import ChunkingConfig
|
|
from .storage import create_storage
|
|
from PIL import Image
|
|
|
|
# Add parent directory to Python path for src imports
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
parent_dir = os.path.dirname(current_dir)
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
|
|
try:
|
|
from services.docreader.src.parser.caption import Caption
|
|
except ImportError:
|
|
# Fallback: try relative import
|
|
try:
|
|
from .caption import Caption
|
|
except ImportError:
|
|
# If both imports fail, set to None
|
|
Caption = None
|
|
logging.warning(
|
|
"Failed to import Caption, image captioning will be unavailable"
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
@dataclass
|
|
class Chunk:
|
|
"""Chunk result"""
|
|
|
|
content: str # Chunk content
|
|
seq: int # Chunk sequence number
|
|
start: int # Chunk start position
|
|
end: int # Chunk end position
|
|
images: List[Dict[str, Any]] = field(default_factory=list) # Images in the chunk
|
|
|
|
|
|
@dataclass
|
|
class ParseResult:
|
|
"""Parse result"""
|
|
|
|
text: str # Extracted text content
|
|
chunks: Optional[List[Chunk]] = None # Chunk results
|
|
|
|
|
|
class BaseParser(ABC):
|
|
"""Base parser interface"""
|
|
|
|
# Class variable for shared OCR engine instance
|
|
_ocr_engine = None
|
|
_ocr_engine_failed = False
|
|
|
|
@classmethod
|
|
def get_ocr_engine(cls, backend_type="paddle", **kwargs):
|
|
"""Get OCR engine instance
|
|
|
|
Args:
|
|
backend_type: OCR engine type, e.g. "paddle", "nanonets"
|
|
**kwargs: Arguments for the OCR engine
|
|
|
|
Returns:
|
|
OCR engine instance or None
|
|
"""
|
|
if cls._ocr_engine is None and not cls._ocr_engine_failed:
|
|
try:
|
|
cls._ocr_engine = OCREngine.get_instance(
|
|
backend_type=backend_type, **kwargs
|
|
)
|
|
if cls._ocr_engine is None:
|
|
cls._ocr_engine_failed = True
|
|
logger.error(f"Failed to initialize OCR engine ({backend_type})")
|
|
return None
|
|
logger.info(f"Successfully initialized OCR engine: {backend_type}")
|
|
except Exception as e:
|
|
cls._ocr_engine_failed = True
|
|
logger.error(f"Failed to initialize OCR engine: {str(e)}")
|
|
return None
|
|
return cls._ocr_engine
|
|
|
|
def __init__(
|
|
self,
|
|
file_name: str = "",
|
|
file_type: str = None,
|
|
enable_multimodal: bool = True,
|
|
chunk_size: int = 1000,
|
|
chunk_overlap: int = 200,
|
|
separators: list = ["\n\n", "\n", "。"],
|
|
ocr_backend: str = "paddle",
|
|
ocr_config: dict = None,
|
|
max_image_size: int = 1920, # Maximum image size
|
|
max_concurrent_tasks: int = 5, # Max concurrent tasks
|
|
max_chunks: int = 1000, # Max number of returned chunks
|
|
chunking_config: ChunkingConfig = None, # Chunking configuration object
|
|
):
|
|
"""Initialize parser
|
|
|
|
Args:
|
|
file_name: File name
|
|
file_type: File type, inferred from file_name if None
|
|
enable_multimodal: Whether to enable multimodal
|
|
chunk_size: Chunk size
|
|
chunk_overlap: Chunk overlap
|
|
separators: List of separators
|
|
ocr_backend: OCR engine type
|
|
ocr_config: OCR engine config
|
|
max_image_size: Maximum image size
|
|
max_concurrent_tasks: Max concurrent tasks
|
|
max_chunks: Max number of returned chunks
|
|
"""
|
|
# Storage client instance
|
|
self._storage = None
|
|
self.file_name = file_name
|
|
self.file_type = file_type or os.path.splitext(file_name)[1]
|
|
self.enable_multimodal = enable_multimodal
|
|
self.chunk_size = chunk_size
|
|
self.chunk_overlap = chunk_overlap
|
|
self.separators = separators
|
|
self.ocr_backend = os.getenv("OCR_BACKEND", ocr_backend)
|
|
self.ocr_config = ocr_config or {}
|
|
self.max_image_size = max_image_size
|
|
self.max_concurrent_tasks = max_concurrent_tasks
|
|
self.max_chunks = max_chunks
|
|
self.chunking_config = chunking_config
|
|
|
|
logger.info(
|
|
f"Initializing {self.__class__.__name__} for file: {file_name}, type: {self.file_type}"
|
|
)
|
|
logger.info(
|
|
f"Parser config: chunk_size={chunk_size}, "
|
|
f"overlap={chunk_overlap}, "
|
|
f"multimodal={enable_multimodal}, "
|
|
f"ocr_backend={ocr_backend}, "
|
|
f"max_chunks={max_chunks}"
|
|
)
|
|
# Only initialize Caption service if multimodal is enabled
|
|
if self.enable_multimodal:
|
|
try:
|
|
self.caption_parser = Caption(self.chunking_config.vlm_config)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize Caption service: {str(e)}")
|
|
self.caption_parser = None
|
|
else:
|
|
self.caption_parser = None
|
|
|
|
def perform_ocr(self, image):
|
|
"""Execute OCR recognition on the image
|
|
|
|
Args:
|
|
image: Image object (PIL.Image or numpy array)
|
|
|
|
Returns:
|
|
Extracted text string
|
|
"""
|
|
start_time = time.time()
|
|
logger.info("Starting OCR recognition")
|
|
resized_image = None
|
|
|
|
try:
|
|
# Resize image to avoid processing large images
|
|
resized_image = self._resize_image_if_needed(image)
|
|
|
|
# Get OCR engine
|
|
ocr_engine = self.get_ocr_engine(
|
|
backend_type=self.ocr_backend, **self.ocr_config
|
|
)
|
|
if ocr_engine is None:
|
|
logger.error(
|
|
f"OCR engine ({self.ocr_backend}) initialization failed or unavailable, "
|
|
"skipping OCR recognition"
|
|
)
|
|
return ""
|
|
|
|
# Execute OCR prediction
|
|
logger.info(f"Executing OCR prediction (using {self.ocr_backend} engine)")
|
|
# Add extra exception handling
|
|
try:
|
|
ocr_result = ocr_engine.predict(resized_image)
|
|
except RuntimeError as e:
|
|
# Handle common CUDA memory issues or other runtime errors
|
|
logger.error(f"OCR prediction runtime error: {str(e)}")
|
|
return ""
|
|
except Exception as e:
|
|
# Handle other prediction errors
|
|
logger.error(f"Unexpected OCR prediction error: {str(e)}")
|
|
return ""
|
|
|
|
process_time = time.time() - start_time
|
|
logger.info(f"OCR recognition completed, time: {process_time:.2f} seconds")
|
|
return ocr_result
|
|
except Exception as e:
|
|
process_time = time.time() - start_time
|
|
logger.error(
|
|
f"OCR recognition error: {str(e)}, time: {process_time:.2f} seconds"
|
|
)
|
|
return ""
|
|
finally:
|
|
# Release image resources
|
|
if resized_image is not image and hasattr(resized_image, "close"):
|
|
# Only close the new image we created, not the original image
|
|
resized_image.close()
|
|
|
|
def _resize_image_if_needed(self, image):
|
|
"""Resize image if it exceeds maximum size limit
|
|
|
|
Args:
|
|
image: Image object (PIL.Image or numpy array)
|
|
|
|
Returns:
|
|
Resized image object
|
|
"""
|
|
try:
|
|
# If it's a PIL Image
|
|
if hasattr(image, "size"):
|
|
width, height = image.size
|
|
if width > self.max_image_size or height > self.max_image_size:
|
|
logger.info(f"Resizing PIL image, original size: {width}x{height}")
|
|
scale = min(
|
|
self.max_image_size / width, self.max_image_size / height
|
|
)
|
|
new_width = int(width * scale)
|
|
new_height = int(height * scale)
|
|
resized_image = image.resize((new_width, new_height))
|
|
logger.info(f"Resized to: {new_width}x{new_height}")
|
|
return resized_image
|
|
else:
|
|
logger.info(
|
|
f"PIL image size {width}x{height} is within limits, no resizing needed"
|
|
)
|
|
return image
|
|
# If it's a numpy array
|
|
elif hasattr(image, "shape"):
|
|
height, width = image.shape[:2]
|
|
if width > self.max_image_size or height > self.max_image_size:
|
|
logger.info(
|
|
f"Resizing numpy image, original size: {width}x{height}"
|
|
)
|
|
scale = min(
|
|
self.max_image_size / width, self.max_image_size / height
|
|
)
|
|
new_width = int(width * scale)
|
|
new_height = int(height * scale)
|
|
# Use PIL for resizing numpy arrays
|
|
pil_image = Image.fromarray(image)
|
|
resized_pil = pil_image.resize((new_width, new_height))
|
|
resized_image = np.array(resized_pil)
|
|
logger.info(f"Resized to: {new_width}x{new_height}")
|
|
return resized_image
|
|
else:
|
|
logger.info(
|
|
f"Numpy image size {width}x{height} is within limits, no resizing needed"
|
|
)
|
|
return image
|
|
else:
|
|
logger.warning(f"Unknown image type: {type(image)}, cannot resize")
|
|
return image
|
|
except Exception as e:
|
|
logger.error(f"Error resizing image: {str(e)}")
|
|
return image
|
|
|
|
def process_image(self, image, image_url=None):
|
|
"""Process image: first perform OCR, then get caption if text is available
|
|
|
|
Args:
|
|
image: Image object (PIL.Image or numpy array)
|
|
image_url: Image URL (if uploaded)
|
|
|
|
Returns:
|
|
tuple: (ocr_text, caption, image_url)
|
|
- ocr_text: OCR extracted text
|
|
- caption: Image description (if OCR has text) or empty string
|
|
- image_url: Image URL (if provided)
|
|
"""
|
|
logger.info("Starting image processing (OCR + optional caption)")
|
|
|
|
# Resize image
|
|
image = self._resize_image_if_needed(image)
|
|
|
|
# Perform OCR recognition
|
|
ocr_text = self.perform_ocr(image)
|
|
caption = ""
|
|
|
|
if self.caption_parser:
|
|
logger.info(
|
|
f"OCR successfully extracted {len(ocr_text)} characters, continuing to get caption"
|
|
)
|
|
# Convert image to base64 for caption generation
|
|
img_base64 = image_to_base64(image)
|
|
if img_base64:
|
|
caption = self.get_image_caption(img_base64)
|
|
if caption:
|
|
logger.info(f"Successfully obtained image caption: {caption}")
|
|
else:
|
|
logger.warning("Failed to get caption")
|
|
else:
|
|
logger.warning("Failed to convert image to base64")
|
|
caption = ""
|
|
else:
|
|
logger.info("Caption service not initialized, skipping caption retrieval")
|
|
|
|
# Release image resources
|
|
del image
|
|
|
|
return ocr_text, caption, image_url
|
|
|
|
async def process_image_async(self, image, image_url=None):
|
|
"""Asynchronously process image: first perform OCR, then get caption if text is available
|
|
|
|
Args:
|
|
image: Image object (PIL.Image or numpy array)
|
|
image_url: Image URL (if uploaded)
|
|
|
|
Returns:
|
|
tuple: (ocr_text, caption, image_url)
|
|
- ocr_text: OCR extracted text
|
|
- caption: Image description (if OCR has text) or empty string
|
|
- image_url: Image URL (if provided)
|
|
"""
|
|
logger.info("Starting asynchronous image processing (OCR + optional caption)")
|
|
resized_image = None
|
|
|
|
try:
|
|
# Resize image
|
|
resized_image = self._resize_image_if_needed(image)
|
|
|
|
# Perform OCR recognition (using run_in_executor to execute synchronous operations in the event loop)
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
# Add timeout mechanism to avoid infinite blocking (30 seconds timeout)
|
|
ocr_task = loop.run_in_executor(None, self.perform_ocr, resized_image)
|
|
ocr_text = await asyncio.wait_for(ocr_task, timeout=30.0)
|
|
except asyncio.TimeoutError:
|
|
logger.error(
|
|
"OCR processing timed out (30 seconds), skipping this image"
|
|
)
|
|
ocr_text = ""
|
|
except Exception as e:
|
|
logger.error(f"OCR processing error: {str(e)}")
|
|
ocr_text = ""
|
|
|
|
logger.info(
|
|
f"OCR successfully extracted {len(ocr_text)} characters, continuing to get caption"
|
|
)
|
|
caption = ""
|
|
if self.caption_parser:
|
|
try:
|
|
# Convert image to base64 for caption generation
|
|
img_base64 = image_to_base64(resized_image)
|
|
if img_base64:
|
|
# Add timeout to avoid blocking caption retrieval (30 seconds timeout)
|
|
caption_task = self.get_image_caption_async(img_base64)
|
|
image_data, caption = await asyncio.wait_for(
|
|
caption_task, timeout=30.0
|
|
)
|
|
if caption:
|
|
logger.info(
|
|
f"Successfully obtained image caption: {caption}"
|
|
)
|
|
else:
|
|
logger.warning("Failed to get caption")
|
|
else:
|
|
logger.warning("Failed to convert image to base64")
|
|
caption = ""
|
|
except asyncio.TimeoutError:
|
|
logger.warning("Caption retrieval timed out, skipping")
|
|
except Exception as e:
|
|
logger.error(f"Failed to get caption: {str(e)}")
|
|
else:
|
|
logger.info(
|
|
"Caption service not initialized, skipping caption retrieval"
|
|
)
|
|
|
|
return ocr_text, caption, image_url
|
|
finally:
|
|
# Release image resources
|
|
if resized_image is not image and hasattr(resized_image, "close"):
|
|
# Only close the new image we created, not the original image
|
|
resized_image.close()
|
|
|
|
async def process_with_limit(self, idx, image, url, semaphore):
|
|
"""Function to process a single image using a semaphore"""
|
|
try:
|
|
logger.info(f"Waiting to process image {idx+1}")
|
|
async with semaphore: # Use semaphore to control concurrency
|
|
logger.info(f"Starting to process image {idx+1}")
|
|
result = await self.process_image_async(image, url)
|
|
logger.info(f"Completed processing image {idx+1}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Error processing image {idx+1}: {str(e)}")
|
|
return ("", "", url) # Return empty result to avoid overall failure
|
|
finally:
|
|
# Manually release image resources
|
|
if hasattr(image, "close"):
|
|
image.close()
|
|
|
|
async def process_multiple_images(self, images_data):
|
|
"""Process multiple images concurrently
|
|
|
|
Args:
|
|
images_data: List of (image, image_url) tuples
|
|
|
|
Returns:
|
|
List of (ocr_text, caption, image_url) tuples
|
|
"""
|
|
logger.info(f"Starting concurrent processing of {len(images_data)} images")
|
|
|
|
if not images_data:
|
|
logger.warning("No image data to process")
|
|
return []
|
|
|
|
# Set max concurrency, reduce concurrency to avoid resource contention
|
|
max_concurrency = min(
|
|
self.max_concurrent_tasks, 1
|
|
) # Reduce concurrency to prevent excessive memory usage
|
|
|
|
# Use semaphore to limit concurrency
|
|
semaphore = asyncio.Semaphore(max_concurrency)
|
|
|
|
# Store results to avoid overall failure due to task failure
|
|
results = []
|
|
|
|
# Create all tasks, but use semaphore to limit actual concurrency
|
|
tasks = [
|
|
self.process_with_limit(i, img, url, semaphore)
|
|
for i, (img, url) in enumerate(images_data)
|
|
]
|
|
|
|
try:
|
|
# Execute all tasks, but set overall timeout
|
|
completed_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# Handle possible exception results
|
|
for i, result in enumerate(completed_results):
|
|
if isinstance(result, Exception):
|
|
logger.error(
|
|
f"Image {i+1} processing returned an exception: {str(result)}"
|
|
)
|
|
# For exceptions, add empty results
|
|
if i < len(images_data):
|
|
results.append(("", "", images_data[i][1]))
|
|
else:
|
|
results.append(result)
|
|
except Exception as e:
|
|
logger.error(f"Error during concurrent image processing: {str(e)}")
|
|
# Add empty results for all images
|
|
results = [("", "", url) for _, url in images_data]
|
|
finally:
|
|
# Clean up references and trigger garbage collection
|
|
images_data.clear()
|
|
logger.info("Image processing resource cleanup complete")
|
|
|
|
logger.info(
|
|
f"Completed concurrent processing of {len(results)}/{len(images_data)} images"
|
|
)
|
|
return results
|
|
|
|
def decode_bytes(self, content: bytes) -> str:
|
|
"""Intelligently decode byte stream, supports multiple encodings
|
|
|
|
Tries to decode in common encodings, if all fail, uses latin-1 as fallback
|
|
|
|
Args:
|
|
content: Byte stream to decode
|
|
|
|
Returns:
|
|
Decoded string
|
|
"""
|
|
logger.info(f"Attempting to decode bytes of length: {len(content)}")
|
|
# Common encodings, sorted by priority
|
|
encodings = ["utf-8", "gb18030", "gb2312", "gbk", "big5", "ascii", "latin-1"]
|
|
text = None
|
|
|
|
# Try decoding with each encoding format
|
|
for encoding in encodings:
|
|
try:
|
|
text = content.decode(encoding)
|
|
logger.info(f"Successfully decoded content using {encoding} encoding")
|
|
break
|
|
except UnicodeDecodeError:
|
|
logger.info(f"Failed to decode using {encoding} encoding")
|
|
continue
|
|
|
|
# If all encodings fail, use latin-1 as fallback
|
|
if text is None:
|
|
text = content.decode("latin-1")
|
|
logger.warning(
|
|
f"Unable to determine correct encoding, using latin-1 as fallback. "
|
|
f"This may cause character issues."
|
|
)
|
|
|
|
logger.info(f"Decoded text length: {len(text)} characters")
|
|
return text
|
|
|
|
def get_image_caption(self, image_data: str) -> str:
|
|
"""Get image description
|
|
|
|
Args:
|
|
image_data: Image data (base64 encoded string or URL)
|
|
|
|
Returns:
|
|
Image description
|
|
"""
|
|
start_time = time.time()
|
|
logger.info(
|
|
f"Getting caption for image: {image_data[:250]}..."
|
|
if len(image_data) > 250
|
|
else f"Getting caption for image: {image_data}"
|
|
)
|
|
caption = self.caption_parser.get_caption(image_data)
|
|
if caption:
|
|
logger.info(
|
|
f"Received caption of length: {len(caption)}, caption: {caption},"
|
|
f"cost: {time.time() - start_time} seconds"
|
|
)
|
|
else:
|
|
logger.warning("Failed to get caption for image")
|
|
return caption
|
|
|
|
async def get_image_caption_async(self, image_data: str) -> Tuple[str, str]:
|
|
"""Asynchronously get image description
|
|
|
|
Args:
|
|
image_data: Image data (base64 encoded string or URL)
|
|
|
|
Returns:
|
|
Tuple[str, str]: Image data and corresponding description
|
|
"""
|
|
caption = self.get_image_caption(image_data)
|
|
return image_data, caption
|
|
|
|
def __init_storage(self):
|
|
"""Initialize storage client based on configuration"""
|
|
if self._storage is None:
|
|
storage_config = (
|
|
self.chunking_config.storage_config if self.chunking_config else None
|
|
)
|
|
self._storage = create_storage(storage_config)
|
|
logger.info(
|
|
f"Initialized storage client: {self._storage.__class__.__name__}"
|
|
)
|
|
return self._storage
|
|
|
|
def upload_file(self, file_path: str) -> str:
|
|
"""Upload file to object storage
|
|
|
|
Args:
|
|
file_path: File path
|
|
|
|
Returns:
|
|
File URL
|
|
"""
|
|
logger.info(f"Uploading file: {file_path}")
|
|
try:
|
|
storage = self.__init_storage()
|
|
return storage.upload_file(file_path)
|
|
except Exception as e:
|
|
logger.error(f"Failed to upload file: {str(e)}")
|
|
return ""
|
|
|
|
def upload_bytes(self, content: bytes, file_ext: str = ".png") -> str:
|
|
"""Upload bytes to object storage
|
|
|
|
Args:
|
|
content: Byte content to upload
|
|
file_ext: File extension
|
|
|
|
Returns:
|
|
File URL
|
|
"""
|
|
logger.info(f"Uploading bytes content, size: {len(content)} bytes")
|
|
try:
|
|
storage = self.__init_storage()
|
|
return storage.upload_bytes(content, file_ext)
|
|
except Exception as e:
|
|
logger.error(f"Failed to upload bytes to storage: {str(e)}")
|
|
traceback.print_exc()
|
|
return ""
|
|
|
|
@abstractmethod
|
|
def parse_into_text(self, content: bytes) -> Union[str, Tuple[str, Dict[str, Any]]]:
|
|
"""Parse document content
|
|
|
|
Args:
|
|
content: Document content
|
|
|
|
Returns:
|
|
Either a string containing the parsed text, or a tuple of (text, image_map)
|
|
where image_map is a dict mapping image URLs to Image objects
|
|
"""
|
|
pass
|
|
|
|
def parse(self, content: bytes) -> ParseResult:
|
|
"""Parse document content
|
|
|
|
Args:
|
|
content: Document content
|
|
|
|
Returns:
|
|
Parse result
|
|
"""
|
|
logger.info(
|
|
f"Parsing document with {self.__class__.__name__}, content size: {len(content)} bytes"
|
|
)
|
|
parse_result = self.parse_into_text(content)
|
|
if isinstance(parse_result, tuple):
|
|
text, image_map = parse_result
|
|
else:
|
|
text = parse_result
|
|
image_map = {}
|
|
logger.info(f"Extracted {len(text)} characters of text from {self.file_name}")
|
|
logger.info(f"Beginning chunking process for text")
|
|
chunks = self.chunk_text(text)
|
|
logger.info(f"Created {len(chunks)} chunks from document")
|
|
|
|
# Limit the number of returned chunks
|
|
if len(chunks) > self.max_chunks:
|
|
logger.warning(
|
|
f"Limiting chunks from {len(chunks)} to maximum {self.max_chunks}"
|
|
)
|
|
chunks = chunks[: self.max_chunks]
|
|
|
|
# If multimodal is enabled and file type is supported, process images in each chunk
|
|
if self.enable_multimodal:
|
|
# Get file extension and convert to lowercase
|
|
file_ext = (
|
|
os.path.splitext(self.file_name)[1].lower()
|
|
if self.file_name
|
|
else (self.file_type.lower() if self.file_type else "")
|
|
)
|
|
|
|
# Define allowed file types for image processing
|
|
allowed_types = [
|
|
".pdf", # PDF files
|
|
".md",
|
|
".markdown", # Markdown files
|
|
".doc",
|
|
".docx", # Word documents
|
|
# Image files
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".bmp",
|
|
".tiff",
|
|
".webp",
|
|
]
|
|
|
|
if file_ext in allowed_types:
|
|
logger.info(
|
|
f"Processing images in each chunk for file type: {file_ext}"
|
|
)
|
|
chunks = self.process_chunks_images(chunks, image_map)
|
|
else:
|
|
logger.info(
|
|
f"Skipping image processing for unsupported file type: {file_ext}"
|
|
)
|
|
|
|
return ParseResult(text=text, chunks=chunks)
|
|
|
|
def _split_into_units(self, text: str) -> List[str]:
|
|
"""
|
|
Args:
|
|
text: 文本内容
|
|
|
|
Returns:
|
|
基本单元的列表
|
|
"""
|
|
logger.info(
|
|
f"Splitting text into basic units with robust structure protection, text length: {len(text)}"
|
|
)
|
|
|
|
# 定义所有需要作为整体保护的结构模式 ---
|
|
table_pattern = r"(?m)(^\|.*\|[ \t]*\r?\n(?:[ \t]*\r?\n)?^\|\s*:?--+.*\r?\n(?:^\|.*\|\r?\n?)*)"
|
|
|
|
# 其他需要保护的结构(代码块、公式块、行内元素)
|
|
code_block_pattern = r"```[\s\S]*?```"
|
|
math_block_pattern = r"\$\$[\s\S]*?\$\$"
|
|
inline_pattern = r"!\[.*?\]\(.*?\)|\[.*?\]\(.*?\)"
|
|
|
|
# 查找所有受保护结构的位置 ---
|
|
protected_ranges = []
|
|
for pattern in [
|
|
table_pattern,
|
|
code_block_pattern,
|
|
math_block_pattern,
|
|
inline_pattern,
|
|
]:
|
|
for match in re.finditer(pattern, text):
|
|
# 确保匹配到的不是空字符串,避免无效范围
|
|
if match.group(0).strip():
|
|
protected_ranges.append((match.start(), match.end()))
|
|
|
|
# 按起始位置排序
|
|
protected_ranges.sort(key=lambda x: x[0])
|
|
logger.info(
|
|
f"Found {len(protected_ranges)} protected structures (tables, code, formulas, images, links)."
|
|
)
|
|
|
|
# 合并可能重叠的保护范围 ---
|
|
# 确保我们有一组不相交的、需要保护的文本块
|
|
if protected_ranges:
|
|
merged_ranges = []
|
|
current_start, current_end = protected_ranges[0]
|
|
|
|
for next_start, next_end in protected_ranges[1:]:
|
|
if next_start < current_end:
|
|
# 如果下一个范围与当前范围重叠,则合并它们
|
|
current_end = max(current_end, next_end)
|
|
else:
|
|
# 如果不重叠,则完成当前范围并开始一个新的范围
|
|
merged_ranges.append((current_start, current_end))
|
|
current_start, current_end = next_start, next_end
|
|
|
|
merged_ranges.append((current_start, current_end))
|
|
protected_ranges = merged_ranges
|
|
logger.info(
|
|
f"After merging overlaps, {len(protected_ranges)} protected ranges remain."
|
|
)
|
|
|
|
# 根据保护范围和分隔符来分割文本 ---
|
|
units = []
|
|
last_end = 0
|
|
|
|
# 定义分隔符的正则表达式,通过加括号来保留分隔符本身
|
|
separator_pattern = f"({'|'.join(re.escape(s) for s in self.separators)})"
|
|
|
|
for start, end in protected_ranges:
|
|
# a. 处理受保护范围之前的文本
|
|
if start > last_end:
|
|
pre_text = text[last_end:start]
|
|
# 对这部分非保护文本进行分割,并保留分隔符
|
|
segments = re.split(separator_pattern, pre_text)
|
|
units.extend([s for s in segments if s]) # 添加所有非空部分
|
|
|
|
# b. 将整个受保护的块(例如,一个完整的表格)作为一个单独的、不可分割的单元添加
|
|
protected_text = text[start:end]
|
|
units.append(protected_text)
|
|
|
|
last_end = end
|
|
|
|
# c. 处理最后一个受保护范围之后的文本
|
|
if last_end < len(text):
|
|
post_text = text[last_end:]
|
|
segments = re.split(separator_pattern, post_text)
|
|
units.extend([s for s in segments if s]) # 添加所有非空部分
|
|
|
|
logger.info(f"Text splitting complete, created {len(units)} final basic units.")
|
|
return units
|
|
|
|
def _find_complete_units(self, units: List[str], target_size: int) -> List[str]:
|
|
"""Find a list of complete units that do not exceed the target size
|
|
|
|
Args:
|
|
units: List of units
|
|
target_size: Target size
|
|
|
|
Returns:
|
|
List of complete units
|
|
"""
|
|
logger.info(f"Finding complete units with target size: {target_size}")
|
|
result = []
|
|
current_size = 0
|
|
|
|
for unit in units:
|
|
unit_size = len(unit)
|
|
if current_size + unit_size > target_size and result:
|
|
logger.info(
|
|
f"Reached target size limit at {current_size} characters, stopping"
|
|
)
|
|
break
|
|
result.append(unit)
|
|
current_size += unit_size
|
|
logger.info(
|
|
f"Added unit of size {unit_size}, current total: {current_size}/{target_size}"
|
|
)
|
|
|
|
logger.info(
|
|
f"Found {len(result)} complete units totaling {current_size} characters"
|
|
)
|
|
return result
|
|
|
|
def chunk_text(self, text: str) -> List[Chunk]:
|
|
"""Chunk text, preserving Markdown structure
|
|
|
|
Args:
|
|
text: Text content
|
|
|
|
Returns:
|
|
List of text chunks
|
|
"""
|
|
if not text:
|
|
logger.warning("Empty text provided for chunking, returning empty list")
|
|
return []
|
|
|
|
logger.info(f"Starting text chunking process, text length: {len(text)}")
|
|
logger.info(
|
|
f"Chunking parameters: size={self.chunk_size}, overlap={self.chunk_overlap}"
|
|
)
|
|
|
|
# Split text into basic units
|
|
units = self._split_into_units(text)
|
|
logger.info(f"Split text into {len(units)} basic units")
|
|
|
|
chunks = []
|
|
current_chunk = []
|
|
current_size = 0
|
|
current_start = 0
|
|
|
|
for i, unit in enumerate(units):
|
|
unit_size = len(unit)
|
|
logger.info(f"Processing unit {i+1}/{len(units)}, size: {unit_size}")
|
|
|
|
# If current chunk plus new unit exceeds size limit, create new chunk
|
|
if current_size + unit_size > self.chunk_size and current_chunk:
|
|
chunk_text = "".join(current_chunk)
|
|
chunks.append(
|
|
Chunk(
|
|
seq=len(chunks),
|
|
content=chunk_text,
|
|
start=current_start,
|
|
end=current_start + len(chunk_text),
|
|
)
|
|
)
|
|
logger.info(f"Created chunk {len(chunks)}, size: {len(chunk_text)}")
|
|
|
|
# Keep overlap, ensuring structure integrity
|
|
if self.chunk_overlap > 0:
|
|
# Calculate target overlap size
|
|
overlap_target = min(self.chunk_overlap, len(chunk_text))
|
|
logger.info(
|
|
f"Calculating overlap with target size: {overlap_target}"
|
|
)
|
|
|
|
# Find complete units from the end
|
|
overlap_units = []
|
|
overlap_size = 0
|
|
|
|
for u in reversed(current_chunk):
|
|
if overlap_size + len(u) > overlap_target:
|
|
logger.info(
|
|
f"Reached overlap target ({overlap_size}/{overlap_target})"
|
|
)
|
|
break
|
|
overlap_units.insert(0, u)
|
|
overlap_size += len(u)
|
|
logger.info(
|
|
f"Added unit to overlap, current overlap size: {overlap_size}"
|
|
)
|
|
|
|
# Remove elements from overlap that are included in separators
|
|
start_index = 0
|
|
for i, u in enumerate(overlap_units):
|
|
# Check if u is in separators
|
|
all_of_separator = True
|
|
for uu in u:
|
|
if uu not in self.separators:
|
|
all_of_separator = False
|
|
break
|
|
if all_of_separator:
|
|
# Remove the first element
|
|
start_index = i + 1
|
|
overlap_size = overlap_size - len(u)
|
|
logger.info(f"Removed separator from overlap: '{u}'")
|
|
else:
|
|
break
|
|
|
|
overlap_units = overlap_units[start_index:]
|
|
logger.info(
|
|
f"Final overlap: {len(overlap_units)} units, {overlap_size} characters"
|
|
)
|
|
|
|
current_chunk = overlap_units
|
|
current_size = overlap_size
|
|
# Update start position, considering overlap
|
|
current_start = current_start + len(chunk_text) - overlap_size
|
|
else:
|
|
logger.info("No overlap configured, starting fresh chunk")
|
|
current_chunk = []
|
|
current_size = 0
|
|
current_start = current_start + len(chunk_text)
|
|
|
|
current_chunk.append(unit)
|
|
current_size += unit_size
|
|
logger.info(
|
|
f"Added unit to current chunk, now at {current_size}/{self.chunk_size} characters"
|
|
)
|
|
|
|
# Add the last chunk
|
|
if current_chunk:
|
|
chunk_text = "".join(current_chunk)
|
|
chunks.append(
|
|
Chunk(
|
|
seq=len(chunks),
|
|
content=chunk_text,
|
|
start=current_start,
|
|
end=current_start + len(chunk_text),
|
|
)
|
|
)
|
|
logger.info(f"Created final chunk {len(chunks)}, size: {len(chunk_text)}")
|
|
|
|
logger.info(f"Chunking complete, created {len(chunks)} chunks from text")
|
|
return chunks
|
|
|
|
def extract_images_from_chunk(self, chunk: Chunk) -> List[Dict[str, str]]:
|
|
"""Extract image information from a chunk
|
|
|
|
Args:
|
|
chunk: Document chunk
|
|
|
|
Returns:
|
|
List of image information, each element contains image URL and match position
|
|
"""
|
|
logger.info(f"Extracting image information from Chunk #{chunk.seq}")
|
|
text = chunk.content
|
|
|
|
# Regex to extract image information from text, supporting Markdown images and HTML images
|
|
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)|<img [^>]*src="([^"]+)" [^>]*>'
|
|
|
|
# Extract image information
|
|
img_matches = list(re.finditer(img_pattern, text))
|
|
logger.info(f"Chunk #{chunk.seq} found {len(img_matches)} images")
|
|
|
|
images_info = []
|
|
for match_idx, match in enumerate(img_matches):
|
|
# Process image URL
|
|
img_url = match.group(2) if match.group(2) else match.group(3)
|
|
alt_text = match.group(1) if match.group(1) else ""
|
|
|
|
# Record image information
|
|
image_info = {
|
|
"original_url": img_url,
|
|
"start": match.start(),
|
|
"end": match.end(),
|
|
"alt_text": alt_text,
|
|
"match_text": text[match.start() : match.end()],
|
|
}
|
|
images_info.append(image_info)
|
|
|
|
logger.info(
|
|
f"Image in Chunk #{chunk.seq} {match_idx+1}: " f"URL={img_url[:50]}..."
|
|
if len(img_url) > 50
|
|
else f"Image in Chunk #{chunk.seq} {match_idx+1}: URL={img_url}"
|
|
)
|
|
|
|
return images_info
|
|
|
|
async def download_and_upload_image(self, img_url: str):
|
|
"""Download image and upload to object storage, if it's already an object storage path or local path, use directly
|
|
|
|
Args:
|
|
img_url: Image URL or local path
|
|
|
|
Returns:
|
|
tuple: (original URL, storage URL, image object), if failed returns (original URL, None, None)
|
|
"""
|
|
|
|
try:
|
|
import requests
|
|
from PIL import Image
|
|
import io
|
|
|
|
# Check if it's already a storage URL (COS or MinIO)
|
|
is_storage_url = any(
|
|
pattern in img_url
|
|
for pattern in ["cos", "myqcloud.com", "minio", ".s3."]
|
|
)
|
|
if is_storage_url:
|
|
logger.info(f"Image already on COS: {img_url}, no need to re-upload")
|
|
try:
|
|
# Still need to get image object for OCR processing
|
|
# Get proxy settings from environment variables
|
|
http_proxy = os.environ.get("EXTERNAL_HTTP_PROXY")
|
|
https_proxy = os.environ.get("EXTERNAL_HTTPS_PROXY")
|
|
proxies = {}
|
|
if http_proxy:
|
|
proxies["http"] = http_proxy
|
|
if https_proxy:
|
|
proxies["https"] = https_proxy
|
|
|
|
response = requests.get(img_url, timeout=5, proxies=proxies)
|
|
if response.status_code == 200:
|
|
image = Image.open(io.BytesIO(response.content))
|
|
try:
|
|
return img_url, img_url, image
|
|
finally:
|
|
# Ensure image resources are also released after the function returns
|
|
# Image will be closed by the caller
|
|
pass
|
|
else:
|
|
logger.warning(
|
|
f"Failed to get storage image: {response.status_code}"
|
|
)
|
|
return img_url, img_url, None
|
|
except Exception as e:
|
|
logger.error(f"Error getting storage image: {str(e)}")
|
|
return img_url, img_url, None
|
|
|
|
# Check if it's a local file path
|
|
elif os.path.exists(img_url) and os.path.isfile(img_url):
|
|
logger.info(f"Using local image file: {img_url}")
|
|
image = None
|
|
try:
|
|
# Read local image
|
|
image = Image.open(img_url)
|
|
# Upload to storage
|
|
with open(img_url, "rb") as f:
|
|
content = f.read()
|
|
storage_url = self.upload_bytes(content)
|
|
logger.info(
|
|
f"Successfully uploaded local image to storage: {storage_url}"
|
|
)
|
|
return img_url, storage_url, image
|
|
except Exception as e:
|
|
logger.error(f"Error processing local image: {str(e)}")
|
|
if image and hasattr(image, "close"):
|
|
image.close()
|
|
return img_url, None, None
|
|
|
|
# Normal remote URL download handling
|
|
else:
|
|
# Get proxy settings from environment variables
|
|
http_proxy = os.environ.get("EXTERNAL_HTTP_PROXY")
|
|
https_proxy = os.environ.get("EXTERNAL_HTTPS_PROXY")
|
|
proxies = {}
|
|
if http_proxy:
|
|
proxies["http"] = http_proxy
|
|
if https_proxy:
|
|
proxies["https"] = https_proxy
|
|
|
|
logger.info(
|
|
f"Downloading image {img_url}, using proxy: {proxies if proxies else 'None'}"
|
|
)
|
|
response = requests.get(img_url, timeout=5, proxies=proxies)
|
|
|
|
if response.status_code == 200:
|
|
# Download successful, create image object
|
|
image = Image.open(io.BytesIO(response.content))
|
|
try:
|
|
# Upload to storage using the method in BaseParser
|
|
storage_url = self.upload_bytes(response.content)
|
|
logger.info(
|
|
f"Successfully uploaded image to storage: {storage_url}"
|
|
)
|
|
return img_url, storage_url, image
|
|
finally:
|
|
# Image will be closed by the caller
|
|
pass
|
|
else:
|
|
logger.warning(f"Failed to download image: {response.status_code}")
|
|
return img_url, None, None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error downloading or processing image: {str(e)}")
|
|
return img_url, None, None
|
|
|
|
async def process_chunk_images_async(
|
|
self, chunk, chunk_idx, total_chunks, image_map=None
|
|
):
|
|
"""Asynchronously process images in a single Chunk
|
|
|
|
Args:
|
|
chunk: Chunk object to process
|
|
chunk_idx: Chunk index
|
|
total_chunks: Total number of chunks
|
|
image_map: Optional dictionary mapping image URLs to Image objects
|
|
|
|
Returns:
|
|
Processed Chunk object
|
|
"""
|
|
|
|
logger.info(
|
|
f"Starting to process images in Chunk #{chunk_idx+1}/{total_chunks}"
|
|
)
|
|
|
|
# Extract image information from the Chunk
|
|
images_info = self.extract_images_from_chunk(chunk)
|
|
if not images_info:
|
|
logger.info(f"Chunk #{chunk_idx+1} found no images")
|
|
return chunk
|
|
|
|
# Prepare images that need to be downloaded and processed
|
|
images_to_process = []
|
|
url_to_info_map = {} # Map URL to image information
|
|
|
|
# Record all image URLs that need to be processed
|
|
for img_info in images_info:
|
|
url = img_info["original_url"]
|
|
url_to_info_map[url] = img_info
|
|
|
|
results = []
|
|
download_tasks = []
|
|
for img_url in url_to_info_map.keys(): # Check if image is already in the image_map
|
|
if image_map and img_url in image_map:
|
|
logger.info(f"Image already in image_map: {img_url}, using cached object")
|
|
results.append((img_url, img_url, image_map[img_url]))
|
|
else:
|
|
download_task = self.download_and_upload_image(img_url)
|
|
download_tasks.append(download_task)
|
|
# Concurrent download and upload of images, ignore images that are already in the image_map
|
|
results.extend(await asyncio.gather(*download_tasks))
|
|
|
|
# Process download results, prepare for OCR processing
|
|
for orig_url, cos_url, image in results:
|
|
if cos_url and image:
|
|
img_info = url_to_info_map[orig_url]
|
|
img_info["cos_url"] = cos_url
|
|
images_to_process.append((image, cos_url))
|
|
|
|
# If no images were successfully downloaded and uploaded, return the original Chunk
|
|
if not images_to_process:
|
|
logger.info(
|
|
f"Chunk #{chunk_idx+1} found no successfully downloaded and uploaded images"
|
|
)
|
|
return chunk
|
|
|
|
# Concurrent processing of all images (OCR + caption)
|
|
logger.info(
|
|
f"Processing {len(images_to_process)} images in Chunk #{chunk_idx+1}"
|
|
)
|
|
|
|
# Concurrent processing of all images
|
|
processed_results = await self.process_multiple_images(images_to_process)
|
|
|
|
# Process OCR and Caption results
|
|
for ocr_text, caption, img_url in processed_results:
|
|
# Find the corresponding original URL
|
|
for orig_url, info in url_to_info_map.items():
|
|
if info.get("cos_url") == img_url:
|
|
info["ocr_text"] = ocr_text if ocr_text else ""
|
|
info["caption"] = caption if caption else ""
|
|
|
|
if ocr_text:
|
|
logger.info(
|
|
f"Image OCR extracted {len(ocr_text)} characters: {img_url}"
|
|
)
|
|
if caption:
|
|
logger.info(f"Obtained image description: '{caption}'")
|
|
break
|
|
|
|
# Add processed image information to the Chunk
|
|
processed_images = []
|
|
for img_info in images_info:
|
|
if "cos_url" in img_info:
|
|
processed_images.append(img_info)
|
|
|
|
# Update image information in the Chunk
|
|
chunk.images = processed_images
|
|
|
|
logger.info(f"Completed image processing in Chunk #{chunk_idx+1}")
|
|
return chunk
|
|
|
|
def process_chunks_images(self, chunks: List[Chunk], image_map=None) -> List[Chunk]:
|
|
"""Concurrent processing of images in all Chunks
|
|
|
|
Args:
|
|
chunks: List of document chunks
|
|
|
|
Returns:
|
|
List of processed document chunks
|
|
"""
|
|
logger.info(
|
|
f"Starting concurrent processing of images in all {len(chunks)} chunks"
|
|
)
|
|
|
|
if not chunks:
|
|
logger.warning("No chunks to process")
|
|
return chunks
|
|
|
|
# Create and run all Chunk concurrent processing tasks
|
|
async def process_all_chunks():
|
|
# Set max concurrency, reduce concurrency to avoid resource contention
|
|
max_concurrency = min(self.max_concurrent_tasks, 1) # Reduce concurrency
|
|
# Use semaphore to limit concurrency
|
|
semaphore = asyncio.Semaphore(max_concurrency)
|
|
|
|
async def process_with_limit(chunk, idx, total):
|
|
"""Use semaphore to control concurrent processing of Chunks"""
|
|
async with semaphore:
|
|
return await self.process_chunk_images_async(
|
|
chunk, idx, total, image_map
|
|
)
|
|
|
|
# Create tasks for all Chunks
|
|
tasks = [
|
|
process_with_limit(chunk, idx, len(chunks))
|
|
for idx, chunk in enumerate(chunks)
|
|
]
|
|
|
|
# Execute all tasks concurrently
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# Handle possible exceptions
|
|
processed_chunks = []
|
|
for i, result in enumerate(results):
|
|
if isinstance(result, Exception):
|
|
logger.error(f"Error processing Chunk {i+1}: {str(result)}")
|
|
# Keep original Chunk
|
|
if i < len(chunks):
|
|
processed_chunks.append(chunks[i])
|
|
else:
|
|
processed_chunks.append(result)
|
|
|
|
return processed_chunks
|
|
|
|
# Create event loop and run all tasks
|
|
try:
|
|
# Check if event loop already exists
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
if loop.is_closed():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
except RuntimeError:
|
|
# If no event loop, create a new one
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
# Execute processing for all Chunks
|
|
processed_chunks = loop.run_until_complete(process_all_chunks())
|
|
logger.info(
|
|
f"Successfully completed concurrent processing of {len(processed_chunks)}/{len(chunks)} chunks"
|
|
)
|
|
|
|
return processed_chunks
|
|
except Exception as e:
|
|
logger.error(f"Error during concurrent chunk processing: {str(e)}")
|
|
return chunks
|