| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- """F043:合成采购需求及真实 Step4/5/6 写出函数,LLM 默认使用桩。"""
- import copy
- import base64
- import io
- import importlib.util
- import json
- import os
- import re
- import sys
- import tempfile
- import unittest
- from types import SimpleNamespace
- from unittest.mock import Mock
- from docx import Document
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- from models import BidOutline, Chapter, ProjectData, TenderAnalysis
- from step3_outlining.scoring_structure import _persist_heading_mappings
- from step4_writing import _persist_all_chapter_docs
- from step4_writing.difficulty_rewrite import (
- MODE, ROLE_BUDGETS, identify_targets, rewrite_difficulty_sections, word_count,
- )
- from step5_reviewing.reviewer import _Reviewer
- from step6_exporting.assembler import assemble_step5_document
- from step6_exporting.docx_builder import build_document
- def selection(*ids):
- return {"targets": [{"id": node_id, "roles": list(ROLE_BUDGETS)} for node_id in ids]}
- def load_stage(number):
- directory = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
- if directory not in sys.path:
- sys.path.insert(0, directory)
- spec = importlib.util.spec_from_file_location(
- f'difficulty_stage{number}', os.path.join(directory, f'test_step{number}.py'))
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
- class DifficultyTests(unittest.TestCase):
- def setUp(self):
- self.target = Chapter(id='3.2', title='二、关键挑战与解决策略', level=2,
- from_template=True, template_original_id='3.2',
- template_original_title='二、关键挑战与解决策略')
- self.root = Chapter(id='3', title='需求理解', template_original_id='3',
- from_template=True, template_chapter_id='3',
- generated_content='需求理解\n保留章正文\n一、服务认知\n保留相邻正文\n'
- '二、关键挑战与解决策略\n旧项目重难点\n'
- '三、其他事项\n保留末节正文',
- children=[Chapter(id='3.1', title='一、服务认知', level=2),
- self.target,
- Chapter(id='3.3', title='三、其他事项', level=2)])
- self.outline = BidOutline(project_name='合成文化中心物业项目', chapters=[self.root])
- _persist_heading_mappings(self.outline)
- self.outline.evaluation_index_entries = [{
- 'entry_type': 'scoring', 'source_id': 'SC-1#1', 'criterion_id': 'SC-1',
- 'display_name': '项目重难点及对策', 'requirement': '分析项目特点和难点并提出措施',
- 'final_heading_id': '3.2', 'final_heading_title': self.target.title,
- }]
- self.analysis = TenderAnalysis(project_name=self.outline.project_name)
- self.data = ProjectData(project_id='synthetic', project_name=self.outline.project_name,
- procurement_docs=[SimpleNamespace(content=(
- '本项目为合成文化中心物业服务。服务区域包含展厅、公共走廊和设备间。'
- '展览开放期间持续提供保洁及秩序维护,闭馆后安排深度清洁。'
- '活动期间人流集中,须与场馆管理方协调,保持疏散通道畅通。'
- '设备巡检发现异常须记录并报告,按授权开展处置,不得擅自停运设备。'
- '采购需求未规定面积、人数或设备数量。'))])
- self.client = Mock()
- self.client.extract_json.side_effect = [
- selection('3.2'),
- {'new_titles': ['项目业态与概况', '重点难点成因分析', '逐项应对及检查闭环']},
- ]
- self.serial = 0
- def generate(**kwargs):
- payload = json.loads(kwargs['user_prompt'])
- expected = int(re.search(r'正文(\d+)字', payload['length']).group(1))
- self.serial += 1
- # 不同节点不同文字,避免确定性去重把桩文本删除。
- marker = '专项正文' + chr(0x4e10 + self.serial)
- return marker + chr(0x5000 + self.serial) * (expected - len(marker))
- self.client.generate.side_effect = generate
- def test_semantic_candidates_no_match_and_scope(self):
- other = Chapter(id='4', title='服务方案', children=[
- Chapter(id='4.1', title='重点难点分析及应对措施', level=2)])
- self.outline.chapters.append(other)
- self.root.children.append(Chapter(id='3.4', title='附件', level=2, is_attachment=True,
- children=[Chapter(id='3.4.1', title='重点难点及措施', level=3)]))
- client = Mock()
- client.extract_json.return_value = selection()
- before = copy.deepcopy(self.outline)
- rewrite_difficulty_sections(self.outline, self.analysis, ProjectData('synthetic', '合成'), client)
- self.assertEqual(self.outline, before)
- payload = json.loads(client.extract_json.call_args.kwargs['user_prompt'])
- self.assertIn('4.1', [n['id'] for n in payload['nodes']])
- self.assertEqual(next(n['path'] for n in payload['nodes'] if n['id'] == '4.1'),
- ['服务方案', '重点难点分析及应对措施'])
- self.assertNotIn('3.4.1', [n['id'] for n in payload['nodes']])
- client.generate.assert_not_called()
- def test_invalid_and_overlapping_ids_rejected(self):
- self.target.children = [Chapter(id='3.2.1', title='服务瓶颈', level=3)]
- for ids in [['4.1'], ['missing'], ['3', '3.2'], ['3.2', '3.2'], ['3.2', '3.2.1']]:
- client = Mock()
- client.extract_json.return_value = selection(*ids)
- with self.subTest(ids=ids), self.assertRaises(ValueError):
- identify_targets(self.outline, client)
- def test_missing_procurement_and_short_generation_fail_without_tree_changes(self):
- before = copy.deepcopy(self.outline)
- with self.assertRaisesRegex(ValueError, '采购需求正文为空'):
- rewrite_difficulty_sections(self.outline, self.analysis, ProjectData('synthetic', '合成'), self.client)
- self.assertEqual(self.outline, before)
- self.setUp()
- self.client.generate.side_effect = None
- self.client.generate.return_value = '内容不足'
- with self.assertRaisesRegex(ValueError, '三次生成'):
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertEqual(self.target.children, [])
- self.assertNotEqual(self.target.content_generation_mode, MODE)
- def test_word_budget_and_bindings(self):
- bindings = copy.deepcopy(self.outline.evaluation_index_entries)
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertEqual(self.outline.evaluation_index_entries, bindings)
- self.assertEqual(sum(word_count(n.supplement_content)
- for n in [self.target, *self.target.children]), 10000)
- self.assertTrue(all(n.level == 3 for n in self.target.children))
- self.assertNotIn('旧项目重难点', self.root.generated_content)
- self.assertIn('保留相邻正文', self.root.generated_content)
- self.assertIn(self.data.procurement_docs[0].content,
- self.client.generate.call_args.kwargs['user_prompt'])
- report = load_stage(4)._build_report(self.outline, self.analysis)
- self.assertEqual(report['difficulty_sections'][0]['body_words'], 10000)
- self.target.supplement_content = ''
- report = load_stage(4)._build_report(self.outline, self.analysis)
- self.assertTrue(any('专项字数不达标' in e for e in report['errors']))
- def test_h4_target_uses_batches_without_h5(self):
- self.target.level = 4
- self.client.extract_json.side_effect = [selection('3.2')]
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertEqual(self.target.children, [])
- self.assertEqual(word_count(self.target.supplement_content), 10000)
- self.assertEqual(self.client.generate.call_count, 5)
- def test_duplicate_title_rejected(self):
- self.client.extract_json.side_effect = [
- selection('3.2'), {'new_titles': ['关键挑战与解决策略']}]
- with self.assertRaisesRegex(ValueError, '重复'):
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- def test_existing_children_keep_ids_and_new_ids_do_not_collide(self):
- child = Chapter(id='3.2.4', title='(一)已有难点分析', level=3)
- self.target.children = [child]
- _persist_heading_mappings(self.outline)
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertIs(self.target.children[0], child)
- self.assertEqual([c.id for c in self.target.children], ['3.2.4', '3.2.5', '3.2.6', '3.2.7'])
- self.assertEqual(self.target.children[1].title, '(二)项目业态与概况')
- self.assertEqual(sum(word_count(n.supplement_content)
- for n in [self.target, *self.target.children]), 10000)
- def test_long_procurement_reads_tail_instead_of_truncating(self):
- self.data.procurement_docs[0].content = '采购事实' * 6000 + '末尾特殊设备停运约束'
- self.client.extract_json.side_effect = [
- selection('3.2'), {'facts': '采购事实'}, {'facts': '末尾特殊设备停运约束'},
- {'new_titles': []}]
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertIn('末尾特殊设备停运约束', self.client.generate.call_args.kwargs['user_prompt'])
- def test_short_first_response_is_regenerated(self):
- generate = self.client.generate.side_effect
- calls = [0]
- def short_first(**kwargs):
- calls[0] += 1
- return '过短' if calls[0] == 1 else generate(**kwargs)
- self.client.generate.side_effect = short_first
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertIn('上次正文2字', self.client.generate.call_args_list[1].kwargs['user_prompt'])
- self.assertEqual(sum(word_count(n.supplement_content)
- for n in [self.target, *self.target.children]), 10000)
- def test_step4_to_step6_headings_prose_and_native_objects(self):
- self._assert_pipeline()
- def test_distributed_roles_share_budget_and_survive_export(self):
- roles = list(ROLE_BUDGETS)
- nodes = [Chapter(id='3.1', title='项目业态', level=2),
- Chapter(id='3.2', title='项目概况', level=2),
- Chapter(id='5.1', title='关键难点成因', level=2),
- Chapter(id='6.1', title='针对性应对措施', level=2)]
- self.root.children = nodes[:2] + [Chapter(id='3.3', title='无关事项', level=2)]
- self.root.generated_content = '项目业态\n旧专项一\n项目概况\n旧专项二\n无关事项\n必须保留正文'
- self.outline.chapters += [Chapter(id='5', title='难点分析', children=[nodes[2]],
- generated_content='关键难点成因\n旧专项三'),
- Chapter(id='6', title='实施措施', children=[nodes[3]],
- generated_content='针对性应对措施\n旧专项四')]
- _persist_heading_mappings(self.outline)
- original = {'project': self.outline.project_name,
- 'heading_mappings': copy.deepcopy(self.outline.heading_mappings),
- 'evaluation_index_entries': copy.deepcopy(self.outline.evaluation_index_entries)}
- self.client.extract_json.side_effect = [
- {'targets': [{'id': n.id, 'roles': [r]} for n, r in zip(nodes, roles)]},
- {'new_titles': []}, {'new_titles': []},
- {'new_titles': ['开放时段作业冲突分析']}, {'new_titles': []}]
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertEqual([sum(word_count(c.supplement_content) for c in [n, *n.children])
- for n in nodes], list(ROLE_BUDGETS.values()))
- self.assertIn('必须保留正文', self.root.generated_content)
- report = load_stage(4)._build_report(self.outline, self.analysis)
- self.assertEqual(len(report['difficulty_sections']), 1)
- self.assertEqual(report['difficulty_sections'][0]['body_words'], 10000)
- prompts = [json.loads(c.kwargs['user_prompt']) for c in self.client.generate.call_args_list]
- measure = next(p for p in prompts if p['assigned_roles'] == ['measures'])
- self.assertIn('5.1', measure['previous_sections'])
- self.assertEqual(sum(t['word_budget'] for t in measure['coordinated_targets']), 10000)
- stage5, stage6 = load_stage(5), load_stage(6)
- with tempfile.TemporaryDirectory() as tmp:
- d4, d5 = os.path.join(tmp, 'step4'), os.path.join(tmp, 'step5')
- template = os.path.join(tmp, 'template.docx')
- doc = Document()
- doc.sections[0].header.paragraphs[0].text = '合成项目'
- doc.sections[0].footer.paragraphs[0].text = '合成页脚'
- doc.save(template)
- _persist_all_chapter_docs(self.outline, d4)
- restored = stage5._load_step4_outline(d4, original, stage5._load_chapter_texts(d4))
- self.assertEqual(restored.evaluation_index_entries, original['evaluation_index_entries'])
- self.assertEqual([n.id for n in restored.flatten()], [n.id for n in self.outline.flatten()])
- stage5._write_step5_chapters_from_step4(stage5._load_step4_records(d4), restored, self.analysis, d5)
- output = os.path.join(tmp, 'distributed.docx')
- assemble_step5_document(stage6._load_chapter_records(d5), restored, output, template_path=template)
- text = '\n'.join(p.text for p in Document(output).paragraphs)
- self.assertIn('必须保留正文', text)
- self.assertNotIn('旧专项', text)
- for n in nodes + nodes[2].children:
- self.assertTrue(re.sub(r'\s+', '', n.supplement_content) in re.sub(r'\s+', '', text))
- child = nodes[2].children[0]
- headings = [p for p in Document(output).paragraphs if p.text == child.title]
- self.assertEqual(len(headings), 1)
- self.assertEqual(headings[0].style.name, 'Heading 3')
- def test_incomplete_roles_rejected(self):
- self.client.extract_json.side_effect = [{'targets': [{'id': '3.2', 'roles': ['analysis']}]}]
- with self.assertRaisesRegex(ValueError, '职责不完整'):
- identify_targets(self.outline, self.client)
- def test_late_distributed_failure_does_not_commit_partial_rewrite(self):
- self.client.extract_json.side_effect = [
- {'targets': [{'id': '3.1', 'roles': ['business_type', 'overview', 'analysis']},
- {'id': '3.2', 'roles': ['measures']}]},
- {'new_titles': []}, {'new_titles': []}]
- generate = self.client.generate.side_effect
- def fail_measures(**kwargs):
- return '过短' if json.loads(kwargs['user_prompt'])['assigned_roles'] == ['measures'] else generate(**kwargs)
- self.client.generate.side_effect = fail_measures
- before = copy.deepcopy(self.outline)
- with self.assertRaisesRegex(ValueError, '三次生成'):
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, self.client)
- self.assertEqual(self.outline, before)
- def test_non_third_chapter_with_different_parent_title_through_export(self):
- self._assert_pipeline(chapter_number=5)
- def test_standalone_top_level_difficulty_chapter_through_export(self):
- self._assert_pipeline(chapter_number=6, standalone=True)
- def _assert_pipeline(self, chapter_number=3, standalone=False):
- from step3_outlining.scoring_structure import _int_to_chinese
- if chapter_number != 3:
- for node in self.outline.flatten():
- node.id = str(chapter_number) + node.id[1:]
- if node.template_original_id:
- node.template_original_id = str(chapter_number) + node.template_original_id[1:]
- self.root.template_chapter_id = str(chapter_number)
- self.root.title = '项目服务实施方案'
- self.root.generated_content = self.root.generated_content.replace('需求理解', self.root.title)
- if standalone:
- self.target = self.root
- self.root.title = '项目关键挑战及应对策略'
- self.root.template_original_title = self.root.title
- self.root.children = []
- self.root.generated_content = self.root.title + '\n旧项目重难点'
- self.root.preserve_template_layout = True
- self.root.template_fill_completed = True
- self.root.content_blocks = [{'block_type': 'template_base'}, {'block_type': 'native_table_plan'}]
- _persist_heading_mappings(self.outline)
- self.outline.evaluation_index_entries[0]['final_heading_id'] = self.target.id
- self.outline.evaluation_index_entries[0]['final_heading_title'] = self.target.title
- self.client.extract_json.side_effect = [
- selection(self.target.id),
- {'new_titles': ['项目业态与概况', '重点难点成因分析', '逐项应对及检查闭环']}]
- # 可选择以真实 LLM 对同一脱敏夹具复验,不读取业务资料。
- client = None if os.environ.get('F043_LIVE_LLM') == '1' else self.client
- original_report = {'project': self.outline.project_name,
- 'heading_mappings': copy.deepcopy(self.outline.heading_mappings),
- 'evaluation_index_entries': copy.deepcopy(self.outline.evaluation_index_entries)}
- rewrite_difficulty_sections(self.outline, self.analysis, self.data, client)
- stage5, stage6 = load_stage(5), load_stage(6)
- with tempfile.TemporaryDirectory() as tmp:
- template = os.path.join(tmp, 'template.docx')
- doc = Document()
- doc.sections[0].header.paragraphs[0].text = self.outline.project_name
- footer = doc.sections[0].footer.paragraphs[0]
- for kind in ['begin', 'end']:
- field = OxmlElement('w:fldChar')
- field.set(qn('w:fldCharType'), kind)
- footer.add_run()._r.append(field)
- if kind == 'begin':
- instruction = OxmlElement('w:instrText')
- instruction.text = ' PAGE '
- footer.add_run()._r.append(instruction)
- doc.add_paragraph(f'第{_int_to_chinese(chapter_number)}章 {self.root.title}', style='Heading 1')
- if not standalone:
- doc.add_paragraph('保留章正文')
- doc.add_paragraph('一、服务认知', style='Heading 2')
- doc.add_paragraph('保留相邻正文')
- doc.add_paragraph(self.target.title, style='Heading 2')
- doc.add_paragraph('旧项目重难点')
- doc.add_paragraph('旧项目重复泛化说明')
- doc.add_paragraph('原生表前说明')
- doc.add_table(rows=1, cols=1).cell(0, 0).text = '受保护表格'
- doc.add_paragraph('原生表后说明')
- doc.add_paragraph('签署人:测试')
- doc.add_picture(io.BytesIO(base64.b64decode(
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=')))
- doc.add_paragraph('图1 原生图片说明')
- protected = doc.add_paragraph()
- bookmark = OxmlElement('w:bookmarkStart')
- bookmark.set(qn('w:id'), '10')
- bookmark.set(qn('w:name'), 'protected')
- protected._p.append(bookmark)
- if not standalone:
- doc.add_paragraph('三、其他事项', style='Heading 2')
- doc.add_paragraph('保留末节正文')
- doc.save(template)
- stage4_dir = os.path.join(tmp, 'step4')
- paths = _persist_all_chapter_docs(self.outline, stage4_dir, template_path=template)
- artifact = Document(paths[0])
- actual = '\n'.join(p.text for p in artifact.paragraphs)
- self.assertNotIn('旧项目', actual)
- preserved = ['原生表前说明', '原生表后说明', '签署人:测试']
- if not standalone:
- preserved += ['保留相邻正文', '保留末节正文']
- for text in preserved:
- self.assertIn(text, actual)
- self.assertEqual(len(artifact.tables), 1)
- self.assertEqual(len(artifact.inline_shapes), 1)
- texts = stage5._load_chapter_texts(stage4_dir)
- reviewed = stage5._load_step4_outline(stage4_dir, original_report, texts)
- self.assertEqual([n.id for n in reviewed.flatten()], [n.id for n in self.outline.flatten()])
- reviewer = _Reviewer.__new__(_Reviewer)
- evidence = reviewer._bound_entry_evidence(reviewed, reviewed.evaluation_index_entries[0])
- for n in self.target.children:
- self.assertTrue(re.sub(r'\s+', '', n.supplement_content) in re.sub(r'\s+', '', evidence))
- self.assertGreaterEqual(word_count(evidence), 9000)
- self.assertLess(word_count(evidence), 11200, '一级专项审核不能将整章与各子节重复计入')
- records = stage5._load_step4_records(stage4_dir)
- stage5_dir = os.path.join(tmp, 'step5')
- stage5._write_step5_chapters_from_step4(records, reviewed, self.analysis, stage5_dir)
- output = os.path.join(tmp, 'final.docx')
- assemble_step5_document(stage6._load_chapter_records(stage5_dir), reviewed,
- output, template_path=template)
- result = Document(output)
- for n in self.target.children:
- matching = [p for p in result.paragraphs if p.text == n.title]
- self.assertEqual(len(matching), 1)
- self.assertEqual(matching[0].style.name, f'Heading {n.level}')
- self.assertTrue(len(matching[0]._p.findall(qn('w:bookmarkStart'))) > 0)
- self.assertEqual(len(result.tables), 2) # 原生表 + 评标索引
- self.assertEqual(len(result.inline_shapes), 1)
- self.assertNotIn('旧项目', '\n'.join(p.text for p in result.paragraphs))
- # CLI 的兼容导出入口同样不能恢复模板旧段落或遗漏新子节。
- cli_output = os.path.join(tmp, 'cli-final.docx')
- original_paths = [c.artifact_path for c in self.outline.chapters]
- build_document(self.outline, template, cli_output, analysis=self.analysis)
- cli_doc = Document(cli_output)
- cli_text = '\n'.join(p.text for p in cli_doc.paragraphs)
- self.assertNotIn('旧项目', cli_text)
- for n in self.target.children:
- self.assertIn(n.title, cli_text)
- self.assertEqual([c.artifact_path for c in self.outline.chapters], original_paths)
- # 不允许新 manifest 携带其他轮次的评分映射或改变原标题树。
- manifest_path = stage5._find_manifest(stage4_dir)
- with open(manifest_path, encoding='utf-8') as file:
- manifest = json.load(file)
- manifest['final_outline']['heading_mappings'][0]['final_title'] = '篡改标题'
- with open(manifest_path, 'w', encoding='utf-8') as file:
- json.dump(manifest, file, ensure_ascii=False)
- with self.assertRaisesRegex(ValueError, '既有标题'):
- stage5._load_step4_outline(stage4_dir, original_report, texts)
- if __name__ == '__main__':
- unittest.main()
|