"""新增一级评分章跨 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()