|
|
@@ -0,0 +1,216 @@
|
|
|
+"""新增一级评分章跨 Step3-6 的离线回归,全部使用合成资料。"""
|
|
|
+
|
|
|
+import copy
|
|
|
+import importlib.util
|
|
|
+import os
|
|
|
+import sys
|
|
|
+import tempfile
|
|
|
+import unittest
|
|
|
+import zipfile
|
|
|
+import xml.etree.ElementTree as ET
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import Mock, patch
|
|
|
+
|
|
|
+from docx import Document
|
|
|
+from docx.oxml import OxmlElement
|
|
|
+from docx.oxml.ns import qn
|
|
|
+from models import BidOutline, Chapter, CompanyInfo, ScoringCriterion, TenderAnalysis
|
|
|
+from step3_outlining import _write_and_enforce_outline_gate
|
|
|
+from step3_outlining.outline_report import build_outline_report, validate_outline_gate
|
|
|
+from step3_outlining.scoring_structure import apply_scoring_structure, _int_to_chinese
|
|
|
+from step4_writing import _get_template_text_for_chapter, _save_chapter_docx, _write_single_chapter
|
|
|
+from step5_reviewing.policies import find_template_integrity_issues
|
|
|
+from step5_reviewing.reviewer import _Reviewer
|
|
|
+from step6_exporting.assembler import assemble_step5_document
|
|
|
+
|
|
|
+
|
|
|
+class NewScoringChapterTests(unittest.TestCase):
|
|
|
+ def test_inserted_chapter_never_reads_template_even_with_stale_section(self):
|
|
|
+ chapter = Chapter(id='6', title='专项类别', is_scoring_inserted=True,
|
|
|
+ template_section='第六章 项目经理')
|
|
|
+ with patch('step4_writing.get_template_text_for_section') as section, patch(
|
|
|
+ 'step4_writing.get_template_text_for_chapter'
|
|
|
+ ) as title:
|
|
|
+ self.assertEqual(_get_template_text_for_chapter(object(), chapter), '')
|
|
|
+ section.assert_not_called()
|
|
|
+ title.assert_not_called()
|
|
|
+
|
|
|
+ def test_shifted_chapter_fallback_uses_template_source_number(self):
|
|
|
+ for source in ({'template_chapter_id': '6'}, {'template_original_id': '6'}):
|
|
|
+ with self.subTest(source=source):
|
|
|
+ chapter = Chapter(id='9', title='经理章节别名', **source)
|
|
|
+ with patch('step4_writing.get_template_text_for_chapter',
|
|
|
+ side_effect=lambda _, title: {'项目经理': '正确经理正文',
|
|
|
+ '错误模板章': '错误正文'}.get(title, '')), patch(
|
|
|
+ 'step3_outlining.template_parser.get_template_chapter_titles',
|
|
|
+ return_value=[('6', '项目经理'), ('9', '错误模板章')]
|
|
|
+ ):
|
|
|
+ self.assertEqual(_get_template_text_for_chapter(
|
|
|
+ SimpleNamespace(file_path='synthetic.docx'), chapter
|
|
|
+ ), '正确经理正文')
|
|
|
+
|
|
|
+ def test_inserted_chapter_writing_has_no_wrong_template_base(self):
|
|
|
+ chapter = self.outline.chapters[5]
|
|
|
+ injector = Mock()
|
|
|
+ injector.get_scoring_context.return_value = ''
|
|
|
+
|
|
|
+ def generate(chapter, *_args):
|
|
|
+ chapter.children[0].supplement_content = '专项执行响应正文'
|
|
|
+ return 1
|
|
|
+
|
|
|
+ with patch('step4_writing.get_template_text_for_chapter',
|
|
|
+ return_value='不应读取的经理正文') as lookup, patch(
|
|
|
+ 'step4_writing._generate_direct_content_blocks', side_effect=generate
|
|
|
+ ):
|
|
|
+ result = _write_single_chapter(
|
|
|
+ chapter, Mock(), injector, self.analysis, CompanyInfo(),
|
|
|
+ object(), SimpleNamespace(enforce_word_limit=False), self.outline, {}
|
|
|
+ )
|
|
|
+ lookup.assert_not_called()
|
|
|
+ self.assertIn('专项执行响应正文', result.generated_content)
|
|
|
+ self.assertNotIn('经理正文', result.generated_content)
|
|
|
+ self.assertEqual(result.content_blocks[0]['block_type'], 'generated_scoring_chapter')
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _load_stage(number):
|
|
|
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
|
+ spec = importlib.util.spec_from_file_location(
|
|
|
+ f'new_scoring_stage{number}', os.path.join(scripts_dir, f'test_step{number}.py')
|
|
|
+ )
|
|
|
+ module = importlib.util.module_from_spec(spec)
|
|
|
+ with patch.object(sys, 'path', [scripts_dir, *sys.path]), patch.dict(os.environ):
|
|
|
+ spec.loader.exec_module(module)
|
|
|
+ return module
|
|
|
+
|
|
|
+ def setUp(self):
|
|
|
+ titles = ['资格证明', '投标报价', '需求理解', '服务方案', '质量保障',
|
|
|
+ '项目经理', '人员配置', '附件']
|
|
|
+ self.template = BidOutline(project_name='合成项目', chapters=[
|
|
|
+ Chapter(id=str(i), title=title, level=1, template_chapter_id=str(i),
|
|
|
+ template_original_id=str(i), from_template=True)
|
|
|
+ for i, title in enumerate(titles, 1)
|
|
|
+ ])
|
|
|
+ self.analysis = TenderAnalysis(project_name='合成项目', scoring_criteria=[
|
|
|
+ ScoringCriterion(id=f'SC-{i}', category=f'专项类别{i}', name=f'专项措施{i}',
|
|
|
+ description=f'说明专项措施{i}的执行流程', max_score=5)
|
|
|
+ for i in range(1, 4)
|
|
|
+ ])
|
|
|
+ self.outline = copy.deepcopy(self.template)
|
|
|
+ self.outline.template_chapters = copy.deepcopy(self.template.chapters)
|
|
|
+ apply_scoring_structure(self.outline, self.analysis.scoring_criteria)
|
|
|
+
|
|
|
+ def test_gate_accepts_new_categories_without_approval(self):
|
|
|
+ for value in ['', '其他历史类别']:
|
|
|
+ with self.subTest(legacy_value=value), patch.dict(
|
|
|
+ os.environ, {'BID_APPROVED_NEW_SCORING_CATEGORIES': value}
|
|
|
+ ), tempfile.TemporaryDirectory() as tmp:
|
|
|
+ _write_and_enforce_outline_gate(
|
|
|
+ self.outline, self.analysis, os.path.join(tmp, 'report.md')
|
|
|
+ )
|
|
|
+ self.assertEqual([c.title for c in self.outline.chapters[5:9]],
|
|
|
+ ['专项类别1', '专项类别2', '专项类别3', '项目经理'])
|
|
|
+ self.assertEqual([c.id for c in self.outline.chapters],
|
|
|
+ [str(i) for i in range(1, 12)])
|
|
|
+
|
|
|
+ def test_gate_still_rejects_unauthorized_and_missing_template_chapters(self):
|
|
|
+ self.outline.chapters[5].is_scoring_inserted = False
|
|
|
+ self.outline.chapters.pop()
|
|
|
+ errors = validate_outline_gate(self.template, self.outline, self.analysis)
|
|
|
+ self.assertTrue(any('授权评分' in error for error in errors))
|
|
|
+ self.assertTrue(any('缺少模板章' in error for error in errors))
|
|
|
+
|
|
|
+ def test_new_chapters_keep_own_content_and_shifted_template_sources_through_export(self):
|
|
|
+ with tempfile.TemporaryDirectory() as tmp:
|
|
|
+ template_path = os.path.join(tmp, 'template.docx')
|
|
|
+ doc = Document()
|
|
|
+ doc.sections[0].header.paragraphs[0].text = '合成项目 合成页眉'
|
|
|
+ doc.sections[0].footer.paragraphs[0].text = '合成页脚'
|
|
|
+ footer = doc.sections[0].footer.paragraphs[0]
|
|
|
+ begin = OxmlElement('w:fldChar')
|
|
|
+ begin.set(qn('w:fldCharType'), 'begin')
|
|
|
+ footer.add_run()._r.append(begin)
|
|
|
+ instruction = OxmlElement('w:instrText')
|
|
|
+ instruction.text = ' PAGE '
|
|
|
+ footer.add_run()._r.append(instruction)
|
|
|
+ end = OxmlElement('w:fldChar')
|
|
|
+ end.set(qn('w:fldCharType'), 'end')
|
|
|
+ footer.add_run()._r.append(end)
|
|
|
+ for chapter in self.template.chapters:
|
|
|
+ doc.add_paragraph(f'第{_int_to_chinese(int(chapter.id))}章 {chapter.title}',
|
|
|
+ style='Heading 1')
|
|
|
+ doc.add_paragraph(f'模板来源正文{chapter.id}')
|
|
|
+ if chapter.id == '6':
|
|
|
+ doc.add_table(rows=1, cols=1).cell(0, 0).text = '经理原生表'
|
|
|
+ doc.save(template_path)
|
|
|
+
|
|
|
+ records = []
|
|
|
+ for chapter in self.outline.chapters:
|
|
|
+ if chapter.is_scoring_inserted:
|
|
|
+ for node in chapter.children:
|
|
|
+ node.supplement_content = f'{node.title}的专属响应正文。'
|
|
|
+ node.content_blocks = [{'block_type': 'scoring_supplement',
|
|
|
+ 'target_heading_id': node.id}]
|
|
|
+ path = _save_chapter_docx(chapter, tmp, template_path=template_path)
|
|
|
+ artifact = Document(path)
|
|
|
+ text = '\n'.join(p.text for p in artifact.paragraphs)
|
|
|
+ if chapter.is_scoring_inserted:
|
|
|
+ self.assertNotIn('模板来源正文', text,
|
|
|
+ '新增评分章不得按最终编号误裁模板正文')
|
|
|
+ self.assertEqual(len(artifact.tables), 0)
|
|
|
+ self.assertIn(chapter.children[0].supplement_content, text)
|
|
|
+ else:
|
|
|
+ self.assertIn(f'模板来源正文{chapter.template_chapter_id}', text)
|
|
|
+ self.assertEqual(artifact.sections[0].header.paragraphs[0].text, '合成项目 合成页眉')
|
|
|
+ records.append(dict(id=chapter.id, title=chapter.title,
|
|
|
+ artifact_path=path, status='complete'))
|
|
|
+
|
|
|
+ # Step5 仍按持久化的最终 ID 找到新增章子节点,授权正文不被误删。
|
|
|
+ stage5 = self._load_stage(5)
|
|
|
+ stage6 = self._load_stage(6)
|
|
|
+ _, payload = build_outline_report(self.template, self.outline, self.analysis)
|
|
|
+ chapter_texts = {
|
|
|
+ r['id']: '\n'.join(p.text for p in Document(r['artifact_path']).paragraphs)
|
|
|
+ for r in records
|
|
|
+ }
|
|
|
+ reviewed = stage5._build_outline_from_report(payload, chapter_texts)
|
|
|
+ reviewer = _Reviewer.__new__(_Reviewer)
|
|
|
+ self.assertEqual(find_template_integrity_issues(self.outline), [])
|
|
|
+ for entry in self.outline.evaluation_index_entries:
|
|
|
+ nodes = reviewer._bound_nodes(self.outline, [entry])
|
|
|
+ self.assertEqual(len(nodes), 1)
|
|
|
+ self.assertEqual(nodes[0].id, entry['final_heading_id'])
|
|
|
+ self.assertIn('专属响应正文', nodes[0].supplement_content)
|
|
|
+ self.assertIn('专属响应正文', reviewer._bound_entry_evidence(reviewed, entry))
|
|
|
+
|
|
|
+ step5_dir = os.path.join(tmp, 'step5')
|
|
|
+ stage5._write_step5_chapters_from_step4(records, reviewed, self.analysis, step5_dir)
|
|
|
+ records = stage6._load_chapter_records(step5_dir)
|
|
|
+
|
|
|
+ output_path = os.path.join(tmp, 'final.docx')
|
|
|
+ report = assemble_step5_document(list(reversed(records)), reviewed,
|
|
|
+ output_path, template_path=template_path)
|
|
|
+ audit = stage6._assert_output_structure(output_path, reviewed)
|
|
|
+ self.assertEqual(audit['chapters'], 11)
|
|
|
+ self.assertEqual(report.chapter_count, 11)
|
|
|
+ self.assertEqual(report.index_row_count, 3)
|
|
|
+ result = Document(output_path)
|
|
|
+ headings = [p.text for p in result.paragraphs if p.style.name == 'Heading 1']
|
|
|
+ self.assertEqual(headings, [
|
|
|
+ f'第{_int_to_chinese(int(c.id))}章 {c.title}' for c in self.outline.chapters
|
|
|
+ ])
|
|
|
+ text = '\n'.join(p.text for p in result.paragraphs)
|
|
|
+ for i in range(1, 9):
|
|
|
+ self.assertEqual(text.count(f'模板来源正文{i}'), 1)
|
|
|
+ self.assertEqual(sum(t.cell(0, 0).text == '经理原生表' for t in result.tables), 1)
|
|
|
+ with zipfile.ZipFile(output_path) as package:
|
|
|
+ root = ET.fromstring(package.read('word/document.xml'))
|
|
|
+ ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
|
+ bookmarks = {n.get(ns + 'name') for n in root.iter(ns + 'bookmarkStart')}
|
|
|
+ refs = [n.text.split()[1] for n in root.iter(ns + 'instrText')
|
|
|
+ if 'PAGEREF' in (n.text or '')]
|
|
|
+ self.assertEqual(len(refs), 3)
|
|
|
+ self.assertTrue(set(refs) <= bookmarks)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ unittest.main()
|