test_step4_difficulty.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. """F043:合成采购需求及真实 Step4/5/6 写出函数,LLM 默认使用桩。"""
  2. import copy
  3. import base64
  4. import io
  5. import importlib.util
  6. import json
  7. import os
  8. import re
  9. import sys
  10. import tempfile
  11. import unittest
  12. from types import SimpleNamespace
  13. from unittest.mock import Mock
  14. from docx import Document
  15. from docx.oxml import OxmlElement
  16. from docx.oxml.ns import qn
  17. from models import BidOutline, Chapter, ProjectData, TenderAnalysis
  18. from step3_outlining.scoring_structure import _persist_heading_mappings
  19. from step4_writing import _persist_all_chapter_docs
  20. from step4_writing.difficulty_rewrite import (
  21. MODE, ROLE_BUDGETS, identify_targets, rewrite_difficulty_sections, word_count,
  22. )
  23. from step5_reviewing.reviewer import _Reviewer
  24. from step6_exporting.assembler import assemble_step5_document
  25. from step6_exporting.docx_builder import build_document
  26. def selection(*ids):
  27. return {"targets": [{"id": node_id, "roles": list(ROLE_BUDGETS)} for node_id in ids]}
  28. def load_stage(number):
  29. directory = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
  30. if directory not in sys.path:
  31. sys.path.insert(0, directory)
  32. spec = importlib.util.spec_from_file_location(
  33. f'difficulty_stage{number}', os.path.join(directory, f'test_step{number}.py'))
  34. module = importlib.util.module_from_spec(spec)
  35. spec.loader.exec_module(module)
  36. return module
  37. class DifficultyTests(unittest.TestCase):
  38. def setUp(self):
  39. self.target = Chapter(id='3.2', title='二、关键挑战与解决策略', level=2,
  40. from_template=True, template_original_id='3.2',
  41. template_original_title='二、关键挑战与解决策略')
  42. self.root = Chapter(id='3', title='需求理解', template_original_id='3',
  43. from_template=True, template_chapter_id='3',
  44. generated_content='需求理解\n保留章正文\n一、服务认知\n保留相邻正文\n'
  45. '二、关键挑战与解决策略\n旧项目重难点\n'
  46. '三、其他事项\n保留末节正文',
  47. children=[Chapter(id='3.1', title='一、服务认知', level=2),
  48. self.target,
  49. Chapter(id='3.3', title='三、其他事项', level=2)])
  50. self.outline = BidOutline(project_name='合成文化中心物业项目', chapters=[self.root])
  51. _persist_heading_mappings(self.outline)
  52. self.outline.evaluation_index_entries = [{
  53. 'entry_type': 'scoring', 'source_id': 'SC-1#1', 'criterion_id': 'SC-1',
  54. 'display_name': '项目重难点及对策', 'requirement': '分析项目特点和难点并提出措施',
  55. 'final_heading_id': '3.2', 'final_heading_title': self.target.title,
  56. }]
  57. self.analysis = TenderAnalysis(project_name=self.outline.project_name)
  58. self.data = ProjectData(project_id='synthetic', project_name=self.outline.project_name,
  59. procurement_docs=[SimpleNamespace(content=(
  60. '本项目为合成文化中心物业服务。服务区域包含展厅、公共走廊和设备间。'
  61. '展览开放期间持续提供保洁及秩序维护,闭馆后安排深度清洁。'
  62. '活动期间人流集中,须与场馆管理方协调,保持疏散通道畅通。'
  63. '设备巡检发现异常须记录并报告,按授权开展处置,不得擅自停运设备。'
  64. '采购需求未规定面积、人数或设备数量。'))])
  65. self.client = Mock()
  66. self.client.extract_json.side_effect = [
  67. selection('3.2'),
  68. {'new_titles': ['项目业态与概况', '重点难点成因分析', '逐项应对及检查闭环']},
  69. ]
  70. self.serial = 0
  71. def generate(**kwargs):
  72. payload = json.loads(kwargs['user_prompt'])
  73. expected = int(re.search(r'正文(\d+)字', payload['length']).group(1))
  74. self.serial += 1
  75. # 不同节点不同文字,避免确定性去重把桩文本删除。
  76. marker = '专项正文' + chr(0x4e10 + self.serial)
  77. return marker + chr(0x5000 + self.serial) * (expected - len(marker))
  78. self.client.generate.side_effect = generate
  79. def test_semantic_candidates_no_match_and_scope(self):
  80. other = Chapter(id='4', title='服务方案', children=[
  81. Chapter(id='4.1', title='重点难点分析及应对措施', level=2)])
  82. self.outline.chapters.append(other)
  83. self.root.children.append(Chapter(id='3.4', title='附件', level=2, is_attachment=True,
  84. children=[Chapter(id='3.4.1', title='重点难点及措施', level=3)]))
  85. client = Mock()
  86. client.extract_json.return_value = selection()
  87. before = copy.deepcopy(self.outline)
  88. rewrite_difficulty_sections(self.outline, self.analysis, ProjectData('synthetic', '合成'), client)
  89. self.assertEqual(self.outline, before)
  90. payload = json.loads(client.extract_json.call_args.kwargs['user_prompt'])
  91. self.assertIn('4.1', [n['id'] for n in payload['nodes']])
  92. self.assertEqual(next(n['path'] for n in payload['nodes'] if n['id'] == '4.1'),
  93. ['服务方案', '重点难点分析及应对措施'])
  94. self.assertNotIn('3.4.1', [n['id'] for n in payload['nodes']])
  95. client.generate.assert_not_called()
  96. def test_invalid_and_overlapping_ids_rejected(self):
  97. self.target.children = [Chapter(id='3.2.1', title='服务瓶颈', level=3)]
  98. for ids in [['4.1'], ['missing'], ['3', '3.2'], ['3.2', '3.2'], ['3.2', '3.2.1']]:
  99. client = Mock()
  100. client.extract_json.return_value = selection(*ids)
  101. with self.subTest(ids=ids), self.assertRaises(ValueError):
  102. identify_targets(self.outline, client)
  103. def test_missing_procurement_and_short_generation_fail_without_tree_changes(self):
  104. before = copy.deepcopy(self.outline)
  105. with self.assertRaisesRegex(ValueError, '采购需求正文为空'):
  106. rewrite_difficulty_sections(self.outline, self.analysis, ProjectData('synthetic', '合成'), self.client)
  107. self.assertEqual(self.outline, before)
  108. self.setUp()
  109. self.client.generate.side_effect = None
  110. self.client.generate.return_value = '内容不足'
  111. with self.assertRaisesRegex(ValueError, '三次生成'):
  112. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  113. self.assertEqual(self.target.children, [])
  114. self.assertNotEqual(self.target.content_generation_mode, MODE)
  115. def test_word_budget_and_bindings(self):
  116. bindings = copy.deepcopy(self.outline.evaluation_index_entries)
  117. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  118. self.assertEqual(self.outline.evaluation_index_entries, bindings)
  119. self.assertEqual(sum(word_count(n.supplement_content)
  120. for n in [self.target, *self.target.children]), 10000)
  121. self.assertTrue(all(n.level == 3 for n in self.target.children))
  122. self.assertNotIn('旧项目重难点', self.root.generated_content)
  123. self.assertIn('保留相邻正文', self.root.generated_content)
  124. self.assertIn(self.data.procurement_docs[0].content,
  125. self.client.generate.call_args.kwargs['user_prompt'])
  126. report = load_stage(4)._build_report(self.outline, self.analysis)
  127. self.assertEqual(report['difficulty_sections'][0]['body_words'], 10000)
  128. self.target.supplement_content = ''
  129. report = load_stage(4)._build_report(self.outline, self.analysis)
  130. self.assertTrue(any('专项字数不达标' in e for e in report['errors']))
  131. def test_h4_target_uses_batches_without_h5(self):
  132. self.target.level = 4
  133. self.client.extract_json.side_effect = [selection('3.2')]
  134. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  135. self.assertEqual(self.target.children, [])
  136. self.assertEqual(word_count(self.target.supplement_content), 10000)
  137. self.assertEqual(self.client.generate.call_count, 5)
  138. def test_duplicate_title_rejected(self):
  139. self.client.extract_json.side_effect = [
  140. selection('3.2'), {'new_titles': ['关键挑战与解决策略']}]
  141. with self.assertRaisesRegex(ValueError, '重复'):
  142. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  143. def test_existing_children_keep_ids_and_new_ids_do_not_collide(self):
  144. child = Chapter(id='3.2.4', title='(一)已有难点分析', level=3)
  145. self.target.children = [child]
  146. _persist_heading_mappings(self.outline)
  147. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  148. self.assertIs(self.target.children[0], child)
  149. self.assertEqual([c.id for c in self.target.children], ['3.2.4', '3.2.5', '3.2.6', '3.2.7'])
  150. self.assertEqual(self.target.children[1].title, '(二)项目业态与概况')
  151. self.assertEqual(sum(word_count(n.supplement_content)
  152. for n in [self.target, *self.target.children]), 10000)
  153. def test_long_procurement_reads_tail_instead_of_truncating(self):
  154. self.data.procurement_docs[0].content = '采购事实' * 6000 + '末尾特殊设备停运约束'
  155. self.client.extract_json.side_effect = [
  156. selection('3.2'), {'facts': '采购事实'}, {'facts': '末尾特殊设备停运约束'},
  157. {'new_titles': []}]
  158. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  159. self.assertIn('末尾特殊设备停运约束', self.client.generate.call_args.kwargs['user_prompt'])
  160. def test_short_first_response_is_regenerated(self):
  161. generate = self.client.generate.side_effect
  162. calls = [0]
  163. def short_first(**kwargs):
  164. calls[0] += 1
  165. return '过短' if calls[0] == 1 else generate(**kwargs)
  166. self.client.generate.side_effect = short_first
  167. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  168. self.assertIn('上次正文2字', self.client.generate.call_args_list[1].kwargs['user_prompt'])
  169. self.assertEqual(sum(word_count(n.supplement_content)
  170. for n in [self.target, *self.target.children]), 10000)
  171. def test_step4_to_step6_headings_prose_and_native_objects(self):
  172. self._assert_pipeline()
  173. def test_distributed_roles_share_budget_and_survive_export(self):
  174. roles = list(ROLE_BUDGETS)
  175. nodes = [Chapter(id='3.1', title='项目业态', level=2),
  176. Chapter(id='3.2', title='项目概况', level=2),
  177. Chapter(id='5.1', title='关键难点成因', level=2),
  178. Chapter(id='6.1', title='针对性应对措施', level=2)]
  179. self.root.children = nodes[:2] + [Chapter(id='3.3', title='无关事项', level=2)]
  180. self.root.generated_content = '项目业态\n旧专项一\n项目概况\n旧专项二\n无关事项\n必须保留正文'
  181. self.outline.chapters += [Chapter(id='5', title='难点分析', children=[nodes[2]],
  182. generated_content='关键难点成因\n旧专项三'),
  183. Chapter(id='6', title='实施措施', children=[nodes[3]],
  184. generated_content='针对性应对措施\n旧专项四')]
  185. _persist_heading_mappings(self.outline)
  186. original = {'project': self.outline.project_name,
  187. 'heading_mappings': copy.deepcopy(self.outline.heading_mappings),
  188. 'evaluation_index_entries': copy.deepcopy(self.outline.evaluation_index_entries)}
  189. self.client.extract_json.side_effect = [
  190. {'targets': [{'id': n.id, 'roles': [r]} for n, r in zip(nodes, roles)]},
  191. {'new_titles': []}, {'new_titles': []},
  192. {'new_titles': ['开放时段作业冲突分析']}, {'new_titles': []}]
  193. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  194. self.assertEqual([sum(word_count(c.supplement_content) for c in [n, *n.children])
  195. for n in nodes], list(ROLE_BUDGETS.values()))
  196. self.assertIn('必须保留正文', self.root.generated_content)
  197. report = load_stage(4)._build_report(self.outline, self.analysis)
  198. self.assertEqual(len(report['difficulty_sections']), 1)
  199. self.assertEqual(report['difficulty_sections'][0]['body_words'], 10000)
  200. prompts = [json.loads(c.kwargs['user_prompt']) for c in self.client.generate.call_args_list]
  201. measure = next(p for p in prompts if p['assigned_roles'] == ['measures'])
  202. self.assertIn('5.1', measure['previous_sections'])
  203. self.assertEqual(sum(t['word_budget'] for t in measure['coordinated_targets']), 10000)
  204. stage5, stage6 = load_stage(5), load_stage(6)
  205. with tempfile.TemporaryDirectory() as tmp:
  206. d4, d5 = os.path.join(tmp, 'step4'), os.path.join(tmp, 'step5')
  207. template = os.path.join(tmp, 'template.docx')
  208. doc = Document()
  209. doc.sections[0].header.paragraphs[0].text = '合成项目'
  210. doc.sections[0].footer.paragraphs[0].text = '合成页脚'
  211. doc.save(template)
  212. _persist_all_chapter_docs(self.outline, d4)
  213. restored = stage5._load_step4_outline(d4, original, stage5._load_chapter_texts(d4))
  214. self.assertEqual(restored.evaluation_index_entries, original['evaluation_index_entries'])
  215. self.assertEqual([n.id for n in restored.flatten()], [n.id for n in self.outline.flatten()])
  216. stage5._write_step5_chapters_from_step4(stage5._load_step4_records(d4), restored, self.analysis, d5)
  217. output = os.path.join(tmp, 'distributed.docx')
  218. assemble_step5_document(stage6._load_chapter_records(d5), restored, output, template_path=template)
  219. text = '\n'.join(p.text for p in Document(output).paragraphs)
  220. self.assertIn('必须保留正文', text)
  221. self.assertNotIn('旧专项', text)
  222. for n in nodes + nodes[2].children:
  223. self.assertTrue(re.sub(r'\s+', '', n.supplement_content) in re.sub(r'\s+', '', text))
  224. child = nodes[2].children[0]
  225. headings = [p for p in Document(output).paragraphs if p.text == child.title]
  226. self.assertEqual(len(headings), 1)
  227. self.assertEqual(headings[0].style.name, 'Heading 3')
  228. def test_incomplete_roles_rejected(self):
  229. self.client.extract_json.side_effect = [{'targets': [{'id': '3.2', 'roles': ['analysis']}]}]
  230. with self.assertRaisesRegex(ValueError, '职责不完整'):
  231. identify_targets(self.outline, self.client)
  232. def test_late_distributed_failure_does_not_commit_partial_rewrite(self):
  233. self.client.extract_json.side_effect = [
  234. {'targets': [{'id': '3.1', 'roles': ['business_type', 'overview', 'analysis']},
  235. {'id': '3.2', 'roles': ['measures']}]},
  236. {'new_titles': []}, {'new_titles': []}]
  237. generate = self.client.generate.side_effect
  238. def fail_measures(**kwargs):
  239. return '过短' if json.loads(kwargs['user_prompt'])['assigned_roles'] == ['measures'] else generate(**kwargs)
  240. self.client.generate.side_effect = fail_measures
  241. before = copy.deepcopy(self.outline)
  242. with self.assertRaisesRegex(ValueError, '三次生成'):
  243. rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
  244. self.assertEqual(self.outline, before)
  245. def test_non_third_chapter_with_different_parent_title_through_export(self):
  246. self._assert_pipeline(chapter_number=5)
  247. def test_standalone_top_level_difficulty_chapter_through_export(self):
  248. self._assert_pipeline(chapter_number=6, standalone=True)
  249. def _assert_pipeline(self, chapter_number=3, standalone=False):
  250. from step3_outlining.scoring_structure import _int_to_chinese
  251. if chapter_number != 3:
  252. for node in self.outline.flatten():
  253. node.id = str(chapter_number) + node.id[1:]
  254. if node.template_original_id:
  255. node.template_original_id = str(chapter_number) + node.template_original_id[1:]
  256. self.root.template_chapter_id = str(chapter_number)
  257. self.root.title = '项目服务实施方案'
  258. self.root.generated_content = self.root.generated_content.replace('需求理解', self.root.title)
  259. if standalone:
  260. self.target = self.root
  261. self.root.title = '项目关键挑战及应对策略'
  262. self.root.template_original_title = self.root.title
  263. self.root.children = []
  264. self.root.generated_content = self.root.title + '\n旧项目重难点'
  265. self.root.preserve_template_layout = True
  266. self.root.template_fill_completed = True
  267. self.root.content_blocks = [{'block_type': 'template_base'}, {'block_type': 'native_table_plan'}]
  268. _persist_heading_mappings(self.outline)
  269. self.outline.evaluation_index_entries[0]['final_heading_id'] = self.target.id
  270. self.outline.evaluation_index_entries[0]['final_heading_title'] = self.target.title
  271. self.client.extract_json.side_effect = [
  272. selection(self.target.id),
  273. {'new_titles': ['项目业态与概况', '重点难点成因分析', '逐项应对及检查闭环']}]
  274. # 可选择以真实 LLM 对同一脱敏夹具复验,不读取业务资料。
  275. client = None if os.environ.get('F043_LIVE_LLM') == '1' else self.client
  276. original_report = {'project': self.outline.project_name,
  277. 'heading_mappings': copy.deepcopy(self.outline.heading_mappings),
  278. 'evaluation_index_entries': copy.deepcopy(self.outline.evaluation_index_entries)}
  279. rewrite_difficulty_sections(self.outline, self.analysis, self.data, client)
  280. stage5, stage6 = load_stage(5), load_stage(6)
  281. with tempfile.TemporaryDirectory() as tmp:
  282. template = os.path.join(tmp, 'template.docx')
  283. doc = Document()
  284. doc.sections[0].header.paragraphs[0].text = self.outline.project_name
  285. footer = doc.sections[0].footer.paragraphs[0]
  286. for kind in ['begin', 'end']:
  287. field = OxmlElement('w:fldChar')
  288. field.set(qn('w:fldCharType'), kind)
  289. footer.add_run()._r.append(field)
  290. if kind == 'begin':
  291. instruction = OxmlElement('w:instrText')
  292. instruction.text = ' PAGE '
  293. footer.add_run()._r.append(instruction)
  294. doc.add_paragraph(f'第{_int_to_chinese(chapter_number)}章 {self.root.title}', style='Heading 1')
  295. if not standalone:
  296. doc.add_paragraph('保留章正文')
  297. doc.add_paragraph('一、服务认知', style='Heading 2')
  298. doc.add_paragraph('保留相邻正文')
  299. doc.add_paragraph(self.target.title, style='Heading 2')
  300. doc.add_paragraph('旧项目重难点')
  301. doc.add_paragraph('旧项目重复泛化说明')
  302. doc.add_paragraph('原生表前说明')
  303. doc.add_table(rows=1, cols=1).cell(0, 0).text = '受保护表格'
  304. doc.add_paragraph('原生表后说明')
  305. doc.add_paragraph('签署人:测试')
  306. doc.add_picture(io.BytesIO(base64.b64decode(
  307. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=')))
  308. doc.add_paragraph('图1 原生图片说明')
  309. protected = doc.add_paragraph()
  310. bookmark = OxmlElement('w:bookmarkStart')
  311. bookmark.set(qn('w:id'), '10')
  312. bookmark.set(qn('w:name'), 'protected')
  313. protected._p.append(bookmark)
  314. if not standalone:
  315. doc.add_paragraph('三、其他事项', style='Heading 2')
  316. doc.add_paragraph('保留末节正文')
  317. doc.save(template)
  318. stage4_dir = os.path.join(tmp, 'step4')
  319. paths = _persist_all_chapter_docs(self.outline, stage4_dir, template_path=template)
  320. artifact = Document(paths[0])
  321. actual = '\n'.join(p.text for p in artifact.paragraphs)
  322. self.assertNotIn('旧项目', actual)
  323. preserved = ['原生表前说明', '原生表后说明', '签署人:测试']
  324. if not standalone:
  325. preserved += ['保留相邻正文', '保留末节正文']
  326. for text in preserved:
  327. self.assertIn(text, actual)
  328. self.assertEqual(len(artifact.tables), 1)
  329. self.assertEqual(len(artifact.inline_shapes), 1)
  330. texts = stage5._load_chapter_texts(stage4_dir)
  331. reviewed = stage5._load_step4_outline(stage4_dir, original_report, texts)
  332. self.assertEqual([n.id for n in reviewed.flatten()], [n.id for n in self.outline.flatten()])
  333. reviewer = _Reviewer.__new__(_Reviewer)
  334. evidence = reviewer._bound_entry_evidence(reviewed, reviewed.evaluation_index_entries[0])
  335. for n in self.target.children:
  336. self.assertTrue(re.sub(r'\s+', '', n.supplement_content) in re.sub(r'\s+', '', evidence))
  337. self.assertGreaterEqual(word_count(evidence), 9000)
  338. self.assertLess(word_count(evidence), 11200, '一级专项审核不能将整章与各子节重复计入')
  339. records = stage5._load_step4_records(stage4_dir)
  340. stage5_dir = os.path.join(tmp, 'step5')
  341. stage5._write_step5_chapters_from_step4(records, reviewed, self.analysis, stage5_dir)
  342. output = os.path.join(tmp, 'final.docx')
  343. assemble_step5_document(stage6._load_chapter_records(stage5_dir), reviewed,
  344. output, template_path=template)
  345. result = Document(output)
  346. for n in self.target.children:
  347. matching = [p for p in result.paragraphs if p.text == n.title]
  348. self.assertEqual(len(matching), 1)
  349. self.assertEqual(matching[0].style.name, f'Heading {n.level}')
  350. self.assertTrue(len(matching[0]._p.findall(qn('w:bookmarkStart'))) > 0)
  351. self.assertEqual(len(result.tables), 2) # 原生表 + 评标索引
  352. self.assertEqual(len(result.inline_shapes), 1)
  353. self.assertNotIn('旧项目', '\n'.join(p.text for p in result.paragraphs))
  354. # CLI 的兼容导出入口同样不能恢复模板旧段落或遗漏新子节。
  355. cli_output = os.path.join(tmp, 'cli-final.docx')
  356. original_paths = [c.artifact_path for c in self.outline.chapters]
  357. build_document(self.outline, template, cli_output, analysis=self.analysis)
  358. cli_doc = Document(cli_output)
  359. cli_text = '\n'.join(p.text for p in cli_doc.paragraphs)
  360. self.assertNotIn('旧项目', cli_text)
  361. for n in self.target.children:
  362. self.assertIn(n.title, cli_text)
  363. self.assertEqual([c.artifact_path for c in self.outline.chapters], original_paths)
  364. # 不允许新 manifest 携带其他轮次的评分映射或改变原标题树。
  365. manifest_path = stage5._find_manifest(stage4_dir)
  366. with open(manifest_path, encoding='utf-8') as file:
  367. manifest = json.load(file)
  368. manifest['final_outline']['heading_mappings'][0]['final_title'] = '篡改标题'
  369. with open(manifest_path, 'w', encoding='utf-8') as file:
  370. json.dump(manifest, file, ensure_ascii=False)
  371. with self.assertRaisesRegex(ValueError, '既有标题'):
  372. stage5._load_step4_outline(stage4_dir, original_report, texts)
  373. if __name__ == '__main__':
  374. unittest.main()