test_new_scoring_chapters.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """新增一级评分章跨 Step3-6 的离线回归,全部使用合成资料。"""
  2. import copy
  3. import importlib.util
  4. import os
  5. import sys
  6. import tempfile
  7. import unittest
  8. import zipfile
  9. import xml.etree.ElementTree as ET
  10. from types import SimpleNamespace
  11. from unittest.mock import Mock, patch
  12. from docx import Document
  13. from docx.oxml import OxmlElement
  14. from docx.oxml.ns import qn
  15. from models import BidOutline, Chapter, CompanyInfo, ScoringCriterion, TenderAnalysis
  16. from step3_outlining import _write_and_enforce_outline_gate
  17. from step3_outlining.outline_report import build_outline_report, validate_outline_gate
  18. from step3_outlining.scoring_structure import apply_scoring_structure, _int_to_chinese
  19. from step4_writing import _get_template_text_for_chapter, _save_chapter_docx, _write_single_chapter
  20. from step5_reviewing.policies import find_template_integrity_issues
  21. from step5_reviewing.reviewer import _Reviewer
  22. from step6_exporting.assembler import assemble_step5_document
  23. class NewScoringChapterTests(unittest.TestCase):
  24. def test_inserted_chapter_never_reads_template_even_with_stale_section(self):
  25. chapter = Chapter(id='6', title='专项类别', is_scoring_inserted=True,
  26. template_section='第六章 项目经理')
  27. with patch('step4_writing.get_template_text_for_section') as section, patch(
  28. 'step4_writing.get_template_text_for_chapter'
  29. ) as title:
  30. self.assertEqual(_get_template_text_for_chapter(object(), chapter), '')
  31. section.assert_not_called()
  32. title.assert_not_called()
  33. def test_shifted_chapter_fallback_uses_template_source_number(self):
  34. for source in ({'template_chapter_id': '6'}, {'template_original_id': '6'}):
  35. with self.subTest(source=source):
  36. chapter = Chapter(id='9', title='经理章节别名', **source)
  37. with patch('step4_writing.get_template_text_for_chapter',
  38. side_effect=lambda _, title: {'项目经理': '正确经理正文',
  39. '错误模板章': '错误正文'}.get(title, '')), patch(
  40. 'step3_outlining.template_parser.get_template_chapter_titles',
  41. return_value=[('6', '项目经理'), ('9', '错误模板章')]
  42. ):
  43. self.assertEqual(_get_template_text_for_chapter(
  44. SimpleNamespace(file_path='synthetic.docx'), chapter
  45. ), '正确经理正文')
  46. def test_inserted_chapter_writing_has_no_wrong_template_base(self):
  47. chapter = self.outline.chapters[5]
  48. injector = Mock()
  49. injector.get_scoring_context.return_value = ''
  50. def generate(chapter, *_args):
  51. chapter.children[0].supplement_content = '专项执行响应正文'
  52. return 1
  53. with patch('step4_writing.get_template_text_for_chapter',
  54. return_value='不应读取的经理正文') as lookup, patch(
  55. 'step4_writing._generate_direct_content_blocks', side_effect=generate
  56. ):
  57. result = _write_single_chapter(
  58. chapter, Mock(), injector, self.analysis, CompanyInfo(),
  59. object(), SimpleNamespace(enforce_word_limit=False), self.outline, {}
  60. )
  61. lookup.assert_not_called()
  62. self.assertIn('专项执行响应正文', result.generated_content)
  63. self.assertNotIn('经理正文', result.generated_content)
  64. self.assertEqual(result.content_blocks[0]['block_type'], 'generated_scoring_chapter')
  65. @staticmethod
  66. def _load_stage(number):
  67. scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
  68. spec = importlib.util.spec_from_file_location(
  69. f'new_scoring_stage{number}', os.path.join(scripts_dir, f'test_step{number}.py')
  70. )
  71. module = importlib.util.module_from_spec(spec)
  72. with patch.object(sys, 'path', [scripts_dir, *sys.path]), patch.dict(os.environ):
  73. spec.loader.exec_module(module)
  74. return module
  75. def setUp(self):
  76. titles = ['资格证明', '投标报价', '需求理解', '服务方案', '质量保障',
  77. '项目经理', '人员配置', '附件']
  78. self.template = BidOutline(project_name='合成项目', chapters=[
  79. Chapter(id=str(i), title=title, level=1, template_chapter_id=str(i),
  80. template_original_id=str(i), from_template=True)
  81. for i, title in enumerate(titles, 1)
  82. ])
  83. self.analysis = TenderAnalysis(project_name='合成项目', scoring_criteria=[
  84. ScoringCriterion(id=f'SC-{i}', category=f'专项类别{i}', name=f'专项措施{i}',
  85. description=f'说明专项措施{i}的执行流程', max_score=5)
  86. for i in range(1, 4)
  87. ])
  88. self.outline = copy.deepcopy(self.template)
  89. self.outline.template_chapters = copy.deepcopy(self.template.chapters)
  90. apply_scoring_structure(self.outline, self.analysis.scoring_criteria)
  91. def test_gate_accepts_new_categories_without_approval(self):
  92. for value in ['', '其他历史类别']:
  93. with self.subTest(legacy_value=value), patch.dict(
  94. os.environ, {'BID_APPROVED_NEW_SCORING_CATEGORIES': value}
  95. ), tempfile.TemporaryDirectory() as tmp:
  96. _write_and_enforce_outline_gate(
  97. self.outline, self.analysis, os.path.join(tmp, 'report.md')
  98. )
  99. self.assertEqual([c.title for c in self.outline.chapters[5:9]],
  100. ['专项类别1', '专项类别2', '专项类别3', '项目经理'])
  101. self.assertEqual([c.id for c in self.outline.chapters],
  102. [str(i) for i in range(1, 12)])
  103. def test_gate_still_rejects_unauthorized_and_missing_template_chapters(self):
  104. self.outline.chapters[5].is_scoring_inserted = False
  105. self.outline.chapters.pop()
  106. errors = validate_outline_gate(self.template, self.outline, self.analysis)
  107. self.assertTrue(any('授权评分' in error for error in errors))
  108. self.assertTrue(any('缺少模板章' in error for error in errors))
  109. def test_new_chapters_keep_own_content_and_shifted_template_sources_through_export(self):
  110. with tempfile.TemporaryDirectory() as tmp:
  111. template_path = os.path.join(tmp, 'template.docx')
  112. doc = Document()
  113. doc.sections[0].header.paragraphs[0].text = '合成项目 合成页眉'
  114. doc.sections[0].footer.paragraphs[0].text = '合成页脚'
  115. footer = doc.sections[0].footer.paragraphs[0]
  116. begin = OxmlElement('w:fldChar')
  117. begin.set(qn('w:fldCharType'), 'begin')
  118. footer.add_run()._r.append(begin)
  119. instruction = OxmlElement('w:instrText')
  120. instruction.text = ' PAGE '
  121. footer.add_run()._r.append(instruction)
  122. end = OxmlElement('w:fldChar')
  123. end.set(qn('w:fldCharType'), 'end')
  124. footer.add_run()._r.append(end)
  125. for chapter in self.template.chapters:
  126. doc.add_paragraph(f'第{_int_to_chinese(int(chapter.id))}章 {chapter.title}',
  127. style='Heading 1')
  128. doc.add_paragraph(f'模板来源正文{chapter.id}')
  129. if chapter.id == '6':
  130. doc.add_table(rows=1, cols=1).cell(0, 0).text = '经理原生表'
  131. doc.save(template_path)
  132. records = []
  133. for chapter in self.outline.chapters:
  134. if chapter.is_scoring_inserted:
  135. for node in chapter.children:
  136. node.supplement_content = f'{node.title}的专属响应正文。'
  137. node.content_blocks = [{'block_type': 'scoring_supplement',
  138. 'target_heading_id': node.id}]
  139. path = _save_chapter_docx(chapter, tmp, template_path=template_path)
  140. artifact = Document(path)
  141. text = '\n'.join(p.text for p in artifact.paragraphs)
  142. if chapter.is_scoring_inserted:
  143. self.assertNotIn('模板来源正文', text,
  144. '新增评分章不得按最终编号误裁模板正文')
  145. self.assertEqual(len(artifact.tables), 0)
  146. self.assertIn(chapter.children[0].supplement_content, text)
  147. else:
  148. self.assertIn(f'模板来源正文{chapter.template_chapter_id}', text)
  149. self.assertEqual(artifact.sections[0].header.paragraphs[0].text, '合成项目 合成页眉')
  150. records.append(dict(id=chapter.id, title=chapter.title,
  151. artifact_path=path, status='complete'))
  152. # Step5 仍按持久化的最终 ID 找到新增章子节点,授权正文不被误删。
  153. stage5 = self._load_stage(5)
  154. stage6 = self._load_stage(6)
  155. _, payload = build_outline_report(self.template, self.outline, self.analysis)
  156. chapter_texts = {
  157. r['id']: '\n'.join(p.text for p in Document(r['artifact_path']).paragraphs)
  158. for r in records
  159. }
  160. reviewed = stage5._build_outline_from_report(payload, chapter_texts)
  161. reviewer = _Reviewer.__new__(_Reviewer)
  162. self.assertEqual(find_template_integrity_issues(self.outline), [])
  163. for entry in self.outline.evaluation_index_entries:
  164. nodes = reviewer._bound_nodes(self.outline, [entry])
  165. self.assertEqual(len(nodes), 1)
  166. self.assertEqual(nodes[0].id, entry['final_heading_id'])
  167. self.assertIn('专属响应正文', nodes[0].supplement_content)
  168. self.assertIn('专属响应正文', reviewer._bound_entry_evidence(reviewed, entry))
  169. step5_dir = os.path.join(tmp, 'step5')
  170. stage5._write_step5_chapters_from_step4(records, reviewed, self.analysis, step5_dir)
  171. records = stage6._load_chapter_records(step5_dir)
  172. output_path = os.path.join(tmp, 'final.docx')
  173. report = assemble_step5_document(list(reversed(records)), reviewed,
  174. output_path, template_path=template_path)
  175. audit = stage6._assert_output_structure(output_path, reviewed)
  176. self.assertEqual(audit['chapters'], 11)
  177. self.assertEqual(report.chapter_count, 11)
  178. self.assertEqual(report.index_row_count, 3)
  179. result = Document(output_path)
  180. headings = [p.text for p in result.paragraphs if p.style.name == 'Heading 1']
  181. self.assertEqual(headings, [
  182. f'第{_int_to_chinese(int(c.id))}章 {c.title}' for c in self.outline.chapters
  183. ])
  184. text = '\n'.join(p.text for p in result.paragraphs)
  185. for i in range(1, 9):
  186. self.assertEqual(text.count(f'模板来源正文{i}'), 1)
  187. self.assertEqual(sum(t.cell(0, 0).text == '经理原生表' for t in result.tables), 1)
  188. with zipfile.ZipFile(output_path) as package:
  189. root = ET.fromstring(package.read('word/document.xml'))
  190. ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
  191. bookmarks = {n.get(ns + 'name') for n in root.iter(ns + 'bookmarkStart')}
  192. refs = [n.text.split()[1] for n in root.iter(ns + 'instrText')
  193. if 'PAGEREF' in (n.text or '')]
  194. self.assertEqual(len(refs), 3)
  195. self.assertTrue(set(refs) <= bookmarks)
  196. if __name__ == '__main__':
  197. unittest.main()