579 lines
20 KiB
Python
579 lines
20 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import time
|
|
import zipfile
|
|
import math
|
|
from pathlib import Path
|
|
|
|
from typing import Any, Dict, List, Optional, Union
|
|
from collections import Counter
|
|
import xml.etree.ElementTree as ET
|
|
from pandas import Timestamp
|
|
from datetime import datetime
|
|
from pandas.api.types import is_datetime64_any_dtype
|
|
|
|
import pandas as pd
|
|
from tabulate import tabulate
|
|
from qwen_agent.log import logger
|
|
from qwen_agent.settings import DEFAULT_WORKSPACE, DEFAULT_MAX_INPUT_TOKENS
|
|
from qwen_agent.tools.base import BaseTool, register_tool
|
|
from qwen_agent.tools.storage import KeyNotExistsError, Storage
|
|
from file_tools.utils import (get_file_type, hash_sha256, is_http_url, get_basename_from_url,
|
|
sanitize_chrome_file_path, save_url_to_local_work_dir)
|
|
from qwen_agent.utils.tokenization_qwen import count_tokens, tokenizer
|
|
from file_tools.idp import IDP
|
|
|
|
# Configuration constants
|
|
PARSER_SUPPORTED_FILE_TYPES = ['pdf', 'docx', 'pptx', 'txt', 'html', 'csv', 'tsv', 'xlsx', 'xls', 'doc', 'zip', '.mp4', '.mov', '.mkv', '.webm', '.mp3', '.wav']
|
|
def str_to_bool(value):
|
|
"""Convert string to boolean, handling common true/false representations"""
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value).lower() in ('true', '1', 'yes', 'on')
|
|
USE_IDP = str_to_bool(os.getenv("USE_IDP", "True"))
|
|
IDP_TIMEOUT = 150000
|
|
ENABLE_CSI = False
|
|
PARAGRAPH_SPLIT_SYMBOL = '\n'
|
|
|
|
|
|
class CustomJSONEncoder(json.JSONEncoder):
|
|
def default(self, obj):
|
|
if isinstance(obj, (datetime, Timestamp)):
|
|
return obj.isoformat()
|
|
return super().default(obj)
|
|
|
|
|
|
class FileParserError(Exception):
|
|
"""Custom exception for document parsing errors"""
|
|
|
|
def __init__(self, message: str, code: str = '400', exception: Optional[Exception] = None):
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.exception = exception
|
|
|
|
|
|
def parse_file_by_idp(file_path: str = None, file_url: str = None) -> List[dict]:
|
|
idp = IDP()
|
|
try:
|
|
fid = idp.file_submit_with_url(file_url) if file_url else idp.file_submit_with_path(file_path)
|
|
if not fid:
|
|
return []
|
|
|
|
for _ in range(10):
|
|
result, status = idp.file_parser_query(fid)
|
|
if status == 'success':
|
|
return process_idp_result(result)
|
|
time.sleep(10)
|
|
|
|
logger.error("IDP parsing timeout")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"IDP processing failed: {str(e)}")
|
|
return []
|
|
|
|
|
|
def process_idp_result(result: dict) -> List[dict]:
|
|
pages = []
|
|
current_page = None
|
|
|
|
for layout in result.get('layouts', []):
|
|
page_num = layout.get('pageNum', 0)
|
|
content = layout.get('markdownContent', '')
|
|
|
|
if current_page and current_page['page_num'] == page_num:
|
|
current_page['content'].append({'text': content})
|
|
else:
|
|
current_page = {'page_num': page_num, 'content': [{'text': content}]}
|
|
pages.append(current_page)
|
|
|
|
return pages
|
|
|
|
|
|
def clean_text(text: str) -> str:
|
|
cleaners = [
|
|
lambda x: re.sub(r'\n+', '\n', x),
|
|
lambda x: x.replace("Add to Qwen's Reading List", ''),
|
|
lambda x: re.sub(r'-{6,}', '-----', x),
|
|
lambda x: x.strip()
|
|
]
|
|
for cleaner in cleaners:
|
|
text = cleaner(text)
|
|
return text
|
|
|
|
|
|
def get_plain_doc(doc: list):
|
|
paras = []
|
|
for page in doc:
|
|
for para in page['content']:
|
|
for k, v in para.items():
|
|
if k in ['text', 'table', 'image']:
|
|
paras.append(v)
|
|
return PARAGRAPH_SPLIT_SYMBOL.join(paras)
|
|
|
|
|
|
def df_to_markdown(df: pd.DataFrame) -> str:
|
|
df = df.dropna(how='all').fillna('')
|
|
return tabulate(df, headers='keys', tablefmt='pipe', showindex=False)
|
|
|
|
|
|
def parse_word(docx_path: str, extract_image: bool = False):
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
from docx import Document
|
|
doc = Document(docx_path)
|
|
|
|
content = []
|
|
for para in doc.paragraphs:
|
|
content.append({'text': para.text})
|
|
for table in doc.tables:
|
|
tbl = []
|
|
for row in table.rows:
|
|
tbl.append('|' + '|'.join([cell.text for cell in row.cells]) + '|')
|
|
tbl = '\n'.join(tbl)
|
|
content.append({'table': tbl})
|
|
return [{'page_num': 1, 'content': content}]
|
|
|
|
|
|
def parse_ppt(path: str, extract_image: bool = False):
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
from pptx import Presentation
|
|
from pptx.exc import PackageNotFoundError
|
|
try:
|
|
ppt = Presentation(path)
|
|
except PackageNotFoundError as ex:
|
|
logger.warning(ex)
|
|
return []
|
|
doc = []
|
|
for slide_number, slide in enumerate(ppt.slides):
|
|
page = {'page_num': slide_number + 1, 'content': []}
|
|
|
|
for shape in slide.shapes:
|
|
if not shape.has_text_frame and not shape.has_table:
|
|
pass
|
|
|
|
if shape.has_text_frame:
|
|
for paragraph in shape.text_frame.paragraphs:
|
|
paragraph_text = ''.join(run.text for run in paragraph.runs)
|
|
paragraph_text = clean_text(paragraph_text)
|
|
if paragraph_text.strip():
|
|
page['content'].append({'text': paragraph_text})
|
|
|
|
if shape.has_table:
|
|
tbl = []
|
|
for row_number, row in enumerate(shape.table.rows):
|
|
tbl.append('|' + '|'.join([cell.text for cell in row.cells]) + '|')
|
|
tbl = '\n'.join(tbl)
|
|
page['content'].append({'table': tbl})
|
|
doc.append(page)
|
|
return doc
|
|
|
|
|
|
def parse_pdf(pdf_path: str, extract_image: bool = False) -> List[dict]:
|
|
# Todo: header and footer
|
|
from pdfminer.high_level import extract_pages
|
|
from pdfminer.layout import LTImage, LTRect, LTTextContainer
|
|
|
|
doc = []
|
|
import pdfplumber
|
|
pdf = pdfplumber.open(pdf_path)
|
|
for i, page_layout in enumerate(extract_pages(pdf_path)):
|
|
page = {'page_num': page_layout.pageid, 'content': []}
|
|
|
|
elements = []
|
|
for element in page_layout:
|
|
elements.append(element)
|
|
|
|
# Init params for table
|
|
table_num = 0
|
|
tables = []
|
|
|
|
for element in elements:
|
|
if isinstance(element, LTRect):
|
|
if not tables:
|
|
tables = extract_tables(pdf, i)
|
|
if table_num < len(tables):
|
|
table_string = table_converter(tables[table_num])
|
|
table_num += 1
|
|
if table_string:
|
|
page['content'].append({'table': table_string, 'obj': element})
|
|
elif isinstance(element, LTTextContainer):
|
|
# Delete line breaks in the same paragraph
|
|
text = element.get_text()
|
|
# Todo: Further analysis using font
|
|
font = get_font(element)
|
|
if text.strip():
|
|
new_content_item = {'text': text, 'obj': element}
|
|
if font:
|
|
new_content_item['font-size'] = round(font[1])
|
|
# new_content_item['font-name'] = font[0]
|
|
page['content'].append(new_content_item)
|
|
elif extract_image and isinstance(element, LTImage):
|
|
# Todo: ocr
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
else:
|
|
pass
|
|
|
|
# merge elements
|
|
page['content'] = postprocess_page_content(page['content'])
|
|
doc.append(page)
|
|
|
|
return doc
|
|
|
|
|
|
def parse_txt(path: str):
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
text = f.read()
|
|
paras = text.split(PARAGRAPH_SPLIT_SYMBOL)
|
|
content = []
|
|
for p in paras:
|
|
content.append({'text': p})
|
|
return [{'page_num': 1, 'content': content}]
|
|
|
|
|
|
def get_font(element):
|
|
from pdfminer.layout import LTChar, LTTextContainer
|
|
|
|
fonts_list = []
|
|
for text_line in element:
|
|
if isinstance(text_line, LTTextContainer):
|
|
for character in text_line:
|
|
if isinstance(character, LTChar):
|
|
fonts_list.append((character.fontname, character.size))
|
|
|
|
fonts_list = list(set(fonts_list))
|
|
if fonts_list:
|
|
counter = Counter(fonts_list)
|
|
most_common_fonts = counter.most_common(1)[0][0]
|
|
return most_common_fonts
|
|
else:
|
|
return []
|
|
|
|
|
|
def extract_tables(pdf, page_num):
|
|
table_page = pdf.pages[page_num]
|
|
tables = table_page.extract_tables()
|
|
return tables
|
|
|
|
|
|
def table_converter(table):
|
|
table_string = ''
|
|
for row_num in range(len(table)):
|
|
row = table[row_num]
|
|
cleaned_row = [
|
|
item.replace('\n', ' ') if item is not None and '\n' in item else 'None' if item is None else item
|
|
for item in row
|
|
]
|
|
table_string += ('|' + '|'.join(cleaned_row) + '|' + '\n')
|
|
table_string = table_string[:-1]
|
|
return table_string
|
|
|
|
|
|
def postprocess_page_content(page_content: list) -> list:
|
|
# rm repetitive identification for table and text
|
|
# Some documents may repeatedly recognize LTRect and LTTextContainer
|
|
table_obj = [p['obj'] for p in page_content if 'table' in p]
|
|
tmp = []
|
|
for p in page_content:
|
|
repetitive = False
|
|
if 'text' in p:
|
|
for t in table_obj:
|
|
if t.bbox[0] <= p['obj'].bbox[0] and p['obj'].bbox[1] <= t.bbox[1] and t.bbox[2] <= p['obj'].bbox[
|
|
2] and p['obj'].bbox[3] <= t.bbox[3]:
|
|
repetitive = True
|
|
break
|
|
|
|
if not repetitive:
|
|
tmp.append(p)
|
|
page_content = tmp
|
|
|
|
# merge paragraphs that have been separated by mistake
|
|
new_page_content = []
|
|
for p in page_content:
|
|
if new_page_content and 'text' in new_page_content[-1] and 'text' in p and abs(
|
|
p.get('font-size', 12) -
|
|
new_page_content[-1].get('font-size', 12)) < 2 and p['obj'].height < p.get('font-size', 12) + 1:
|
|
# Merge those lines belonging to a paragraph
|
|
new_page_content[-1]['text'] += f' {p["text"]}'
|
|
# new_page_content[-1]['font-name'] = p.get('font-name', '')
|
|
new_page_content[-1]['font-size'] = p.get('font-size', 12)
|
|
else:
|
|
p.pop('obj')
|
|
new_page_content.append(p)
|
|
for i in range(len(new_page_content)):
|
|
if 'text' in new_page_content[i]:
|
|
new_page_content[i]['text'] = clean_text(new_page_content[i]['text'])
|
|
return new_page_content
|
|
|
|
|
|
def extract_xls_schema(file_path: str) -> Dict[str, Any]:
|
|
xls = pd.ExcelFile(file_path)
|
|
schema = {
|
|
"sheets": [],
|
|
"n_sheets": len(xls.sheet_names)
|
|
}
|
|
|
|
for sheet_name in xls.sheet_names:
|
|
df = xls.parse(sheet_name, nrows=3) # 读取前3行
|
|
|
|
dtype_mapping = {
|
|
'object': 'string',
|
|
'datetime64[ns]': 'datetime',
|
|
'timedelta64[ns]': 'timedelta'
|
|
}
|
|
dtypes = df.dtypes.astype(str).replace(dtype_mapping).to_dict()
|
|
|
|
sample_df = df.head(3).copy()
|
|
for col in sample_df.columns:
|
|
if is_datetime64_any_dtype(sample_df[col]):
|
|
sample_df[col] = sample_df[col].dt.strftime('%Y-%m-%dT%H:%M:%S')
|
|
|
|
sheet_info = {
|
|
"name": sheet_name,
|
|
"columns": df.columns.tolist(),
|
|
"dtypes": dtypes,
|
|
"sample_data": sample_df.to_dict(orient='list')
|
|
}
|
|
schema["sheets"].append(sheet_info)
|
|
|
|
return schema
|
|
|
|
|
|
def extract_csv_schema(file_path: str) -> Dict[str, Any]:
|
|
df_dtype = pd.read_csv(file_path, nrows=100)
|
|
df_sample = pd.read_csv(file_path, nrows=3)
|
|
|
|
return {
|
|
"columns": df_dtype.columns.tolist(),
|
|
"dtypes": df_dtype.dtypes.astype(str).to_dict(),
|
|
"sample_data": df_sample.to_dict(orient='list'),
|
|
"estimated_total_rows": _estimate_total_rows(file_path)
|
|
}
|
|
|
|
|
|
def _estimate_total_rows(file_path) -> int:
|
|
with open(file_path, 'rb') as f:
|
|
line_count = 0
|
|
chunk_size = 1024 * 1024
|
|
while chunk := f.read(chunk_size):
|
|
line_count += chunk.count(b'\n')
|
|
return line_count - 1
|
|
|
|
|
|
def parse_tabular_file(file_path: str, **kwargs) -> List[dict]:
|
|
try:
|
|
df = pd.read_excel(file_path) if file_path.endswith(('.xlsx', '.xls')) else \
|
|
pd.read_csv(file_path, **kwargs)
|
|
if count_tokens(df_to_markdown(df)) > DEFAULT_MAX_INPUT_TOKENS:
|
|
schema = extract_xls_schema(file_path) if file_path.endswith(('.xlsx', '.xls')) else \
|
|
extract_csv_schema(file_path)
|
|
return [{'page_num': 1, 'content': [{'schema': schema}]}]
|
|
else:
|
|
return [{'page_num': 1, 'content': [{'table': df_to_markdown(df)}]}]
|
|
except Exception as e:
|
|
logger.error(f"Table parsing failed: {str(e)}")
|
|
return []
|
|
|
|
|
|
def parse_zip(file_path: str, extract_dir: str) -> List[dict]:
|
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
|
zip_ref.extractall(extract_dir)
|
|
return [os.path.join(extract_dir, f) for f in zip_ref.namelist()]
|
|
|
|
|
|
def parse_html(file_path: str) -> List[dict]:
|
|
from bs4 import BeautifulSoup
|
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
soup = BeautifulSoup(f, 'lxml')
|
|
|
|
content = [{'text': clean_text(p.get_text())}
|
|
for p in soup.find_all(['p', 'div']) if p.get_text().strip()]
|
|
|
|
return [{
|
|
'page_num': 1,
|
|
'content': content,
|
|
'title': soup.title.string if soup.title else ''
|
|
}]
|
|
|
|
|
|
def extract_xml_skeleton_markdown(xml_file):
|
|
tree = ET.parse(xml_file)
|
|
root = tree.getroot()
|
|
markdown_lines = []
|
|
|
|
def process_element(element, level=0, parent_path="", is_last=True, prefix=""):
|
|
if level > 0:
|
|
connector = "└── " if is_last else "├── "
|
|
markdown_lines.append(f"{prefix}{connector}**{element.tag}**")
|
|
else:
|
|
markdown_lines.append(f"## Root: {element.tag}")
|
|
|
|
if element.attrib:
|
|
attrs = [f"`{k}`" for k in element.attrib.keys()]
|
|
attr_line = f"{prefix}{' ' if level > 0 else ''}*Attributes:* {', '.join(attrs)}"
|
|
markdown_lines.append(attr_line)
|
|
|
|
if element.text and element.text.strip():
|
|
text_line = f"{prefix}{' ' if level > 0 else ''}*Has text content*"
|
|
markdown_lines.append(text_line)
|
|
seen_tags = set()
|
|
unique_children = []
|
|
for child in element:
|
|
if child.tag not in seen_tags:
|
|
seen_tags.add(child.tag)
|
|
unique_children.append(child)
|
|
|
|
for i, child in enumerate(unique_children):
|
|
is_last_child = (i == len(unique_children) - 1)
|
|
child_prefix = prefix + (" " if is_last else "│ ")
|
|
process_element(child, level + 1,
|
|
f"{parent_path}/{element.tag}" if parent_path else element.tag,
|
|
is_last_child, child_prefix)
|
|
|
|
process_element(root)
|
|
markdown_content = "\n".join(markdown_lines)
|
|
return markdown_content
|
|
|
|
|
|
def parse_xml(file_path: str) -> List[dict]:
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
text = f.read()
|
|
if count_tokens(text) > DEFAULT_MAX_INPUT_TOKENS:
|
|
schema = extract_xml_skeleton_markdown(file_path)
|
|
content = [{'schema': schema}]
|
|
else:
|
|
content = [{'text': text}]
|
|
return [{'page_num': 1, 'content': content}]
|
|
|
|
|
|
def compress(results: list) -> list[str]:
|
|
compress_results = []
|
|
max_token = math.floor(DEFAULT_MAX_INPUT_TOKENS / len(results))
|
|
for result in results:
|
|
token_list = tokenizer.tokenize(result)
|
|
token_list = token_list[:min(len(token_list), max_token)]
|
|
compress_results.append(tokenizer.convert_tokens_to_string(token_list))
|
|
return compress_results
|
|
|
|
|
|
# @register_tool('file_parser')
|
|
class SingleFileParser(BaseTool):
|
|
name="file_parser"
|
|
description = f"File parsing tool, supports parsing data in {'/'.join(PARSER_SUPPORTED_FILE_TYPES)} formats, and returns the parsed markdown format data."
|
|
parameters = [{
|
|
'name': 'url',
|
|
'type': 'string',
|
|
'description': 'The full path of the file to be parsed, which can be a local path or a downloadable http(s) link.',
|
|
'required': True
|
|
}]
|
|
|
|
def __init__(self, cfg: Optional[Dict] = None):
|
|
super().__init__(cfg)
|
|
self.data_root = self.cfg.get('path', os.path.join(DEFAULT_WORKSPACE, 'tools', self.name))
|
|
self.db = Storage({'storage_root_path': self.data_root})
|
|
self.structured_doc = self.cfg.get('structured_doc', True)
|
|
|
|
|
|
self.parsers = {
|
|
'pdf': parse_pdf,
|
|
'docx': parse_word,
|
|
'doc': parse_word,
|
|
'pptx': parse_ppt,
|
|
'txt': parse_txt,
|
|
'jsonl': parse_txt,
|
|
'jsonld': parse_txt,
|
|
'pdb': parse_txt,
|
|
'py': parse_txt,
|
|
'html': parse_html,
|
|
'xml': parse_xml,
|
|
'csv': lambda p: parse_tabular_file(p, sep=','),
|
|
'tsv': lambda p: parse_tabular_file(p, sep='\t'),
|
|
'xlsx': parse_tabular_file,
|
|
'xls': parse_tabular_file,
|
|
'zip': self.parse_zip
|
|
}
|
|
|
|
def call(self, params: Union[str, dict], **kwargs) -> Union[str, list]:
|
|
params = self._verify_json_format_args(params)
|
|
file_path = self._prepare_file(params['url'])
|
|
try:
|
|
cached = self.db.get(f'{hash_sha256(file_path)}_ori')
|
|
return self._flatten_result(json.loads(cached))
|
|
except KeyNotExistsError:
|
|
return self._flatten_result(self._process_new_file(file_path))
|
|
|
|
def _prepare_file(self, path: str) -> str:
|
|
if is_http_url(path):
|
|
download_dir = os.path.join(self.data_root, hash_sha256(path))
|
|
os.makedirs(download_dir, exist_ok=True)
|
|
return save_url_to_local_work_dir(path, download_dir)
|
|
return sanitize_chrome_file_path(path)
|
|
|
|
def _process_new_file(self, file_path: str) -> Union[str, list]:
|
|
file_type = get_file_type(file_path)
|
|
idp_types = ['pdf', 'docx', 'pptx', 'xlsx', 'jpg', 'png', 'mp3']
|
|
logger.info(f'Start parsing {file_path}...')
|
|
logger.info(f'File type {file_type}...')
|
|
logger.info(f"structured_doc {self.cfg.get('structured_doc')}...")
|
|
|
|
if file_type not in idp_types:
|
|
file_type = get_basename_from_url(file_path).split('.')[-1].lower()
|
|
|
|
try:
|
|
if USE_IDP and file_type in idp_types:
|
|
try:
|
|
results = parse_file_by_idp(file_path=file_path)
|
|
except Exception as e:
|
|
results = self.parsers[file_type](file_path)
|
|
else:
|
|
results = self.parsers[file_type](file_path)
|
|
tokens = 0
|
|
for page in results:
|
|
for para in page['content']:
|
|
if 'schema' in para:
|
|
para['token'] = count_tokens(json.dumps(para['schema']))
|
|
else:
|
|
para['token'] = count_tokens(para.get('text', para.get('table')))
|
|
tokens += para['token']
|
|
|
|
if not results or not tokens:
|
|
logger.error(f"Parsing failed: No information was parsed")
|
|
raise FileParserError("Document parsing failed")
|
|
else:
|
|
self._cache_result(file_path, results)
|
|
return results
|
|
except Exception as e:
|
|
logger.error(f"Parsing failed: {str(e)}")
|
|
raise FileParserError("Document parsing failed", exception=e)
|
|
|
|
def _cache_result(self, file_path: str, result: list):
|
|
cache_key = f'{hash_sha256(file_path)}_ori'
|
|
self.db.put(cache_key, json.dumps(result, ensure_ascii=False))
|
|
logger.info(f'The parsing result of {file_path} has been cached')
|
|
|
|
def _flatten_result(self, result: list) -> str:
|
|
return PARAGRAPH_SPLIT_SYMBOL.join(
|
|
para.get('text', para.get('table', ''))
|
|
for page in result for para in page['content']
|
|
)
|
|
|
|
def parse_zip(self, file_path: str) -> List[dict]:
|
|
extract_dir = os.path.join(self.data_root, f"zip_{hash_sha256(file_path)}")
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
|
|
results = []
|
|
for extracted_file in parse_zip(file_path, extract_dir):
|
|
if (ft := get_file_type(extracted_file)) in self.parsers:
|
|
try:
|
|
results.extend(self.parsers[ft](extracted_file))
|
|
except Exception as e:
|
|
logger.warning(f"Skip files {extracted_file}: {str(e)}")
|
|
|
|
if not results:
|
|
raise ValueError("No parseable content found in the ZIP file")
|
|
return results
|