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