table_extractor.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564
  1. """
  2. 表格结构提取器(V2 新增)
  3. 负责:
  4. 1. 从招标 PDF 提取完整表格(单元格级,含合并信息)→ List[ExtractedTable]
  5. 2. 从模板 DOCX 提取所有表格结构(含占位符检测)→ List[ExtractedTable]
  6. 3. 从参考投标文件提取段落信息,用于标题层级推断
  7. 设计哲学:逻辑提取为主,LLM 按需辅助。
  8. """
  9. from __future__ import annotations
  10. import logging
  11. import os
  12. import re
  13. from typing import Dict, List, Optional, Set, Tuple
  14. from docx import Document as DocxDocument
  15. from models import ExtractedTable, TableCell, SkeletonParagraph
  16. logger = logging.getLogger(__name__)
  17. # “投标报价分项明细表”在参考投书中不是一张表,而是由
  18. # “三、投标报价分项明细表”等文字和 明细1..明细8、附表1 多张物理表组成的完整片段。
  19. # 提取时必须按原文顺序保留整段文字和所有表,写成一个单独的 DOCX 片段。
  20. _BID_DETAIL_GROUP_NAME = "投标报价分项明细表"
  21. _BID_DETAIL_ARTIFACT_NAME = f"{_BID_DETAIL_GROUP_NAME}.docx"
  22. _BID_DETAIL_SECTION_RE = re.compile(
  23. r"(明细\s*[一二三四五六七八九十百\d]+\s*[::]\s*[^|]{0,80})"
  24. )
  25. _BID_DETAIL_APPENDIX_RE = re.compile(
  26. r"(附表\s*\d*\s*[::]\s*[^|]{0,80})"
  27. )
  28. _REFERENCE_ARTIFACT_RE = re.compile(r"^\d{2,}_.*\.docx$", re.IGNORECASE)
  29. def _bid_detail_section_label(table: ExtractedTable) -> str:
  30. """从表格标题/说明中取出“明细N:...”或“附表N:...”分节标签。"""
  31. text = " | ".join(
  32. value.strip()
  33. for value in (
  34. getattr(table, "caption", "") or "",
  35. getattr(table, "title_hint", "") or "",
  36. )
  37. if value and value.strip()
  38. )
  39. if not text:
  40. return ""
  41. for pattern in (_BID_DETAIL_SECTION_RE, _BID_DETAIL_APPENDIX_RE):
  42. match = pattern.search(text)
  43. if match:
  44. return match.group(1).strip()
  45. return ""
  46. def _is_bid_detail_group_start(table: ExtractedTable) -> bool:
  47. """判断某张参考表是否可作为“投标报价分项明细表”合并序列的起点。"""
  48. text = " ".join(
  49. str(getattr(table, attr, "") or "")
  50. for attr in ("title_hint", "caption")
  51. )
  52. return (
  53. "投标报价分项明细表" in text
  54. or "报价分项明细表" in text
  55. or "投标报价分项明细" in text
  56. )
  57. def _find_bid_detail_group(
  58. tables: List[ExtractedTable],
  59. ) -> Tuple[Optional[int], Optional[int]]:
  60. """定位“投标报价分项明细表”连续物理表区间。"""
  61. if not tables:
  62. return None, None
  63. start_index = next(
  64. (
  65. index
  66. for index, table in enumerate(tables)
  67. if _is_bid_detail_group_start(table)
  68. ),
  69. None,
  70. )
  71. if start_index is None:
  72. return None, None
  73. end_index = start_index + 1
  74. while end_index < len(tables) and _bid_detail_section_label(tables[end_index]):
  75. end_index += 1
  76. return start_index, end_index
  77. def _build_reference_fragment_impl(
  78. docx_path: str,
  79. start_idx: int,
  80. end_idx: int,
  81. ) -> List[bytes]:
  82. """流式截取参考 DOCX 正文中一段连续“段落 + 表格”的 OOXML 字节。"""
  83. import zipfile
  84. import xml.etree.ElementTree as ET
  85. w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
  86. items: List[bytes] = []
  87. with zipfile.ZipFile(docx_path) as archive:
  88. with archive.open("word/document.xml") as xml_stream:
  89. stack: List[str] = []
  90. body_depth: Optional[int] = None
  91. child_index = -1
  92. for event, node in ET.iterparse(
  93. xml_stream, events=("start", "end")
  94. ):
  95. if event == "start":
  96. stack.append(node.tag)
  97. if node.tag == f"{w}body":
  98. body_depth = len(stack)
  99. continue
  100. is_body_child = (
  101. body_depth is not None
  102. and len(stack) == body_depth + 1
  103. )
  104. if is_body_child and node.tag in (f"{w}p", f"{w}tbl"):
  105. child_index += 1
  106. if start_idx <= child_index <= end_idx:
  107. items.append(ET.tostring(node, encoding="utf-8"))
  108. node.clear()
  109. if child_index > end_idx:
  110. break
  111. if node.tag == f"{w}body":
  112. body_depth = None
  113. stack.pop()
  114. return items
  115. def _build_reference_fragment(
  116. docx_path: str,
  117. start_idx: int,
  118. end_idx: int,
  119. ) -> List[bytes]:
  120. """隔离执行参考 DOCX 片段截取,原生崩溃时安全返回空列表。"""
  121. result = _run_reference_xml_worker(
  122. "fragment", docx_path, start_idx, end_idx
  123. )
  124. return result if isinstance(result, list) else []
  125. def _build_bid_detail_fragment(
  126. tables: List[ExtractedTable],
  127. docx_path: str,
  128. ) -> List[ExtractedTable]:
  129. """把参考投书中“投标报价分项明细表”的文字 + 多张物理表保留为完整片段。"""
  130. if not tables or not docx_path or not os.path.isfile(docx_path):
  131. return tables
  132. start_index, end_index = _find_bid_detail_group(tables)
  133. if start_index is None or end_index is None:
  134. return tables
  135. group = tables[start_index:end_index]
  136. first = group[0]
  137. stream = _build_reference_doc_stream(docx_path)
  138. table_stream_positions = [
  139. index
  140. for index, item in enumerate(stream)
  141. if item.get("kind") == "tbl"
  142. ]
  143. if not table_stream_positions:
  144. return tables
  145. first_table_idx = int(getattr(first, "para_idx", -1))
  146. if not (0 <= first_table_idx < len(table_stream_positions)):
  147. return tables
  148. first_stream = table_stream_positions[first_table_idx]
  149. # 从第一张明细表往前找“三、投标报价分项明细表”标题,把它一并纳入片段。
  150. start_stream = first_stream
  151. for index in range(first_stream - 1, max(0, first_stream - 10) - 1, -1):
  152. text = str(stream[index].get("text", "") or "")
  153. if "投标报价分项明细表" in text or "报价分项明细表" in text:
  154. start_stream = index
  155. break
  156. last_table_idx = int(getattr(group[-1], "para_idx", -1))
  157. if not (0 <= last_table_idx < len(table_stream_positions)):
  158. return tables
  159. last_stream = table_stream_positions[last_table_idx]
  160. fragment_xml = _build_reference_fragment(
  161. docx_path, start_stream, last_stream
  162. )
  163. if not fragment_xml:
  164. return tables
  165. fragment = ExtractedTable(
  166. table_id=f"{getattr(first, 'table_id', '') or 'REF-BID-DETAIL'}-FRAGMENT",
  167. source_file=docx_path,
  168. source_type="reference_docx",
  169. para_idx=first_table_idx,
  170. rows=0,
  171. cols=0,
  172. cells=[],
  173. caption=_BID_DETAIL_GROUP_NAME,
  174. title_hint=_BID_DETAIL_GROUP_NAME,
  175. table_type="bid_detail_table",
  176. )
  177. fragment.is_fragment = True
  178. fragment.fragment_xml = fragment_xml
  179. fragment.fragment_source_indices = [
  180. int(getattr(table, "para_idx", -1)) for table in group
  181. ]
  182. logger.info(
  183. f"参考投书“{_BID_DETAIL_GROUP_NAME}”片段: "
  184. f"{len(fragment_xml)} 个段落/表格,来源物理表索引 "
  185. f"{fragment.fragment_source_indices}"
  186. )
  187. return [
  188. *tables[:start_index],
  189. fragment,
  190. *tables[end_index:],
  191. ]
  192. def _write_fragment_docx(fragment_xml: List[bytes], path: str) -> str:
  193. """把“文字 + 多张表”片段原样写入单个 DOCX。"""
  194. os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
  195. doc = DocxDocument()
  196. from docx.oxml import parse_xml
  197. for xml_bytes in fragment_xml or []:
  198. try:
  199. element = parse_xml(xml_bytes)
  200. doc.element.body.insert_element_before(element, "w:sectPr")
  201. except Exception as exc:
  202. logger.warning(f"片段元素写入失败,跳过: {exc}")
  203. doc.save(path)
  204. return os.path.abspath(path)
  205. # ============================================================
  206. # 招标表格分类(供提示词+代码方式提取结果回填分类)
  207. #
  208. # 说明:招标 PDF 的表格/函/表提取已统一走 document_parser 的
  209. # extract_items_from_tender(LLM 识别 + pdf_table_to_docx 结构匹配),
  210. # 本模块不再提供独立的纯结构提取入口,仅保留模板 DOCX 表格提取、
  211. # 参考投标骨架段落提取,以及招标表格分类等共享逻辑。
  212. # ============================================================
  213. # 招标表格分类关键词(按优先级排序:响应表 > 评分 > 格式函件 > 参考格式)
  214. _TENDER_TABLE_TYPE_KEYWORDS: Dict[str, List[str]] = {
  215. "scoring": [
  216. "评分", "分值", "评审项目", "评审内容", "评标标准",
  217. "评分办法", "综合评分", "打分",
  218. ],
  219. "qualification_response": [
  220. "资格条件", "资格要求", "资格性审查", "具备条件说明",
  221. "投标人资格", "符合性审查",
  222. ],
  223. "substantive_response": [
  224. "实质性要求", "实质性响应", "带★", "★号",
  225. "★", "必须满足", "不可偏离",
  226. ],
  227. "bid_form": [
  228. "投标函", "开标一览表", "法定代表人授权", "法定代表人证明",
  229. "法定代表人", "投标总价", "授权委托书", "报价表",
  230. ],
  231. "reference_format": [
  232. "类似项目", "项目负责人", "人员配备",
  233. "项目经理", "简历表", "项目业绩", "业绩",
  234. ],
  235. }
  236. def _classify_tender_table(
  237. cells: List[List[TableCell]],
  238. title_hint: str = "",
  239. ) -> str:
  240. """基于单元格内容 + 表格上方标题自动分类招标表格类型
  241. 分类策略:
  242. 1. 表格标题优先:标题明确含"实质性要求响应表"/"资格条件响应表"时
  243. 直接判定(表头可能是通用的"项目内容/具备的条件说明(要求)",
  244. 无法区分两类响应表,必须依赖标题)
  245. 2. 关键词兜底:评分 > 实质性 > 资格 > 格式函件 > 参考格式 > other
  246. ("实质性要求"优先级高于"具备的条件说明",避免实质性响应表
  247. 被资格类关键词抢走——两表表头结构相同)
  248. """
  249. all_text = " ".join(
  250. cell.text for row in cells for cell in row if cell.text.strip()
  251. )
  252. hint_text = title_hint.replace(" ", "").replace("\n", "") or ""
  253. # ---- 1. 标题优先判定 ----
  254. if hint_text:
  255. if "实质性要求响应表" in hint_text:
  256. return "substantive_response"
  257. if "资格条件响应表" in hint_text:
  258. return "qualification_response"
  259. if "客观分评审因素响应情况表" in hint_text:
  260. return "scoring"
  261. if "开标一览表" in hint_text:
  262. return "bid_open_table"
  263. if "投标报价汇总表" in hint_text or "报价汇总表" in hint_text:
  264. return "bid_summary_table"
  265. if "报价明细表" in hint_text or "报价分项明细表" in hint_text:
  266. return "bid_detail_table"
  267. if "投标函" in hint_text:
  268. return "bid_form"
  269. # ---- 2. 关键词兜底(实质性在资格之前:两表表头结构相同,内容均含
  270. # "实质性要求/资格条件"字样,先匹配实质性的专属词) ----
  271. priority_order = [
  272. "scoring",
  273. "substantive_response",
  274. "qualification_response",
  275. "bid_form",
  276. "reference_format",
  277. ]
  278. for table_type in priority_order:
  279. for kw in _TENDER_TABLE_TYPE_KEYWORDS.get(table_type, []):
  280. if kw in all_text:
  281. return table_type
  282. return "other"
  283. # ============================================================
  284. # 2. 模板 DOCX → ExtractedTable
  285. # ============================================================
  286. def extract_tables_from_template_docx(docx_path: str) -> List[ExtractedTable]:
  287. """从模板 DOCX 中提取所有表格(含占位符标记)
  288. 使用 python-docx 遍历表格,保留:
  289. - 原始格式(字号、加粗、对齐)
  290. - 合并单元格信息
  291. - %%...%% 占位符检测
  292. Args:
  293. docx_path: 模板 DOCX 路径
  294. Returns:
  295. ExtractedTable 列表
  296. """
  297. if not os.path.exists(docx_path):
  298. logger.warning(f"模板文件不存在: {docx_path}")
  299. return []
  300. doc = DocxDocument(docx_path)
  301. extracted: List[ExtractedTable] = []
  302. for table_idx, table in enumerate(doc.tables):
  303. cells, grid_cols = _extract_docx_table_grid(table)
  304. rows = len(cells)
  305. cols = grid_cols
  306. if rows == 0 or cols == 0:
  307. continue
  308. has_placeholders = any(
  309. re.search(r"%%(.+?)%%", cell.text)
  310. for row in cells for cell in row
  311. )
  312. # 提取表格标题(表格前一两个段落的文本)
  313. caption = _extract_table_caption_from_xml(doc, table_idx)
  314. extracted.append(ExtractedTable(
  315. table_id=f"TEMPLATE-T{len(extracted) + 1:02d}",
  316. source_file=docx_path,
  317. source_type="template_docx",
  318. para_idx=table_idx,
  319. rows=rows,
  320. cols=cols,
  321. cells=cells,
  322. caption=caption,
  323. has_placeholders=has_placeholders,
  324. table_type=_classify_template_table(cells, caption),
  325. ))
  326. logger.info(
  327. f"模板表格提取完成: {len(extracted)} 个表格 "
  328. f"(来源: {os.path.basename(docx_path)}),"
  329. f"{sum(1 for t in extracted if t.has_placeholders)} 个含占位符"
  330. )
  331. return extracted
  332. def _extract_docx_table_grid(table) -> Tuple[List[List[TableCell]], int]:
  333. """从 python-docx Table 提取完整单元格网格(保留合并单元格结构)。
  334. 原实现直接使用 ``table.rows[i].cells``,python-docx 会把合并单元格
  335. 在每行重复展开,导致 gridSpan/vMerge 的合并结构丢失(开标一览表
  336. “报价总价(大写):”跨 2 列、“人民币…元整”跨 3 列被拆开)。
  337. 本实现按 w:tbl 的 XML 原始结构逐行重建紧凑网格:
  338. - 普通单元格占 1 个网格列;
  339. - gridSpan 单元格占用 colspan 个网格列,仅在起始格保留文本,
  340. 其余网格位置填入空白 continuation 格;
  341. - vMerge 合并列:起始行记录 rowspan,后续行在该列填入
  342. is_merged_continuation 空白格(不再重复文本);
  343. - 返回的行数是物理行数(含仅作为 vMerge 延续的空行),
  344. grid_cols 为表格网格总列数。
  345. Returns:
  346. (cells, grid_cols)
  347. cells[row][col] 为 TableCell;合并起始格携带 colspan/rowspan,
  348. 被合并覆盖的网格位置为 blank continuation 格。
  349. """
  350. from docx.oxml.ns import qn
  351. tbl = getattr(table, "_element", None)
  352. if tbl is None:
  353. return [], 0
  354. grid_cols = 0
  355. grid = tbl.tblGrid
  356. if grid is not None:
  357. grid_cols = len(grid.findall(qn("w:gridCol")))
  358. rows = tbl.findall(qn("w:tr"))
  359. if not rows:
  360. return [], grid_cols
  361. # 先扫一遍所有行,统计每行实际占用的网格列数(取最大值)
  362. max_cols = grid_cols
  363. if max_cols <= 0:
  364. for tr in rows:
  365. row_cols = 0
  366. for tc in tr.findall(qn("w:tc")):
  367. tc_pr = tc.find(qn("w:tcPr"))
  368. colspan = 1
  369. if tc_pr is not None:
  370. gs = tc_pr.find(qn("w:gridSpan"))
  371. if gs is not None:
  372. try:
  373. colspan = max(1, int(gs.get(qn("w:val")) or 1))
  374. except (TypeError, ValueError):
  375. colspan = 1
  376. row_cols += colspan
  377. max_cols = max(max_cols, row_cols)
  378. grid_cols = max_cols
  379. cells: List[List[TableCell]] = []
  380. # 当前从上一行延续下来的纵向合并:grid_col -> 起始格信息
  381. active_vmerge: Dict[int, TableCell] = {}
  382. for tr in rows:
  383. row_cells: List[TableCell] = [None] * grid_cols
  384. continued_origins: set = set()
  385. # 1) 纵向合并延续:被上一行 vMerge 覆盖的网格列先填 continuation 格
  386. # (真实 XML 行若含 vMerge continue 会再次覆盖同一位置,无冲突;
  387. # 若某合并未延续,处理后会被移出 active_vmerge)
  388. for c, origin in active_vmerge.items():
  389. if c >= grid_cols:
  390. continue
  391. for k in range(origin.colspan):
  392. cc = c + k
  393. if cc < grid_cols and row_cells[cc] is None:
  394. row_cells[cc] = TableCell(
  395. text="",
  396. row=len(cells),
  397. col=cc,
  398. rowspan=1,
  399. colspan=1,
  400. is_merged_origin=False,
  401. is_merged_continuation=True,
  402. )
  403. # 2) 处理本行实际 w:tc(放在下一个空网格位置)
  404. col = 0
  405. used_vmerge_cols: set = set()
  406. for tc in tr.findall(qn("w:tc")):
  407. tc_pr = tc.find(qn("w:tcPr"))
  408. colspan = 1
  409. vmerge_val = ""
  410. if tc_pr is not None:
  411. gs = tc_pr.find(qn("w:gridSpan"))
  412. if gs is not None:
  413. try:
  414. colspan = max(1, int(gs.get(qn("w:val")) or 1))
  415. except (TypeError, ValueError):
  416. colspan = 1
  417. vm = tc_pr.find(qn("w:vMerge"))
  418. if vm is not None:
  419. vmerge_val = vm.get(qn("w:val")) or "continue"
  420. if vmerge_val == "continue":
  421. # 延续格必须对应到上一行 active_vmerge 预填的 continuation 格,
  422. # 不能因该格已被预填而跳到下一列,否则纵向合并会错位。
  423. target_col = None
  424. for c in sorted(active_vmerge.keys()):
  425. if c in used_vmerge_cols or c >= grid_cols:
  426. continue
  427. if (
  428. row_cells[c] is not None
  429. and row_cells[c].is_merged_continuation
  430. ):
  431. target_col = c
  432. break
  433. if target_col is None:
  434. while col < grid_cols and row_cells[col] is not None:
  435. col += 1
  436. target_col = col
  437. if target_col < grid_cols:
  438. used_vmerge_cols.add(target_col)
  439. continued_origins.add(target_col)
  440. if row_cells[target_col] is None:
  441. row_cells[target_col] = TableCell(
  442. text="",
  443. row=len(cells),
  444. col=target_col,
  445. rowspan=1,
  446. colspan=1,
  447. is_merged_origin=False,
  448. is_merged_continuation=True,
  449. )
  450. col = target_col + colspan
  451. continue
  452. while col < grid_cols and row_cells[col] is not None:
  453. col += 1
  454. if col >= grid_cols:
  455. break
  456. text = "".join(
  457. node.text or "" for node in tc.iter(qn("w:t"))
  458. )
  459. font_size, bold, alignment = _extract_tc_format(tc)
  460. # 防御:网格列数小于 colspan 和时截断,避免越界
  461. if col + colspan > grid_cols:
  462. colspan = max(1, grid_cols - col)
  463. else:
  464. is_origin = vmerge_val in ("", "restart")
  465. # 起始行行号未知:rowspan 先记 1,扫完所有行后回填
  466. cell = TableCell(
  467. text=text,
  468. row=len(cells),
  469. col=col,
  470. rowspan=1,
  471. colspan=colspan,
  472. is_merged_origin=(colspan > 1 or vmerge_val == "restart"),
  473. is_merged_continuation=False,
  474. font_size=font_size,
  475. bold=bold,
  476. alignment=alignment,
  477. )
  478. for k in range(colspan):
  479. if k == 0:
  480. row_cells[col] = cell
  481. else:
  482. row_cells[col + k] = TableCell(
  483. text="",
  484. row=len(cells),
  485. col=col + k,
  486. rowspan=1,
  487. colspan=1,
  488. is_merged_origin=False,
  489. is_merged_continuation=True,
  490. )
  491. if vmerge_val == "restart":
  492. active_vmerge[col] = cell
  493. col += colspan
  494. # 3) 本行未延续的纵向合并结束,移出 active_vmerge(后续行不再占位)
  495. for c in [c for c in active_vmerge if c not in continued_origins]:
  496. del active_vmerge[c]
  497. # 补齐行尾未覆盖的网格列(防御性)
  498. for c in range(grid_cols):
  499. if row_cells[c] is None:
  500. row_cells[c] = TableCell(
  501. text="",
  502. row=len(cells),
  503. col=c,
  504. rowspan=1,
  505. colspan=1,
  506. is_merged_origin=False,
  507. is_merged_continuation=True,
  508. )
  509. cells.append(row_cells)
  510. # 回填 vMerge 起始格的实际行数:按起始格的 colspan 范围统计连续
  511. # continuation 行数。这样既支持普通纵向合并,也支持“横向+纵向”
  512. # 混合合并,不把合并类型写死。
  513. if cells:
  514. for ri, row in enumerate(cells):
  515. for ci, cell in enumerate(row):
  516. if not cell.is_merged_origin or cell.rowspan != 1:
  517. continue
  518. colspan = max(1, int(cell.colspan or 1))
  519. rowspan = 1
  520. for rr in range(ri + 1, len(cells)):
  521. all_cont = True
  522. for cc in range(ci, ci + colspan):
  523. if cc >= grid_cols:
  524. all_cont = False
  525. break
  526. target = cells[rr][cc]
  527. if not (target.is_merged_continuation or target.text == ""):
  528. all_cont = False
  529. break
  530. if not all_cont:
  531. break
  532. rowspan += 1
  533. if rowspan > 1:
  534. cell.rowspan = rowspan
  535. cell.is_merged_origin = True
  536. return cells, grid_cols
  537. def _extract_tc_format(tc) -> Tuple[Optional[float], bool, str]:
  538. """从 w:tc 提取字号(pt)/加粗/段落对齐,尽力而为,失败返回默认值。"""
  539. from docx.oxml.ns import qn
  540. font_size: Optional[float] = None
  541. bold = False
  542. alignment = ""
  543. try:
  544. for r_node in tc.iter(qn("w:r")):
  545. r_pr = r_node.find(qn("w:rPr"))
  546. if r_pr is None:
  547. continue
  548. sz = r_pr.find(qn("w:sz"))
  549. if sz is not None and font_size is None:
  550. try:
  551. font_size = int(sz.get(qn("w:val")) or 0) / 2.0
  552. except (TypeError, ValueError):
  553. pass
  554. b_node = r_pr.find(qn("w:b"))
  555. if b_node is not None:
  556. val = b_node.get(qn("w:val"))
  557. if val is None or val in ("1", "true", "on"):
  558. bold = True
  559. if not alignment:
  560. for p_node in tc.iter(qn("w:p")):
  561. p_pr = p_node.find(qn("w:pPr"))
  562. if p_pr is None:
  563. continue
  564. jc = p_pr.find(qn("w:jc"))
  565. if jc is not None:
  566. val = jc.get(qn("w:val")) or ""
  567. if val in ("left", "center", "right", "both", "distribute"):
  568. alignment = "center" if val == "distribute" else val
  569. break
  570. except Exception:
  571. pass
  572. return font_size, bold, alignment
  573. def _extract_table_caption_from_xml(doc: DocxDocument, table_idx: int) -> str:
  574. """从文档 XML 结构中提取表格前面的标题/说明文字(段落)"""
  575. try:
  576. body = doc.element.body
  577. table_elements = body.findall(
  578. "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tbl"
  579. )
  580. if table_idx >= len(table_elements):
  581. return ""
  582. tbl = table_elements[table_idx]
  583. prev = tbl.getprevious()
  584. recent = []
  585. while prev is not None and len(recent) < 3:
  586. tag = prev.tag.split("}")[-1] if "}" in prev.tag else prev.tag
  587. if tag != "p":
  588. break
  589. para_text = "".join(
  590. node.text or ""
  591. for node in prev.iter()
  592. if node.tag.endswith("}t") and node.text
  593. )
  594. if para_text.strip():
  595. recent.append(para_text.strip())
  596. else:
  597. break
  598. prev = prev.getprevious()
  599. caption = " | ".join(reversed(recent)).strip()
  600. return caption[:300] if caption else ""
  601. except Exception:
  602. return ""
  603. def _classify_template_table(cells: List[List[TableCell]], caption: str) -> str:
  604. """基于模板表格内容分类"""
  605. all_text = caption + " " + " ".join(
  606. cell.text for row in cells for cell in row if cell.text.strip()
  607. )
  608. if any(kw in all_text for kw in ["开标一览表"]):
  609. return "bid_open_table"
  610. if any(kw in all_text for kw in ["报价汇总表"]):
  611. return "bid_summary_table"
  612. if any(kw in all_text for kw in ["报价明细表", "报价分项明细表"]):
  613. return "bid_detail_table"
  614. if any(kw in all_text for kw in ["投标函"]):
  615. return "bid_form"
  616. if any(kw in all_text for kw in ["资格条件", "资格要求", "资格性"]):
  617. return "qualification_response"
  618. if any(kw in all_text for kw in ["实质性要求", "实质性响应", "偏离"]):
  619. return "substantive_response"
  620. if any(kw in all_text for kw in ["类似项目", "业绩", "项目负责人", "项目经理"]):
  621. return "reference_format"
  622. if any(kw in all_text for kw in ["报价", "一览表", "汇总"]):
  623. return "bid_form"
  624. return "other"
  625. # ============================================================
  626. # 参考投书表格 LLM 增强提取(V3 新增)
  627. # 结构提取之外,用 LLM 补充表格名称、所属章节、用途、表头项目信息字段,
  628. # 使 Step 4/6 在"招标/模板均无对应表"时能按名称从参考投书中取用表格。
  629. # 处理单元:一张表 + 所在页(跨页表格整表为一个单元)+ 前文上下文,
  630. # 单元之间并发调用 LLM。
  631. # ============================================================
  632. _REFERENCE_TABLE_ENRICH_PROMPT = """你是投标文件表格识别专家。下方是参考投标文件中提取出的若干表格(每个表格给出:
  633. 编号、表格前后文提示、行列数、前两行内容预览)。请对每个表格输出其补充信息。
  634. 输出严格 JSON:
  635. {"tables": [
  636. {"table_id": "与输入编号一致",
  637. "name": "表格正式名称(如:开标一览表、投标报价汇总表、资格条件响应表)",
  638. "chapter": "所属章节(如:第二章:投标报价)",
  639. "purpose": "用途,如:报价/响应/清单/证明/考核表",
  640. "header_fields": ["项目名称:...", "项目编号:...", "包号:..."],
  641. "column_notes": "各列含义简要说明",
  642. "can_reuse": true,
  643. "notes": "备注(含需要人工确认的事项)"}
  644. ]}
  645. 要求:
  646. 1. name 必须是标准表格名称,不含序号前缀(如"一、""(二)");
  647. 2. 表格前后文包含"开标一览表"等标题时,name 取该标题;
  648. 3. header_fields 提取表格上方的"项目名称:""项目编号:""包号:"等项目信息行(原样);
  649. 4. 没有把握的字段填空字符串,不得编造;can_reuse 默认 true;
  650. 5. 只输出 JSON,不输出任何其他文字。"""
  651. def _build_reference_table_preview(tables: List[ExtractedTable]) -> str:
  652. """把结构表格压缩成 LLM 可读的预览文本"""
  653. lines = []
  654. for idx, t in enumerate(tables):
  655. caption = (getattr(t, "caption", "") or "").strip()
  656. title_hint = (getattr(t, "title_hint", "") or "").strip()
  657. ctx = " / ".join(x for x in (title_hint, caption) if x)
  658. rows = getattr(t, "rows", 0) or (len(t.cells) if t.cells else 0)
  659. cols = getattr(t, "cols", 0)
  660. if not cols and t.cells:
  661. cols = max(len(r) for r in t.cells) if t.cells else 0
  662. preview_rows = []
  663. for r in (t.cells or [])[:2]:
  664. cells = [c.text.replace("\n", " ")[:16] for c in r]
  665. preview_rows.append(" | ".join(cells))
  666. lines.append(
  667. f"[{idx}] 上下文: {ctx[:120]}\n"
  668. f" 规格: {rows} 行 x {cols} 列\n"
  669. f" 预览:\n" + "\n".join(f" {r}" for r in preview_rows)
  670. )
  671. return "\n\n".join(lines)
  672. # 无分页标记的 DOCX,按字符流估算页数(中文文档每页约 1000 字符)
  673. _CHARS_PER_PAGE_ESTIMATE = 1000
  674. def _build_reference_doc_stream(docx_path: str) -> List[dict]:
  675. """在隔离子进程中构建参考 DOCX 正文流。
  676. 即使 ``ElementTree.iterparse`` 使用流式读取,Windows 上同一主进程先后
  677. 加载 PDF/ONNX/python-docx 后,Expat 仍可能直接触发 0xC0000005,无法由
  678. Python ``try/except`` 捕获。隔离后原生崩溃只损失可选的表格上下文,不会
  679. 终止 Step6;失败时返回空流,LLM 仍可依据表格标题和单元格内容识别。
  680. """
  681. result = _run_reference_xml_worker("stream", docx_path)
  682. return result if isinstance(result, list) else []
  683. def _build_reference_doc_stream_impl(docx_path: str) -> List[dict]:
  684. """用标准库 ET 流式解析 document.xml,按正文顺序输出段落/表格流。
  685. 返回 [{kind: "p"|"tbl", text: str, char_start: int, char_end: int}, ...]
  686. - 段落:拼接该段全部 w:t 文本;
  687. - 表格:拼接全部单元格文本(跨页表格仍是一个 w:tbl 元素,天然完整)。
  688. char_start/char_end 用于估算表格所在页。
  689. """
  690. import zipfile
  691. import xml.etree.ElementTree as ET
  692. w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
  693. stream = []
  694. pos = 0
  695. # 184 MB 级参考投书的 document.xml 解压后可能达到数百 MB。
  696. # ``z.read`` + ``ET.fromstring`` 会同时保留 XML bytes 和完整元素树,
  697. # Windows/PyCharm 下可能在 Python 抛出 MemoryError 前先由 Expat 触发
  698. # 0xC0000005。改用 ZipExtFile + iterparse,只保留当前 body 直属块。
  699. with zipfile.ZipFile(docx_path) as z:
  700. with z.open("word/document.xml") as xml_stream:
  701. stack = []
  702. body_depth = None
  703. for event, node in ET.iterparse(
  704. xml_stream, events=("start", "end")
  705. ):
  706. if event == "start":
  707. stack.append(node.tag)
  708. if node.tag == f"{w}body":
  709. body_depth = len(stack)
  710. continue
  711. is_body_child = (
  712. body_depth is not None
  713. and len(stack) == body_depth + 1
  714. )
  715. if is_body_child and node.tag in (f"{w}p", f"{w}tbl"):
  716. texts = [t.text or "" for t in node.iter(f"{w}t")]
  717. if node.tag == f"{w}p":
  718. text = "".join(texts).strip()
  719. kind = "p"
  720. span = len(text)
  721. else:
  722. text = " ".join(
  723. value.strip() for value in texts if value.strip()
  724. )
  725. kind = "tbl"
  726. span = max(len(text), 1)
  727. stream.append({
  728. "kind": kind,
  729. "text": text,
  730. "char_start": pos,
  731. "char_end": pos + span,
  732. })
  733. pos += span + 1
  734. node.clear()
  735. if node.tag == f"{w}body":
  736. body_depth = None
  737. stack.pop()
  738. return stream
  739. def _run_reference_xml_worker(
  740. mode: str,
  741. docx_path: str,
  742. *extra_args,
  743. ):
  744. """隔离执行大型 DOCX XML 解析;原生崩溃时安全返回 ``None``。"""
  745. import pickle
  746. import subprocess
  747. import sys
  748. import tempfile
  749. worker = os.path.join(
  750. os.path.dirname(os.path.abspath(__file__)),
  751. "_reference_xml_worker.py",
  752. )
  753. fd, result_path = tempfile.mkstemp(prefix="reference_xml_", suffix=".pkl")
  754. os.close(fd)
  755. try:
  756. command = [
  757. sys.executable,
  758. worker,
  759. mode,
  760. os.path.abspath(docx_path),
  761. result_path,
  762. *[str(arg) for arg in extra_args],
  763. ]
  764. try:
  765. completed = subprocess.run(
  766. command,
  767. capture_output=True,
  768. timeout=900,
  769. )
  770. except subprocess.TimeoutExpired:
  771. logger.warning(f"参考 DOCX XML 子进程超时: mode={mode}")
  772. return None
  773. if completed.returncode != 0:
  774. logger.warning(
  775. "参考 DOCX XML 子进程异常,使用安全降级: "
  776. f"mode={mode}, rc={completed.returncode}"
  777. )
  778. return None
  779. if not os.path.isfile(result_path) or os.path.getsize(result_path) == 0:
  780. logger.warning(f"参考 DOCX XML 子进程未产生结果: mode={mode}")
  781. return None
  782. with open(result_path, "rb") as file:
  783. return pickle.load(file)
  784. except Exception as exc:
  785. logger.warning(f"参考 DOCX XML 隔离解析失败: mode={mode}, {exc}")
  786. return None
  787. finally:
  788. try:
  789. os.unlink(result_path)
  790. except OSError:
  791. pass
  792. def _estimate_page_range(char_start: int, char_end: int) -> Tuple[int, int]:
  793. """按字符流估算页码范围(近似值,供人工定位用)"""
  794. p1 = char_start // _CHARS_PER_PAGE_ESTIMATE + 1
  795. p2 = max(p1, char_end // _CHARS_PER_PAGE_ESTIMATE + 1)
  796. return p1, p2
  797. def _collect_table_context(
  798. stream: List[dict],
  799. tbl_idx: int,
  800. ) -> Tuple[int, str, List[str]]:
  801. """收集表格前文上下文(所属章节标题、项目信息行)与项目信息字段行"""
  802. # 找到第 tbl_idx 张表格在流中的位置
  803. seen = -1
  804. pos = -1
  805. for i, item in enumerate(stream):
  806. if item["kind"] == "tbl":
  807. seen += 1
  808. if seen == tbl_idx:
  809. pos = i
  810. break
  811. if pos < 0:
  812. return -1, "", []
  813. context_lines = []
  814. header_fields = []
  815. for j in range(pos - 1, max(-1, pos - 6), -1):
  816. item = stream[j]
  817. if item["kind"] != "p":
  818. continue
  819. text = item["text"]
  820. if not text:
  821. continue
  822. context_lines.insert(0, text)
  823. if re.match(r"^(项目名称|项目编号|招标编号|招标项目编号|包号|包件号|投标人名称)[::]", text):
  824. header_fields.append(text)
  825. # 遇到章/节标题即停止向上收集
  826. if re.match(r"^第\s*[一二三四五六七八九十百\d]+\s*章", text) or \
  827. re.match(r"^[一二三四五六七八九十百]+、", text):
  828. break
  829. return pos, "\n".join(context_lines[:5]), header_fields
  830. _REFERENCE_TABLE_UNIT_PROMPT = """你是投标文件表格识别专家。下面是一张从参考投标文件中提取的表格(含
  831. 文档页码估算、前文上下文、表格行列数与内容预览)。请输出该表格的补充信息。
  832. 输出严格 JSON:
  833. {"name": "表格正式名称(如:开标一览表、投标报价汇总表、资格条件响应表,不含序号前缀)",
  834. "chapter": "所属章节(如:第二章:投标报价)",
  835. "purpose": "用途,如:报价/响应/清单/证明/考核表",
  836. "header_fields": ["项目名称:...", "项目编号:...", "包号:..."],
  837. "column_notes": "各列含义简要说明",
  838. "can_reuse": true,
  839. "notes": "备注(含需要人工确认的事项)"}
  840. 要求:
  841. 1. name 优先取前文上下文中的表格标题(如"一、开标一览表"→"开标一览表");
  842. 2. header_fields 输出表格上方的"项目名称:""项目编号:""包号:"等项目信息行(原样);
  843. 3. 没有把握的字段填空字符串,不得编造;can_reuse 默认 true;
  844. 4. 只输出 JSON,不输出任何其他文字。"""
  845. def _llm_enrich_one_table(
  846. tbl_idx: int,
  847. table: ExtractedTable,
  848. docx_path: str,
  849. stream: List[dict],
  850. ) -> Optional[dict]:
  851. """单个表格单元的 LLM 增强(供线程池并发调用)"""
  852. from llm_client import LLMClient
  853. stream_pos, context, header_fields = _collect_table_context(stream, tbl_idx)
  854. rows = len(table.cells) if table.cells else table.rows
  855. cols = table.cols
  856. if not cols and table.cells:
  857. cols = max(len(r) for r in table.cells) if table.cells else 0
  858. if stream_pos >= 0:
  859. p1, p2 = _estimate_page_range(
  860. stream[stream_pos]["char_start"],
  861. stream[stream_pos]["char_end"],
  862. )
  863. else:
  864. p1, p2 = 1, 1
  865. # 预览:前 8 行 + 行数(跨页表格整表为单元,行数完整)
  866. preview_rows = []
  867. for r in (table.cells or [])[:8]:
  868. preview_rows.append(
  869. " | ".join(c.text.replace("\n", " ")[:20] for c in r)
  870. )
  871. if rows > 8:
  872. preview_rows.append(f"...(共 {rows} 行)")
  873. user_prompt = (
  874. f"## 表格单元 {tbl_idx + 1}\n"
  875. f"文档页码(估算): 第 {p1}~{p2} 页\n"
  876. f"前文上下文:\n{context or '(无)'}\n"
  877. f"规格: {rows} 行 x {cols} 列\n"
  878. f"内容预览:\n" + "\n".join(f" {r}" for r in preview_rows)
  879. )
  880. llm = LLMClient()
  881. result = llm.extract_json(
  882. system_prompt=_REFERENCE_TABLE_UNIT_PROMPT,
  883. user_prompt=user_prompt,
  884. max_tokens=4096,
  885. )
  886. if not isinstance(result, dict):
  887. return None
  888. if not header_fields and isinstance(result.get("header_fields"), list):
  889. # 用前文扫描到的项目信息行兜底
  890. result["header_fields"] = header_fields or result["header_fields"]
  891. return result
  892. def enrich_reference_tables_with_llm(
  893. tables: List[ExtractedTable],
  894. llm=None,
  895. docx_path: str = "",
  896. ) -> List[ExtractedTable]:
  897. """用 LLM 补充参考投书表格的名称/所属章节/用途/表头项目信息字段。
  898. LLM 失败时原样返回结构表格(不影响主流程)。
  899. """
  900. if not tables:
  901. return tables
  902. if not tables:
  903. return tables
  904. # 构建文档流(页码估算 + 前文上下文)
  905. stream = _build_reference_doc_stream(docx_path) if docx_path else []
  906. if len(stream) < len(tables):
  907. # 流缺失时退化为简单估算
  908. stream = [
  909. {"kind": "tbl", "char_start": i * 1000, "char_end": (i + 1) * 1000}
  910. for i in range(len(tables))
  911. ]
  912. from concurrent.futures import ThreadPoolExecutor, as_completed
  913. max_workers = min(5, len(tables))
  914. metas = {}
  915. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  916. futures = {
  917. executor.submit(
  918. _llm_enrich_one_table, idx, table, docx_path, stream
  919. ): idx
  920. for idx, table in enumerate(tables)
  921. }
  922. for future in as_completed(futures):
  923. idx = futures[future]
  924. try:
  925. result = future.result()
  926. except Exception as e:
  927. logger.warning(
  928. f"参考投书表格 LLM 增强 [{idx}] 失败(保留结构表格): {e}"
  929. )
  930. continue
  931. if isinstance(result, dict):
  932. metas[idx] = result
  933. if not metas:
  934. logger.warning("参考投书表格 LLM 增强全部失败,保留结构表格")
  935. return tables
  936. updated = 0
  937. for idx, t in enumerate(tables):
  938. meta = metas.get(idx)
  939. if not meta:
  940. continue
  941. name = (meta.get("name") or "").strip()
  942. chapter = (meta.get("chapter") or "").strip()
  943. header_fields = meta.get("header_fields") or []
  944. if name:
  945. t.title_hint = name
  946. if not t.caption:
  947. t.caption = name
  948. # 用表名重新分类(开标一览表/报价汇总表/报价明细表等)
  949. new_type = _classify_tender_table(
  950. t.cells or [], title_hint=name
  951. )
  952. if new_type != "other":
  953. t.table_type = new_type
  954. if chapter and chapter not in (t.caption or ""):
  955. t.caption = f"{chapter} {t.caption or name}".strip()
  956. if isinstance(header_fields, list):
  957. t.header_fields = [
  958. str(h).strip() for h in header_fields if str(h).strip()
  959. ]
  960. updated += 1
  961. if updated:
  962. logger.info(f"参考投书表格 LLM 增强: {updated} 张表格补充名称/章节/字段信息")
  963. return tables
  964. def write_reference_tables_to_docx(
  965. tables: List[ExtractedTable],
  966. output_dir: str,
  967. ) -> int:
  968. """把参考投书表格逐张写成独立 DOCX,便于人工核对。
  969. 参考投书可能包含横纵交错的大型合并表。使用 python-docx 逐格
  970. ``table.cell(...).merge(...)`` 重建这类表时,会反复展开逻辑网格;在
  971. Windows/PyCharm 中已观察到 lxml 在垃圾回收阶段直接以 0xC0000005
  972. 崩溃。这里优先流式读取来源 DOCX 的原生 ``w:tbl`` XML 并整表克隆,
  973. 同时也能保留 tblPr/tblGrid、单元格宽度、边框和合并属性。
  974. """
  975. if not tables or not output_dir:
  976. return 0
  977. os.makedirs(output_dir, exist_ok=True)
  978. _clean_reference_table_artifacts(output_dir)
  979. source_table_xml: Dict[str, List[bytes]] = {}
  980. for source_file in {
  981. os.path.abspath(t.source_file)
  982. for t in tables
  983. if getattr(t, "source_file", "")
  984. and not getattr(t, "is_fragment", False)
  985. and os.path.isfile(t.source_file)
  986. }:
  987. try:
  988. source_table_xml[source_file] = _stream_source_table_xml(source_file)
  989. except Exception as exc:
  990. logger.warning(
  991. f"参考投书原生表格 XML 读取失败,将使用无合并兜底: "
  992. f"{os.path.basename(source_file)} ({exc})"
  993. )
  994. written = 0
  995. for idx, t in enumerate(tables, 1):
  996. name = (getattr(t, "title_hint", "") or getattr(t, "caption", "") or f"表格{idx}").strip()
  997. if bool(getattr(t, "is_fragment", False)):
  998. path = os.path.join(output_dir, _BID_DETAIL_ARTIFACT_NAME)
  999. _write_fragment_docx(
  1000. list(getattr(t, "fragment_xml", []) or []), path
  1001. )
  1002. t.fragment_docx_path = os.path.abspath(path)
  1003. t.artifact_path = os.path.abspath(path)
  1004. written += 1
  1005. logger.info(
  1006. f" 参考投书“{_BID_DETAIL_GROUP_NAME}”片段已落盘: {path}"
  1007. )
  1008. continue
  1009. else:
  1010. safe = re.sub(r'[<>:"/\\|?*]+', "_", name)[:50] or f"table_{idx}"
  1011. path = os.path.join(output_dir, f"{idx:02d}_{safe}.docx")
  1012. doc = DocxDocument()
  1013. doc.add_heading(f"{idx}. {name}", level=1)
  1014. if getattr(t, "caption", ""):
  1015. doc.add_paragraph(str(t.caption))
  1016. source_file = os.path.abspath(t.source_file) if t.source_file else ""
  1017. source_index = int(getattr(t, "para_idx", -1))
  1018. xml_items = source_table_xml.get(source_file, [])
  1019. if 0 <= source_index < len(xml_items):
  1020. from docx.oxml import parse_xml
  1021. source_tbl = parse_xml(xml_items[source_index])
  1022. doc.element.body.insert_element_before(source_tbl, "w:sectPr")
  1023. else:
  1024. _append_unmerged_reference_table(doc, t)
  1025. doc.save(path)
  1026. t.artifact_path = os.path.abspath(path)
  1027. written += 1
  1028. logger.info(f"参考投书表格已落盘: {written} 个 DOCX -> {output_dir}")
  1029. return written
  1030. def _clean_reference_table_artifacts(output_dir: str) -> int:
  1031. """清理参考表旧产物,避免重跑后同序号不同命名文件重复累积。"""
  1032. if not output_dir or not os.path.isdir(output_dir):
  1033. return 0
  1034. removed = 0
  1035. for name in os.listdir(output_dir):
  1036. if name == _BID_DETAIL_ARTIFACT_NAME or _REFERENCE_ARTIFACT_RE.match(name):
  1037. path = os.path.join(output_dir, name)
  1038. if os.path.isfile(path):
  1039. try:
  1040. os.remove(path)
  1041. removed += 1
  1042. except OSError as exc:
  1043. logger.warning(f"清理参考表旧产物失败: {name} ({exc})")
  1044. if removed:
  1045. logger.info(f"已清理参考表旧产物: {removed} 个文件 -> {output_dir}")
  1046. return removed
  1047. def _stream_source_table_xml(docx_path: str) -> List[bytes]:
  1048. """隔离读取来源表格 XML;失败时由调用方使用无合并安全兜底。"""
  1049. result = _run_reference_xml_worker("tables", docx_path)
  1050. return result if isinstance(result, list) else []
  1051. def _stream_source_table_xml_impl(docx_path: str) -> List[bytes]:
  1052. """以恒定内存读取正文直属表格 XML,避免再次构造完整 lxml 文档树。"""
  1053. import zipfile
  1054. import xml.etree.ElementTree as ET
  1055. w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
  1056. items: List[bytes] = []
  1057. with zipfile.ZipFile(docx_path) as archive:
  1058. with archive.open("word/document.xml") as xml_stream:
  1059. stack: List[str] = []
  1060. body_depth: Optional[int] = None
  1061. for event, node in ET.iterparse(xml_stream, events=("start", "end")):
  1062. if event == "start":
  1063. stack.append(node.tag)
  1064. if node.tag == f"{w}body":
  1065. body_depth = len(stack)
  1066. continue
  1067. if (
  1068. body_depth is not None
  1069. and len(stack) == body_depth + 1
  1070. and node.tag == f"{w}tbl"
  1071. ):
  1072. items.append(ET.tostring(node, encoding="utf-8"))
  1073. node.clear()
  1074. if node.tag == f"{w}body":
  1075. body_depth = None
  1076. stack.pop()
  1077. return items
  1078. def _append_unmerged_reference_table(doc, table_data: ExtractedTable) -> None:
  1079. """来源 XML 不可用时的安全兜底:保留文字网格,不执行危险的合并。"""
  1080. rows = getattr(table_data, "cells", None) or []
  1081. if not rows:
  1082. doc.add_paragraph("(无表格数据)")
  1083. return
  1084. cols = getattr(table_data, "cols", 0) or max(len(row) for row in rows)
  1085. table = doc.add_table(rows=len(rows), cols=cols)
  1086. for ri, row in enumerate(rows):
  1087. for ci, cell_data in enumerate(row[:cols]):
  1088. if isinstance(cell_data, TableCell):
  1089. if cell_data.is_merged_continuation:
  1090. continue
  1091. text = cell_data.text or ""
  1092. else:
  1093. text = str(cell_data)
  1094. table.rows[ri].cells[ci].text = text
  1095. def extract_reference_tables_with_llm(
  1096. docx_path: str,
  1097. output_dir: str = "",
  1098. ) -> List[ExtractedTable]:
  1099. """参考投书表格统一提取入口:结构提取 + LLM 补充名称/章节/用途/表头字段。
  1100. 提供 output_dir 时,把每张表写成独立 DOCX 便于人工核对。
  1101. """
  1102. tables = extract_tables_from_template_docx(docx_path)
  1103. if not tables:
  1104. return tables
  1105. for table in tables:
  1106. table.source_type = "reference_docx"
  1107. tables = enrich_reference_tables_with_llm(tables, docx_path=docx_path)
  1108. tables = _build_bid_detail_fragment(tables, docx_path)
  1109. if output_dir:
  1110. write_reference_tables_to_docx(tables, output_dir)
  1111. return tables
  1112. # ============================================================
  1113. # 3. 参考投标文件 → 段落 diff → 骨架段落
  1114. # ============================================================
  1115. def extract_skeleton_paragraphs(
  1116. reference_path: str,
  1117. template_path: str = "",
  1118. similarity_threshold: float = 0.75,
  1119. reference_texts=None,
  1120. template_texts=None,
  1121. ) -> List[SkeletonParagraph]:
  1122. """从参考投标文件中提取骨架段落(用于推断模板标题层级)
  1123. 工作流程:
  1124. 1. 读取参考投标文件的所有段落
  1125. 2. 如果提供了模板路径,与模板段落做 diff 比对
  1126. 3. 相似度 ≥ similarity_threshold 的段落标记为"骨架段落"
  1127. 4. 根据字号和格式推断标题层级(Heading 1/2/3)
  1128. 骨架段落的含义:
  1129. 模板缺少 Word 标题样式(Heading 1/2/3),但参考投标文件中有。
  1130. 通过比对两者相似的段落,可以反推模板中各段落应该使用什么层级的标题。
  1131. Args:
  1132. reference_path: 参考投标文件路径(PDF/DOCX)
  1133. template_path: 模板 DOCX 路径(可选,用于 diff 比对)
  1134. similarity_threshold: 相似度阈值(默认 0.75,即 75% 相似 = 骨架段落)
  1135. reference_texts: 参考文件正文段落文本(可选,传入则不再重复打开大文件)
  1136. template_texts: 模板正文段落文本(可选,传入则不再重复打开大文件)
  1137. Returns:
  1138. SkeletonParagraph 列表
  1139. """
  1140. if not os.path.exists(reference_path):
  1141. logger.warning(f"参考投标文件不存在: {reference_path}")
  1142. return []
  1143. # 读取参考文件段落(优先使用调用方已读好的文本,避免重复打开大文件)
  1144. if reference_texts is not None:
  1145. ref_paras = [
  1146. {"text": t.strip(), "font_size": None, "bold": False}
  1147. for t in reference_texts
  1148. if t.strip()
  1149. ]
  1150. else:
  1151. ref_paras = _read_paragraphs(reference_path)
  1152. if not ref_paras:
  1153. return []
  1154. # 读取模板段落(如提供了模板路径)
  1155. template_paras: List[str] = []
  1156. if template_path and os.path.exists(template_path):
  1157. if template_texts is not None:
  1158. template_paras = [
  1159. {"text": t.strip(), "font_size": None, "bold": False}
  1160. for t in template_texts
  1161. if t.strip()
  1162. ]
  1163. else:
  1164. template_paras = _read_paragraphs(template_path)
  1165. # 模板段落前缀索引(与 template_parser._diff_skeleton 同策略):
  1166. # 只对同前缀段落做相似度比对,避免 O(N×M) 全量比对——既大幅提速,
  1167. # 也避免海量字符串分配触发大文件解析后的内存损坏(0xC0000005)。
  1168. template_index: Dict[str, List[str]] = {}
  1169. for tp in template_paras:
  1170. t = tp["text"].strip()
  1171. if t and len(t) >= 4:
  1172. key = _norm_prefix(t[:20])
  1173. template_index.setdefault(key, []).append(t)
  1174. skeletons: List[SkeletonParagraph] = []
  1175. for i, para in enumerate(ref_paras):
  1176. text = para["text"].strip()
  1177. if not text or len(text) < 4:
  1178. continue # 跳过太短的段落
  1179. # 与模板段落做 diff(如果提供了模板)
  1180. best_similarity = 0.0
  1181. if template_paras:
  1182. key = _norm_prefix(text[:20])
  1183. for t in template_index.get(key, []):
  1184. sim = _jaccard_similarity(text, t)
  1185. if sim > best_similarity:
  1186. best_similarity = sim
  1187. # 推断标题层级
  1188. inferred_level = _infer_heading_level(para)
  1189. skeletons.append(SkeletonParagraph(
  1190. para_idx=i,
  1191. text=text,
  1192. similarity=best_similarity if template_paras else 1.0,
  1193. inferred_level=inferred_level,
  1194. font_size=para.get("font_size"),
  1195. bold=para.get("bold", False),
  1196. is_heading=(inferred_level > 0),
  1197. ))
  1198. # 统计
  1199. heading_count = sum(1 for s in skeletons if s.is_heading)
  1200. high_sim_count = sum(
  1201. 1 for s in skeletons if s.similarity >= similarity_threshold
  1202. )
  1203. logger.info(
  1204. f"骨架段落提取完成: {len(skeletons)} 个段落, "
  1205. f"{heading_count} 个标题段落, "
  1206. f"{high_sim_count} 个高相似度(≥{similarity_threshold:.0%})骨架段落"
  1207. )
  1208. return skeletons
  1209. def _norm_prefix(s: str) -> str:
  1210. """归一化前缀:数字→#,用于段落前缀索引"""
  1211. return re.sub(r"\d+", "#", (s or "").strip())
  1212. def _read_paragraphs(file_path: str) -> List[Dict]:
  1213. """读取文件的段落列表(含格式信息)
  1214. 支持 PDF(通过 pdfplumber)和 DOCX(通过 python-docx)。
  1215. 返回格式:[{"text": str, "font_size": float|None, "bold": bool}, ...]
  1216. """
  1217. ext = os.path.splitext(file_path)[1].lower()
  1218. paragraphs: List[Dict] = []
  1219. if ext == ".pdf":
  1220. try:
  1221. import pdfplumber
  1222. with pdfplumber.open(file_path) as pdf:
  1223. for page in pdf.pages:
  1224. text = page.extract_text()
  1225. if text:
  1226. for line in text.split("\n"):
  1227. line = line.strip()
  1228. if line:
  1229. paragraphs.append({
  1230. "text": line,
  1231. "font_size": None,
  1232. "bold": False,
  1233. })
  1234. except Exception as e:
  1235. logger.warning(f"PDF 段落读取失败: {e}")
  1236. elif ext in (".docx", ".doc"):
  1237. # 大文件(≥20MB,如 104MB 模板 / 112MB 参考标书)用标准库 ET 读取,
  1238. # 避免 python-docx/lxml 反复解析大文件导致的原生崩溃(0xC0000005)。
  1239. # ET 路径不提供字号/加粗,标题层级推断回退到正则规则。
  1240. if os.path.getsize(file_path) >= 20 * 1024 * 1024:
  1241. try:
  1242. from doc_reader.reader import read_docx_paragraph_texts_et
  1243. for text in read_docx_paragraph_texts_et(file_path):
  1244. if text.strip():
  1245. paragraphs.append({
  1246. "text": text.strip(),
  1247. "font_size": None,
  1248. "bold": False,
  1249. })
  1250. return paragraphs
  1251. except Exception as e:
  1252. logger.warning(f"DOCX 段落读取失败(ET): {e}")
  1253. return paragraphs
  1254. try:
  1255. doc = DocxDocument(file_path)
  1256. for i, para in enumerate(doc.paragraphs):
  1257. text = para.text.strip()
  1258. if not text:
  1259. continue
  1260. bold = False
  1261. runs = para.runs if para.runs else []
  1262. if runs and all(r.bold for r in runs if r.text.strip()):
  1263. bold = True
  1264. # 注意:不要访问 para.style(python-docx 1.2.0 在 Python 3.13 下
  1265. # 遍历样式枚举会崩溃);style 字段下游并未使用,只保留 text/字号/加粗
  1266. paragraphs.append({
  1267. "text": text,
  1268. "font_size": _get_para_font_size(para),
  1269. "bold": bold,
  1270. })
  1271. except Exception as e:
  1272. logger.warning(f"DOCX 段落读取失败: {e}")
  1273. return paragraphs
  1274. def _get_para_font_size(para) -> Optional[float]:
  1275. """获取段落的首个有效字号"""
  1276. for run in (para.runs or []):
  1277. if run.font.size and run.text.strip():
  1278. return run.font.size.pt
  1279. return None
  1280. def _jaccard_similarity(text1: str, text2: str) -> float:
  1281. """计算两个文本的 Jaccard 相似度(基于字符 2-gram)
  1282. 用于段落级别的快速比对,不依赖分词。
  1283. """
  1284. def _bigrams(s: str) -> Set[str]:
  1285. s = s.replace(" ", "").replace("\n", "")
  1286. return {s[i:i + 2] for i in range(len(s) - 1)}
  1287. b1 = _bigrams(text1)
  1288. b2 = _bigrams(text2)
  1289. if not b1 or not b2:
  1290. return 0.0
  1291. intersection = len(b1 & b2)
  1292. union = len(b1 | b2)
  1293. return intersection / union if union > 0 else 0.0
  1294. def _infer_heading_level(para_info: Dict) -> int:
  1295. """根据段落特征推断标题层级
  1296. 推断规则(优先级从高到低):
  1297. 1. 格式特征:字号 + 加粗组合判断
  1298. 2. 内容特征:中文序号模式判断
  1299. 3. 长度特征:短文本更可能是标题
  1300. Returns:
  1301. 0 = 正文, 1 = 章(Heading 1), 2 = 节(Heading 2), 3 = 小节(Heading 3)
  1302. """
  1303. text = para_info.get("text", "").strip()
  1304. font_size = para_info.get("font_size")
  1305. bold = para_info.get("bold", False)
  1306. # 规则 1:由格式推断
  1307. if font_size:
  1308. if font_size >= 16 and bold:
  1309. return 1 # 大字号加粗 = 章标题
  1310. if font_size >= 14 and bold:
  1311. return 2 # 中字号加粗 = 节标题
  1312. if font_size >= 12 and bold:
  1313. return 3 # 小字号加粗 = 小节标题
  1314. # 规则 2:由中文序号模式推断
  1315. if re.match(r"^第[一二三四五六七八九十\d]+章", text):
  1316. return 1
  1317. if re.match(r"^[一二三四五六七八九十]+[、,,]", text) and len(text) < 30:
  1318. return 2
  1319. if re.match(r"^[((][一二三四五六七八九十]+[))]", text) and len(text) < 30:
  1320. return 3
  1321. # 规则 3:由长度和加粗推断(标题通常较短)
  1322. if bold and len(text) < 20:
  1323. return 2
  1324. if bold and len(text) < 40:
  1325. return 3
  1326. return 0
  1327. # ============================================================
  1328. # 4. 批量提取:一步完成全部表格/段落提取
  1329. # ============================================================
  1330. def extract_all(
  1331. template_docx_path: str = "",
  1332. reference_bid_path: str = "",
  1333. ) -> Dict:
  1334. """批量提取:模板表格 + 参考投标骨架段落
  1335. Args:
  1336. template_docx_path: 模板 DOCX 路径
  1337. reference_bid_path: 参考投标文件路径
  1338. Returns:
  1339. {
  1340. "template_tables": List[ExtractedTable],
  1341. "skeleton_paragraphs": List[SkeletonParagraph],
  1342. }
  1343. """
  1344. result: Dict = {
  1345. "template_tables": [],
  1346. "skeleton_paragraphs": [],
  1347. }
  1348. if template_docx_path:
  1349. result["template_tables"] = extract_tables_from_template_docx(template_docx_path)
  1350. if reference_bid_path:
  1351. result["skeleton_paragraphs"] = extract_skeleton_paragraphs(
  1352. reference_path=reference_bid_path,
  1353. template_path=template_docx_path,
  1354. )
  1355. return result
  1356. __all__ = [
  1357. "extract_tables_from_template_docx",
  1358. "extract_skeleton_paragraphs",
  1359. "extract_all",
  1360. ]