run_pipeline.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. """
  2. Pipeline 入口 — 按阶段运行投标书生成流程
  3. 用法:
  4. python run_pipeline.py --phase 1 # 使用默认路径
  5. python run_pipeline.py --phase 1 --tender data/招标.pdf # 指定新招标
  6. python run_pipeline.py --phase 1 --history data/history/ # 指定历史数据目录
  7. python run_pipeline.py --phase 2
  8. python run_pipeline.py --phase 1-3
  9. python run_pipeline.py --list
  10. """
  11. import sys
  12. import os
  13. import json
  14. import glob
  15. sys.stdout.reconfigure(encoding="utf-8")
  16. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  17. # ── 阶段配置 ──
  18. PHASES = {
  19. 1: {
  20. "name": "phase1_parsing",
  21. "title": "招标解析与历史比对",
  22. "desc": "输入新招标PDF + 历史招投标数据目录 → 输出招标信息、表格、历史浓缩及相似度权重",
  23. "outputs": [
  24. "output/phase1_new_tender.json",
  25. "output/phase1_tender_tables/tables.json",
  26. "output/phase1_historical_summary.json",
  27. ],
  28. },
  29. 2: {
  30. "name": "phase2_outline",
  31. "title": "投标大纲生成",
  32. "desc": "基于新招标要求和加权后的历史参考,用 LLM 生成目录大纲",
  33. "outputs": ["output/phase2_bid_outline.json"],
  34. "deps": [1],
  35. },
  36. 3: {
  37. "name": "phase3_collection",
  38. "title": "公司信息模板生成",
  39. "desc": "生成投标公司需要补充的信息清单(简化版 JSON)",
  40. "outputs": ["output/phase3_company_template.json"],
  41. "deps": [1],
  42. },
  43. 4: {
  44. "name": "phase4_filling",
  45. "title": "内容填充(待开发)",
  46. "outputs": ["output/phase4_bid_fill_draft.json"],
  47. "deps": [2, 3],
  48. },
  49. 5: {
  50. "name": "phase5_validation",
  51. "title": "一致性校验(待开发)",
  52. "outputs": ["output/phase5_consistency_report.json"],
  53. "deps": [4],
  54. },
  55. 6: {
  56. "name": "phase6_export",
  57. "title": "DOCX 导出(待开发)",
  58. "outputs": ["output/phase6_bid_final.docx"],
  59. "deps": [4, 5],
  60. },
  61. }
  62. # ── 阶段执行 ──
  63. def run_phase(phase_num: int, tender: str = None, history: str = None):
  64. info = PHASES[phase_num]
  65. print(f"\n{'='*60}")
  66. print(f"Phase {phase_num}: {info['title']}")
  67. print(f"{'='*60}")
  68. print(f"说明: {info['desc']}")
  69. for dep in info.get("deps", []):
  70. for out_path in PHASES[dep]["outputs"]:
  71. if not os.path.exists(os.path.join(BASE_DIR, out_path)):
  72. print(f" [WARN] 依赖 Phase {dep} 输出不存在: {out_path}")
  73. if phase_num == 1:
  74. _run_phase1(tender, history)
  75. elif phase_num == 2:
  76. _run_phase2()
  77. elif phase_num == 3:
  78. _run_phase3()
  79. else:
  80. print(f" [INFO] Phase {phase_num} 尚未开发")
  81. # ════════════════════════════════════════════════════════════
  82. # Phase 1: 新招标解析 + 历史招投标比对
  83. # ════════════════════════════════════════════════════════════
  84. def _run_phase1(tender_path: str = None, history_dir: str = None):
  85. """
  86. 输入:
  87. - 新招标文件 PDF(1 份)
  88. - 历史数据目录(N 组),每组结构:
  89. 项目文件夹/
  90. ├── 招标文件.pdf ← 历史招标(用于和新招标算相似度)
  91. └── 投标文件.pdf ← 对应的投标(按相似度加权影响新投标)
  92. 输出:
  93. - output/phase1_new_tender.json 新招标解析结果(文本+特征+表格索引)
  94. - output/phase1_tender_tables/tables.json 表格元数据
  95. - output/phase1_historical_summary.json 历史数据汇总(含相似度权重+投标结构)
  96. """
  97. from phase1_parsing.extract_pdf_text import extract_text, extract_tender_sections, extract_project_overview
  98. from phase1_parsing.extract_pdf_tables import extract_tables
  99. from phase1_parsing.analyze_similarity import calculate_similarity
  100. # ── 1. 解析新招标文件 ────────────────────────────
  101. tender_pdf = tender_path or "data/松江区机关事务管理局物业管理服务招标文件.pdf"
  102. if not os.path.exists(tender_pdf):
  103. print(f"[ERROR] 新招标文件不存在: {tender_pdf}")
  104. return
  105. print(f"\n[Phase1] 解析新招标文件: {tender_pdf}")
  106. tender_text = extract_text(tender_pdf, "output/phase1_raw/tender_text.txt")
  107. # 章节分割
  108. sections = extract_tender_sections(tender_text)
  109. for name, content in sections.items():
  110. print(f" 章节 {name}: {len(content)} 字符")
  111. # 表格提取
  112. print("\n 提取表格...")
  113. try:
  114. tables_meta = extract_tables(tender_pdf, "output/phase1_tender_tables")
  115. table_count = tables_meta.get("total_tables", 0)
  116. print(f" 共 {table_count} 个表格")
  117. except Exception as e:
  118. print(f" [SKIP] 表格提取跳过: {e}")
  119. table_count = 0
  120. # 项目概况
  121. overview = extract_project_overview(tender_text)
  122. print(f" 项目概况: {overview.get('project_name', '未知')}")
  123. # 保存新招标解析
  124. new_tender_data = {
  125. "source_file": tender_pdf,
  126. "text_file": "output/phase1_raw/tender_text.txt",
  127. "total_chars": len(tender_text),
  128. "sections": {k: len(v) for k, v in sections.items()},
  129. "tables_count": table_count,
  130. "overview": overview,
  131. }
  132. os.makedirs("output", exist_ok=True)
  133. with open("output/phase1_new_tender.json", "w", encoding="utf-8") as f:
  134. json.dump(new_tender_data, f, ensure_ascii=False, indent=2)
  135. print(" [OK] 已保存: output/phase1_new_tender.json")
  136. # ── 2. 扫描历史数据 ─────────────────────────────
  137. history_dir = history_dir or "data/历史数据"
  138. if not os.path.isdir(history_dir):
  139. print(f"\n[Phase1] 历史数据目录不存在: {history_dir}")
  140. print(" 将仅输出新招标解析结果")
  141. return
  142. groups = _scan_history_groups(history_dir)
  143. if not groups:
  144. print(f"\n[Phase1] 在 {history_dir} 下未找到历史数据")
  145. return
  146. print(f"\n[Phase1] 历史数据: 发现 {len(groups)} 组")
  147. for g in groups:
  148. print(f" [{g['group_id']}] {g.get('name', '')}")
  149. # ── 3. 相似度计算 ───────────────────────────────
  150. print("\n[Phase1] 计算历史招标与新招标的相似度...")
  151. similarity_results = calculate_similarity(tender_text, groups, use_llm=True)
  152. print("\n 相似度排名:")
  153. for r in similarity_results:
  154. bar = "█" * int(r["similarity_score"] * 20) + "░" * (20 - int(r["similarity_score"] * 20))
  155. print(f" {r['similarity_score']:.0%} {bar} {r['name']} ({r['similarity_level']})")
  156. print(f" 理由: {r['similarity_reason'][:80]}")
  157. # ── 4. 输出历史汇总 ─────────────────────────────
  158. summary = {
  159. "new_tender": {
  160. "file": tender_pdf,
  161. "overview": overview,
  162. "text_file": "output/phase1_raw/tender_text.txt",
  163. },
  164. "historical_count": len(groups),
  165. "historical_data": similarity_results,
  166. "note": "每项含 similarity_score(0~1) 表示与新招标的相似度,weight 为归一化权重。"
  167. "相似度越高,对应的投标文件对结果影响越大。",
  168. }
  169. with open("output/phase1_historical_summary.json", "w", encoding="utf-8") as f:
  170. json.dump(summary, f, ensure_ascii=False, indent=2)
  171. print(f"\n[Phase1] 历史汇总已保存: output/phase1_historical_summary.json")
  172. def _scan_history_groups(history_dir: str) -> list:
  173. """
  174. 扫描历史数据目录。支持两种结构:
  175. 结构1: 项目文件夹模式(推荐)
  176. history_dir/
  177. ├── 项目A/
  178. │ ├── 招标文件.pdf
  179. │ └── 投标文件.pdf
  180. ├── 项目B/
  181. │ ├── 招标文件.pdf
  182. │ └── 投标文件.pdf
  183. 结构2: 平铺模式(自动配对)
  184. history_dir/
  185. ├── XX项目-招标文件.pdf
  186. ├── XX项目-投标文件.pdf
  187. ├── YY项目-招标文件.pdf
  188. └── YY项目-投标文件.pdf
  189. """
  190. from phase1_parsing.extract_pdf_text import extract_text
  191. groups = []
  192. seen = set()
  193. # 结构1: 子文件夹模式
  194. for sub_dir in sorted(glob.glob(os.path.join(history_dir, "*"))):
  195. if not os.path.isdir(sub_dir):
  196. continue
  197. tender_file = _find_file(sub_dir, "*招标*.pdf") or _find_file(sub_dir, "*tender*.pdf")
  198. bid_file = _find_file(sub_dir, "*投标*.pdf") or _find_file(sub_dir, "*bid*.pdf")
  199. if tender_file and bid_file:
  200. group_id = os.path.basename(sub_dir)
  201. print(f" [扫描] {group_id}: 招标={os.path.basename(tender_file)}, 投标={os.path.basename(bid_file)}")
  202. tender_text = extract_text(tender_file)
  203. bid_text = extract_text(bid_file)
  204. groups.append({
  205. "group_id": group_id,
  206. "name": group_id,
  207. "tender_file": tender_file,
  208. "bid_file": bid_file,
  209. "tender_text": tender_text,
  210. "bid_text": bid_text,
  211. })
  212. seen.add(sub_dir)
  213. # 结构2: 平铺自动配对
  214. all_pdfs = sorted(glob.glob(os.path.join(history_dir, "*.pdf")))
  215. pending = []
  216. for fp in all_pdfs:
  217. fname = os.path.basename(fp)
  218. if fp in seen:
  219. continue
  220. if "招标" in fname or "tender" in fname.lower():
  221. pending.append(("tender", fp))
  222. elif "投标" in fname or "bid" in fname.lower():
  223. pending.append(("bid", fp))
  224. # 简单配对: 按顺序招标+投标为一组
  225. i = 0
  226. while i < len(pending) - 1:
  227. if pending[i][0] == "tender" and pending[i + 1][0] == "bid":
  228. tender_file = pending[i][1]
  229. bid_file = pending[i + 1][1]
  230. group_id = f"history_group_{len(groups) + 1}"
  231. print(f" [扫描] {group_id}: 招标={os.path.basename(tender_file)}, 投标={os.path.basename(bid_file)}")
  232. tender_text = extract_text(tender_file)
  233. bid_text = extract_text(bid_file)
  234. groups.append({
  235. "group_id": group_id,
  236. "name": group_id,
  237. "tender_file": tender_file,
  238. "bid_file": bid_file,
  239. "tender_text": tender_text,
  240. "bid_text": bid_text,
  241. })
  242. i += 2
  243. else:
  244. i += 1
  245. return groups
  246. def _find_file(directory: str, pattern: str) -> str:
  247. """在目录中匹配第一个文件"""
  248. files = glob.glob(os.path.join(directory, pattern))
  249. return files[0] if files else None
  250. # ════════════════════════════════════════════════════════════
  251. # Phase 2: 投标大纲生成
  252. # ════════════════════════════════════════════════════════════
  253. def _run_phase2():
  254. """读取 Phase1 输出 → 生成目录大纲(历史相似度高的优先参考)"""
  255. from phase2_outline.generate_outline import generate_outline, print_outline_preview
  256. new_tender_file = "output/phase1_new_tender.json"
  257. hist_file = "output/phase1_historical_summary.json"
  258. if not os.path.exists(new_tender_file):
  259. print(f"[ERROR] 请先运行 Phase 1: 未找到 {new_tender_file}")
  260. return
  261. # 读取新招标文本
  262. with open(new_tender_file, "r", encoding="utf-8") as f:
  263. new_tender = json.load(f)
  264. tender_text_file = new_tender.get("text_file", "output/phase1_raw/tender_text.txt")
  265. if not os.path.exists(tender_text_file):
  266. print(f"[ERROR] 招标文本不存在: {tender_text_file}")
  267. return
  268. with open(tender_text_file, "r", encoding="utf-8") as f:
  269. tender_text = f.read()
  270. # 读取历史汇总
  271. bid_toc = []
  272. if os.path.exists(hist_file):
  273. with open(hist_file, "r", encoding="utf-8") as f:
  274. hist_data = json.load(f)
  275. # 取相似度最高的历史投标结构作为参考
  276. hist_items = hist_data.get("historical_data", [])
  277. if hist_items:
  278. best = hist_items[0]
  279. bid_toc = best.get("bid_toc", [])
  280. print(f"\n[Phase2] 参考历史: {best.get('name', 'N/A')} "
  281. f"(相似度 {best.get('similarity_score', 0):.0%}, 权重 {best.get('weight', 0):.0%})")
  282. print(f"\n[Phase2] 招标文本: {len(tender_text)} 字符")
  283. print(f"[Phase2] 历史投标目录: {len(bid_toc)} 行")
  284. outline = generate_outline(tender_text, bid_toc,
  285. output_path="output/phase2_bid_outline.json")
  286. print_outline_preview(outline)
  287. # ════════════════════════════════════════════════════════════
  288. # Phase 3: 公司信息模板生成
  289. # ════════════════════════════════════════════════════════════
  290. def _run_phase3():
  291. from phase3_collection.generate_template import generate_template
  292. template = generate_template(output_path="output/phase3_company_template.json")
  293. items = template["投标公司信息收集表"]["信息项"]
  294. print(f"\n共 {len(items)} 项信息待补充:")
  295. for item in items:
  296. print(f" □ {item['名称']}")
  297. # ── CLI ──
  298. def list_phases():
  299. print("\nPipeline 阶段:\n")
  300. for num in sorted(PHASES):
  301. info = PHASES[num]
  302. deps = f" (依赖: Phase {', '.join(str(d) for d in info['deps'])})" if info.get("deps") else ""
  303. print(f" Phase {num}: {info['title']}{deps}")
  304. print(f" {info['desc']}")
  305. for o in info["outputs"]:
  306. fp = os.path.join(BASE_DIR, o)
  307. status = "✓" if os.path.exists(fp) else " "
  308. print(f" [{status}] {o}")
  309. print()
  310. def main():
  311. import argparse
  312. parser = argparse.ArgumentParser(description="投标书生成 Pipeline")
  313. parser.add_argument("--phase", help="运行阶段: 1, 2, 1-3")
  314. parser.add_argument("--all", action="store_true", help="全流程")
  315. parser.add_argument("--list", action="store_true", help="查看阶段说明")
  316. parser.add_argument("--tender", default=None, help="新招标文件 PDF 路径")
  317. parser.add_argument("--history", default=None, help="历史数据目录(含多组招投标文件夹)")
  318. args = parser.parse_args()
  319. if args.list:
  320. list_phases()
  321. return
  322. phases_to_run = []
  323. if args.all:
  324. phases_to_run = sorted(PHASES.keys())
  325. elif args.phase:
  326. for part in args.phase.split(","):
  327. if "-" in part:
  328. s, e = part.split("-")
  329. phases_to_run.extend(range(int(s), int(e) + 1))
  330. else:
  331. phases_to_run.append(int(part))
  332. if not phases_to_run:
  333. parser.print_help()
  334. list_phases()
  335. return
  336. print("执行顺序:", " → ".join(f"Phase {p}" for p in phases_to_run))
  337. for p in phases_to_run:
  338. if p in PHASES:
  339. run_phase(p, tender=args.tender, history=args.history)
  340. else:
  341. print(f"[ERROR] 未知阶段: Phase {p}")
  342. if __name__ == "__main__":
  343. main()