Skip to content

Commit 7245683

Browse files
committed
refactor: simplify get_level_block parameters and enhance regex handling in SplitModel
【【知识库】markdown文件中前后有相同内容的标题但层级不同,分段时标题错误】 https://www.tapd.cn/tapd_fe/62980211/bug/detail/1162980211001077656
1 parent a9ac056 commit 7245683

2 files changed

Lines changed: 150 additions & 20 deletions

File tree

‎apps/common/tests_split_model.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import re
2+
import unittest
3+
from types import SimpleNamespace
4+
5+
from common.handle.impl.text.text_split_handle import TextSplitHandle
6+
from common.utils.split_model import SplitModel, get_split_model
7+
8+
9+
class SplitModelTestCase(unittest.TestCase):
10+
@staticmethod
11+
def paragraphs(text, filename='test.md'):
12+
return [
13+
(' '.join(row['title'].split()), row['content'].strip())
14+
for row in get_split_model(filename).parse(text)
15+
]
16+
17+
def test_same_heading_at_different_levels(self):
18+
for level in range(1, 6):
19+
with self.subTest(level=level):
20+
parent, child = '#' * level, '#' * (level + 1)
21+
text = (
22+
f'{parent} 员工手册目录\n\n'
23+
f'{child} 第一部分:公司概述\n目录条目一\n\n'
24+
f'{child} 第二部分:人力资源\n目录条目二\n\n'
25+
f'{parent} 第一部分:公司概述\n正文内容\n'
26+
)
27+
self.assertEqual(self.paragraphs(text), [
28+
('员工手册目录 第一部分:公司概述', '目录条目一'),
29+
('员工手册目录 第二部分:人力资源', '目录条目二'),
30+
('第一部分:公司概述', '正文内容'),
31+
])
32+
33+
def test_heading_text_in_body_does_not_start_section(self):
34+
text = '# 目录\n引用:# 正文\n\n# 正文\n正文内容'
35+
self.assertEqual(self.paragraphs(text), [
36+
('目录', '引用:# 正文'),
37+
('正文', '正文内容'),
38+
])
39+
40+
def test_heading_text_in_code_block_does_not_start_section(self):
41+
text = '# 目录\n```markdown\n# 正文\n```\n\n# 正文\n正文内容'
42+
self.assertEqual(self.paragraphs(text), [
43+
('目录', '```markdown\n# 正文\n```'),
44+
('正文', '正文内容'),
45+
])
46+
47+
def test_repeated_headings_keep_each_body(self):
48+
text = '# 章节\n第一段提到 # 章节\n\n# 章节\n第二段'
49+
self.assertEqual(self.paragraphs(text), [
50+
('章节', '第一段提到 # 章节'),
51+
('章节', '第二段'),
52+
])
53+
54+
def test_heading_prefix_does_not_end_section(self):
55+
text = '# 前言\n引用:# 公司概述扩展\n\n# 公司概述扩展\n扩展内容\n\n# 公司概述\n正文'
56+
self.assertEqual(self.paragraphs(text), [
57+
('前言', '引用:# 公司概述扩展'),
58+
('公司概述扩展', '扩展内容'),
59+
('公司概述', '正文'),
60+
])
61+
62+
def test_preamble_is_preserved(self):
63+
text = '前言提到 # 正文\n\n# 正文\n正文内容'
64+
self.assertEqual(self.paragraphs(text), [
65+
('', '前言提到 # 正文'),
66+
('正文', '正文内容'),
67+
])
68+
69+
def test_custom_patterns_with_capture_groups(self):
70+
pattern = re.compile(r'(?m)^(# .+)$|^(## .+)$')
71+
text = '# 目录\n引用:## 公司概述\n\n## 公司概述\n正文'
72+
result = SplitModel([pattern], with_filter=False).parse(text)
73+
self.assertEqual([(row['title'].strip(), row['content'].strip()) for row in result], [
74+
('目录', '引用:## 公司概述'),
75+
('公司概述', '正文'),
76+
])
77+
78+
def test_invalid_patterns_fall_back_to_plain_text(self):
79+
for pattern in (None, '', ' ', '['):
80+
with self.subTest(pattern=pattern):
81+
self.assertEqual(SplitModel([pattern]).parse('普通文本'), [
82+
{'title': '', 'content': '普通文本'},
83+
])
84+
85+
def test_long_heading_keeps_text_after_title_limit(self):
86+
heading = '# ' + '标题' * 150
87+
rows = get_split_model('test.md').parse(heading + '\n正文')
88+
self.assertEqual(len(rows), 1)
89+
self.assertEqual(rows[0]['title'], heading[:255].replace('#', ''))
90+
self.assertEqual(rows[0]['content'], heading[255:] + '\n正文')
91+
92+
def test_plain_text_splitting(self):
93+
self.assertEqual(self.paragraphs('第一段\n\n第二段', 'test.txt'), [
94+
('', '第一段'),
95+
('', '第二段'),
96+
])
97+
98+
def test_text_upload_handler_keeps_directory_under_its_heading(self):
99+
text = (
100+
'# 员工手册目录\n\n'
101+
'## 第一部分:公司概述\n目录条目一\n\n'
102+
'## 第二部分:人力资源\n目录条目二\n\n'
103+
'# 第一部分:公司概述\n\n'
104+
'## 公司简介\n正文内容\n'
105+
)
106+
for newline in ('\n', '\r\n', '\r'):
107+
with self.subTest(newline=repr(newline)):
108+
result = TextSplitHandle().handle(
109+
SimpleNamespace(name='员工手册目录.md'), [], False, 100000,
110+
lambda _: text.replace('\n', newline).encode('utf-8'), None,
111+
)
112+
self.assertEqual([
113+
(' '.join(row['title'].split()), row['content'].strip()) for row in result['content']
114+
], [
115+
('员工手册目录 第一部分:公司概述', '目录条目一'),
116+
('员工手册目录 第二部分:人力资源', '目录条目二'),
117+
('第一部分:公司概述 公司简介', '正文内容'),
118+
])
119+
120+
121+
if __name__ == '__main__':
122+
unittest.main()

‎apps/common/utils/split_model.py‎

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,18 @@
1313
import jieba
1414

1515

16-
def get_level_block(text, level_content_list, level_content_index, cursor):
16+
def get_level_block(text, level_content_list, level_content_index):
1717
"""
1818
从文本中获取块数据
1919
:param text: 文本
2020
:param level_content_list: 拆分的title数组
2121
:param level_content_index: 指定的下标
22-
:param cursor: 开始的下标位置
2322
:return: 拆分后的文本数据
2423
"""
2524
start_content: str = level_content_list[level_content_index].get('content')
26-
next_content = level_content_list[level_content_index + 1].get("content") if level_content_index + 1 < len(
27-
level_content_list) else None
28-
start_index = text.index(start_content, cursor)
29-
end_index = text.index(next_content, start_index + 1) if next_content is not None else len(text)
25+
start_index = level_content_list[level_content_index]['start']
26+
end_index = level_content_list[level_content_index + 1]['start'] if level_content_index + 1 < len(
27+
level_content_list) else len(text)
3028
return text[start_index + len(start_content):end_index], end_index
3129

3230

@@ -180,8 +178,20 @@ def parse_level(text, pattern: str):
180178
:param pattern: 正则
181179
:return: 符合正则的文本
182180
"""
183-
masked_text = mask_code_blocks(text)
184-
level_content_list = list(map(to_tree_obj, [r[0:255] for r in re_findall(pattern, masked_text) if r is not None]))
181+
if pattern is None or (isinstance(pattern, str) and not pattern.strip()):
182+
return []
183+
try:
184+
matches = re.finditer(pattern, mask_code_blocks(text))
185+
except re.error:
186+
return []
187+
level_content_list = []
188+
for match in matches:
189+
groups = range(1, match.re.groups + 1) if match.re.groups else [0]
190+
for group in groups:
191+
content = match.group(group)
192+
if content:
193+
# 保留正则匹配位置,避免同名标题在其他层级、正文或代码块中被错误定位。
194+
level_content_list.append({**to_tree_obj(content[:255]), 'start': match.start(group)})
185195
# 过滤掉空标题或只包含#和空白字符的标题
186196
filtered_list = [item for item in level_content_list
187197
if item['content'].strip(' ') and item['content'].replace('#', '').strip(' ')]
@@ -387,26 +397,24 @@ def parse_to_tree(self, text: str, index=0):
387397
level_content_list = parse_title_level(text, self.content_level_pattern, index)
388398
if len(level_content_list) == 0:
389399
return [to_tree_obj(row, 'block') for row in smart_split_paragraph(text, limit=self.limit)]
390-
if index == 0 and text.lstrip().index(level_content_list[0]["content"].lstrip()) != 0:
391-
level_content_list.insert(0, to_tree_obj(""))
400+
if index == 0 and text[:level_content_list[0]['start']].strip():
401+
level_content_list.insert(0, {**to_tree_obj(""), 'start': 0})
392402

393403
cursor = 0
394404
level_title_content_list = [item for item in level_content_list if item.get('state') == 'title']
395405
for i in range(len(level_title_content_list)):
396-
start_content: str = level_title_content_list[i].get('content')
397-
if cursor < text.index(start_content, cursor):
398-
for row in smart_split_paragraph(text[cursor: text.index(start_content, cursor)], limit=self.limit):
399-
level_content_list.insert(0, to_tree_obj(row, 'block'))
400-
401-
block, cursor = get_level_block(text, level_title_content_list, i, cursor)
406+
start_index = level_title_content_list[i]['start']
407+
if cursor < start_index:
408+
level_content_list[0:0] = [
409+
to_tree_obj(row, 'block')
410+
for row in smart_split_paragraph(text[cursor:start_index], limit=self.limit)
411+
]
412+
413+
block, cursor = get_level_block(text, level_title_content_list, i)
402414
if len(block) == 0:
403415
continue
404416
children = self.parse_to_tree(text=block, index=index + 1)
405417
level_title_content_list[i]['children'] = children
406-
first_child_idx_in_block = block.lstrip().index(children[0]["content"].lstrip())
407-
if first_child_idx_in_block != 0:
408-
inner_children = self.parse_to_tree(block[:first_child_idx_in_block], index + 1)
409-
level_title_content_list[i]['children'].extend(inner_children)
410418
return level_content_list
411419

412420
def parse(self, text: str):

0 commit comments

Comments
 (0)