From c1292fcacc29057de17d631428e01cdb6a25c0bb Mon Sep 17 00:00:00 2001 From: sladro Date: Wed, 19 Nov 2025 10:11:21 +0800 Subject: [PATCH] feat: add validation and toc pipeline upgrades Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- 111 - 副本/analysis_result.json | 501 ------------------ 111 - 副本/tasks.json | 68 --- 111/analysis_result.json | 501 ------------------ 111/tasks.json | 68 --- config/prompts.yaml | 3 + doc/江南造船厂前端.md | 128 ----- src/bidmaster/__init__.py | 22 +- src/bidmaster/agents/analysis.py | 79 ++- src/bidmaster/agents/builders/toc_builder.py | 3 +- src/bidmaster/agents/content_writer.py | 2 +- src/bidmaster/agents/interaction.py | 2 +- src/bidmaster/cli/project.py | 59 ++- src/bidmaster/cli/write.py | 8 +- src/bidmaster/config/settings.py | 2 +- src/bidmaster/internal/__init__.py | 9 +- src/bidmaster/models/project.py | 2 - src/bidmaster/nodes/content/init_config.py | 52 ++ src/bidmaster/nodes/content/interact_user.py | 2 +- src/bidmaster/nodes/content/save_to_word.py | 27 +- src/bidmaster/nodes/toc/apply_suggestions.py | 2 +- src/bidmaster/nodes/toc/base_mixins.py | 2 +- src/bidmaster/nodes/toc/category_manager.py | 27 +- src/bidmaster/nodes/toc/constants.py | 17 +- src/bidmaster/nodes/toc/factories.py | 4 +- src/bidmaster/nodes/toc/finalize_chapters.py | 20 +- .../nodes/toc/generate_sub_chapters.py | 14 +- src/bidmaster/nodes/toc/group_criteria.py | 3 +- .../nodes/toc/optimize_with_feedback.py | 1 - src/bidmaster/nodes/toc/review_structure.py | 3 +- src/bidmaster/tools/parser.py | 10 +- src/bidmaster/tools/rag.py | 4 +- src/bidmaster/tools/table.py | 1 - src/bidmaster/tools/word.py | 422 ++++++++++++--- src/bidmaster/tools/word_formatter.py | 3 +- src/bidmaster/utils/guidance.py | 47 ++ src/bidmaster/utils/monitoring.py | 49 ++ src/bidmaster/utils/prompt_planner.py | 24 + src/bidmaster/utils/quality.py | 81 +++ src/bidmaster/utils/retry.py | 62 +++ src/bidmaster/utils/timeout_input.py | 2 - src/bidmaster/utils/validation.py | 254 +++++++++ tests/unit/test_timeout_input.py | 1 - tests/unit/test_validation.py | 40 ++ tests/unit/test_word.py | 198 ++++++- 44 files changed, 1411 insertions(+), 1418 deletions(-) delete mode 100644 111 - 副本/analysis_result.json delete mode 100644 111 - 副本/tasks.json delete mode 100644 111/analysis_result.json delete mode 100644 111/tasks.json delete mode 100644 doc/江南造船厂前端.md create mode 100644 src/bidmaster/utils/guidance.py create mode 100644 src/bidmaster/utils/monitoring.py create mode 100644 src/bidmaster/utils/quality.py create mode 100644 src/bidmaster/utils/retry.py create mode 100644 src/bidmaster/utils/validation.py create mode 100644 tests/unit/test_validation.py diff --git a/111 - 副本/analysis_result.json b/111 - 副本/analysis_result.json deleted file mode 100644 index 732cb45..0000000 --- a/111 - 副本/analysis_result.json +++ /dev/null @@ -1,501 +0,0 @@ -{ - "project_name": "111", - "source_file": "C:\\Users\\sladr\\Downloads\\test2.docx", - "success": true, - "technical_count": 6, - "commercial_count": 3, - "deviation_count": 0, - "chapter_count": 7, - "execution_time": 415.01412439346313, - "warnings": [ - "AI审查: 5条优化建议" - ], - "bid_structure": { - "scoring_criteria": [ - { - "item_name": "质保期", - "max_score": 3.0, - "description": "售后质保期2年,得1分。售后质保期延长至3年,得2分。售后质保期延长至4年,得3分。", - "category": "after_sales", - "chapter_id": "chapter_02" - }, - { - "item_name": "硬件配置", - "max_score": 8.0, - "description": "以确保设备性配置不低于招标文件硬件配置要求。打分标准设备硬件的品牌、硬件配置高低、技术先进性、稳定性、兼容性以及售后服务质量等每项指标综合打分。硬件未满足技术要求按每1项不满足扣1分,扣完为止。(招标文件中参数要求提供的检测报告、功能截图、证书等证明材料供应商必须按要求提供,否则评委不予采信)", - "category": "equipment_spec", - "chapter_id": "chapter_04" - }, - { - "item_name": "软件功能", - "max_score": 8.0, - "description": "应按照磋商文件功能要求实现,各项软件功能完成率,软件功能按每1项不满足扣1分,扣完为止。", - "category": "technical_solution", - "chapter_id": "chapter_05" - }, - { - "item_name": "系统架构", - "max_score": 5.0, - "description": "对系统架构、实施内容、技术特点、安装规范、售后服务等方面进行说明,缺少1项扣1分,扣完为止。", - "category": "technical_solution", - "chapter_id": "chapter_06" - }, - { - "item_name": "网路安全防护", - "max_score": 5.0, - "description": "1.防火墙配置、入侵检测系统、安全更新和补丁管理、用户权限管理、安全培训和意识提升、数据加密根据以上各项内容的打分情况,可以综合评估组织的网络安全防护水平,缺少1项扣1分,扣完为止。", - "category": "quality_safety", - "chapter_id": "chapter_07" - }, - { - "item_name": "技术实力", - "max_score": 3.0, - "description": "投标人具有自主研发的实时视频分析平台系统的知识产权和AI识别方法的发明专利,每提供一个得1分,共3分。(提供证书原件的扫描件并加盖公章)", - "category": "compliance", - "chapter_id": "chapter_08" - } - ], - "deviation_items": [], - "chapters": [ - { - "id": "chapter_1", - "title": "评标索引表(技术评分完全对应)", - "level": 1, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2", - "title": "设备规格 (8.0分)", - "level": 1, - "score": 8.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_2_1", - "title": "硬件配置方案", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_2_1_1", - "title": "核心硬件技术参数", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_2", - "title": "设备性能指标说明", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_3", - "title": "硬件兼容性分析", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_4", - "title": "配置方案优势阐述", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_3", - "title": "技术方案 (13.0分)", - "level": 1, - "score": 13.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_1", - "title": "软件功能设计", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_1_1", - "title": "核心功能模块设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_2", - "title": "用户交互界面设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_3", - "title": "数据处理与分析功能", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_4", - "title": "系统集成与接口设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_3_2", - "title": "系统架构设计", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_2_1", - "title": "总体架构设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_2_2", - "title": "技术选型与框架设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_2_3", - "title": "性能与扩展性设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_4", - "title": "质量安全 (5.0分)", - "level": 1, - "score": 5.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_4_1", - "title": "网络安全防护体系", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_4_1_1", - "title": "网络安全架构设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_2", - "title": "网络安全防护措施", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_3", - "title": "网络安全监控与预警", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_4", - "title": "网络安全应急响应", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_5", - "title": "网络安全管理制度", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_5", - "title": "合规响应 (3.0分)", - "level": 1, - "score": 3.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_5_1", - "title": "技术实力", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_5_1_1", - "title": "核心技术团队介绍", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_2", - "title": "技术研发能力展示", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_3", - "title": "技术设备与工具配置", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_4", - "title": "技术成果与专利情况", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_5", - "title": "技术质量管理体系", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_6", - "title": "培训服务", - "level": 1, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_6_1", - "title": "培训计划", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_6_2", - "title": "培训内容", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_6_3", - "title": "培训方式", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7", - "title": "售后服务 (3.0分)", - "level": 1, - "score": 3.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_1", - "title": "质保期服务承诺", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_1_1", - "title": "质保期限与范围", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_2", - "title": "质保期内服务内容", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_3", - "title": "质保期响应机制", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_4", - "title": "质保期满后服务延续方案", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_2", - "title": "技术支持服务", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_2_1", - "title": "技术支持团队配置", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_2_2", - "title": "技术支持响应时间", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_2_3", - "title": "技术支持服务方式", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_3", - "title": "维护服务", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_3_1", - "title": "定期维护计划", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_3_2", - "title": "故障处理流程", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_3_3", - "title": "备品备件保障", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_4", - "title": "客户服务保障", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_4_1", - "title": "客户服务热线", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_4_2", - "title": "客户满意度调查", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_4_3", - "title": "客户投诉处理机制", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - } - ] - } -} \ No newline at end of file diff --git a/111 - 副本/tasks.json b/111 - 副本/tasks.json deleted file mode 100644 index cfd617c..0000000 --- a/111 - 副本/tasks.json +++ /dev/null @@ -1,68 +0,0 @@ -[ - { - "id": 1, - "title": "质保期", - "chapter_id": "chapter_02", - "score": 3.0, - "description": "售后质保期2年,得1分。售后质保期延长至3年,得2分。售后质保期延长至4年,得3分。", - "category": "after_sales", - "status": "pending", - "content": "", - "placeholder": "{{chapter_02_content}}" - }, - { - "id": 2, - "title": "硬件配置", - "chapter_id": "chapter_04", - "score": 8.0, - "description": "以确保设备性配置不低于招标文件硬件配置要求。打分标准设备硬件的品牌、硬件配置高低、技术先进性、稳定性、兼容性以及售后服务质量等每项指标综合打分。硬件未满足技术要求按每1项不满足扣1分,扣完为止。(招标文件中参数要求提供的检测报告、功能截图、证书等证明材料供应商必须按要求提供,否则评委不予采信)", - "category": "equipment_spec", - "status": "pending", - "content": "", - "placeholder": "{{chapter_04_content}}" - }, - { - "id": 3, - "title": "软件功能", - "chapter_id": "chapter_05", - "score": 8.0, - "description": "应按照磋商文件功能要求实现,各项软件功能完成率,软件功能按每1项不满足扣1分,扣完为止。", - "category": "technical_solution", - "status": "pending", - "content": "", - "placeholder": "{{chapter_05_content}}" - }, - { - "id": 4, - "title": "系统架构", - "chapter_id": "chapter_06", - "score": 5.0, - "description": "对系统架构、实施内容、技术特点、安装规范、售后服务等方面进行说明,缺少1项扣1分,扣完为止。", - "category": "technical_solution", - "status": "pending", - "content": "", - "placeholder": "{{chapter_06_content}}" - }, - { - "id": 5, - "title": "网路安全防护", - "chapter_id": "chapter_07", - "score": 5.0, - "description": "1.防火墙配置、入侵检测系统、安全更新和补丁管理、用户权限管理、安全培训和意识提升、数据加密根据以上各项内容的打分情况,可以综合评估组织的网络安全防护水平,缺少1项扣1分,扣完为止。", - "category": "quality_safety", - "status": "pending", - "content": "", - "placeholder": "{{chapter_07_content}}" - }, - { - "id": 6, - "title": "技术实力", - "chapter_id": "chapter_08", - "score": 3.0, - "description": "投标人具有自主研发的实时视频分析平台系统的知识产权和AI识别方法的发明专利,每提供一个得1分,共3分。(提供证书原件的扫描件并加盖公章)", - "category": "compliance", - "status": "pending", - "content": "", - "placeholder": "{{chapter_08_content}}" - } -] \ No newline at end of file diff --git a/111/analysis_result.json b/111/analysis_result.json deleted file mode 100644 index 732cb45..0000000 --- a/111/analysis_result.json +++ /dev/null @@ -1,501 +0,0 @@ -{ - "project_name": "111", - "source_file": "C:\\Users\\sladr\\Downloads\\test2.docx", - "success": true, - "technical_count": 6, - "commercial_count": 3, - "deviation_count": 0, - "chapter_count": 7, - "execution_time": 415.01412439346313, - "warnings": [ - "AI审查: 5条优化建议" - ], - "bid_structure": { - "scoring_criteria": [ - { - "item_name": "质保期", - "max_score": 3.0, - "description": "售后质保期2年,得1分。售后质保期延长至3年,得2分。售后质保期延长至4年,得3分。", - "category": "after_sales", - "chapter_id": "chapter_02" - }, - { - "item_name": "硬件配置", - "max_score": 8.0, - "description": "以确保设备性配置不低于招标文件硬件配置要求。打分标准设备硬件的品牌、硬件配置高低、技术先进性、稳定性、兼容性以及售后服务质量等每项指标综合打分。硬件未满足技术要求按每1项不满足扣1分,扣完为止。(招标文件中参数要求提供的检测报告、功能截图、证书等证明材料供应商必须按要求提供,否则评委不予采信)", - "category": "equipment_spec", - "chapter_id": "chapter_04" - }, - { - "item_name": "软件功能", - "max_score": 8.0, - "description": "应按照磋商文件功能要求实现,各项软件功能完成率,软件功能按每1项不满足扣1分,扣完为止。", - "category": "technical_solution", - "chapter_id": "chapter_05" - }, - { - "item_name": "系统架构", - "max_score": 5.0, - "description": "对系统架构、实施内容、技术特点、安装规范、售后服务等方面进行说明,缺少1项扣1分,扣完为止。", - "category": "technical_solution", - "chapter_id": "chapter_06" - }, - { - "item_name": "网路安全防护", - "max_score": 5.0, - "description": "1.防火墙配置、入侵检测系统、安全更新和补丁管理、用户权限管理、安全培训和意识提升、数据加密根据以上各项内容的打分情况,可以综合评估组织的网络安全防护水平,缺少1项扣1分,扣完为止。", - "category": "quality_safety", - "chapter_id": "chapter_07" - }, - { - "item_name": "技术实力", - "max_score": 3.0, - "description": "投标人具有自主研发的实时视频分析平台系统的知识产权和AI识别方法的发明专利,每提供一个得1分,共3分。(提供证书原件的扫描件并加盖公章)", - "category": "compliance", - "chapter_id": "chapter_08" - } - ], - "deviation_items": [], - "chapters": [ - { - "id": "chapter_1", - "title": "评标索引表(技术评分完全对应)", - "level": 1, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2", - "title": "设备规格 (8.0分)", - "level": 1, - "score": 8.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_2_1", - "title": "硬件配置方案", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_2_1_1", - "title": "核心硬件技术参数", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_2", - "title": "设备性能指标说明", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_3", - "title": "硬件兼容性分析", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_2_1_4", - "title": "配置方案优势阐述", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_3", - "title": "技术方案 (13.0分)", - "level": 1, - "score": 13.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_1", - "title": "软件功能设计", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_1_1", - "title": "核心功能模块设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_2", - "title": "用户交互界面设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_3", - "title": "数据处理与分析功能", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_1_4", - "title": "系统集成与接口设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_3_2", - "title": "系统架构设计", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_3_2_1", - "title": "总体架构设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_2_2", - "title": "技术选型与框架设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_3_2_3", - "title": "性能与扩展性设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_4", - "title": "质量安全 (5.0分)", - "level": 1, - "score": 5.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_4_1", - "title": "网络安全防护体系", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_4_1_1", - "title": "网络安全架构设计", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_2", - "title": "网络安全防护措施", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_3", - "title": "网络安全监控与预警", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_4", - "title": "网络安全应急响应", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_4_1_5", - "title": "网络安全管理制度", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_5", - "title": "合规响应 (3.0分)", - "level": 1, - "score": 3.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_5_1", - "title": "技术实力", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_5_1_1", - "title": "核心技术团队介绍", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_2", - "title": "技术研发能力展示", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_3", - "title": "技术设备与工具配置", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_4", - "title": "技术成果与专利情况", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_5_1_5", - "title": "技术质量管理体系", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - }, - { - "id": "chapter_6", - "title": "培训服务", - "level": 1, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_6_1", - "title": "培训计划", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_6_2", - "title": "培训内容", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_6_3", - "title": "培训方式", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7", - "title": "售后服务 (3.0分)", - "level": 1, - "score": 3.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_1", - "title": "质保期服务承诺", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_1_1", - "title": "质保期限与范围", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_2", - "title": "质保期内服务内容", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_3", - "title": "质保期响应机制", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_1_4", - "title": "质保期满后服务延续方案", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_2", - "title": "技术支持服务", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_2_1", - "title": "技术支持团队配置", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_2_2", - "title": "技术支持响应时间", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_2_3", - "title": "技术支持服务方式", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_3", - "title": "维护服务", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_3_1", - "title": "定期维护计划", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_3_2", - "title": "故障处理流程", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_3_3", - "title": "备品备件保障", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - }, - { - "id": "chapter_7_4", - "title": "客户服务保障", - "level": 2, - "score": 0.0, - "template_placeholder": null, - "children": [ - { - "id": "chapter_7_4_1", - "title": "客户服务热线", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_4_2", - "title": "客户满意度调查", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - }, - { - "id": "chapter_7_4_3", - "title": "客户投诉处理机制", - "level": 3, - "score": 0.0, - "template_placeholder": null, - "children": [] - } - ] - } - ] - } - ] - } -} \ No newline at end of file diff --git a/111/tasks.json b/111/tasks.json deleted file mode 100644 index cfd617c..0000000 --- a/111/tasks.json +++ /dev/null @@ -1,68 +0,0 @@ -[ - { - "id": 1, - "title": "质保期", - "chapter_id": "chapter_02", - "score": 3.0, - "description": "售后质保期2年,得1分。售后质保期延长至3年,得2分。售后质保期延长至4年,得3分。", - "category": "after_sales", - "status": "pending", - "content": "", - "placeholder": "{{chapter_02_content}}" - }, - { - "id": 2, - "title": "硬件配置", - "chapter_id": "chapter_04", - "score": 8.0, - "description": "以确保设备性配置不低于招标文件硬件配置要求。打分标准设备硬件的品牌、硬件配置高低、技术先进性、稳定性、兼容性以及售后服务质量等每项指标综合打分。硬件未满足技术要求按每1项不满足扣1分,扣完为止。(招标文件中参数要求提供的检测报告、功能截图、证书等证明材料供应商必须按要求提供,否则评委不予采信)", - "category": "equipment_spec", - "status": "pending", - "content": "", - "placeholder": "{{chapter_04_content}}" - }, - { - "id": 3, - "title": "软件功能", - "chapter_id": "chapter_05", - "score": 8.0, - "description": "应按照磋商文件功能要求实现,各项软件功能完成率,软件功能按每1项不满足扣1分,扣完为止。", - "category": "technical_solution", - "status": "pending", - "content": "", - "placeholder": "{{chapter_05_content}}" - }, - { - "id": 4, - "title": "系统架构", - "chapter_id": "chapter_06", - "score": 5.0, - "description": "对系统架构、实施内容、技术特点、安装规范、售后服务等方面进行说明,缺少1项扣1分,扣完为止。", - "category": "technical_solution", - "status": "pending", - "content": "", - "placeholder": "{{chapter_06_content}}" - }, - { - "id": 5, - "title": "网路安全防护", - "chapter_id": "chapter_07", - "score": 5.0, - "description": "1.防火墙配置、入侵检测系统、安全更新和补丁管理、用户权限管理、安全培训和意识提升、数据加密根据以上各项内容的打分情况,可以综合评估组织的网络安全防护水平,缺少1项扣1分,扣完为止。", - "category": "quality_safety", - "status": "pending", - "content": "", - "placeholder": "{{chapter_07_content}}" - }, - { - "id": 6, - "title": "技术实力", - "chapter_id": "chapter_08", - "score": 3.0, - "description": "投标人具有自主研发的实时视频分析平台系统的知识产权和AI识别方法的发明专利,每提供一个得1分,共3分。(提供证书原件的扫描件并加盖公章)", - "category": "compliance", - "status": "pending", - "content": "", - "placeholder": "{{chapter_08_content}}" - } -] \ No newline at end of file diff --git a/config/prompts.yaml b/config/prompts.yaml index 2debeb8..923333c 100644 --- a/config/prompts.yaml +++ b/config/prompts.yaml @@ -286,6 +286,9 @@ content_prompts: 上下文参考: {context_summary} + 重点提示: + {guidance_part} + 评分说明: {requirements_summary}{emphasis_part}{rag_part} diff --git a/doc/江南造船厂前端.md b/doc/江南造船厂前端.md deleted file mode 100644 index 6c39c07..0000000 --- a/doc/江南造船厂前端.md +++ /dev/null @@ -1,128 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## 项目概述 - -这是一个基于Three.js的3D模型查看器和船厂三维数据管理展示系统。项目包含两个主要页面: -- `index.html`: 主要的3D模型查看器界面,支持模型加载、选择、测量、标注等功能 -- `1.html`: 船舶建造精度数字化平台的数据分析界面,通过iframe嵌入3D查看器 - -## 技术架构 - -### 前端技术栈 -- **Three.js**: 核心3D渲染引擎 -- **原生JavaScript**: ES6+ 模块化架构,无框架依赖 -- **HTML5/CSS3**: 响应式布局,使用Flexbox和Grid -- **Tailwind CSS**: 样式框架(仅在1.html中使用) -- **ECharts**: 数据可视化图表(仅在1.html中使用) - -### 核心模块结构 - -所有JavaScript模块位于 `js/` 目录下,采用模块化设计: - -#### 核心管理器 -- **ModelViewer** (`main.js`): 主应用入口,管理整个3D场景 -- **ModelManager** (`ModelManager.js`): 模型管理,处理模型的加载和切换 -- **ModelLoader** (`ModelLoader.js`): 模型加载器,支持GLTF格式 -- **ModelAPI** (`ModelAPI.js`): 模型API接口,提供程序化控制 - -#### 功能管理器 -- **SelectionManager** (`SelectionManager.js`): 零件选择和高亮 -- **MeasurementManager** (`MeasurementManager.js`): 3D测量工具(距离、角度、面积) -- **ClippingManager** (`ClippingManager.js`): 切面控制 -- **AnnotationManager** (`AnnotationManager.js`): 3D标注功能 -- **ModelTreeManager** (`ModelTreeManager.js`): 模型结构树管理 -- **AnomalyDisplayManager** (`AnomalyDisplayManager.js`): 异常状态显示 -- **LayerDisplayManager** (`LayerDisplayManager.js`): 分层显示管理 - -#### UI和状态管理 -- **UIController** (`UIController.js`): UI控制器,管理所有界面交互 -- **ObjectStateManager** (`ObjectStateManager.js`): 对象状态管理 -- **StorageManager** (`StorageManager.js`): 本地存储管理 -- **OutlineManager** (`OutlineManager.js`): 边缘高亮效果 - -### 资源结构 -- **models/**: 3D模型文件(.glb格式) -- **css/**: 样式文件(主要是style.css) -- **image/**: UI图标和图片资源 -- **three/**: Three.js库文件 - -## 开发环境 - -### 启动开发服务器 -由于项目使用ES6模块和本地文件引用,需要通过HTTP服务器运行: - -```bash -# 使用Live Server(推荐) -# VS Code安装Live Server扩展,配置端口5501 -# 右键点击index.html选择"Open with Live Server" - -# 或使用Python内置服务器 -python -m http.server 5501 - -# 或使用Node.js http-server -npx http-server -p 5501 -``` - -### 访问页面 -- 3D模型查看器: `http://localhost:5501/index.html` -- 数据分析平台: `http://localhost:5501/1.html` - -## 开发约定 - -### 模型文件管理 -- 支持的格式: .glb (推荐) -- 模型文件存放在 `models/` 目录 -- 默认加载模型: `CX0856_CB01C.glb` - -### 配置和状态管理 -- 使用 `StorageManager` 进行本地存储 -- 配置项通过 `ObjectStateManager` 管理 -- 模型状态通过事件系统同步 - -### 代码风格 -- 使用ES6+语法和模块 -- 采用面向对象设计模式 -- 每个功能模块独立文件 -- 使用驼峰命名法 -- 保持代码注释的完整性 - -### 事件通信 -项目使用自定义事件系统进行模块间通信: -- 模型加载完成事件 -- 选择状态变化事件 -- UI状态更新事件 -- 测量结果事件 - -### 浏览器兼容性 -- 支持现代浏览器(Chrome 88+, Firefox 85+, Safari 14+) -- 依赖ES6模块支持 -- 需要WebGL 2.0支持 - -## 调试和测试 - -### 控制台调试 -- 使用 `window.modelViewer` 访问主应用实例 -- 各管理器实例可通过modelViewer实例访问 -- 开启浏览器开发者工具查看Three.js性能面板 - -### 性能优化 -- 大模型文件建议压缩优化 -- 使用LOD (Level of Detail) 技术 -- 合理使用材质和纹理 -- 避免过度的实时计算 - -## 常见问题 - -### CORS错误 -确保通过HTTP服务器访问,而不是直接打开HTML文件 - -### 模型加载失败 -检查模型文件路径和格式,确保.glb文件完整性 - -### Three.js版本兼容 -项目基于Three.js r150+,更新版本时注意API变更 - -### 内存泄漏 -及时清理不需要的几何体、材质和纹理对象 \ No newline at end of file diff --git a/src/bidmaster/__init__.py b/src/bidmaster/__init__.py index e54a17b..6b68c88 100644 --- a/src/bidmaster/__init__.py +++ b/src/bidmaster/__init__.py @@ -1,20 +1,18 @@ -"""BidMaster-CLI 主包 - 统一导出接口 - -按照规范要求,导出三个核心工具函数。 -""" - -__all__ = ["parse", "generate", "assemble"] - -# CLI入口 -def main(): - """CLI主入口函数""" - from .cli.main import cli - cli() +"""BidMaster-CLI 主包 - 统一导出接口""" from .tools.parser import BidParser from .tools.rag import RAGTool from .tools.table import TableGenerator +__all__ = ["parse", "generate", "assemble"] + + +def main(): + """CLI主入口函数""" + from .cli.main import cli + + cli() + # 创建工具函数的便捷接口 def parse(file_path: str, **kwargs): """解析招标文件 diff --git a/src/bidmaster/agents/analysis.py b/src/bidmaster/agents/analysis.py index e178c77..efc4fe0 100644 --- a/src/bidmaster/agents/analysis.py +++ b/src/bidmaster/agents/analysis.py @@ -7,6 +7,7 @@ """ import logging +from enum import Enum from pathlib import Path from typing import List, Dict, Any, TypedDict import asyncio @@ -14,7 +15,15 @@ import asyncio from langgraph.graph import StateGraph, END from pydantic import BaseModel, Field -from ..tools.parser import BidParser, BidStructure, ScoringCriteria, DeviationItem, DocumentChapter +from ..tools.parser import ( + BidParser, + BidStructure, + ScoringCriteria, + DeviationItem, + DocumentChapter, + TechnicalCategory, + ChapterGuidance, +) from ..config import get_settings from ..nodes.toc.base_mixins import WorkflowUtilsMixin from .base import BaseAgentFactory @@ -39,12 +48,30 @@ def _validate_template_file(file_path: str) -> bool: return False +class SectionScope(str, Enum): + """章节范围配置""" + + TECHNICAL_ONLY = "technical_only" + FULL = "full" + + +TECHNICAL_SCOPE_WHITELIST = { + TechnicalCategory.TECHNICAL_SOLUTION, + TechnicalCategory.EQUIPMENT_SPEC, + TechnicalCategory.IMPLEMENTATION, + TechnicalCategory.QUALITY_SAFETY, + TechnicalCategory.AFTER_SALES, + TechnicalCategory.COMPLIANCE, +} + + class AnalysisAgentState(TypedDict): """Analysis Agent的状态定义""" # 输入参数 source_file: str interaction_handler: Any # InteractionHandler实例 + section_scope: str # 技术章节范围 # 执行状态 current_step: str @@ -67,6 +94,7 @@ class AnalysisAgentState(TypedDict): pending_suggestions: List[Dict[str, Any]] # 待处理的AI建议 auto_optimization_rounds: int # 自动优化迭代次数 auto_toc_max_rounds: int # 最大自动优化次数 + chapter_guidance_map: Dict[str, ChapterGuidance] # 最终输出 bid_structure: BidStructure | None @@ -88,6 +116,7 @@ class AnalysisResult(BaseModel): error_message: str | None = Field(default=None, description="错误信息") warnings: List[str] = Field(default_factory=list, description="警告信息") execution_time: float = Field(default=0.0, description="执行时间(秒)") + section_scope: str = Field(default=SectionScope.TECHNICAL_ONLY.value, description="章节范围") # ========== LangGraph 节点函数 ========== @@ -233,6 +262,10 @@ def parse_content_node(state: AnalysisAgentState) -> AnalysisAgentState: technical_criteria = [] commercial_criteria = [] deviation_items = [] + try: + section_scope = SectionScope(state.get("section_scope", SectionScope.TECHNICAL_ONLY.value)) + except ValueError: + section_scope = SectionScope.TECHNICAL_ONLY # 解析评分表 scoring_tables = state["classified_tables"].get("scoring", []) @@ -258,7 +291,14 @@ def parse_content_node(state: AnalysisAgentState) -> AnalysisAgentState: logger.info(f"内容解析完成: 技术项{len(technical_criteria)}个, 商务项{len(commercial_criteria)}个, 偏离项{len(deviation_items)}个") - state["technical_criteria"] = technical_criteria + if section_scope == SectionScope.FULL: + scoped_criteria = technical_criteria + commercial_criteria + else: + scoped_criteria = [c for c in technical_criteria if c.category in TECHNICAL_SCOPE_WHITELIST] + if not scoped_criteria: + state.setdefault("warnings", []).append("技术范围过滤后未找到可用的评分项") + + state["technical_criteria"] = scoped_criteria state["commercial_criteria"] = commercial_criteria state["deviation_items"] = deviation_items state["current_step"] = "parse_content" @@ -401,9 +441,13 @@ def finalize_structure_node(state: AnalysisAgentState) -> AnalysisAgentState: class AnalysisAgent(BaseAgentFactory): """Analysis Agent - 第一阶段分析Agent""" - def __init__(self, interaction_handler=None): + def __init__(self, interaction_handler=None, section_scope: str = SectionScope.TECHNICAL_ONLY.value): self.settings = get_settings() self.interaction_handler = interaction_handler + try: + self.section_scope = SectionScope(section_scope).value + except ValueError: + self.section_scope = SectionScope.TECHNICAL_ONLY.value self.graph = self._build_graph() def _build_graph(self) -> StateGraph: @@ -482,6 +526,7 @@ class AnalysisAgent(BaseAgentFactory): initial_state = AnalysisAgentState( source_file=source_file, interaction_handler=self.interaction_handler, + section_scope=self.section_scope, current_step="", progress=0.0, should_continue=True, @@ -498,6 +543,7 @@ class AnalysisAgent(BaseAgentFactory): pending_suggestions=[], auto_optimization_rounds=0, auto_toc_max_rounds=self.settings.auto_toc_max_rounds, + chapter_guidance_map={}, bid_structure=None, error="", warnings=[] @@ -516,7 +562,8 @@ class AnalysisAgent(BaseAgentFactory): success=False, error_message=final_state["error"], warnings=final_state.get("warnings", []), - execution_time=time.time() - start_time + execution_time=time.time() - start_time, + section_scope=final_state.get("section_scope", self.section_scope) ) else: bid_structure = final_state["bid_structure"] @@ -528,7 +575,8 @@ class AnalysisAgent(BaseAgentFactory): deviation_count=len(final_state.get("deviation_items", [])), chapter_count=len(bid_structure.chapters) if bid_structure else 0, warnings=final_state.get("warnings", []), - execution_time=time.time() - start_time + execution_time=time.time() - start_time, + section_scope=final_state.get("section_scope", self.section_scope) ) logger.info(f"Analysis Agent执行完成,耗时{result.execution_time:.2f}秒") @@ -546,4 +594,25 @@ class AnalysisAgent(BaseAgentFactory): """同步执行接口(用于CLI调用)""" return asyncio.run(self.execute(source_file)) + @classmethod + def create_silent(cls, section_scope: str = SectionScope.TECHNICAL_ONLY.value): + from .interaction import InteractionHandler, InteractionMode + + handler = InteractionHandler(mode=InteractionMode.SILENT) + return cls(handler, section_scope=section_scope) + + @classmethod + def create_programmatic(cls, presets: Dict[str, Any], section_scope: str = SectionScope.TECHNICAL_ONLY.value): + from .interaction import InteractionHandler, InteractionMode + + handler = InteractionHandler(mode=InteractionMode.PROGRAMMATIC, presets=presets) + return cls(handler, section_scope=section_scope) + + @classmethod + def create_interactive(cls, section_scope: str = SectionScope.TECHNICAL_ONLY.value): + from .interaction import InteractionHandler, InteractionMode + + handler = InteractionHandler(mode=InteractionMode.INTERACTIVE) + return cls(handler, section_scope=section_scope) + diff --git a/src/bidmaster/agents/builders/toc_builder.py b/src/bidmaster/agents/builders/toc_builder.py index 5d8da5d..a7df8d7 100644 --- a/src/bidmaster/agents/builders/toc_builder.py +++ b/src/bidmaster/agents/builders/toc_builder.py @@ -4,9 +4,8 @@ """ import logging -from typing import Dict, Any, Optional, Callable +from typing import Dict, Any, Optional -from langgraph.graph import END from ..base import AgentBuilder, BaseAgent, BaseAgentFactory from ...nodes.toc import ( diff --git a/src/bidmaster/agents/content_writer.py b/src/bidmaster/agents/content_writer.py index 4358593..1693d38 100644 --- a/src/bidmaster/agents/content_writer.py +++ b/src/bidmaster/agents/content_writer.py @@ -5,7 +5,7 @@ import logging from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict from .single_chapter_agent import SingleChapterAgent diff --git a/src/bidmaster/agents/interaction.py b/src/bidmaster/agents/interaction.py index cdae028..d6f2ee0 100644 --- a/src/bidmaster/agents/interaction.py +++ b/src/bidmaster/agents/interaction.py @@ -283,7 +283,7 @@ class InteractionHandler: if "pattern" in validation: import re if not re.match(validation["pattern"], text): - return f"文本格式不符合要求" + return "文本格式不符合要求" return None diff --git a/src/bidmaster/cli/project.py b/src/bidmaster/cli/project.py index 2a49784..a3033ae 100644 --- a/src/bidmaster/cli/project.py +++ b/src/bidmaster/cli/project.py @@ -5,6 +5,7 @@ import logging from pathlib import Path +from typing import Any, Dict import click from rich.console import Console @@ -135,7 +136,11 @@ def parse(scoring_file: str, deviation_file: str | None, template_file: str | No @click.option("--presets", "-p", type=str, help="程序化模式的预设配置JSON字符串") -def new(mode: str, presets: str): +@click.option("--section-scope", + type=click.Choice(['technical_only', 'full']), + default='technical_only', + help="章节范围:technical_only只保留技术部分,full包含全部评分项") +def new(mode: str, presets: str, section_scope: str): """核心命令:解析招标文件,生成任务清单和Word框架""" try: console.print("🎯 BidMaster 标书项目创建向导", style="bold blue") @@ -177,7 +182,7 @@ def new(mode: str, presets: str): # 根据模式创建AnalysisAgent if mode == 'silent': - analysis_agent = AnalysisAgent.create_silent() + analysis_agent = AnalysisAgent.create_silent(section_scope=section_scope) console.print("📌 使用静默模式(自动选择默认值)", style="dim") elif mode == 'programmatic': if not presets: @@ -185,13 +190,13 @@ def new(mode: str, presets: str): return try: preset_dict = json.loads(presets) - analysis_agent = AnalysisAgent.create_programmatic(preset_dict) + analysis_agent = AnalysisAgent.create_programmatic(preset_dict, section_scope=section_scope) console.print(f"📌 使用程序化模式,预设配置: {list(preset_dict.keys())}", style="dim") except json.JSONDecodeError: console.print("❌ 预设配置JSON格式错误", style="red") return else: # interactive - analysis_agent = AnalysisAgent.create_interactive() + analysis_agent = AnalysisAgent.create_interactive(section_scope=section_scope) console.print("📌 使用交互模式", style="dim") # 执行完整的分析流程(包括目录生成) @@ -214,6 +219,7 @@ def new(mode: str, presets: str): data = { "project_name": project_name, "source_file": str(Path(bidding_file).absolute()), + "section_scope": result.section_scope, "success": result.success, "technical_count": result.technical_count, "commercial_count": result.commercial_count, @@ -249,6 +255,17 @@ def new(mode: str, presets: str): tasks = [] task_id = 1 + guidance_map: Dict[str, Any] = {} + + def _collect_guidance(chapter_obj): + if chapter_obj.guidance: + guidance_map[chapter_obj.id] = chapter_obj.guidance.dict() + for child in chapter_obj.children: + _collect_guidance(child) + + for chapter in bid_structure.chapters: + _collect_guidance(chapter) + # 为每个评分项创建任务 for criteria in bid_structure.scoring_criteria: tasks.append({ @@ -260,7 +277,8 @@ def new(mode: str, presets: str): "category": criteria.category.value, "status": "pending", "content": "", - "placeholder": f"{{{{{criteria.chapter_id}_content}}}}" + "placeholder": f"{{{{{criteria.chapter_id}_content}}}}", + "guidance": guidance_map.get(criteria.chapter_id) }) task_id += 1 @@ -338,10 +356,11 @@ def new(mode: str, presets: str): console.print(f"📚 生成章节: {result.chapter_count}个") console.print(f"📝 生成任务: {len(tasks)}个") console.print(f"⏱️ 处理耗时: {result.execution_time:.2f}秒") + console.print(f"⚙️ 目录范围: {result.section_scope}") # 显示警告信息(如果有) if result.warnings: - console.print(f"\n⚠️ 警告信息:", style="yellow") + console.print("\n⚠️ 警告信息:", style="yellow") for warning in result.warnings: console.print(f" • {warning}", style="yellow") @@ -356,9 +375,9 @@ def new(mode: str, presets: str): console.print("\n📚 项目目录结构:") _display_chapters_recursive(bid_structure.chapters) - console.print(f"\n🎯 下一步:", style="dim") + console.print("\n🎯 下一步:", style="dim") console.print(f" • 打开 {template_file.name} 开始编写标书", style="dim") - console.print(f" • 使用 'generate task ' 为具体任务生成内容", style="dim") + console.print(" • 使用 'generate task ' 为具体任务生成内容", style="dim") except Exception as e: console.print(f"❌ 项目创建失败: {e}", style="red") @@ -372,6 +391,7 @@ def _serialize_chapter(chapter): "level": chapter.level, "score": chapter.score, "template_placeholder": chapter.template_placeholder, + "guidance": chapter.guidance.dict() if getattr(chapter, "guidance", None) else None, "children": [_serialize_chapter(child) for child in chapter.children] } @@ -411,6 +431,7 @@ def status(project_dir: str): console.print(f"📋 {analysis_data.get('project_name', '标书项目')}", style="bold blue") console.print(f"📁 项目路径: {project_path.absolute()}") console.print(f"📄 源文件: {analysis_data.get('source_file', 'N/A')}") + console.print(f"⚙️ 目录范围: {analysis_data.get('section_scope', 'technical_only')}") # 统计信息 technical_count = len(analysis_data.get('technical_criteria', [])) @@ -418,7 +439,7 @@ def status(project_dir: str): deviation_count = len(analysis_data.get('deviation_items', [])) total_tasks = len(tasks_data) - console.print(f"\n📊 项目概况:") + console.print("\n📊 项目概况:") console.print(f" • 技术评分项: {technical_count}项") console.print(f" • 商务评分项: {commercial_count}项(已排除)") console.print(f" • 偏离项: {deviation_count}项") @@ -430,7 +451,7 @@ def status(project_dir: str): status = task.get('status', 'unknown') status_count[status] = status_count.get(status, 0) + 1 - console.print(f"\n✅ 任务进度:") + console.print("\n✅ 任务进度:") status_names = { 'pending': '待处理', 'in_progress': '进行中', @@ -443,7 +464,7 @@ def status(project_dir: str): console.print(f" • {name}: {count}个") # 显示任务清单 - console.print(f"\n📝 任务清单:") + console.print("\n📝 任务清单:") task_table = Table(title="项目任务列表") task_table.add_column("ID", justify="right", style="cyan", no_wrap=True) task_table.add_column("标题", style="yellow") @@ -451,18 +472,19 @@ def status(project_dir: str): task_table.add_column("状态", style="green") for task in tasks_data[:10]: # 只显示前10个任务 + status_text = task.get('status', 'pending') status_style = { 'pending': 'yellow', 'in_progress': 'blue', 'completed': 'green', 'failed': 'red' - }.get(task.get('status'), 'white') + }.get(status_text, 'white') task_table.add_row( str(task['id']), task['title'][:50] + "..." if len(task['title']) > 50 else task['title'], f"{task['score']}分", - task.get('status', 'pending') + f"[{status_style}]{status_text}[/]" ) console.print(task_table) @@ -470,9 +492,9 @@ def status(project_dir: str): if len(tasks_data) > 10: console.print(f" ... 还有{len(tasks_data) - 10}个任务") - console.print(f"\n🎯 下一步操作:", style="dim") - console.print(f" • generate task - 为具体任务生成内容", style="dim") - console.print(f" • generate full - 为所有待处理任务生成内容", style="dim") + console.print("\n🎯 下一步操作:", style="dim") + console.print(" • generate task - 为具体任务生成内容", style="dim") + console.print(" • generate full - 为所有待处理任务生成内容", style="dim") except Exception as e: console.print(f"❌ 查看项目状态失败: {e}", style="red") @@ -520,16 +542,17 @@ def smart_parse(word_file: str): console.print(f"📋 偏离项: {result.deviation_count}项") console.print(f"📚 生成章节: {result.chapter_count}个") console.print(f"⏱️ 处理耗时: {result.execution_time:.2f}秒") + console.print(f"⚙️ 目录范围: {result.section_scope}") # 显示警告信息(如果有) if result.warnings: - console.print(f"\n⚠️ 警告信息:", style="yellow") + console.print("\n⚠️ 警告信息:", style="yellow") for warning in result.warnings: console.print(f" • {warning}", style="yellow") # 显示技术评分标准 if bid_structure.scoring_criteria: - console.print(f"\n📊 技术评分标准详情:") + console.print("\n📊 技术评分标准详情:") table = Table(title="技术评分标准解析结果") table.add_column("技术类别", style="green") table.add_column("评分项", style="yellow") diff --git a/src/bidmaster/cli/write.py b/src/bidmaster/cli/write.py index 5627cd4..e5c7449 100644 --- a/src/bidmaster/cli/write.py +++ b/src/bidmaster/cli/write.py @@ -66,7 +66,7 @@ def start(project_dir: str, mode: str): console.print("❌ 未找到Word文档", style="red") return console.print(f"📄 找到Word文档: {Path(word_file).name}", style="green") - console.print(f"📋 将直接从Word文档提取章节结构", style="dim") + console.print("📋 将直接从Word文档提取章节结构", style="dim") # 创建Agent if mode == "silent": @@ -111,9 +111,9 @@ def start(project_dir: str, mode: str): ) console.print("\n", stats_panel) - console.print(f"\n🎯 下一步:", style="dim") + console.print("\n🎯 下一步:", style="dim") console.print(f" • 打开 {Path(result['word_file']).name} 查看生成的内容", style="dim") - console.print(f" • 使用 Ctrl+A -> F9 刷新Word中的目录和页码", style="dim") + console.print(" • 使用 Ctrl+A -> F9 刷新Word中的目录和页码", style="dim") else: console.print("\n❌ 内容填写失败", style="red") @@ -166,7 +166,7 @@ def _find_word_file(project_path: Path) -> str: if len(docx_files) == 1: return str(docx_files[0]) elif len(docx_files) > 1: - console.print(f"\n⚠️ 找到多个Word文档:", style="yellow") + console.print("\n⚠️ 找到多个Word文档:", style="yellow") for i, f in enumerate(docx_files, 1): console.print(f" {i}. {f.name}") console.print("\n提示:将只有一个.docx文件放在项目目录中", style="yellow") diff --git a/src/bidmaster/config/settings.py b/src/bidmaster/config/settings.py index 70930e4..79b1b26 100644 --- a/src/bidmaster/config/settings.py +++ b/src/bidmaster/config/settings.py @@ -7,7 +7,7 @@ """ from pathlib import Path -from typing import Any, Literal +from typing import Any from enum import Enum import yaml diff --git a/src/bidmaster/internal/__init__.py b/src/bidmaster/internal/__init__.py index ed713b9..7062ff3 100644 --- a/src/bidmaster/internal/__init__.py +++ b/src/bidmaster/internal/__init__.py @@ -1,8 +1,3 @@ -"""Internal!W - 螰 bimport +"""Experimental internal utilities module placeholder.""" -dU+y螰Ƃ ( -@ lq -B!W2 -""" - -__all__ = [] # U \ No newline at end of file +__all__: list[str] = [] diff --git a/src/bidmaster/models/project.py b/src/bidmaster/models/project.py index b0a10f4..dd41145 100644 --- a/src/bidmaster/models/project.py +++ b/src/bidmaster/models/project.py @@ -5,8 +5,6 @@ from datetime import datetime from enum import Enum -from pathlib import Path -from typing import Any from uuid import UUID, uuid4 from pydantic import BaseModel, Field diff --git a/src/bidmaster/nodes/content/init_config.py b/src/bidmaster/nodes/content/init_config.py index bd0626f..9956184 100644 --- a/src/bidmaster/nodes/content/init_config.py +++ b/src/bidmaster/nodes/content/init_config.py @@ -327,6 +327,7 @@ class InitConfigNode(BaseNode): return {} chapter_titles: Dict[str, str] = {} + chapter_guidance: Dict[str, Dict[str, Any]] = {} def _collect_titles(nodes: List[Dict[str, Any]] | None) -> None: if not nodes: @@ -336,6 +337,9 @@ class InitConfigNode(BaseNode): title = node.get("title", "") if chapter_id and title: chapter_titles[chapter_id] = title + guidance = node.get("guidance") + if chapter_id and guidance: + chapter_guidance[chapter_id] = guidance _collect_titles(node.get("children")) _collect_titles(data.get("chapters", [])) @@ -373,6 +377,10 @@ class InitConfigNode(BaseNode): entry["requirements"].append(description) entry["rubric_points"].extend(self._split_rubric_points(description)) + self._merge_guidance(entry, chapter_guidance.get(chapter_id)) + + return self._finalize_metadata_entries(metadata) + return self._finalize_metadata_entries(metadata) def _parse_tasks_metadata(self, file_path: Path) -> Dict[str, Dict[str, Any]]: @@ -408,6 +416,8 @@ class InitConfigNode(BaseNode): entry["requirements"].append(description) entry["rubric_points"].extend(self._split_rubric_points(description)) + self._merge_guidance(entry, task.get("guidance")) + return self._finalize_metadata_entries(metadata) def _split_rubric_points(self, text: str) -> List[str]: @@ -418,6 +428,44 @@ class InitConfigNode(BaseNode): clean_parts = [p.strip().strip("::·•") for p in parts if len(p.strip()) >= 3] return clean_parts + def _merge_guidance(self, + entry: Dict[str, Any], + guidance: Optional[Dict[str, Any]]) -> None: + if not guidance: + return + + target = entry.setdefault( + "guidance", + { + "key_requirements": [], + "suggested_evidence": [], + "context_snippets": [], + } + ) + + for key in ("key_requirements", "suggested_evidence", "context_snippets"): + values = guidance.get(key) or [] + for value in values: + if value and value not in target[key]: + target[key].append(value) + + def _normalize_guidance(self, guidance: Optional[Dict[str, Any]]) -> Optional[Dict[str, List[str]]]: + if not guidance: + return None + + normalized: Dict[str, List[str]] = {} + for key in ("key_requirements", "suggested_evidence", "context_snippets"): + values = guidance.get(key) or [] + dedup: List[str] = [] + for value in values: + clean = (value or "").strip() + if clean and clean not in dedup: + dedup.append(clean) + if dedup: + normalized[key] = dedup + + return normalized or None + def _finalize_metadata_entries(self, metadata: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: finalized: Dict[str, Dict[str, Any]] = {} for key, entry in metadata.items(): @@ -441,6 +489,7 @@ class InitConfigNode(BaseNode): "rubric_points": dedup_points, "source": entry.get("source"), "chapter_id_source": entry.get("chapter_id_source"), + "guidance": self._normalize_guidance(entry.get("guidance")), } return finalized @@ -473,6 +522,7 @@ class InitConfigNode(BaseNode): "rubric_points": meta.get("rubric_points", []), "source": meta.get("source"), "chapter_id_source": meta.get("chapter_id_source"), + "guidance": meta.get("guidance"), } chapter["requirements"] = enriched["requirements"] @@ -480,6 +530,8 @@ class InitConfigNode(BaseNode): chapter["score"] = enriched["score"] if enriched["category"]: chapter["category"] = enriched["category"] + if enriched.get("guidance"): + chapter["guidance"] = enriched["guidance"] chapter_metadata[chapter["id"]] = enriched diff --git a/src/bidmaster/nodes/content/interact_user.py b/src/bidmaster/nodes/content/interact_user.py index e5ecd9a..9d452df 100644 --- a/src/bidmaster/nodes/content/interact_user.py +++ b/src/bidmaster/nodes/content/interact_user.py @@ -128,7 +128,7 @@ class InteractWithUserNode(BaseNode): choice = interaction_handler( interaction_type="choice", - prompt=f"选择知识库", + prompt="选择知识库", options=[ ("default", store_info), ("none", "不使用RAG") diff --git a/src/bidmaster/nodes/content/save_to_word.py b/src/bidmaster/nodes/content/save_to_word.py index a09fb15..6f0c0a6 100644 --- a/src/bidmaster/nodes/content/save_to_word.py +++ b/src/bidmaster/nodes/content/save_to_word.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Dict from ..base import BaseNode, NodeContext +from ...utils.validation import ContentValidationError, ContentValidator logger = logging.getLogger(__name__) @@ -21,6 +22,9 @@ class SaveToWordNode(BaseNode): 3. 更新处理进度 """ + def __init__(self) -> None: + self._content_validator = ContentValidator() + @property def name(self) -> str: return "save_to_word" @@ -53,7 +57,21 @@ class SaveToWordNode(BaseNode): if not content: raise ValueError(f"章节 {chapter_id} 的内容未生成") - logger.info(f"开始保存章节内容到Word: {chapter_id} - {chapter['title']}") + current_index = state.get("current_chapter_index", 0) + total_chapters = len(state.get("chapter_queue", [])) or 1 + logger.info( + "保存章节内容 [%s/%s]: %s - %s", + current_index + 1, + total_chapters, + chapter_id, + chapter.get("title"), + ) + + try: + self._content_validator.validate(chapter, content) + except ContentValidationError as exc: + logger.error("章节 %s 内容未通过合规校验: %s", chapter_id, exc) + raise ValueError(f"章节 {chapter_id} 内容未通过合规校验: {exc}") from exc # 填充到Word文档 self._fill_word_document(word_file, chapter, content) @@ -62,9 +80,6 @@ class SaveToWordNode(BaseNode): completed_chapters = state.get("completed_chapters", []) completed_chapters.append(chapter_id) - current_index = state.get("current_chapter_index", 0) - total_chapters = len(state.get("chapter_queue", [])) - logger.info(f"章节保存完成 [{current_index + 1}/{total_chapters}]: {chapter_id}") # 直接在state上更新,确保下次循环能读取到最新值 @@ -100,8 +115,8 @@ class SaveToWordNode(BaseNode): placeholder = f"{{{{{chapter['id']}_content}}}}" try: - # 填充占位符 - word_processor.fill_placeholder(word_path, placeholder, content) + # 填充章节内容 + word_processor.fill_chapter_content(word_path, chapter, content) logger.info(f"成功填充占位符: {placeholder}") except Exception as e: diff --git a/src/bidmaster/nodes/toc/apply_suggestions.py b/src/bidmaster/nodes/toc/apply_suggestions.py index b5d7197..eadc979 100644 --- a/src/bidmaster/nodes/toc/apply_suggestions.py +++ b/src/bidmaster/nodes/toc/apply_suggestions.py @@ -75,7 +75,7 @@ class ApplyReviewSuggestionsNode(BaseNode, TocNodeBase): try: # 展示AI的完整评估结果 - assessment_text = f"AI审查评估:\n" + assessment_text = "AI审查评估:\n" if overall_assessment: assessment_text += f"总体评价: {overall_assessment}\n" if optimization_score: diff --git a/src/bidmaster/nodes/toc/base_mixins.py b/src/bidmaster/nodes/toc/base_mixins.py index 2c42c82..d197fd6 100644 --- a/src/bidmaster/nodes/toc/base_mixins.py +++ b/src/bidmaster/nodes/toc/base_mixins.py @@ -5,7 +5,7 @@ import logging from abc import ABC -from typing import Dict, Any, Optional, Callable +from typing import Dict, Any, Callable logger = logging.getLogger(__name__) diff --git a/src/bidmaster/nodes/toc/category_manager.py b/src/bidmaster/nodes/toc/category_manager.py index 49f788e..e891715 100644 --- a/src/bidmaster/nodes/toc/category_manager.py +++ b/src/bidmaster/nodes/toc/category_manager.py @@ -4,10 +4,10 @@ """ import logging -from typing import Dict, List, Any +from typing import Dict, List from ...tools.parser import ScoringCriteria, DocumentChapter -from .constants import CATEGORY_NAMES, CATEGORY_ORDER +from .constants import CATEGORY_NAMES, CATEGORY_ORDER, CATEGORY_TITLE_HINTS logger = logging.getLogger(__name__) @@ -103,6 +103,27 @@ class CategoryManager: return "_".join(parts[2:]) + @staticmethod + def infer_category_for_chapter(chapter: DocumentChapter) -> str: + """通过章节ID或标题推断类别""" + + category = CategoryManager.extract_category_from_chapter_id(chapter.id) + if category: + return category + + title = (chapter.title or "").lower() + if not title: + return "" + + for category_key, hints in CATEGORY_TITLE_HINTS.items(): + for hint in hints: + if not hint: + continue + if hint.lower() in title: + return category_key + + return "" + @staticmethod def find_corresponding_criteria(chapter: DocumentChapter, technical_criteria: List[ScoringCriteria]) -> List[ScoringCriteria]: @@ -115,7 +136,7 @@ class CategoryManager: Returns: 对应的评分项列表 """ - category = CategoryManager.extract_category_from_chapter_id(chapter.id) + category = CategoryManager.infer_category_for_chapter(chapter) if not category: return [] diff --git a/src/bidmaster/nodes/toc/constants.py b/src/bidmaster/nodes/toc/constants.py index 10e3ebc..9e4d1d8 100644 --- a/src/bidmaster/nodes/toc/constants.py +++ b/src/bidmaster/nodes/toc/constants.py @@ -10,7 +10,8 @@ CATEGORY_NAMES = { "equipment_spec": "设备规格", "quality_safety": "质量安全", "after_sales": "售后服务", - "implementation": "实施方案" + "implementation": "实施方案", + "commercial": "商务响应" } # 预定义类别顺序 @@ -20,9 +21,21 @@ CATEGORY_ORDER = [ "implementation", "quality_safety", "after_sales", - "compliance" + "compliance", + "commercial" ] +# 章节标题关键字提示 +CATEGORY_TITLE_HINTS = { + "technical_solution": [CATEGORY_NAMES["technical_solution"], "技术部分", "总体要求"], + "equipment_spec": [CATEGORY_NAMES["equipment_spec"], "供货", "设备"], + "implementation": [CATEGORY_NAMES["implementation"], "实施方案", "安装调试"], + "quality_safety": [CATEGORY_NAMES["quality_safety"], "质量", "安全"], + "after_sales": [CATEGORY_NAMES["after_sales"], "售后", "培训"], + "compliance": [CATEGORY_NAMES["compliance"], "合规", "条款"], + "commercial": [CATEGORY_NAMES["commercial"], "商务"], +} + # 默认子章节模板 DEFAULT_SUB_CHAPTERS_TEMPLATE = [ {"title": "方案概述", "level": 2}, diff --git a/src/bidmaster/nodes/toc/factories.py b/src/bidmaster/nodes/toc/factories.py index 00cf94e..6acb8dc 100644 --- a/src/bidmaster/nodes/toc/factories.py +++ b/src/bidmaster/nodes/toc/factories.py @@ -4,9 +4,9 @@ """ import logging -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any -from ...tools.parser import DocumentChapter, ScoringCriteria +from ...tools.parser import DocumentChapter from .constants import CATEGORY_NAMES logger = logging.getLogger(__name__) diff --git a/src/bidmaster/nodes/toc/finalize_chapters.py b/src/bidmaster/nodes/toc/finalize_chapters.py index c383787..a5f92e4 100644 --- a/src/bidmaster/nodes/toc/finalize_chapters.py +++ b/src/bidmaster/nodes/toc/finalize_chapters.py @@ -10,6 +10,7 @@ from ..base import BaseNode, NodeContext from ...tools.parser import DocumentChapter from .factories import ChapterFactory from .base_mixins import TocNodeBase +from .category_manager import CategoryManager logger = logging.getLogger(__name__) @@ -44,6 +45,8 @@ class FinalizeChaptersNode(BaseNode, TocNodeBase): # 确保标准章节存在 final_chapters = self._ensure_standard_chapters(final_chapters) + self._apply_saved_guidance(final_chapters, state.get("chapter_guidance_map")) + self.log_step_info("finalize_chapters", f"最终生成{len(final_chapters)}个章节") return self._update_state(state, @@ -76,4 +79,19 @@ class FinalizeChaptersNode(BaseNode, TocNodeBase): # 可以在此添加其他必要的标准章节 # 例如:封面、目录、声明等 - return final_chapters \ No newline at end of file + return final_chapters + + def _apply_saved_guidance(self, + chapters: List[DocumentChapter], + guidance_map: Dict[str, Any] | None) -> None: + if not guidance_map: + return + + for chapter in chapters: + category_key = CategoryManager.infer_category_for_chapter(chapter) + guidance = guidance_map.get(category_key) + if guidance: + chapter.guidance = guidance + + if chapter.children: + self._apply_saved_guidance(chapter.children, guidance_map) \ No newline at end of file diff --git a/src/bidmaster/nodes/toc/generate_sub_chapters.py b/src/bidmaster/nodes/toc/generate_sub_chapters.py index 8a25856..027a98c 100644 --- a/src/bidmaster/nodes/toc/generate_sub_chapters.py +++ b/src/bidmaster/nodes/toc/generate_sub_chapters.py @@ -13,6 +13,7 @@ from .factories import ChapterFactory from .llm_helper import LLMHelper from .base_mixins import TocNodeBase from ...utils.document_context import DocumentContextSearcher +from ...utils.guidance import build_chapter_guidance logger = logging.getLogger(__name__) @@ -50,6 +51,8 @@ class GenerateSubChaptersNode(BaseNode, TocNodeBase): except Exception as exc: self.log_step_info("context_searcher", f"上下文检索器初始化失败: {exc}") + guidance_map = dict(state.get("chapter_guidance_map", {})) + # 为每个章节生成子标题 enhanced_chapters = [] for chapter in preliminary_chapters: @@ -60,9 +63,14 @@ class GenerateSubChaptersNode(BaseNode, TocNodeBase): ) enhanced_chapters.append(enhanced_chapter) + if getattr(enhanced_chapter, "guidance", None): + category_key = CategoryManager.infer_category_for_chapter(enhanced_chapter) + if category_key: + guidance_map[category_key] = enhanced_chapter.guidance + self.log_step_info("generate_sub_chapters", f"完成{len(enhanced_chapters)}个章节的子标题生成") - return self._update_state(state, preliminary_chapters=enhanced_chapters) + return self._update_state(state, preliminary_chapters=enhanced_chapters, chapter_guidance_map=guidance_map) def _enhance_chapter_with_subs(self, chapter: DocumentChapter, @@ -102,6 +110,10 @@ class GenerateSubChaptersNode(BaseNode, TocNodeBase): self.log_step_info("enhance_chapter", f"章节 {chapter.title} AI生成子标题失败") raise RuntimeError(error_msg) + guidance = build_chapter_guidance(chapter, corresponding_criteria, context_snippets) + if guidance: + chapter.guidance = guidance + return chapter def _collect_context_snippets(self, diff --git a/src/bidmaster/nodes/toc/group_criteria.py b/src/bidmaster/nodes/toc/group_criteria.py index 6cf0bde..f0dc326 100644 --- a/src/bidmaster/nodes/toc/group_criteria.py +++ b/src/bidmaster/nodes/toc/group_criteria.py @@ -4,10 +4,9 @@ """ import logging -from typing import Dict, List, Any +from typing import Dict, Any from ..base import BaseNode, NodeContext -from ...tools.parser import ScoringCriteria from .category_manager import CategoryManager from .base_mixins import TocNodeBase diff --git a/src/bidmaster/nodes/toc/optimize_with_feedback.py b/src/bidmaster/nodes/toc/optimize_with_feedback.py index bcd649e..371ac9f 100644 --- a/src/bidmaster/nodes/toc/optimize_with_feedback.py +++ b/src/bidmaster/nodes/toc/optimize_with_feedback.py @@ -11,7 +11,6 @@ from ..base import BaseNode, NodeContext from ...tools.parser import DocumentChapter from .base_mixins import TocNodeBase from .llm_helper import LLMHelper -from .factories import ChapterFactory from ...config.prompt_manager import get_prompt_manager logger = logging.getLogger(__name__) diff --git a/src/bidmaster/nodes/toc/review_structure.py b/src/bidmaster/nodes/toc/review_structure.py index 1606303..b4c2aa0 100644 --- a/src/bidmaster/nodes/toc/review_structure.py +++ b/src/bidmaster/nodes/toc/review_structure.py @@ -4,10 +4,9 @@ """ import logging -from typing import Dict, List, Any +from typing import Dict, Any from ..base import BaseNode, NodeContext -from ...tools.parser import ScoringCriteria, DocumentChapter from .llm_helper import LLMHelper from .base_mixins import TocNodeBase from ...utils.document_context import DocumentContextSearcher diff --git a/src/bidmaster/tools/parser.py b/src/bidmaster/tools/parser.py index 49d62b6..cb9ee54 100644 --- a/src/bidmaster/tools/parser.py +++ b/src/bidmaster/tools/parser.py @@ -11,7 +11,6 @@ from enum import Enum import pandas as pd from docx import Document -from openai import OpenAI from pydantic import BaseModel, Field from ..config import get_settings @@ -51,6 +50,14 @@ class DeviationItem(BaseModel): chapter_id: str = Field(..., description="对应章节ID") +class ChapterGuidance(BaseModel): + """章节写作提示""" + + key_requirements: List[str] = Field(default_factory=list, description="关键评分要求") + suggested_evidence: List[str] = Field(default_factory=list, description="建议支撑材料") + context_snippets: List[str] = Field(default_factory=list, description="上下文摘录") + + class DocumentChapter(BaseModel): """文档章节""" @@ -60,6 +67,7 @@ class DocumentChapter(BaseModel): score: float | None = Field(default=None, description="评分值") children: List['DocumentChapter'] = Field(default_factory=list, description="子章节") template_placeholder: str | None = Field(default=None, description="模板占位符") + guidance: ChapterGuidance | None = Field(default=None, description="章节写作提示") diff --git a/src/bidmaster/tools/rag.py b/src/bidmaster/tools/rag.py index 1cb052e..d45108a 100644 --- a/src/bidmaster/tools/rag.py +++ b/src/bidmaster/tools/rag.py @@ -12,7 +12,6 @@ import chromadb from chromadb.config import Settings as ChromaSettings from chromadb.utils import embedding_functions from langchain.text_splitter import RecursiveCharacterTextSplitter -from sentence_transformers import SentenceTransformer from langchain_community.document_loaders import ( PyPDFLoader, TextLoader, @@ -215,6 +214,8 @@ class RAGTool: # 构建提示词变量 emphasis_part = f'\n特别强调:{emphasis}' if emphasis else '' rag_part = f'\n\n参考资料:\n{rag_context}' if rag_context else '' + guidance_notes = (context.get('guidance_notes') or '').strip() + guidance_part = guidance_notes or '(暂无重点提示)' prompt_variables = { "title": task_title, @@ -226,6 +227,7 @@ class RAGTool: "consistency_rules": context.get('consistency_rules', '1. 保持章节语气与格式一致'), "context_summary": context.get('context_summary', '(暂无可引用的上下文)'), "emphasis_part": emphasis_part, + "guidance_part": guidance_part, "rag_part": rag_part, } diff --git a/src/bidmaster/tools/table.py b/src/bidmaster/tools/table.py index 28dff5c..4d7c855 100644 --- a/src/bidmaster/tools/table.py +++ b/src/bidmaster/tools/table.py @@ -5,7 +5,6 @@ import json from pathlib import Path -from typing import Any class TableGenerator: diff --git a/src/bidmaster/tools/word.py b/src/bidmaster/tools/word.py index 9d2e6e2..4cfea3d 100644 --- a/src/bidmaster/tools/word.py +++ b/src/bidmaster/tools/word.py @@ -5,17 +5,18 @@ import logging import re +import unicodedata +from collections import defaultdict from pathlib import Path -from typing import List +from typing import Any, Dict, List, Optional, Tuple from docx import Document -from docx.shared import Inches, Pt, Cm -from docx.enum.style import WD_STYLE_TYPE +from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.text.paragraph import Paragraph -from .parser import DocumentChapter +from .parser import DocumentChapter, ChapterGuidance from ..config.settings import get_settings logger = logging.getLogger(__name__) @@ -35,12 +36,12 @@ NUMBERING_PATTERNS = [ re.compile(r'^([A-Z]\.\s+)'), # A. ] -MARKDOWN_HEADING_PATTERN = re.compile(r'^#{2,4}\s+') +MARKDOWN_HEADING_PATTERN = re.compile(r'^(#{2,})\s+') ORDERED_LIST_PATTERN = re.compile(r'^(\d+[\.))]|(\d+))\s+') UNORDERED_LIST_PATTERN = re.compile(r'^[-*•]\s+') BOLD_TEXT_PATTERN = re.compile(r'\*\*(.+?)\*\*') ITALIC_TEXT_PATTERN = re.compile(r'\*(.+?)\*') -CHAPTER_ID_PATTERN = re.compile(r'chapter_(\d+(?:_\d+)*)') +CHAPTER_ID_PATTERN = re.compile(r'chapter_([a-zA-Z0-9_]+)') HEADING_LEVEL_PATTERN = re.compile(r'^(#+)') # 字体常量(非格式参数) @@ -110,6 +111,9 @@ class WordProcessor: run = para.add_run(title_text) run.bold = True + if getattr(chapter, "guidance", None): + self._add_guidance_block(doc, chapter.guidance) + # 为有内容的章节添加占位符 if chapter.template_placeholder: content_para = doc.add_paragraph(f"\n{chapter.template_placeholder}\n") @@ -134,6 +138,25 @@ class WordProcessor: if chapter.level == PRIMARY_CHAPTER_LEVEL: doc.add_paragraph() # 添加空行 + def _add_guidance_block(self, doc: Document, guidance: ChapterGuidance) -> None: + """在章节下方插入写作提示""" + + lines = ["【重点提示】"] + if guidance.key_requirements: + lines.append("关键要求:") + lines.extend([f" • {item}" for item in guidance.key_requirements]) + if guidance.suggested_evidence: + lines.append("建议支撑:") + lines.extend([f" • {item}" for item in guidance.suggested_evidence]) + if guidance.context_snippets: + lines.append("招标摘录:") + lines.extend([f" • {item}" for item in guidance.context_snippets]) + + block_text = "\n".join(lines) + para = doc.add_paragraph(block_text) + for run in para.runs: + run.italic = True + def _check_has_numbering(self, title: str) -> bool: """检查标题是否包含编号 @@ -154,37 +177,100 @@ class WordProcessor: return NUMBERING_PATTERNS[0].sub('', title.strip()) def fill_placeholder(self, doc_path: str, placeholder: str, content: str) -> None: - """填充Word文档:在对应标题后插入内容 + """兼容旧接口, 根据占位符填充Word文档内容""" - Args: - doc_path: Word文档路径 - placeholder: 占位符(用于解析章节编号,如 {{chapter_2_1_1_content}}) - content: 要填充的内容 + match = CHAPTER_ID_PATTERN.search(placeholder) + if not match: + raise ValueError(f"无法从占位符解析章节编号: {placeholder}") - Raises: - ValueError: 占位符格式错误或未找到对应标题 - FileNotFoundError: 文档不存在 - """ - doc = Document(doc_path) + chapter_id = match.group(0) + chapter_meta = { + "id": chapter_id, + "placeholder": placeholder, + "title": chapter_id, + "normalized_title": self._normalize_heading_text(chapter_id), + "level": None, + "heading_number": None, + "order_index": None, + } - # 1. 解析占位符提取章节编号 - chapter_number = self._parse_chapter_number_from_placeholder(placeholder) + try: + chapter_number = self._parse_chapter_number_from_placeholder(placeholder) + except ValueError: + chapter_number = None - # 2. 查找对应标题 - target_paragraph, _ = self._find_heading_by_chapter_id(doc, chapter_number) + if chapter_number: + chapter_meta["level"] = chapter_number.count(".") + 1 - # 3. 获取章节层级 - chapter_level = self._get_chapter_level_from_heading(target_paragraph) + self.fill_chapter_content(doc_path, chapter_meta, content) + + def fill_chapter_content(self, doc_path: str | Path, chapter_meta: Dict[str, Any], content: str) -> None: + """填充指定章节的内容到Word文档""" + + if not Path(doc_path).exists(): + raise FileNotFoundError(f"Word文档不存在: {doc_path}") + + doc = Document(str(doc_path)) + headings_index = self._extract_document_headings(doc) + + placeholder = chapter_meta.get("placeholder") + chapter_number = None + if placeholder: + try: + chapter_number = self._parse_chapter_number_from_placeholder(placeholder) + except ValueError: + chapter_number = None + + target_paragraph, target_index, matched_strategy = self._locate_heading( + chapter_meta, chapter_number, headings_index + ) + + # 确定章节层级 + chapter_level = chapter_meta.get("level") + if not chapter_level and target_paragraph: + chapter_level = self._get_chapter_level_from_heading(target_paragraph) + chapter_level = chapter_level or DEFAULT_CHAPTER_LEVEL - # 4. 解析Markdown内容为段落结构 parsed_paragraphs = self._parse_markdown_to_paragraphs(content, chapter_level) - # 5. 在标题后插入段落 - self._insert_parsed_paragraphs(doc, target_paragraph, parsed_paragraphs) + if matched_strategy == "any_heading_fallback": + notice_text = ( + f"【系统提示】章节“{chapter_meta.get('title', chapter_meta.get('id', ''))}”未能精准定位," + "已将内容插入至文档末尾标题,请检查模板结构。" + ) + parsed_paragraphs = [{'type': 'notice', 'text': notice_text}] + parsed_paragraphs - # 6. 保存文档 - doc.save(doc_path) - logger.info(f"成功填充内容到文档: {placeholder}") + if target_paragraph is None: + logger.warning( + "章节 %s 未能定位原始标题, 将内容追加至文末", chapter_meta.get("id") + ) + self._append_content_to_document_end(doc, chapter_meta, parsed_paragraphs, chapter_level) + else: + if matched_strategy == "any_heading_fallback": + logger.warning( + "章节 %s 未精准匹配标题,已退回到最后一个标题", + chapter_meta.get("id"), + ) + elif matched_strategy: + logger.info( + "章节 %s 通过策略 %s 匹配到标题: %s", + chapter_meta.get("id"), + matched_strategy, + target_paragraph.text.strip(), + ) + else: + logger.info( + "章节 %s 匹配到标题: %s", + chapter_meta.get("id"), + target_paragraph.text.strip(), + ) + + self._insert_parsed_paragraphs(doc, target_paragraph, parsed_paragraphs) + + doc.save(str(doc_path)) + logger.info( + "成功填充内容到文档: %s", chapter_meta.get("placeholder", chapter_meta.get("id")) + ) def _parse_markdown_to_paragraphs(self, content: str, chapter_level: int) -> list: """解析Markdown内容为段落结构列表 @@ -209,30 +295,14 @@ class WordProcessor: # Markdown标题:## 标题 if MARKDOWN_HEADING_PATTERN.match(line): - level = len(HEADING_LEVEL_PATTERN.match(line).group(1)) - title_text = MARKDOWN_HEADING_PATTERN.sub('', line) - - # 3级标题内容:## → 加粗段落(非标题样式) - if chapter_level == DEFAULT_CHAPTER_LEVEL: - paragraphs.append({ - 'type': 'bold_paragraph', - 'text': title_text, - 'indent': False - }) - # 2级标题内容:## → Heading 4 - elif chapter_level == 2: - paragraphs.append({ - 'type': 'heading', - 'text': title_text, - 'level': 4 - }) - # 1级标题内容:## → Heading 3 - else: - paragraphs.append({ - 'type': 'heading', - 'text': title_text, - 'level': 3 - }) + title_text = MARKDOWN_HEADING_PATTERN.sub('', line).strip() + if not title_text: + i += 1 + continue + paragraphs.append({ + 'type': 'bold_paragraph', + 'text': title_text, + }) # 有序列表:1. / 1) / (1) elif ORDERED_LIST_PATTERN.match(line): @@ -267,6 +337,212 @@ class WordProcessor: return paragraphs + def _extract_document_headings(self, doc: Document) -> List[Dict[str, Any]]: + """提取文档中所有标题段落,包含任意层级""" + + headings: List[Dict[str, Any]] = [] + level_counters: defaultdict[int, int] = defaultdict(int) + + for index, para in enumerate(doc.paragraphs): + style_name = getattr(para.style, 'name', '') or '' + if not style_name.lower().startswith('heading'): + continue + + level = self._infer_heading_level(para) + level_key = level if level is not None else 0 + level_counters[level_key] += 1 + + if level is not None: + for deeper_level in list(level_counters.keys()): + if isinstance(deeper_level, int) and deeper_level > level: + level_counters[deeper_level] = 0 + + headings.append({ + 'paragraph': para, + 'index': index, + 'level': level, + 'style_name': style_name, + 'normalized_text': self._normalize_heading_text(para.text), + 'raw_text': para.text.strip(), + 'number': self._extract_leading_number(para.text), + 'order_index': level_counters[level_key], + }) + + return headings + + def _locate_heading( + self, + chapter_meta: Dict[str, Any], + chapter_number: Optional[str], + headings_index: List[Dict[str, Any]], + ) -> Tuple[Optional[Paragraph], int, Optional[str]]: + """根据章节元数据定位标题段落""" + + attempts: List[Tuple[str, callable]] = [] + + if chapter_number: + attempts.append( + ( + "placeholder_number", + lambda: self._match_heading_by_number(headings_index, chapter_number), + ) + ) + + meta_number = chapter_meta.get("heading_number") + if meta_number and meta_number != chapter_number: + attempts.append( + ( + "chapter_number", + lambda: self._match_heading_by_number(headings_index, meta_number), + ) + ) + + normalized_title = chapter_meta.get("normalized_title") + if normalized_title: + attempts.append( + ( + "normalized_title", + lambda: self._match_heading_by_title(headings_index, normalized_title), + ) + ) + + title = chapter_meta.get("title") + if title and (not normalized_title or self._normalize_heading_text(title) != normalized_title): + attempts.append( + ( + "raw_title", + lambda: self._match_heading_by_title( + headings_index, self._normalize_heading_text(title) + ), + ) + ) + + level = chapter_meta.get("level") + order_index = chapter_meta.get("order_index") + if isinstance(order_index, int) and order_index > 0: + attempts.append( + ( + "sequence_index", + lambda: self._match_heading_by_sequence(headings_index, level, order_index), + ) + ) + + for strategy, matcher in attempts: + paragraph, index = matcher() + if paragraph is not None: + return paragraph, index, strategy + + if headings_index: + fallback = headings_index[-1] + return fallback['paragraph'], fallback['index'], "any_heading_fallback" + + return None, -1, None + + def _match_heading_by_number( + self, headings_index: List[Dict[str, Any]], chapter_number: str + ) -> Tuple[Optional[Paragraph], int]: + for heading in headings_index: + text = heading['raw_text'] + stored_number = heading.get('number') + if stored_number and stored_number == chapter_number: + return heading['paragraph'], heading['index'] + if self._paragraph_matches_number(text, chapter_number): + return heading['paragraph'], heading['index'] + return None, -1 + + def _match_heading_by_title( + self, headings_index: List[Dict[str, Any]], normalized_title: str + ) -> Tuple[Optional[Paragraph], int]: + if not normalized_title: + return None, -1 + + for heading in headings_index: + if heading['normalized_text'] == normalized_title: + return heading['paragraph'], heading['index'] + + return None, -1 + + def _match_heading_by_sequence( + self, headings_index: List[Dict[str, Any]], level: Optional[int], order_index: int + ) -> Tuple[Optional[Paragraph], int]: + count = 0 + for heading in headings_index: + heading_level = heading.get('level') + if level is not None and heading_level != level: + continue + count += 1 + if count == order_index: + return heading['paragraph'], heading['index'] + + return None, -1 + + @staticmethod + def _paragraph_matches_number(text: str, chapter_number: str) -> bool: + target = text.strip() + candidates = [ + f"{chapter_number} ", + f"{chapter_number}.", + f"{chapter_number}。", + f"{chapter_number}、", + f"{chapter_number})", + f"{chapter_number})", + f"{chapter_number}.", + ] + + return any(target.startswith(candidate) for candidate in candidates) + + @staticmethod + def _extract_leading_number(text: str | None) -> Optional[str]: + if not text: + return None + + stripped = text.strip() + match = re.match(r'^(\d+(?:\.\d+)*)', stripped) + if match: + return match.group(1).rstrip('.') + return None + + @staticmethod + def _infer_heading_level(paragraph: Paragraph) -> Optional[int]: + style_name = paragraph.style.name + match = re.search(r"(\d+)$", style_name) + if match: + return int(match.group(1)) + return None + + @staticmethod + def _normalize_heading_text(text: str | None) -> str: + if not text: + return "" + + normalized = unicodedata.normalize("NFKC", text) + normalized = re.sub(r"\s+", "", normalized) + return normalized.lower() + + def _append_content_to_document_end( + self, + doc: Document, + chapter_meta: Dict[str, Any], + parsed_paragraphs: List[dict], + chapter_level: int, + ) -> None: + notice_text = ( + f"【系统提示】未能定位原始章节“{chapter_meta.get('title', chapter_meta.get('id', ''))}”," + "以下内容已自动追加,请检查模板标题格式。" + ) + notice_para = doc.add_paragraph(notice_text) + for run in notice_para.runs: + run.italic = True + + fallback_heading = doc.add_paragraph() + heading_text = chapter_meta.get('title') or chapter_meta.get('id') or "未命名章节" + run = fallback_heading.add_run(heading_text) + run.bold = True + run.font.name = FONT_HEITI + run.font.size = Pt(settings.heading_font_size) + + self._insert_parsed_paragraphs(doc, fallback_heading, parsed_paragraphs) + def _find_heading_by_chapter_id(self, doc: Document, chapter_number: str) -> tuple[Paragraph, int]: """根据章节编号查找标题段落 @@ -282,9 +558,8 @@ class WordProcessor: """ for i, para in enumerate(doc.paragraphs): if para.style.name.startswith('Heading'): - text = para.text.strip() - if text.startswith(f"{chapter_number} ") or text.startswith(f"{chapter_number}."): - logger.info(f"找到匹配标题: {text}") + if self._paragraph_matches_number(para.text, chapter_number): + logger.info(f"找到匹配标题: {para.text.strip()}") return para, i raise ValueError(f"未找到编号为 {chapter_number} 的标题") @@ -320,9 +595,16 @@ class WordProcessor: """ match = CHAPTER_ID_PATTERN.search(placeholder) if not match: + # 支持自定义章节ID,但无法解析出数字编号时返回None + if 'chapter_' in placeholder: + return None raise ValueError(f"无法从占位符解析章节编号: {placeholder}") - chapter_number = match.group(1).replace('_', '.') + chapter_body = match.group(1) + if not chapter_body or not re.search(r'\d', chapter_body): + return None + + chapter_number = chapter_body.replace('_', '.') return chapter_number def _create_paragraph_element(self, doc: Document, current_element, para_struct: dict) -> Paragraph: @@ -347,19 +629,11 @@ class WordProcessor: para_type = para_struct['type'] text = para_struct['text'] - # 根据类型应用不同样式 if para_type == 'heading': - # 子标题:使用Heading样式 - level = para_struct.get('level', 4) - try: - new_para.style = f'Heading {level}' - new_para.text = text - except KeyError: - run = new_para.add_run(text) - run.bold = True - run.font.size = Pt(settings.heading_font_size) + para_type = 'bold_paragraph' - elif para_type == 'bold_paragraph': + # 根据类型应用不同样式 + if para_type == 'bold_paragraph': # 加粗段落(3级标题内的小标题) try: new_para.style = 'Normal' @@ -404,6 +678,20 @@ class WordProcessor: run.font.name = FONT_SONGTI run.font.size = Pt(settings.normal_font_size) + elif para_type == 'notice': + try: + new_para.style = 'Normal' + except KeyError: + pass + para_format.first_line_indent = Cm(0) + para_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE + para_format.space_after = Pt(settings.space_after_small) + + run = new_para.add_run(text) + run.italic = True + run.font.name = FONT_SONGTI + run.font.size = Pt(settings.normal_font_size) + else: # paragraph # 普通段落 try: diff --git a/src/bidmaster/tools/word_formatter.py b/src/bidmaster/tools/word_formatter.py index 3ad826c..ef5dbdc 100644 --- a/src/bidmaster/tools/word_formatter.py +++ b/src/bidmaster/tools/word_formatter.py @@ -5,7 +5,6 @@ import logging import re -from pathlib import Path from typing import Dict, Optional from enum import Enum @@ -84,7 +83,7 @@ class WordFormatter: logger.warning(f"无法识别格式: {part}") if not format_dict: - logger.warning(f"未识别到任何格式,使用默认") + logger.warning("未识别到任何格式,使用默认") return WordFormatter.get_default_format() return format_dict diff --git a/src/bidmaster/utils/guidance.py b/src/bidmaster/utils/guidance.py new file mode 100644 index 0000000..889aa83 --- /dev/null +++ b/src/bidmaster/utils/guidance.py @@ -0,0 +1,47 @@ +"""章节写作提示构建工具。""" + +from __future__ import annotations + +from typing import Iterable, List, Optional + +from ..tools.parser import ChapterGuidance, DocumentChapter, ScoringCriteria + + +def _format_requirement(criterion: ScoringCriteria) -> str: + base = f"{criterion.item_name}({criterion.max_score:g}分)" + description = (criterion.description or "").strip().replace("\n", " ") + if description: + trimmed = description[:120].rstrip() + if len(description) > 120: + trimmed += "..." + return f"{base}:{trimmed}" + return base + + +def _format_evidence(criterion: ScoringCriteria) -> str: + return f"提供{criterion.item_name}相关的方案、记录或佐证材料" + + +def build_chapter_guidance( + chapter: DocumentChapter, + criteria: Iterable[ScoringCriteria], + context_snippets: Optional[List[str]] = None, +) -> ChapterGuidance | None: + """根据评分项和上下文生成章节提示。""" + + criteria_list = list(criteria) + if not criteria_list: + return None + + key_requirements = [_format_requirement(c) for c in criteria_list[:3]] + suggested_evidence = [_format_evidence(c) for c in criteria_list[:3]] + snippets = (context_snippets or [])[:3] + + return ChapterGuidance( + key_requirements=key_requirements, + suggested_evidence=suggested_evidence, + context_snippets=snippets, + ) + + +__all__ = ["build_chapter_guidance"] diff --git a/src/bidmaster/utils/monitoring.py b/src/bidmaster/utils/monitoring.py new file mode 100644 index 0000000..efc9806 --- /dev/null +++ b/src/bidmaster/utils/monitoring.py @@ -0,0 +1,49 @@ +"""基础监控与指标采集。""" + +from __future__ import annotations + +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Dict, List + + +@dataclass(slots=True) +class MetricRecord: + name: str + duration: float + details: Dict[str, str] = field(default_factory=dict) + + +class MetricsRecorder: + """事件耗时记录器。""" + + def __init__(self) -> None: + self._records: List[MetricRecord] = [] + + @contextmanager + def track(self, name: str, **details: str): + start = time.perf_counter() + try: + yield + finally: + duration = time.perf_counter() - start + self._records.append(MetricRecord(name=name, duration=duration, details=details)) + + def add(self, name: str, duration: float, **details: str) -> None: + self._records.append(MetricRecord(name=name, duration=duration, details=details)) + + def to_dict(self) -> Dict[str, List[dict]]: + return { + "records": [ + { + "name": record.name, + "duration": round(record.duration, 4), + "details": record.details, + } + for record in self._records + ] + } + + +__all__ = ["MetricsRecorder", "MetricRecord"] diff --git a/src/bidmaster/utils/prompt_planner.py b/src/bidmaster/utils/prompt_planner.py index 671ffb5..f0f5ad1 100644 --- a/src/bidmaster/utils/prompt_planner.py +++ b/src/bidmaster/utils/prompt_planner.py @@ -46,6 +46,7 @@ class PromptPlanner: objectives = self._build_objectives(requirements, metadata, emphasis) consistency_rules = self._build_consistency_rules(chapter_path, parent_context) + guidance_notes = self._format_guidance_notes((metadata or {}).get("guidance") or chapter.get("guidance")) context_parts: List[str] = [] if parent_context: @@ -68,6 +69,7 @@ class PromptPlanner: "context_summary": context_summary, "emphasis": emphasis or "", "category": (metadata or {}).get("category"), + "guidance_notes": guidance_notes, } return spec @@ -175,6 +177,28 @@ class PromptPlanner: return info + def _format_guidance_notes(self, guidance: Optional[Dict[str, Any]]) -> str: + if not guidance: + return "" + + lines: List[str] = [] + key_requirements = guidance.get("key_requirements") or [] + if key_requirements: + lines.append("【关键要求】") + lines.extend(f"- {item}" for item in key_requirements) + + suggested_evidence = guidance.get("suggested_evidence") or [] + if suggested_evidence: + lines.append("【建议支撑】") + lines.extend(f"- {item}" for item in suggested_evidence) + + context_snippets = guidance.get("context_snippets") or [] + if context_snippets: + lines.append("【招标摘录】") + lines.extend(f"- {item}" for item in context_snippets) + + return "\n".join(lines).strip() + def _split_requirements(self, requirements: str) -> List[str]: if not requirements: return [] diff --git a/src/bidmaster/utils/quality.py b/src/bidmaster/utils/quality.py new file mode 100644 index 0000000..06a2cc9 --- /dev/null +++ b/src/bidmaster/utils/quality.py @@ -0,0 +1,81 @@ +"""质量检查工具。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + + +@dataclass(slots=True) +class QualityIssue: + code: str + message: str + severity: str = "warning" # info | warning | error + + +class QualityChecklist: + """提供基础质量检查。""" + + def __init__(self, *, min_content_len: int = 120) -> None: + self.min_content_len = min_content_len + + def check_generation(self, task: Dict[str, Any], content: str) -> List[QualityIssue]: + issues: List[QualityIssue] = [] + + if not content or len(content.strip()) < self.min_content_len: + issues.append( + QualityIssue( + code="content_too_short", + message=f"生成内容长度不足 {self.min_content_len} 字符", + ) + ) + + required_keywords = task.get("quality_keywords") or [] + missing = [kw for kw in required_keywords if kw not in content] + if missing: + issues.append( + QualityIssue( + code="missing_keywords", + message=f"缺少关键字: {', '.join(missing)}", + ) + ) + + return issues + + def check_assembly(self, chapter_map: Dict[str, Any], tasks: List[Dict[str, Any]]) -> List[QualityIssue]: + issues: List[QualityIssue] = [] + + for task in tasks: + chapter_id = task.get("chapter_id") + if not chapter_id: + issues.append( + QualityIssue( + code="missing_chapter", + message=f"任务 {task.get('id')} 缺少章节映射", + severity="error", + ) + ) + continue + + if chapter_id not in chapter_map: + issues.append( + QualityIssue( + code="chapter_not_found", + message=f"章节 {chapter_id} 在模板中未找到", + severity="error", + ) + ) + + status = task.get("status") + if status not in {"completed", "approved"}: + issues.append( + QualityIssue( + code="task_not_completed", + message=f"任务 {task.get('id')} 状态为 {status}", + ) + ) + + return issues + + +__all__ = ["QualityChecklist", "QualityIssue"] diff --git a/src/bidmaster/utils/retry.py b/src/bidmaster/utils/retry.py new file mode 100644 index 0000000..9f63613 --- /dev/null +++ b/src/bidmaster/utils/retry.py @@ -0,0 +1,62 @@ +"""轻量重试策略实现。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Iterable, List, Tuple, Type + + +@dataclass(slots=True) +class RetryAttempt: + """单次尝试记录。""" + + attempt: int + succeeded: bool + error: Exception | None = None + + +@dataclass(slots=True) +class RetryReport: + """重试执行结果。""" + + attempts: List[RetryAttempt] = field(default_factory=list) + + @property + def succeeded(self) -> bool: + return any(item.succeeded for item in self.attempts) + + +class RetryPolicy: + """简单重试策略。""" + + def __init__( + self, + *, + max_attempts: int = 2, + retry_exceptions: Iterable[Type[Exception]] | None = None, + ) -> None: + if max_attempts < 1: + raise ValueError("max_attempts 必须 >= 1") + self.max_attempts = max_attempts + self.retry_exceptions = tuple(retry_exceptions or (Exception,)) + + def run(self, func: Callable, *args, **kwargs) -> Tuple[RetryReport, object | None]: + report = RetryReport() + last_exception: Exception | None = None + + for attempt in range(1, self.max_attempts + 1): + try: + result = func(*args, **kwargs) + report.attempts.append(RetryAttempt(attempt=attempt, succeeded=True)) + return report, result + except self.retry_exceptions as exc: # type: ignore[misc] + report.attempts.append( + RetryAttempt(attempt=attempt, succeeded=False, error=exc) + ) + last_exception = exc + if last_exception is not None: + raise last_exception + raise RuntimeError("RetryPolicy未捕获到异常但执行失败") + + +__all__ = ["RetryPolicy", "RetryReport", "RetryAttempt"] diff --git a/src/bidmaster/utils/timeout_input.py b/src/bidmaster/utils/timeout_input.py index 17c3801..e17df62 100644 --- a/src/bidmaster/utils/timeout_input.py +++ b/src/bidmaster/utils/timeout_input.py @@ -339,8 +339,6 @@ def _interactive_readline_windows(prompt: str, timeout: int) -> Tuple[str, bool] def _interactive_readline_posix(prompt: str, timeout: int) -> Tuple[str, bool]: import select - import termios - import tty fd = sys.stdin.fileno() if fd < 0: diff --git a/src/bidmaster/utils/validation.py b/src/bidmaster/utils/validation.py new file mode 100644 index 0000000..e042368 --- /dev/null +++ b/src/bidmaster/utils/validation.py @@ -0,0 +1,254 @@ +"""文件校验工具集。 + +提供对招标文件进行基础校验的能力,包括: +- 路径有效性与文件大小检查 +- 格式支持与转换提示 +- 统一的告警与问题描述结构 + +该模块仅做轻量规则校验,不负责实际格式转换。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +SUPPORTED_PRIMARY_SUFFIXES = {".docx"} +SUPPORTED_CONVERTIBLE_SUFFIXES = {".doc", ".pdf"} + + +@dataclass(slots=True) +class FileValidationIssue: + """单条校验问题。""" + + code: str + message: str + severity: str = "warning" # warning | error + + +@dataclass(slots=True) +class FileValidationReport: + """文件校验报告。""" + + path: Path + size_mb: float + processable: bool + requires_conversion: bool + issues: List[FileValidationIssue] = field(default_factory=list) + + @property + def is_valid(self) -> bool: + """是否无阻塞问题。""" + + return all(issue.severity != "error" for issue in self.issues) + + def to_dict(self) -> dict: + """转换为可序列化字典。""" + + return { + "path": str(self.path), + "size_mb": round(self.size_mb, 2), + "processable": self.processable, + "requires_conversion": self.requires_conversion, + "issues": [ + { + "code": issue.code, + "message": issue.message, + "severity": issue.severity, + } + for issue in self.issues + ], + } + + +class FileValidator: + """招标文件校验器。""" + + def __init__(self, *, max_size_mb: float = 80.0) -> None: + self._max_size_mb = max_size_mb + + def validate(self, file_path: str | Path) -> FileValidationReport: + """执行校验。 + + Args: + file_path: 待检验文件路径 + + Returns: + FileValidationReport + """ + + path = Path(file_path).expanduser().resolve() + issues: List[FileValidationIssue] = [] + + if not path.exists(): + issues.append( + FileValidationIssue( + code="file_not_found", + message=f"文件不存在: {path}", + severity="error", + ) + ) + return FileValidationReport( + path=path, + size_mb=0.0, + processable=False, + requires_conversion=False, + issues=issues, + ) + + size_mb = path.stat().st_size / (1024 * 1024) + if size_mb == 0: + issues.append( + FileValidationIssue( + code="empty_file", + message="文件大小为0,无法解析", + severity="error", + ) + ) + + if size_mb > self._max_size_mb: + issues.append( + FileValidationIssue( + code="file_too_large", + message=f"文件大小 {size_mb:.1f}MB 超出限制({self._max_size_mb}MB)", + ) + ) + + suffix = path.suffix.lower() + processable = suffix in SUPPORTED_PRIMARY_SUFFIXES + requires_conversion = False + + if suffix in SUPPORTED_PRIMARY_SUFFIXES: + pass + elif suffix in SUPPORTED_CONVERTIBLE_SUFFIXES: + requires_conversion = True + issues.append( + FileValidationIssue( + code="needs_conversion", + message=f"检测到 {suffix} 文件,建议先转换为 .docx 再解析", + ) + ) + else: + issues.append( + FileValidationIssue( + code="unsupported_format", + message=f"不支持的文件格式: {suffix}", + severity="error", + ) + ) + + return FileValidationReport( + path=path, + size_mb=size_mb, + processable=processable, + requires_conversion=requires_conversion, + issues=issues, + ) + + +@dataclass(slots=True) +class ContentValidationIssue: + """章节内容校验问题""" + + code: str + message: str + + +class ContentValidationError(ValueError): + """章节内容校验异常""" + + def __init__(self, issues: List[ContentValidationIssue]): + self.issues = issues + message = "; ".join(issue.message for issue in issues) + super().__init__(message) + + +class ContentValidator: + """章节内容合规校验器""" + + def __init__(self) -> None: + self._opening_patterns = [ + re.compile(r"^第[一二三四五六七八九十百千0-9]+章"), + re.compile(r"^[一二三四五六七八九十]+、"), + ] + self._opening_blacklist = ( + "商务条款响应情况", + "技术规格响应说明", + "商务和技术方面存在的偏差说明", + ) + self._forbidden_phrases = ( + "经认真研读招标文件要求", + "现就商务和技术方面存在的偏差说明", + "商务条款响应情况", + "技术规格响应说明", + ) + + def validate(self, chapter: Dict[str, Any], content: str) -> None: + issues: List[ContentValidationIssue] = [] + + heading_issue = self.ensure_heading_alignment(chapter, content) + if heading_issue: + issues.append(ContentValidationIssue(code="heading_misaligned", message=heading_issue)) + + template_issue = self.detect_cross_chapter_signature(content) + if template_issue: + issues.append(ContentValidationIssue(code="template_detected", message=template_issue)) + + if issues: + raise ContentValidationError(issues) + + def ensure_heading_alignment(self, chapter: Dict[str, Any], content: str) -> Optional[str]: + line = self._first_meaningful_line(content) + if not line: + return None + + cleaned = self._normalize_line(line) + + for pattern in self._opening_patterns: + if pattern.match(cleaned): + return ( + f"检测到新的章节标题“{line}”,请直接编写《{chapter.get('title', chapter.get('id'))}》正文内容" + ) + + for keyword in self._opening_blacklist: + if cleaned.startswith(keyword): + return ( + f"检测到与章节无关的套话“{line}”,请按章节《{chapter.get('title', chapter.get('id'))}》展开" + ) + + return None + + def detect_cross_chapter_signature(self, content: str) -> Optional[str]: + if not content: + return None + + lowered = content.strip() + for phrase in self._forbidden_phrases: + if phrase and phrase in lowered: + return f"检测到跨章节模板短语“{phrase}”,请使用本章节专属内容" + return None + + def _first_meaningful_line(self, content: str) -> Optional[str]: + for raw_line in content.splitlines(): + stripped = raw_line.strip() + if stripped: + return stripped + return None + + def _normalize_line(self, line: str) -> str: + normalized = line.lstrip('#').strip() + normalized = re.sub(r'^[\d\.\-•\s、()()]+', '', normalized) + return normalized + + +__all__ = [ + "FileValidator", + "FileValidationReport", + "FileValidationIssue", + "ContentValidator", + "ContentValidationIssue", + "ContentValidationError", +] diff --git a/tests/unit/test_timeout_input.py b/tests/unit/test_timeout_input.py index 3746ab0..ae33cd0 100644 --- a/tests/unit/test_timeout_input.py +++ b/tests/unit/test_timeout_input.py @@ -1,7 +1,6 @@ """timeout_input 实用函数的单元测试""" import importlib.util -import sys from pathlib import Path import pytest diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 0000000..bc11d62 --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,40 @@ +from bidmaster.utils.validation import ContentValidationError, ContentValidator + + +def test_content_validator_rejects_cross_chapter_phrase() -> None: + validator = ContentValidator() + chapter = {"id": "chapter_1", "title": "服务方案"} + content = ( + "经认真研读招标文件要求,结合我司实际情况,现就商务和技术方面存在的偏差说明如下:\n" + "商务条款响应情况\n我司完全响应……" + ) + + try: + validator.validate(chapter, content) + except ContentValidationError as exc: + assert "跨章节模板" in str(exc) + else: + raise AssertionError("内容校验应当失败") + + +def test_content_validator_accepts_normal_content() -> None: + validator = ContentValidator() + chapter = {"id": "chapter_2", "title": "技术路线"} + content = ( + "我们以矿山智慧化平台为核心,构建“实时感知—风险预警—指挥调度”闭环。\n" + "- 通过多源感知网络实现危险场景秒级采集;\n" + "- 以知识图谱关联隐患并生成操作工序清单。" + ) + + validator.validate(chapter, content) + + +def test_content_validator_allows_legitimate_intro() -> None: + validator = ContentValidator() + chapter = {"id": "chapter_a", "title": "商务和技术偏差表"} + content = ( + "在本次投标过程中,我们对招标文件进行了全面分析,并结合我司情况编制偏差说明。\n" + "我们将在响应表中逐条列出商务及技术条款的差异与说明。" + ) + + validator.validate(chapter, content) diff --git a/tests/unit/test_word.py b/tests/unit/test_word.py index 35673cf..d927ba5 100644 --- a/tests/unit/test_word.py +++ b/tests/unit/test_word.py @@ -1 +1,197 @@ -# Word处理器测试 \ No newline at end of file +"""Word处理器章节填充逻辑单元测试""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +from docx import Document + +from bidmaster.tools.word import WordProcessor + + +@pytest.fixture() +def word_processor() -> WordProcessor: + return WordProcessor() + + +def _create_document(path: Path, headings: list[tuple[str, int]]) -> None: + doc = Document() + for text, level in headings: + doc.add_heading(text, level=level) + doc.save(path) + + +def _count_heading_paragraphs(doc: Document) -> int: + return sum(1 for para in doc.paragraphs if para.style.name.startswith("Heading")) + + +def test_fill_chapter_with_numbered_heading(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "numbered.docx" + _create_document(doc_path, [("1 商务和技术偏差表", 1)]) + + chapter_meta = { + "id": "chapter_1", + "title": "商务和技术偏差表", + "level": 1, + "placeholder": "{{chapter_1_content}}", + "normalized_title": word_processor._normalize_heading_text("商务和技术偏差表"), + "heading_number": "1", + "order_index": 1, + } + + word_processor.fill_chapter_content(doc_path, chapter_meta, "测试段落内容") + + doc = Document(doc_path) + assert any("测试段落内容" in para.text for para in doc.paragraphs) + + +def test_fill_chapter_without_number_uses_title(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "title_only.docx" + _create_document(doc_path, [("商务和技术偏差表", 1)]) + + chapter_meta = { + "id": "chapter_1", + "title": "商务和技术偏差表", + "level": 1, + "placeholder": "{{chapter_1_content}}", + "normalized_title": word_processor._normalize_heading_text("商务和技术偏差表"), + "heading_number": None, + "order_index": 1, + } + + word_processor.fill_chapter_content(doc_path, chapter_meta, "服务方案内容") + + doc = Document(doc_path) + assert any("服务方案内容" in para.text for para in doc.paragraphs) + + +def test_fill_chapter_sequence_fallback(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "sequence.docx" + _create_document( + doc_path, + [ + ("项目背景", 1), + ("售后服务和质保期服务计划", 1), + ], + ) + + chapter_meta = { + "id": "chapter_11", + "title": "售后服务和质保期服务计划", + "level": 1, + "placeholder": "{{chapter_11_content}}", + "normalized_title": word_processor._normalize_heading_text("售后服务和质保期服务计划"), + "heading_number": None, + "order_index": 2, + } + + word_processor.fill_chapter_content(doc_path, chapter_meta, "售后保障措施") + + doc = Document(doc_path) + assert any("售后保障措施" in para.text for para in doc.paragraphs) + + +def test_fill_chapter_appends_when_not_found(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "fallback.docx" + _create_document(doc_path, [("项目概述", 1)]) + + chapter_meta = { + "id": "chapter_missing", + "title": "未匹配章节", + "level": 1, + "placeholder": "{{chapter_missing_content}}", + "normalized_title": word_processor._normalize_heading_text("未匹配章节"), + "heading_number": None, + "order_index": 2, + } + + word_processor.fill_chapter_content(doc_path, chapter_meta, "新增内容") + + doc = Document(doc_path) + assert any("【系统提示】" in para.text for para in doc.paragraphs) + assert any("新增内容" in para.text for para in doc.paragraphs) + + +def test_fill_does_not_create_additional_headings(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "no_new_headings.docx" + _create_document( + doc_path, + [ + ("1 总体方案", 1), + ("1.1 服务范围", 2), + ], + ) + + before = Document(doc_path) + before_count = _count_heading_paragraphs(before) + + chapter_meta = { + "id": "chapter_1_1", + "title": "服务范围", + "level": 2, + "placeholder": "{{chapter_1_1_content}}", + "normalized_title": word_processor._normalize_heading_text("服务范围"), + "heading_number": "1.1", + "order_index": 1, + } + + content = "## 小节标题\n服务内容描述" + word_processor.fill_chapter_content(doc_path, chapter_meta, content) + + after = Document(doc_path) + after_count = _count_heading_paragraphs(after) + assert after_count == before_count + + +def test_markdown_headings_render_as_bold_paragraphs(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "markdown.docx" + _create_document(doc_path, [("1 技术方案", 1)]) + + chapter_meta = { + "id": "chapter_1", + "title": "技术方案", + "level": 1, + "placeholder": "{{chapter_1_content}}", + "normalized_title": word_processor._normalize_heading_text("技术方案"), + "heading_number": "1", + "order_index": 1, + } + + word_processor.fill_chapter_content( + doc_path, + chapter_meta, + "## 子标题\n段落内容", + ) + + doc = Document(doc_path) + bold_paras = [para for para in doc.paragraphs if para.text.strip() == "子标题"] + assert bold_paras, "应生成加粗段落" + assert all(not para.style.name.startswith("Heading") for para in bold_paras) + assert any(any(run.bold for run in para.runs) for para in bold_paras) + + +def test_fill_matches_higher_level_heading(word_processor: WordProcessor) -> None: + with TemporaryDirectory() as tmp_dir: + doc_path = Path(tmp_dir) / "deep_heading.docx" + _create_document(doc_path, [("1.1.1.1 深度章节", 4)]) + + chapter_meta = { + "id": "chapter_1_1_1_1", + "title": "深度章节", + "level": 4, + "placeholder": "{{chapter_1_1_1_1_content}}", + "normalized_title": word_processor._normalize_heading_text("深度章节"), + "heading_number": "1.1.1.1", + "order_index": 1, + } + + word_processor.fill_chapter_content(doc_path, chapter_meta, "深度内容") + + doc = Document(doc_path) + assert any("深度内容" in para.text for para in doc.paragraphs) \ No newline at end of file