mirror of
https://github.com/zenlm/zen-agent.git
synced 2026-10-30 14:58:52 +00:00
488 lines
17 KiB
Python
488 lines
17 KiB
Python
# Copyright 2023 The Qwen team, Alibaba Group. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from collections import Counter
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
from qwen_agent.log import logger
|
|
from qwen_agent.settings import DEFAULT_WORKSPACE
|
|
from qwen_agent.tools.base import BaseTool, register_tool
|
|
from qwen_agent.tools.storage import KeyNotExistsError, Storage
|
|
from qwen_agent.utils.str_processing import rm_cid, rm_continuous_placeholders, rm_hexadecimal
|
|
from qwen_agent.utils.tokenization_qwen import count_tokens
|
|
from qwen_agent.utils.utils import (get_file_type, hash_sha256, is_http_url, read_text_from_file,
|
|
sanitize_chrome_file_path, save_url_to_local_work_dir)
|
|
|
|
|
|
def clean_paragraph(text):
|
|
text = rm_cid(text)
|
|
text = rm_hexadecimal(text)
|
|
text = rm_continuous_placeholders(text)
|
|
return text
|
|
|
|
|
|
class DocParserError(Exception):
|
|
|
|
def __init__(self,
|
|
exception: Optional[Exception] = None,
|
|
code: Optional[str] = None,
|
|
message: Optional[str] = None,
|
|
extra: Optional[dict] = None):
|
|
if exception is not None:
|
|
super().__init__(exception)
|
|
else:
|
|
super().__init__(f'\nError code: {code}. Error message: {message}')
|
|
self.exception = exception
|
|
self.code = code
|
|
self.message = message
|
|
self.extra = extra
|
|
|
|
|
|
PARAGRAPH_SPLIT_SYMBOL = '\n'
|
|
|
|
|
|
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})
|
|
|
|
# Due to the pages in Word are not fixed, the entire document is returned as one page
|
|
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_paragraph(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_txt(path: str):
|
|
text = read_text_from_file(path)
|
|
paras = text.split(PARAGRAPH_SPLIT_SYMBOL)
|
|
content = []
|
|
for p in paras:
|
|
content.append({'text': p})
|
|
|
|
# Due to the pages in txt are not fixed, the entire document is returned as one page
|
|
return [{'page_num': 1, 'content': content}]
|
|
|
|
|
|
def df_to_md(df) -> str:
|
|
|
|
def replace_long_dashes(text):
|
|
if text.replace('-', '').replace(':', '').strip():
|
|
return text
|
|
pattern = r'-{6,}'
|
|
replaced_text = re.sub(pattern, '-----', text)
|
|
return replaced_text
|
|
|
|
from tabulate import tabulate
|
|
df = df.dropna(how='all')
|
|
df = df.dropna(axis=1, how='all')
|
|
df = df.fillna('')
|
|
md_table = tabulate(df, headers='keys', tablefmt='pipe', showindex=False)
|
|
|
|
md_table = '\n'.join([
|
|
'|'.join(replace_long_dashes(' ' + cell.strip() + ' ' if cell else '')
|
|
for cell in row.split('|'))
|
|
for row in md_table.split('\n')
|
|
])
|
|
return md_table
|
|
|
|
|
|
def parse_excel(file_path: str, extract_image: bool = False) -> List[dict]:
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
import pandas as pd
|
|
|
|
excel_file = pd.ExcelFile(file_path)
|
|
md_tables = []
|
|
for sheet_name in excel_file.sheet_names:
|
|
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
|
md_table = df_to_md(df)
|
|
md_tables.append(f'### Sheet: {sheet_name}\n{md_table}')
|
|
|
|
return [{'page_num': i + 1, 'content': [{'table': md_tables[i]}]} for i in range(len(md_tables))]
|
|
|
|
|
|
def parse_csv(file_path: str, extract_image: bool = False) -> List[dict]:
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
import pandas as pd
|
|
md_tables = []
|
|
try:
|
|
df = pd.read_csv(file_path, encoding_errors='replace', on_bad_lines='skip')
|
|
except Exception as ex:
|
|
# Directly converted from Excel
|
|
logger.warning(ex)
|
|
return parse_excel(file_path, extract_image)
|
|
md_table = df_to_md(df)
|
|
md_tables.append(md_table) # There is only one table available
|
|
|
|
return [{'page_num': i + 1, 'content': [{'table': md_tables[i]}]} for i in range(len(md_tables))]
|
|
|
|
|
|
def parse_tsv(file_path: str, extract_image: bool = False) -> List[dict]:
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
import pandas as pd
|
|
md_tables = []
|
|
try:
|
|
df = pd.read_csv(file_path, sep='\t', encoding_errors='replace', on_bad_lines='skip')
|
|
except Exception as ex:
|
|
# Directly converted from Excel
|
|
logger.warning(ex)
|
|
return parse_excel(file_path, extract_image)
|
|
md_table = df_to_md(df)
|
|
md_tables.append(md_table) # There is only one table available
|
|
|
|
return [{'page_num': i + 1, 'content': [{'table': md_tables[i]}]} for i in range(len(md_tables))]
|
|
|
|
|
|
def parse_html_bs(path: str, extract_image: bool = False):
|
|
if extract_image:
|
|
raise ValueError('Currently, extracting images is not supported!')
|
|
|
|
def pre_process_html(s):
|
|
# replace multiple newlines
|
|
s = re.sub('\n+', '\n', s)
|
|
# replace special string
|
|
s = s.replace("Add to Qwen's Reading List", '')
|
|
return s
|
|
|
|
try:
|
|
from bs4 import BeautifulSoup
|
|
except Exception:
|
|
raise ValueError('Please install bs4 by `pip install beautifulsoup4`')
|
|
bs_kwargs = {'features': 'lxml'}
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
soup = BeautifulSoup(f, **bs_kwargs)
|
|
|
|
text = soup.get_text()
|
|
|
|
if soup.title:
|
|
title = str(soup.title.string)
|
|
else:
|
|
title = ''
|
|
|
|
text = pre_process_html(text)
|
|
paras = text.split(PARAGRAPH_SPLIT_SYMBOL)
|
|
content = []
|
|
for p in paras:
|
|
p = clean_paragraph(p)
|
|
if p.strip():
|
|
content.append({'text': p})
|
|
|
|
# The entire document is returned as one page
|
|
return [{'page_num': 1, 'content': content, 'title': title}]
|
|
|
|
|
|
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 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
|
|
_p = p['text']
|
|
new_page_content[-1]['text'] += f' {_p}'
|
|
# 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_paragraph(new_page_content[i]['text'])
|
|
return new_page_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
|
|
|
|
|
|
PARSER_SUPPORTED_FILE_TYPES = ['pdf', 'docx', 'pptx', 'txt', 'html', 'csv', 'tsv', 'xlsx', 'xls']
|
|
|
|
|
|
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)
|
|
|
|
|
|
@register_tool('simple_doc_parser')
|
|
class SimpleDocParser(BaseTool):
|
|
description = f"提取出一个文档的内容,支持类型包括:{' / '.join(PARSER_SUPPORTED_FILE_TYPES)}"
|
|
parameters = {
|
|
'type': 'object',
|
|
'properties': {
|
|
'url': {
|
|
'description': '待提取的文件的路径,可以是一个本地路径或可下载的http(s)链接',
|
|
'type': 'string',
|
|
}
|
|
},
|
|
'required': ['url'],
|
|
}
|
|
|
|
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.extract_image = self.cfg.get('extract_image', False)
|
|
self.structured_doc = self.cfg.get('structured_doc', False)
|
|
|
|
self.db = Storage({'storage_root_path': self.data_root})
|
|
|
|
def call(self, params: Union[str, dict], **kwargs) -> Union[str, list]:
|
|
"""Parse pdf by url, and return the formatted content.
|
|
|
|
Returns:
|
|
Extracted doc as plain text or the following list format:
|
|
[
|
|
{'page_num': 1,
|
|
'content': [
|
|
{'text': 'This is one paragraph'},
|
|
{'table': 'This is one table'}
|
|
],
|
|
'title': 'If extracted, this is the title of the doc.'},
|
|
{'page_num': 2,
|
|
'content': [
|
|
{'text': 'This is one paragraph'},
|
|
{'table': 'This is one table'}
|
|
]}
|
|
]
|
|
"""
|
|
|
|
params = self._verify_json_format_args(params)
|
|
path = params['url']
|
|
cached_name_ori = f'{hash_sha256(path)}_ori'
|
|
try:
|
|
# Directly load the parsed doc
|
|
parsed_file = self.db.get(cached_name_ori)
|
|
parsed_file = json.loads(parsed_file)
|
|
logger.info(f'Read parsed {path} from cache.')
|
|
except KeyNotExistsError:
|
|
logger.info(f'Start parsing {path}...')
|
|
time1 = time.time()
|
|
|
|
f_type = get_file_type(path)
|
|
if f_type in PARSER_SUPPORTED_FILE_TYPES:
|
|
if path.startswith('https://') or path.startswith('http://') or re.match(
|
|
r'^[A-Za-z]:\\', path) or re.match(r'^[A-Za-z]:/', path):
|
|
path = path
|
|
else:
|
|
path = sanitize_chrome_file_path(path)
|
|
|
|
os.makedirs(self.data_root, exist_ok=True)
|
|
if is_http_url(path):
|
|
# download online url
|
|
tmp_file_root = os.path.join(self.data_root, hash_sha256(path))
|
|
os.makedirs(tmp_file_root, exist_ok=True)
|
|
path = save_url_to_local_work_dir(path, tmp_file_root)
|
|
try:
|
|
if f_type == 'pdf':
|
|
parsed_file = parse_pdf(path, self.extract_image)
|
|
elif f_type == 'docx':
|
|
parsed_file = parse_word(path, self.extract_image)
|
|
elif f_type == 'pptx':
|
|
parsed_file = parse_ppt(path, self.extract_image)
|
|
elif f_type == 'txt':
|
|
parsed_file = parse_txt(path)
|
|
elif f_type == 'html':
|
|
parsed_file = parse_html_bs(path, self.extract_image)
|
|
elif f_type == 'csv':
|
|
parsed_file = parse_csv(path, self.extract_image)
|
|
elif f_type == 'tsv':
|
|
parsed_file = parse_tsv(path, self.extract_image)
|
|
elif f_type in ['xlsx', 'xls']:
|
|
parsed_file = parse_excel(path, self.extract_image)
|
|
else:
|
|
_t = '/'.join(PARSER_SUPPORTED_FILE_TYPES)
|
|
raise ValueError(
|
|
f'Failed: The current parser does not support this file type! Supported types: {_t}')
|
|
except Exception as ex:
|
|
exception_type = type(ex).__name__
|
|
exception_message = str(ex)
|
|
raise DocParserError(code=exception_type, message=exception_message)
|
|
|
|
for page in parsed_file:
|
|
for para in page['content']:
|
|
# Todo: More attribute types
|
|
para['token'] = count_tokens(para.get('text', para.get('table')))
|
|
time2 = time.time()
|
|
logger.info(f'Finished parsing {path}. Time spent: {time2 - time1} seconds.')
|
|
# Cache the parsing doc
|
|
self.db.put(cached_name_ori, json.dumps(parsed_file, ensure_ascii=False, indent=2))
|
|
|
|
if not self.structured_doc:
|
|
return get_plain_doc(parsed_file)
|
|
else:
|
|
return parsed_file
|