Files
wehub-resource-sync 3f679793e8
Linting and Formatting / lint-and-format (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:50 +08:00

473 lines
15 KiB
Python

"""
Utility functions for RAGAnything
Contains helper functions for content separation, text insertion, and other utilities
"""
from __future__ import annotations
import base64
import inspect
from typing import Dict, List, Any, Tuple
from pathlib import Path
from lightrag.utils import logger
def normalize_caption_list(value: Any) -> List[str]:
"""Return captions and footnotes as a clean list of strings."""
if value is None:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return [value.strip()]
return []
def get_table_body(item: Dict[str, Any]) -> Any:
"""Read table content across common content-list alias fields."""
if item.get("table_body") not in (None, ""):
return item.get("table_body")
if item.get("table_data") not in (None, ""):
return item.get("table_data")
return item.get("text", "")
def format_table_body(table_body: Any) -> str:
"""Serialize table content for prompts and chunks without dropping aliases.
Strings are passed through unchanged. List-of-lists (the common
``table_data`` shape from non-MinerU parsers) are rendered as a simple
Markdown table so the LLM sees structured rows instead of a Python repr.
Other shapes fall back to a newline-joined string of ``str(...)`` items.
"""
if isinstance(table_body, str):
return table_body
if isinstance(table_body, list):
if not table_body:
return ""
if all(isinstance(row, (list, tuple)) for row in table_body):
rendered_rows = [
"| " + " | ".join(str(cell) for cell in row) + " |"
for row in table_body
]
if len(rendered_rows) >= 1:
column_count = max(len(row) for row in table_body)
separator = "| " + " | ".join(["---"] * column_count) + " |"
rendered_rows.insert(1, separator)
return "\n".join(rendered_rows)
return "\n".join(str(row) for row in table_body)
return str(table_body)
def get_equation_text_and_format(item: Dict[str, Any]) -> Tuple[str, str]:
"""Read equation content while preserving LaTeX aliases from content lists.
Field priority follows MinerU first (``text`` + ``text_format``), then
falls back to ``latex`` and ``equation`` aliases used by other parsers.
The textual description is intentionally NOT concatenated into the
equation body: the ``equation_chunk`` template has a separate
``enhanced_caption`` slot for that.
"""
text = str(item.get("text", "") or "").strip()
latex = str(item.get("latex", "") or "").strip()
equation = str(item.get("equation", "") or "").strip()
equation_format = str(item.get("text_format", "") or "").strip()
if text:
equation_text = text
elif latex:
equation_text = latex
if not equation_format:
equation_format = "latex"
elif equation:
equation_text = equation
else:
equation_text = ""
return equation_text, equation_format
def extract_section_path_from_content_list(
content_list: List[Dict[str, Any]], current_index: int
) -> str:
"""Build a hierarchical section path from preceding heading blocks.
MinerU content lists keep document order, and heading blocks are exposed as
text items with a positive ``text_level``. For a given item index, we walk
the preceding items and keep the latest heading at each level to reconstruct
a stable chapter/section path such as ``Introduction > Method > Ablation``.
"""
if not content_list or current_index is None:
return ""
try:
limit = max(0, int(current_index))
except (TypeError, ValueError):
return ""
heading_chain: List[Tuple[int, str]] = []
for item in content_list[:limit]:
if not isinstance(item, dict):
continue
if item.get("type", "text") != "text":
continue
text = str(item.get("text", "") or "").strip()
if not text:
continue
try:
level = int(item.get("text_level", 0) or 0)
except (TypeError, ValueError):
continue
if level <= 0:
continue
while heading_chain and heading_chain[-1][0] >= level:
heading_chain.pop()
heading_chain.append((level, text))
return " > ".join(text for _, text in heading_chain)
def extract_neighbor_text_from_content_list(
content_list: List[Dict[str, Any]], current_index: int, window_size: int = 3
) -> str:
"""Collect nearby text blocks around an item index from MinerU content list."""
if not content_list or current_index is None:
return ""
try:
idx = int(current_index)
except (TypeError, ValueError):
return ""
if idx < 0 or idx >= len(content_list):
return ""
start_idx = max(0, idx - window_size)
end_idx = min(len(content_list), idx + window_size + 1)
parts: List[str] = []
for pos in range(start_idx, end_idx):
if pos == idx:
continue
item = content_list[pos]
if not isinstance(item, dict):
continue
if item.get("type", "text") != "text":
continue
text = str(item.get("text", "") or "").strip()
if text:
parts.append(text)
return " ".join(parts)
def separate_content(
content_list: List[Dict[str, Any]],
) -> Tuple[str, List[Dict[str, Any]]]:
"""
Separate text content and multimodal content
Args:
content_list: Content list from MinerU parsing
Returns:
(text_content, multimodal_items): Pure text content and multimodal items list
"""
text_parts = []
multimodal_items = []
for index, item in enumerate(content_list):
content_type = item.get("type", "text")
if content_type == "text":
# Text content
text = item.get("text", "")
if text.strip():
text_parts.append(text)
else:
# Multimodal content (image, table, equation, etc.)
multimodal_item = dict(item)
multimodal_item.setdefault("_content_list_index", index)
if content_type == "image":
multimodal_item.setdefault(
"_section_path",
extract_section_path_from_content_list(content_list, index),
)
multimodal_item.setdefault(
"_neighbor_text",
extract_neighbor_text_from_content_list(content_list, index),
)
multimodal_items.append(multimodal_item)
# Merge all text content
text_content = "\n\n".join(text_parts)
logger.info("Content separation complete:")
logger.info(f" - Text content length: {len(text_content)} characters")
logger.info(f" - Multimodal items count: {len(multimodal_items)}")
# Count multimodal types
modal_types = {}
for item in multimodal_items:
modal_type = item.get("type", "unknown")
modal_types[modal_type] = modal_types.get(modal_type, 0) + 1
if modal_types:
logger.info(f" - Multimodal type distribution: {modal_types}")
return text_content, multimodal_items
def encode_image_to_base64(image_path: str) -> str:
"""
Encode image file to base64 string
Args:
image_path: Path to the image file
Returns:
str: Base64 encoded string, empty string if encoding fails
"""
try:
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
return encoded_string
except Exception as e:
logger.error(f"Failed to encode image {image_path}: {e}")
return ""
def validate_image_file(image_path: str, max_size_mb: int = 50) -> bool:
"""
Validate if a file is a valid image file
Args:
image_path: Path to the image file
max_size_mb: Maximum file size in MB
Returns:
bool: True if valid, False otherwise
"""
try:
path = Path(image_path)
logger.debug(f"Validating image path: {image_path}")
logger.debug(f"Resolved path object: {path}")
logger.debug(f"Path exists check: {path.exists()}")
# Check if file exists and is not a symlink (for security)
if not path.exists():
logger.warning(f"Image file not found: {image_path}")
return False
if path.is_symlink():
logger.warning(f"Blocking symlink for security: {image_path}")
return False
# Check file extension
image_extensions = [
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".webp",
".tiff",
".tif",
]
path_lower = str(path).lower()
has_valid_extension = any(path_lower.endswith(ext) for ext in image_extensions)
logger.debug(
f"File extension check - path: {path_lower}, valid: {has_valid_extension}"
)
if not has_valid_extension:
logger.warning(f"File does not appear to be an image: {image_path}")
return False
# Check file size
file_size = path.stat().st_size
max_size = max_size_mb * 1024 * 1024
logger.debug(
f"File size check - size: {file_size} bytes, max: {max_size} bytes"
)
if file_size > max_size:
logger.warning(f"Image file too large ({file_size} bytes): {image_path}")
return False
logger.debug(f"Image validation successful: {image_path}")
return True
except Exception as e:
logger.error(f"Error validating image file {image_path}: {e}")
return False
async def insert_text_content(
lightrag,
input: str | list[str],
split_by_character: str | None = None,
split_by_character_only: bool = False,
ids: str | list[str] | None = None,
file_paths: str | list[str] | None = None,
):
"""
Insert pure text content into LightRAG
Args:
lightrag: LightRAG instance
input: Single document string or list of document strings
split_by_character: if split_by_character is not None, split the string by character, if chunk longer than
chunk_token_size, it will be split again by token size.
split_by_character_only: if split_by_character_only is True, split the string by character only, when
split_by_character is None, this parameter is ignored.
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
file_paths: single string of the file path or list of file paths, used for citation
"""
logger.info("Starting text content insertion into LightRAG...")
# Use LightRAG's insert method with all parameters
await lightrag.ainsert(
input=input,
file_paths=file_paths,
split_by_character=split_by_character,
split_by_character_only=split_by_character_only,
ids=ids,
)
logger.info("Text content insertion complete")
async def insert_text_content_with_multimodal_content(
lightrag,
input: str | list[str],
multimodal_content: list[dict[str, any]] | None = None,
split_by_character: str | None = None,
split_by_character_only: bool = False,
ids: str | list[str] | None = None,
file_paths: str | list[str] | None = None,
scheme_name: str | None = None,
):
"""
Insert pure text content into LightRAG
Args:
lightrag: LightRAG instance
input: Single document string or list of document strings
multimodal_content: Multimodal content list (optional)
split_by_character: if split_by_character is not None, split the string by character, if chunk longer than
chunk_token_size, it will be split again by token size.
split_by_character_only: if split_by_character_only is True, split the string by character only, when
split_by_character is None, this parameter is ignored.
ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated
file_paths: single string of the file path or list of file paths, used for citation
scheme_name: scheme name (optional)
"""
logger.info("Starting text content insertion into LightRAG...")
insert_kwargs = {
"input": input,
"file_paths": file_paths,
"split_by_character": split_by_character,
"split_by_character_only": split_by_character_only,
"ids": ids,
}
try:
insert_signature = inspect.signature(lightrag.ainsert)
supported_params = insert_signature.parameters
accepts_any_kwargs = any(
parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in supported_params.values()
)
except (TypeError, ValueError):
supported_params = {}
accepts_any_kwargs = True
if multimodal_content is not None and (
accepts_any_kwargs or "multimodal_content" in supported_params
):
insert_kwargs["multimodal_content"] = multimodal_content
elif multimodal_content is not None:
logger.warning(
"LightRAG ainsert() does not accept multimodal_content; "
"retrying with text-only insertion so doc_status is still created"
)
if scheme_name is not None and (
accepts_any_kwargs or "scheme_name" in supported_params
):
insert_kwargs["scheme_name"] = scheme_name
elif scheme_name is not None:
logger.warning(
"LightRAG ainsert() does not accept scheme_name; "
"continuing without it for compatibility"
)
await lightrag.ainsert(**insert_kwargs)
logger.info("Text content insertion complete")
def get_processor_for_type(modal_processors: Dict[str, Any], content_type: str):
"""
Get appropriate processor based on content type
Args:
modal_processors: Dictionary of available processors
content_type: Content type
Returns:
Corresponding processor instance
"""
# Direct mapping to corresponding processor
if content_type == "image":
return modal_processors.get("image")
elif content_type == "table":
return modal_processors.get("table")
elif content_type == "equation":
return modal_processors.get("equation")
else:
# For other types, use generic processor
return modal_processors.get("generic")
def get_processor_supports(proc_type: str) -> List[str]:
"""Get processor supported features"""
supports_map = {
"image": [
"Image content analysis",
"Visual understanding",
"Image description generation",
"Image entity extraction",
],
"table": [
"Table structure analysis",
"Data statistics",
"Trend identification",
"Table entity extraction",
],
"equation": [
"Mathematical formula parsing",
"Variable identification",
"Formula meaning explanation",
"Formula entity extraction",
],
"generic": [
"General content analysis",
"Structured processing",
"Entity extraction",
],
}
return supports_map.get(proc_type, ["Basic processing"])