| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564 |
- """
- 表格结构提取器(V2 新增)
- 负责:
- 1. 从招标 PDF 提取完整表格(单元格级,含合并信息)→ List[ExtractedTable]
- 2. 从模板 DOCX 提取所有表格结构(含占位符检测)→ List[ExtractedTable]
- 3. 从参考投标文件提取段落信息,用于标题层级推断
- 设计哲学:逻辑提取为主,LLM 按需辅助。
- """
- from __future__ import annotations
- import logging
- import os
- import re
- from typing import Dict, List, Optional, Set, Tuple
- from docx import Document as DocxDocument
- from models import ExtractedTable, TableCell, SkeletonParagraph
- logger = logging.getLogger(__name__)
- # “投标报价分项明细表”在参考投书中不是一张表,而是由
- # “三、投标报价分项明细表”等文字和 明细1..明细8、附表1 多张物理表组成的完整片段。
- # 提取时必须按原文顺序保留整段文字和所有表,写成一个单独的 DOCX 片段。
- _BID_DETAIL_GROUP_NAME = "投标报价分项明细表"
- _BID_DETAIL_ARTIFACT_NAME = f"{_BID_DETAIL_GROUP_NAME}.docx"
- _BID_DETAIL_SECTION_RE = re.compile(
- r"(明细\s*[一二三四五六七八九十百\d]+\s*[::]\s*[^|]{0,80})"
- )
- _BID_DETAIL_APPENDIX_RE = re.compile(
- r"(附表\s*\d*\s*[::]\s*[^|]{0,80})"
- )
- _REFERENCE_ARTIFACT_RE = re.compile(r"^\d{2,}_.*\.docx$", re.IGNORECASE)
- def _bid_detail_section_label(table: ExtractedTable) -> str:
- """从表格标题/说明中取出“明细N:...”或“附表N:...”分节标签。"""
- text = " | ".join(
- value.strip()
- for value in (
- getattr(table, "caption", "") or "",
- getattr(table, "title_hint", "") or "",
- )
- if value and value.strip()
- )
- if not text:
- return ""
- for pattern in (_BID_DETAIL_SECTION_RE, _BID_DETAIL_APPENDIX_RE):
- match = pattern.search(text)
- if match:
- return match.group(1).strip()
- return ""
- def _is_bid_detail_group_start(table: ExtractedTable) -> bool:
- """判断某张参考表是否可作为“投标报价分项明细表”合并序列的起点。"""
- text = " ".join(
- str(getattr(table, attr, "") or "")
- for attr in ("title_hint", "caption")
- )
- return (
- "投标报价分项明细表" in text
- or "报价分项明细表" in text
- or "投标报价分项明细" in text
- )
- def _find_bid_detail_group(
- tables: List[ExtractedTable],
- ) -> Tuple[Optional[int], Optional[int]]:
- """定位“投标报价分项明细表”连续物理表区间。"""
- if not tables:
- return None, None
- start_index = next(
- (
- index
- for index, table in enumerate(tables)
- if _is_bid_detail_group_start(table)
- ),
- None,
- )
- if start_index is None:
- return None, None
- end_index = start_index + 1
- while end_index < len(tables) and _bid_detail_section_label(tables[end_index]):
- end_index += 1
- return start_index, end_index
- def _build_reference_fragment_impl(
- docx_path: str,
- start_idx: int,
- end_idx: int,
- ) -> List[bytes]:
- """流式截取参考 DOCX 正文中一段连续“段落 + 表格”的 OOXML 字节。"""
- import zipfile
- import xml.etree.ElementTree as ET
- w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
- items: List[bytes] = []
- with zipfile.ZipFile(docx_path) as archive:
- with archive.open("word/document.xml") as xml_stream:
- stack: List[str] = []
- body_depth: Optional[int] = None
- child_index = -1
- for event, node in ET.iterparse(
- xml_stream, events=("start", "end")
- ):
- if event == "start":
- stack.append(node.tag)
- if node.tag == f"{w}body":
- body_depth = len(stack)
- continue
- is_body_child = (
- body_depth is not None
- and len(stack) == body_depth + 1
- )
- if is_body_child and node.tag in (f"{w}p", f"{w}tbl"):
- child_index += 1
- if start_idx <= child_index <= end_idx:
- items.append(ET.tostring(node, encoding="utf-8"))
- node.clear()
- if child_index > end_idx:
- break
- if node.tag == f"{w}body":
- body_depth = None
- stack.pop()
- return items
- def _build_reference_fragment(
- docx_path: str,
- start_idx: int,
- end_idx: int,
- ) -> List[bytes]:
- """隔离执行参考 DOCX 片段截取,原生崩溃时安全返回空列表。"""
- result = _run_reference_xml_worker(
- "fragment", docx_path, start_idx, end_idx
- )
- return result if isinstance(result, list) else []
- def _build_bid_detail_fragment(
- tables: List[ExtractedTable],
- docx_path: str,
- ) -> List[ExtractedTable]:
- """把参考投书中“投标报价分项明细表”的文字 + 多张物理表保留为完整片段。"""
- if not tables or not docx_path or not os.path.isfile(docx_path):
- return tables
- start_index, end_index = _find_bid_detail_group(tables)
- if start_index is None or end_index is None:
- return tables
- group = tables[start_index:end_index]
- first = group[0]
- stream = _build_reference_doc_stream(docx_path)
- table_stream_positions = [
- index
- for index, item in enumerate(stream)
- if item.get("kind") == "tbl"
- ]
- if not table_stream_positions:
- return tables
- first_table_idx = int(getattr(first, "para_idx", -1))
- if not (0 <= first_table_idx < len(table_stream_positions)):
- return tables
- first_stream = table_stream_positions[first_table_idx]
- # 从第一张明细表往前找“三、投标报价分项明细表”标题,把它一并纳入片段。
- start_stream = first_stream
- for index in range(first_stream - 1, max(0, first_stream - 10) - 1, -1):
- text = str(stream[index].get("text", "") or "")
- if "投标报价分项明细表" in text or "报价分项明细表" in text:
- start_stream = index
- break
- last_table_idx = int(getattr(group[-1], "para_idx", -1))
- if not (0 <= last_table_idx < len(table_stream_positions)):
- return tables
- last_stream = table_stream_positions[last_table_idx]
- fragment_xml = _build_reference_fragment(
- docx_path, start_stream, last_stream
- )
- if not fragment_xml:
- return tables
- fragment = ExtractedTable(
- table_id=f"{getattr(first, 'table_id', '') or 'REF-BID-DETAIL'}-FRAGMENT",
- source_file=docx_path,
- source_type="reference_docx",
- para_idx=first_table_idx,
- rows=0,
- cols=0,
- cells=[],
- caption=_BID_DETAIL_GROUP_NAME,
- title_hint=_BID_DETAIL_GROUP_NAME,
- table_type="bid_detail_table",
- )
- fragment.is_fragment = True
- fragment.fragment_xml = fragment_xml
- fragment.fragment_source_indices = [
- int(getattr(table, "para_idx", -1)) for table in group
- ]
- logger.info(
- f"参考投书“{_BID_DETAIL_GROUP_NAME}”片段: "
- f"{len(fragment_xml)} 个段落/表格,来源物理表索引 "
- f"{fragment.fragment_source_indices}"
- )
- return [
- *tables[:start_index],
- fragment,
- *tables[end_index:],
- ]
- def _write_fragment_docx(fragment_xml: List[bytes], path: str) -> str:
- """把“文字 + 多张表”片段原样写入单个 DOCX。"""
- os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
- doc = DocxDocument()
- from docx.oxml import parse_xml
- for xml_bytes in fragment_xml or []:
- try:
- element = parse_xml(xml_bytes)
- doc.element.body.insert_element_before(element, "w:sectPr")
- except Exception as exc:
- logger.warning(f"片段元素写入失败,跳过: {exc}")
- doc.save(path)
- return os.path.abspath(path)
- # ============================================================
- # 招标表格分类(供提示词+代码方式提取结果回填分类)
- #
- # 说明:招标 PDF 的表格/函/表提取已统一走 document_parser 的
- # extract_items_from_tender(LLM 识别 + pdf_table_to_docx 结构匹配),
- # 本模块不再提供独立的纯结构提取入口,仅保留模板 DOCX 表格提取、
- # 参考投标骨架段落提取,以及招标表格分类等共享逻辑。
- # ============================================================
- # 招标表格分类关键词(按优先级排序:响应表 > 评分 > 格式函件 > 参考格式)
- _TENDER_TABLE_TYPE_KEYWORDS: Dict[str, List[str]] = {
- "scoring": [
- "评分", "分值", "评审项目", "评审内容", "评标标准",
- "评分办法", "综合评分", "打分",
- ],
- "qualification_response": [
- "资格条件", "资格要求", "资格性审查", "具备条件说明",
- "投标人资格", "符合性审查",
- ],
- "substantive_response": [
- "实质性要求", "实质性响应", "带★", "★号",
- "★", "必须满足", "不可偏离",
- ],
- "bid_form": [
- "投标函", "开标一览表", "法定代表人授权", "法定代表人证明",
- "法定代表人", "投标总价", "授权委托书", "报价表",
- ],
- "reference_format": [
- "类似项目", "项目负责人", "人员配备",
- "项目经理", "简历表", "项目业绩", "业绩",
- ],
- }
- def _classify_tender_table(
- cells: List[List[TableCell]],
- title_hint: str = "",
- ) -> str:
- """基于单元格内容 + 表格上方标题自动分类招标表格类型
- 分类策略:
- 1. 表格标题优先:标题明确含"实质性要求响应表"/"资格条件响应表"时
- 直接判定(表头可能是通用的"项目内容/具备的条件说明(要求)",
- 无法区分两类响应表,必须依赖标题)
- 2. 关键词兜底:评分 > 实质性 > 资格 > 格式函件 > 参考格式 > other
- ("实质性要求"优先级高于"具备的条件说明",避免实质性响应表
- 被资格类关键词抢走——两表表头结构相同)
- """
- all_text = " ".join(
- cell.text for row in cells for cell in row if cell.text.strip()
- )
- hint_text = title_hint.replace(" ", "").replace("\n", "") or ""
- # ---- 1. 标题优先判定 ----
- if hint_text:
- if "实质性要求响应表" in hint_text:
- return "substantive_response"
- if "资格条件响应表" in hint_text:
- return "qualification_response"
- if "客观分评审因素响应情况表" in hint_text:
- return "scoring"
- if "开标一览表" in hint_text:
- return "bid_open_table"
- if "投标报价汇总表" in hint_text or "报价汇总表" in hint_text:
- return "bid_summary_table"
- if "报价明细表" in hint_text or "报价分项明细表" in hint_text:
- return "bid_detail_table"
- if "投标函" in hint_text:
- return "bid_form"
- # ---- 2. 关键词兜底(实质性在资格之前:两表表头结构相同,内容均含
- # "实质性要求/资格条件"字样,先匹配实质性的专属词) ----
- priority_order = [
- "scoring",
- "substantive_response",
- "qualification_response",
- "bid_form",
- "reference_format",
- ]
- for table_type in priority_order:
- for kw in _TENDER_TABLE_TYPE_KEYWORDS.get(table_type, []):
- if kw in all_text:
- return table_type
- return "other"
- # ============================================================
- # 2. 模板 DOCX → ExtractedTable
- # ============================================================
- def extract_tables_from_template_docx(docx_path: str) -> List[ExtractedTable]:
- """从模板 DOCX 中提取所有表格(含占位符标记)
- 使用 python-docx 遍历表格,保留:
- - 原始格式(字号、加粗、对齐)
- - 合并单元格信息
- - %%...%% 占位符检测
- Args:
- docx_path: 模板 DOCX 路径
- Returns:
- ExtractedTable 列表
- """
- if not os.path.exists(docx_path):
- logger.warning(f"模板文件不存在: {docx_path}")
- return []
- doc = DocxDocument(docx_path)
- extracted: List[ExtractedTable] = []
- for table_idx, table in enumerate(doc.tables):
- cells, grid_cols = _extract_docx_table_grid(table)
- rows = len(cells)
- cols = grid_cols
- if rows == 0 or cols == 0:
- continue
- has_placeholders = any(
- re.search(r"%%(.+?)%%", cell.text)
- for row in cells for cell in row
- )
- # 提取表格标题(表格前一两个段落的文本)
- caption = _extract_table_caption_from_xml(doc, table_idx)
- extracted.append(ExtractedTable(
- table_id=f"TEMPLATE-T{len(extracted) + 1:02d}",
- source_file=docx_path,
- source_type="template_docx",
- para_idx=table_idx,
- rows=rows,
- cols=cols,
- cells=cells,
- caption=caption,
- has_placeholders=has_placeholders,
- table_type=_classify_template_table(cells, caption),
- ))
- logger.info(
- f"模板表格提取完成: {len(extracted)} 个表格 "
- f"(来源: {os.path.basename(docx_path)}),"
- f"{sum(1 for t in extracted if t.has_placeholders)} 个含占位符"
- )
- return extracted
- def _extract_docx_table_grid(table) -> Tuple[List[List[TableCell]], int]:
- """从 python-docx Table 提取完整单元格网格(保留合并单元格结构)。
- 原实现直接使用 ``table.rows[i].cells``,python-docx 会把合并单元格
- 在每行重复展开,导致 gridSpan/vMerge 的合并结构丢失(开标一览表
- “报价总价(大写):”跨 2 列、“人民币…元整”跨 3 列被拆开)。
- 本实现按 w:tbl 的 XML 原始结构逐行重建紧凑网格:
- - 普通单元格占 1 个网格列;
- - gridSpan 单元格占用 colspan 个网格列,仅在起始格保留文本,
- 其余网格位置填入空白 continuation 格;
- - vMerge 合并列:起始行记录 rowspan,后续行在该列填入
- is_merged_continuation 空白格(不再重复文本);
- - 返回的行数是物理行数(含仅作为 vMerge 延续的空行),
- grid_cols 为表格网格总列数。
- Returns:
- (cells, grid_cols)
- cells[row][col] 为 TableCell;合并起始格携带 colspan/rowspan,
- 被合并覆盖的网格位置为 blank continuation 格。
- """
- from docx.oxml.ns import qn
- tbl = getattr(table, "_element", None)
- if tbl is None:
- return [], 0
- grid_cols = 0
- grid = tbl.tblGrid
- if grid is not None:
- grid_cols = len(grid.findall(qn("w:gridCol")))
- rows = tbl.findall(qn("w:tr"))
- if not rows:
- return [], grid_cols
- # 先扫一遍所有行,统计每行实际占用的网格列数(取最大值)
- max_cols = grid_cols
- if max_cols <= 0:
- for tr in rows:
- row_cols = 0
- for tc in tr.findall(qn("w:tc")):
- tc_pr = tc.find(qn("w:tcPr"))
- colspan = 1
- if tc_pr is not None:
- gs = tc_pr.find(qn("w:gridSpan"))
- if gs is not None:
- try:
- colspan = max(1, int(gs.get(qn("w:val")) or 1))
- except (TypeError, ValueError):
- colspan = 1
- row_cols += colspan
- max_cols = max(max_cols, row_cols)
- grid_cols = max_cols
- cells: List[List[TableCell]] = []
- # 当前从上一行延续下来的纵向合并:grid_col -> 起始格信息
- active_vmerge: Dict[int, TableCell] = {}
- for tr in rows:
- row_cells: List[TableCell] = [None] * grid_cols
- continued_origins: set = set()
- # 1) 纵向合并延续:被上一行 vMerge 覆盖的网格列先填 continuation 格
- # (真实 XML 行若含 vMerge continue 会再次覆盖同一位置,无冲突;
- # 若某合并未延续,处理后会被移出 active_vmerge)
- for c, origin in active_vmerge.items():
- if c >= grid_cols:
- continue
- for k in range(origin.colspan):
- cc = c + k
- if cc < grid_cols and row_cells[cc] is None:
- row_cells[cc] = TableCell(
- text="",
- row=len(cells),
- col=cc,
- rowspan=1,
- colspan=1,
- is_merged_origin=False,
- is_merged_continuation=True,
- )
- # 2) 处理本行实际 w:tc(放在下一个空网格位置)
- col = 0
- used_vmerge_cols: set = set()
- for tc in tr.findall(qn("w:tc")):
- tc_pr = tc.find(qn("w:tcPr"))
- colspan = 1
- vmerge_val = ""
- if tc_pr is not None:
- gs = tc_pr.find(qn("w:gridSpan"))
- if gs is not None:
- try:
- colspan = max(1, int(gs.get(qn("w:val")) or 1))
- except (TypeError, ValueError):
- colspan = 1
- vm = tc_pr.find(qn("w:vMerge"))
- if vm is not None:
- vmerge_val = vm.get(qn("w:val")) or "continue"
- if vmerge_val == "continue":
- # 延续格必须对应到上一行 active_vmerge 预填的 continuation 格,
- # 不能因该格已被预填而跳到下一列,否则纵向合并会错位。
- target_col = None
- for c in sorted(active_vmerge.keys()):
- if c in used_vmerge_cols or c >= grid_cols:
- continue
- if (
- row_cells[c] is not None
- and row_cells[c].is_merged_continuation
- ):
- target_col = c
- break
- if target_col is None:
- while col < grid_cols and row_cells[col] is not None:
- col += 1
- target_col = col
- if target_col < grid_cols:
- used_vmerge_cols.add(target_col)
- continued_origins.add(target_col)
- if row_cells[target_col] is None:
- row_cells[target_col] = TableCell(
- text="",
- row=len(cells),
- col=target_col,
- rowspan=1,
- colspan=1,
- is_merged_origin=False,
- is_merged_continuation=True,
- )
- col = target_col + colspan
- continue
- while col < grid_cols and row_cells[col] is not None:
- col += 1
- if col >= grid_cols:
- break
- text = "".join(
- node.text or "" for node in tc.iter(qn("w:t"))
- )
- font_size, bold, alignment = _extract_tc_format(tc)
- # 防御:网格列数小于 colspan 和时截断,避免越界
- if col + colspan > grid_cols:
- colspan = max(1, grid_cols - col)
- else:
- is_origin = vmerge_val in ("", "restart")
- # 起始行行号未知:rowspan 先记 1,扫完所有行后回填
- cell = TableCell(
- text=text,
- row=len(cells),
- col=col,
- rowspan=1,
- colspan=colspan,
- is_merged_origin=(colspan > 1 or vmerge_val == "restart"),
- is_merged_continuation=False,
- font_size=font_size,
- bold=bold,
- alignment=alignment,
- )
- for k in range(colspan):
- if k == 0:
- row_cells[col] = cell
- else:
- row_cells[col + k] = TableCell(
- text="",
- row=len(cells),
- col=col + k,
- rowspan=1,
- colspan=1,
- is_merged_origin=False,
- is_merged_continuation=True,
- )
- if vmerge_val == "restart":
- active_vmerge[col] = cell
- col += colspan
- # 3) 本行未延续的纵向合并结束,移出 active_vmerge(后续行不再占位)
- for c in [c for c in active_vmerge if c not in continued_origins]:
- del active_vmerge[c]
- # 补齐行尾未覆盖的网格列(防御性)
- for c in range(grid_cols):
- if row_cells[c] is None:
- row_cells[c] = TableCell(
- text="",
- row=len(cells),
- col=c,
- rowspan=1,
- colspan=1,
- is_merged_origin=False,
- is_merged_continuation=True,
- )
- cells.append(row_cells)
- # 回填 vMerge 起始格的实际行数:按起始格的 colspan 范围统计连续
- # continuation 行数。这样既支持普通纵向合并,也支持“横向+纵向”
- # 混合合并,不把合并类型写死。
- if cells:
- for ri, row in enumerate(cells):
- for ci, cell in enumerate(row):
- if not cell.is_merged_origin or cell.rowspan != 1:
- continue
- colspan = max(1, int(cell.colspan or 1))
- rowspan = 1
- for rr in range(ri + 1, len(cells)):
- all_cont = True
- for cc in range(ci, ci + colspan):
- if cc >= grid_cols:
- all_cont = False
- break
- target = cells[rr][cc]
- if not (target.is_merged_continuation or target.text == ""):
- all_cont = False
- break
- if not all_cont:
- break
- rowspan += 1
- if rowspan > 1:
- cell.rowspan = rowspan
- cell.is_merged_origin = True
- return cells, grid_cols
- def _extract_tc_format(tc) -> Tuple[Optional[float], bool, str]:
- """从 w:tc 提取字号(pt)/加粗/段落对齐,尽力而为,失败返回默认值。"""
- from docx.oxml.ns import qn
- font_size: Optional[float] = None
- bold = False
- alignment = ""
- try:
- for r_node in tc.iter(qn("w:r")):
- r_pr = r_node.find(qn("w:rPr"))
- if r_pr is None:
- continue
- sz = r_pr.find(qn("w:sz"))
- if sz is not None and font_size is None:
- try:
- font_size = int(sz.get(qn("w:val")) or 0) / 2.0
- except (TypeError, ValueError):
- pass
- b_node = r_pr.find(qn("w:b"))
- if b_node is not None:
- val = b_node.get(qn("w:val"))
- if val is None or val in ("1", "true", "on"):
- bold = True
- if not alignment:
- for p_node in tc.iter(qn("w:p")):
- p_pr = p_node.find(qn("w:pPr"))
- if p_pr is None:
- continue
- jc = p_pr.find(qn("w:jc"))
- if jc is not None:
- val = jc.get(qn("w:val")) or ""
- if val in ("left", "center", "right", "both", "distribute"):
- alignment = "center" if val == "distribute" else val
- break
- except Exception:
- pass
- return font_size, bold, alignment
- def _extract_table_caption_from_xml(doc: DocxDocument, table_idx: int) -> str:
- """从文档 XML 结构中提取表格前面的标题/说明文字(段落)"""
- try:
- body = doc.element.body
- table_elements = body.findall(
- "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tbl"
- )
- if table_idx >= len(table_elements):
- return ""
- tbl = table_elements[table_idx]
- prev = tbl.getprevious()
- recent = []
- while prev is not None and len(recent) < 3:
- tag = prev.tag.split("}")[-1] if "}" in prev.tag else prev.tag
- if tag != "p":
- break
- para_text = "".join(
- node.text or ""
- for node in prev.iter()
- if node.tag.endswith("}t") and node.text
- )
- if para_text.strip():
- recent.append(para_text.strip())
- else:
- break
- prev = prev.getprevious()
- caption = " | ".join(reversed(recent)).strip()
- return caption[:300] if caption else ""
- except Exception:
- return ""
- def _classify_template_table(cells: List[List[TableCell]], caption: str) -> str:
- """基于模板表格内容分类"""
- all_text = caption + " " + " ".join(
- cell.text for row in cells for cell in row if cell.text.strip()
- )
- if any(kw in all_text for kw in ["开标一览表"]):
- return "bid_open_table"
- if any(kw in all_text for kw in ["报价汇总表"]):
- return "bid_summary_table"
- if any(kw in all_text for kw in ["报价明细表", "报价分项明细表"]):
- return "bid_detail_table"
- if any(kw in all_text for kw in ["投标函"]):
- return "bid_form"
- if any(kw in all_text for kw in ["资格条件", "资格要求", "资格性"]):
- return "qualification_response"
- if any(kw in all_text for kw in ["实质性要求", "实质性响应", "偏离"]):
- return "substantive_response"
- if any(kw in all_text for kw in ["类似项目", "业绩", "项目负责人", "项目经理"]):
- return "reference_format"
- if any(kw in all_text for kw in ["报价", "一览表", "汇总"]):
- return "bid_form"
- return "other"
- # ============================================================
- # 参考投书表格 LLM 增强提取(V3 新增)
- # 结构提取之外,用 LLM 补充表格名称、所属章节、用途、表头项目信息字段,
- # 使 Step 4/6 在"招标/模板均无对应表"时能按名称从参考投书中取用表格。
- # 处理单元:一张表 + 所在页(跨页表格整表为一个单元)+ 前文上下文,
- # 单元之间并发调用 LLM。
- # ============================================================
- _REFERENCE_TABLE_ENRICH_PROMPT = """你是投标文件表格识别专家。下方是参考投标文件中提取出的若干表格(每个表格给出:
- 编号、表格前后文提示、行列数、前两行内容预览)。请对每个表格输出其补充信息。
- 输出严格 JSON:
- {"tables": [
- {"table_id": "与输入编号一致",
- "name": "表格正式名称(如:开标一览表、投标报价汇总表、资格条件响应表)",
- "chapter": "所属章节(如:第二章:投标报价)",
- "purpose": "用途,如:报价/响应/清单/证明/考核表",
- "header_fields": ["项目名称:...", "项目编号:...", "包号:..."],
- "column_notes": "各列含义简要说明",
- "can_reuse": true,
- "notes": "备注(含需要人工确认的事项)"}
- ]}
- 要求:
- 1. name 必须是标准表格名称,不含序号前缀(如"一、""(二)");
- 2. 表格前后文包含"开标一览表"等标题时,name 取该标题;
- 3. header_fields 提取表格上方的"项目名称:""项目编号:""包号:"等项目信息行(原样);
- 4. 没有把握的字段填空字符串,不得编造;can_reuse 默认 true;
- 5. 只输出 JSON,不输出任何其他文字。"""
- def _build_reference_table_preview(tables: List[ExtractedTable]) -> str:
- """把结构表格压缩成 LLM 可读的预览文本"""
- lines = []
- for idx, t in enumerate(tables):
- caption = (getattr(t, "caption", "") or "").strip()
- title_hint = (getattr(t, "title_hint", "") or "").strip()
- ctx = " / ".join(x for x in (title_hint, caption) if x)
- rows = getattr(t, "rows", 0) or (len(t.cells) if t.cells else 0)
- cols = getattr(t, "cols", 0)
- if not cols and t.cells:
- cols = max(len(r) for r in t.cells) if t.cells else 0
- preview_rows = []
- for r in (t.cells or [])[:2]:
- cells = [c.text.replace("\n", " ")[:16] for c in r]
- preview_rows.append(" | ".join(cells))
- lines.append(
- f"[{idx}] 上下文: {ctx[:120]}\n"
- f" 规格: {rows} 行 x {cols} 列\n"
- f" 预览:\n" + "\n".join(f" {r}" for r in preview_rows)
- )
- return "\n\n".join(lines)
- # 无分页标记的 DOCX,按字符流估算页数(中文文档每页约 1000 字符)
- _CHARS_PER_PAGE_ESTIMATE = 1000
- def _build_reference_doc_stream(docx_path: str) -> List[dict]:
- """在隔离子进程中构建参考 DOCX 正文流。
- 即使 ``ElementTree.iterparse`` 使用流式读取,Windows 上同一主进程先后
- 加载 PDF/ONNX/python-docx 后,Expat 仍可能直接触发 0xC0000005,无法由
- Python ``try/except`` 捕获。隔离后原生崩溃只损失可选的表格上下文,不会
- 终止 Step6;失败时返回空流,LLM 仍可依据表格标题和单元格内容识别。
- """
- result = _run_reference_xml_worker("stream", docx_path)
- return result if isinstance(result, list) else []
- def _build_reference_doc_stream_impl(docx_path: str) -> List[dict]:
- """用标准库 ET 流式解析 document.xml,按正文顺序输出段落/表格流。
- 返回 [{kind: "p"|"tbl", text: str, char_start: int, char_end: int}, ...]
- - 段落:拼接该段全部 w:t 文本;
- - 表格:拼接全部单元格文本(跨页表格仍是一个 w:tbl 元素,天然完整)。
- char_start/char_end 用于估算表格所在页。
- """
- import zipfile
- import xml.etree.ElementTree as ET
- w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
- stream = []
- pos = 0
- # 184 MB 级参考投书的 document.xml 解压后可能达到数百 MB。
- # ``z.read`` + ``ET.fromstring`` 会同时保留 XML bytes 和完整元素树,
- # Windows/PyCharm 下可能在 Python 抛出 MemoryError 前先由 Expat 触发
- # 0xC0000005。改用 ZipExtFile + iterparse,只保留当前 body 直属块。
- with zipfile.ZipFile(docx_path) as z:
- with z.open("word/document.xml") as xml_stream:
- stack = []
- body_depth = None
- for event, node in ET.iterparse(
- xml_stream, events=("start", "end")
- ):
- if event == "start":
- stack.append(node.tag)
- if node.tag == f"{w}body":
- body_depth = len(stack)
- continue
- is_body_child = (
- body_depth is not None
- and len(stack) == body_depth + 1
- )
- if is_body_child and node.tag in (f"{w}p", f"{w}tbl"):
- texts = [t.text or "" for t in node.iter(f"{w}t")]
- if node.tag == f"{w}p":
- text = "".join(texts).strip()
- kind = "p"
- span = len(text)
- else:
- text = " ".join(
- value.strip() for value in texts if value.strip()
- )
- kind = "tbl"
- span = max(len(text), 1)
- stream.append({
- "kind": kind,
- "text": text,
- "char_start": pos,
- "char_end": pos + span,
- })
- pos += span + 1
- node.clear()
- if node.tag == f"{w}body":
- body_depth = None
- stack.pop()
- return stream
- def _run_reference_xml_worker(
- mode: str,
- docx_path: str,
- *extra_args,
- ):
- """隔离执行大型 DOCX XML 解析;原生崩溃时安全返回 ``None``。"""
- import pickle
- import subprocess
- import sys
- import tempfile
- worker = os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- "_reference_xml_worker.py",
- )
- fd, result_path = tempfile.mkstemp(prefix="reference_xml_", suffix=".pkl")
- os.close(fd)
- try:
- command = [
- sys.executable,
- worker,
- mode,
- os.path.abspath(docx_path),
- result_path,
- *[str(arg) for arg in extra_args],
- ]
- try:
- completed = subprocess.run(
- command,
- capture_output=True,
- timeout=900,
- )
- except subprocess.TimeoutExpired:
- logger.warning(f"参考 DOCX XML 子进程超时: mode={mode}")
- return None
- if completed.returncode != 0:
- logger.warning(
- "参考 DOCX XML 子进程异常,使用安全降级: "
- f"mode={mode}, rc={completed.returncode}"
- )
- return None
- if not os.path.isfile(result_path) or os.path.getsize(result_path) == 0:
- logger.warning(f"参考 DOCX XML 子进程未产生结果: mode={mode}")
- return None
- with open(result_path, "rb") as file:
- return pickle.load(file)
- except Exception as exc:
- logger.warning(f"参考 DOCX XML 隔离解析失败: mode={mode}, {exc}")
- return None
- finally:
- try:
- os.unlink(result_path)
- except OSError:
- pass
- def _estimate_page_range(char_start: int, char_end: int) -> Tuple[int, int]:
- """按字符流估算页码范围(近似值,供人工定位用)"""
- p1 = char_start // _CHARS_PER_PAGE_ESTIMATE + 1
- p2 = max(p1, char_end // _CHARS_PER_PAGE_ESTIMATE + 1)
- return p1, p2
- def _collect_table_context(
- stream: List[dict],
- tbl_idx: int,
- ) -> Tuple[int, str, List[str]]:
- """收集表格前文上下文(所属章节标题、项目信息行)与项目信息字段行"""
- # 找到第 tbl_idx 张表格在流中的位置
- seen = -1
- pos = -1
- for i, item in enumerate(stream):
- if item["kind"] == "tbl":
- seen += 1
- if seen == tbl_idx:
- pos = i
- break
- if pos < 0:
- return -1, "", []
- context_lines = []
- header_fields = []
- for j in range(pos - 1, max(-1, pos - 6), -1):
- item = stream[j]
- if item["kind"] != "p":
- continue
- text = item["text"]
- if not text:
- continue
- context_lines.insert(0, text)
- if re.match(r"^(项目名称|项目编号|招标编号|招标项目编号|包号|包件号|投标人名称)[::]", text):
- header_fields.append(text)
- # 遇到章/节标题即停止向上收集
- if re.match(r"^第\s*[一二三四五六七八九十百\d]+\s*章", text) or \
- re.match(r"^[一二三四五六七八九十百]+、", text):
- break
- return pos, "\n".join(context_lines[:5]), header_fields
- _REFERENCE_TABLE_UNIT_PROMPT = """你是投标文件表格识别专家。下面是一张从参考投标文件中提取的表格(含
- 文档页码估算、前文上下文、表格行列数与内容预览)。请输出该表格的补充信息。
- 输出严格 JSON:
- {"name": "表格正式名称(如:开标一览表、投标报价汇总表、资格条件响应表,不含序号前缀)",
- "chapter": "所属章节(如:第二章:投标报价)",
- "purpose": "用途,如:报价/响应/清单/证明/考核表",
- "header_fields": ["项目名称:...", "项目编号:...", "包号:..."],
- "column_notes": "各列含义简要说明",
- "can_reuse": true,
- "notes": "备注(含需要人工确认的事项)"}
- 要求:
- 1. name 优先取前文上下文中的表格标题(如"一、开标一览表"→"开标一览表");
- 2. header_fields 输出表格上方的"项目名称:""项目编号:""包号:"等项目信息行(原样);
- 3. 没有把握的字段填空字符串,不得编造;can_reuse 默认 true;
- 4. 只输出 JSON,不输出任何其他文字。"""
- def _llm_enrich_one_table(
- tbl_idx: int,
- table: ExtractedTable,
- docx_path: str,
- stream: List[dict],
- ) -> Optional[dict]:
- """单个表格单元的 LLM 增强(供线程池并发调用)"""
- from llm_client import LLMClient
- stream_pos, context, header_fields = _collect_table_context(stream, tbl_idx)
- rows = len(table.cells) if table.cells else table.rows
- cols = table.cols
- if not cols and table.cells:
- cols = max(len(r) for r in table.cells) if table.cells else 0
- if stream_pos >= 0:
- p1, p2 = _estimate_page_range(
- stream[stream_pos]["char_start"],
- stream[stream_pos]["char_end"],
- )
- else:
- p1, p2 = 1, 1
- # 预览:前 8 行 + 行数(跨页表格整表为单元,行数完整)
- preview_rows = []
- for r in (table.cells or [])[:8]:
- preview_rows.append(
- " | ".join(c.text.replace("\n", " ")[:20] for c in r)
- )
- if rows > 8:
- preview_rows.append(f"...(共 {rows} 行)")
- user_prompt = (
- f"## 表格单元 {tbl_idx + 1}\n"
- f"文档页码(估算): 第 {p1}~{p2} 页\n"
- f"前文上下文:\n{context or '(无)'}\n"
- f"规格: {rows} 行 x {cols} 列\n"
- f"内容预览:\n" + "\n".join(f" {r}" for r in preview_rows)
- )
- llm = LLMClient()
- result = llm.extract_json(
- system_prompt=_REFERENCE_TABLE_UNIT_PROMPT,
- user_prompt=user_prompt,
- max_tokens=4096,
- )
- if not isinstance(result, dict):
- return None
- if not header_fields and isinstance(result.get("header_fields"), list):
- # 用前文扫描到的项目信息行兜底
- result["header_fields"] = header_fields or result["header_fields"]
- return result
- def enrich_reference_tables_with_llm(
- tables: List[ExtractedTable],
- llm=None,
- docx_path: str = "",
- ) -> List[ExtractedTable]:
- """用 LLM 补充参考投书表格的名称/所属章节/用途/表头项目信息字段。
- LLM 失败时原样返回结构表格(不影响主流程)。
- """
- if not tables:
- return tables
- if not tables:
- return tables
- # 构建文档流(页码估算 + 前文上下文)
- stream = _build_reference_doc_stream(docx_path) if docx_path else []
- if len(stream) < len(tables):
- # 流缺失时退化为简单估算
- stream = [
- {"kind": "tbl", "char_start": i * 1000, "char_end": (i + 1) * 1000}
- for i in range(len(tables))
- ]
- from concurrent.futures import ThreadPoolExecutor, as_completed
- max_workers = min(5, len(tables))
- metas = {}
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- futures = {
- executor.submit(
- _llm_enrich_one_table, idx, table, docx_path, stream
- ): idx
- for idx, table in enumerate(tables)
- }
- for future in as_completed(futures):
- idx = futures[future]
- try:
- result = future.result()
- except Exception as e:
- logger.warning(
- f"参考投书表格 LLM 增强 [{idx}] 失败(保留结构表格): {e}"
- )
- continue
- if isinstance(result, dict):
- metas[idx] = result
- if not metas:
- logger.warning("参考投书表格 LLM 增强全部失败,保留结构表格")
- return tables
- updated = 0
- for idx, t in enumerate(tables):
- meta = metas.get(idx)
- if not meta:
- continue
- name = (meta.get("name") or "").strip()
- chapter = (meta.get("chapter") or "").strip()
- header_fields = meta.get("header_fields") or []
- if name:
- t.title_hint = name
- if not t.caption:
- t.caption = name
- # 用表名重新分类(开标一览表/报价汇总表/报价明细表等)
- new_type = _classify_tender_table(
- t.cells or [], title_hint=name
- )
- if new_type != "other":
- t.table_type = new_type
- if chapter and chapter not in (t.caption or ""):
- t.caption = f"{chapter} {t.caption or name}".strip()
- if isinstance(header_fields, list):
- t.header_fields = [
- str(h).strip() for h in header_fields if str(h).strip()
- ]
- updated += 1
- if updated:
- logger.info(f"参考投书表格 LLM 增强: {updated} 张表格补充名称/章节/字段信息")
- return tables
- def write_reference_tables_to_docx(
- tables: List[ExtractedTable],
- output_dir: str,
- ) -> int:
- """把参考投书表格逐张写成独立 DOCX,便于人工核对。
- 参考投书可能包含横纵交错的大型合并表。使用 python-docx 逐格
- ``table.cell(...).merge(...)`` 重建这类表时,会反复展开逻辑网格;在
- Windows/PyCharm 中已观察到 lxml 在垃圾回收阶段直接以 0xC0000005
- 崩溃。这里优先流式读取来源 DOCX 的原生 ``w:tbl`` XML 并整表克隆,
- 同时也能保留 tblPr/tblGrid、单元格宽度、边框和合并属性。
- """
- if not tables or not output_dir:
- return 0
- os.makedirs(output_dir, exist_ok=True)
- _clean_reference_table_artifacts(output_dir)
- source_table_xml: Dict[str, List[bytes]] = {}
- for source_file in {
- os.path.abspath(t.source_file)
- for t in tables
- if getattr(t, "source_file", "")
- and not getattr(t, "is_fragment", False)
- and os.path.isfile(t.source_file)
- }:
- try:
- source_table_xml[source_file] = _stream_source_table_xml(source_file)
- except Exception as exc:
- logger.warning(
- f"参考投书原生表格 XML 读取失败,将使用无合并兜底: "
- f"{os.path.basename(source_file)} ({exc})"
- )
- written = 0
- for idx, t in enumerate(tables, 1):
- name = (getattr(t, "title_hint", "") or getattr(t, "caption", "") or f"表格{idx}").strip()
- if bool(getattr(t, "is_fragment", False)):
- path = os.path.join(output_dir, _BID_DETAIL_ARTIFACT_NAME)
- _write_fragment_docx(
- list(getattr(t, "fragment_xml", []) or []), path
- )
- t.fragment_docx_path = os.path.abspath(path)
- t.artifact_path = os.path.abspath(path)
- written += 1
- logger.info(
- f" 参考投书“{_BID_DETAIL_GROUP_NAME}”片段已落盘: {path}"
- )
- continue
- else:
- safe = re.sub(r'[<>:"/\\|?*]+', "_", name)[:50] or f"table_{idx}"
- path = os.path.join(output_dir, f"{idx:02d}_{safe}.docx")
- doc = DocxDocument()
- doc.add_heading(f"{idx}. {name}", level=1)
- if getattr(t, "caption", ""):
- doc.add_paragraph(str(t.caption))
- source_file = os.path.abspath(t.source_file) if t.source_file else ""
- source_index = int(getattr(t, "para_idx", -1))
- xml_items = source_table_xml.get(source_file, [])
- if 0 <= source_index < len(xml_items):
- from docx.oxml import parse_xml
- source_tbl = parse_xml(xml_items[source_index])
- doc.element.body.insert_element_before(source_tbl, "w:sectPr")
- else:
- _append_unmerged_reference_table(doc, t)
- doc.save(path)
- t.artifact_path = os.path.abspath(path)
- written += 1
- logger.info(f"参考投书表格已落盘: {written} 个 DOCX -> {output_dir}")
- return written
- def _clean_reference_table_artifacts(output_dir: str) -> int:
- """清理参考表旧产物,避免重跑后同序号不同命名文件重复累积。"""
- if not output_dir or not os.path.isdir(output_dir):
- return 0
- removed = 0
- for name in os.listdir(output_dir):
- if name == _BID_DETAIL_ARTIFACT_NAME or _REFERENCE_ARTIFACT_RE.match(name):
- path = os.path.join(output_dir, name)
- if os.path.isfile(path):
- try:
- os.remove(path)
- removed += 1
- except OSError as exc:
- logger.warning(f"清理参考表旧产物失败: {name} ({exc})")
- if removed:
- logger.info(f"已清理参考表旧产物: {removed} 个文件 -> {output_dir}")
- return removed
- def _stream_source_table_xml(docx_path: str) -> List[bytes]:
- """隔离读取来源表格 XML;失败时由调用方使用无合并安全兜底。"""
- result = _run_reference_xml_worker("tables", docx_path)
- return result if isinstance(result, list) else []
- def _stream_source_table_xml_impl(docx_path: str) -> List[bytes]:
- """以恒定内存读取正文直属表格 XML,避免再次构造完整 lxml 文档树。"""
- import zipfile
- import xml.etree.ElementTree as ET
- w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
- items: List[bytes] = []
- with zipfile.ZipFile(docx_path) as archive:
- with archive.open("word/document.xml") as xml_stream:
- stack: List[str] = []
- body_depth: Optional[int] = None
- for event, node in ET.iterparse(xml_stream, events=("start", "end")):
- if event == "start":
- stack.append(node.tag)
- if node.tag == f"{w}body":
- body_depth = len(stack)
- continue
- if (
- body_depth is not None
- and len(stack) == body_depth + 1
- and node.tag == f"{w}tbl"
- ):
- items.append(ET.tostring(node, encoding="utf-8"))
- node.clear()
- if node.tag == f"{w}body":
- body_depth = None
- stack.pop()
- return items
- def _append_unmerged_reference_table(doc, table_data: ExtractedTable) -> None:
- """来源 XML 不可用时的安全兜底:保留文字网格,不执行危险的合并。"""
- rows = getattr(table_data, "cells", None) or []
- if not rows:
- doc.add_paragraph("(无表格数据)")
- return
- cols = getattr(table_data, "cols", 0) or max(len(row) for row in rows)
- table = doc.add_table(rows=len(rows), cols=cols)
- for ri, row in enumerate(rows):
- for ci, cell_data in enumerate(row[:cols]):
- if isinstance(cell_data, TableCell):
- if cell_data.is_merged_continuation:
- continue
- text = cell_data.text or ""
- else:
- text = str(cell_data)
- table.rows[ri].cells[ci].text = text
- def extract_reference_tables_with_llm(
- docx_path: str,
- output_dir: str = "",
- ) -> List[ExtractedTable]:
- """参考投书表格统一提取入口:结构提取 + LLM 补充名称/章节/用途/表头字段。
- 提供 output_dir 时,把每张表写成独立 DOCX 便于人工核对。
- """
- tables = extract_tables_from_template_docx(docx_path)
- if not tables:
- return tables
- for table in tables:
- table.source_type = "reference_docx"
- tables = enrich_reference_tables_with_llm(tables, docx_path=docx_path)
- tables = _build_bid_detail_fragment(tables, docx_path)
- if output_dir:
- write_reference_tables_to_docx(tables, output_dir)
- return tables
- # ============================================================
- # 3. 参考投标文件 → 段落 diff → 骨架段落
- # ============================================================
- def extract_skeleton_paragraphs(
- reference_path: str,
- template_path: str = "",
- similarity_threshold: float = 0.75,
- reference_texts=None,
- template_texts=None,
- ) -> List[SkeletonParagraph]:
- """从参考投标文件中提取骨架段落(用于推断模板标题层级)
- 工作流程:
- 1. 读取参考投标文件的所有段落
- 2. 如果提供了模板路径,与模板段落做 diff 比对
- 3. 相似度 ≥ similarity_threshold 的段落标记为"骨架段落"
- 4. 根据字号和格式推断标题层级(Heading 1/2/3)
- 骨架段落的含义:
- 模板缺少 Word 标题样式(Heading 1/2/3),但参考投标文件中有。
- 通过比对两者相似的段落,可以反推模板中各段落应该使用什么层级的标题。
- Args:
- reference_path: 参考投标文件路径(PDF/DOCX)
- template_path: 模板 DOCX 路径(可选,用于 diff 比对)
- similarity_threshold: 相似度阈值(默认 0.75,即 75% 相似 = 骨架段落)
- reference_texts: 参考文件正文段落文本(可选,传入则不再重复打开大文件)
- template_texts: 模板正文段落文本(可选,传入则不再重复打开大文件)
- Returns:
- SkeletonParagraph 列表
- """
- if not os.path.exists(reference_path):
- logger.warning(f"参考投标文件不存在: {reference_path}")
- return []
- # 读取参考文件段落(优先使用调用方已读好的文本,避免重复打开大文件)
- if reference_texts is not None:
- ref_paras = [
- {"text": t.strip(), "font_size": None, "bold": False}
- for t in reference_texts
- if t.strip()
- ]
- else:
- ref_paras = _read_paragraphs(reference_path)
- if not ref_paras:
- return []
- # 读取模板段落(如提供了模板路径)
- template_paras: List[str] = []
- if template_path and os.path.exists(template_path):
- if template_texts is not None:
- template_paras = [
- {"text": t.strip(), "font_size": None, "bold": False}
- for t in template_texts
- if t.strip()
- ]
- else:
- template_paras = _read_paragraphs(template_path)
- # 模板段落前缀索引(与 template_parser._diff_skeleton 同策略):
- # 只对同前缀段落做相似度比对,避免 O(N×M) 全量比对——既大幅提速,
- # 也避免海量字符串分配触发大文件解析后的内存损坏(0xC0000005)。
- template_index: Dict[str, List[str]] = {}
- for tp in template_paras:
- t = tp["text"].strip()
- if t and len(t) >= 4:
- key = _norm_prefix(t[:20])
- template_index.setdefault(key, []).append(t)
- skeletons: List[SkeletonParagraph] = []
- for i, para in enumerate(ref_paras):
- text = para["text"].strip()
- if not text or len(text) < 4:
- continue # 跳过太短的段落
- # 与模板段落做 diff(如果提供了模板)
- best_similarity = 0.0
- if template_paras:
- key = _norm_prefix(text[:20])
- for t in template_index.get(key, []):
- sim = _jaccard_similarity(text, t)
- if sim > best_similarity:
- best_similarity = sim
- # 推断标题层级
- inferred_level = _infer_heading_level(para)
- skeletons.append(SkeletonParagraph(
- para_idx=i,
- text=text,
- similarity=best_similarity if template_paras else 1.0,
- inferred_level=inferred_level,
- font_size=para.get("font_size"),
- bold=para.get("bold", False),
- is_heading=(inferred_level > 0),
- ))
- # 统计
- heading_count = sum(1 for s in skeletons if s.is_heading)
- high_sim_count = sum(
- 1 for s in skeletons if s.similarity >= similarity_threshold
- )
- logger.info(
- f"骨架段落提取完成: {len(skeletons)} 个段落, "
- f"{heading_count} 个标题段落, "
- f"{high_sim_count} 个高相似度(≥{similarity_threshold:.0%})骨架段落"
- )
- return skeletons
- def _norm_prefix(s: str) -> str:
- """归一化前缀:数字→#,用于段落前缀索引"""
- return re.sub(r"\d+", "#", (s or "").strip())
- def _read_paragraphs(file_path: str) -> List[Dict]:
- """读取文件的段落列表(含格式信息)
- 支持 PDF(通过 pdfplumber)和 DOCX(通过 python-docx)。
- 返回格式:[{"text": str, "font_size": float|None, "bold": bool}, ...]
- """
- ext = os.path.splitext(file_path)[1].lower()
- paragraphs: List[Dict] = []
- if ext == ".pdf":
- try:
- import pdfplumber
- with pdfplumber.open(file_path) as pdf:
- for page in pdf.pages:
- text = page.extract_text()
- if text:
- for line in text.split("\n"):
- line = line.strip()
- if line:
- paragraphs.append({
- "text": line,
- "font_size": None,
- "bold": False,
- })
- except Exception as e:
- logger.warning(f"PDF 段落读取失败: {e}")
- elif ext in (".docx", ".doc"):
- # 大文件(≥20MB,如 104MB 模板 / 112MB 参考标书)用标准库 ET 读取,
- # 避免 python-docx/lxml 反复解析大文件导致的原生崩溃(0xC0000005)。
- # ET 路径不提供字号/加粗,标题层级推断回退到正则规则。
- if os.path.getsize(file_path) >= 20 * 1024 * 1024:
- try:
- from doc_reader.reader import read_docx_paragraph_texts_et
- for text in read_docx_paragraph_texts_et(file_path):
- if text.strip():
- paragraphs.append({
- "text": text.strip(),
- "font_size": None,
- "bold": False,
- })
- return paragraphs
- except Exception as e:
- logger.warning(f"DOCX 段落读取失败(ET): {e}")
- return paragraphs
- try:
- doc = DocxDocument(file_path)
- for i, para in enumerate(doc.paragraphs):
- text = para.text.strip()
- if not text:
- continue
- bold = False
- runs = para.runs if para.runs else []
- if runs and all(r.bold for r in runs if r.text.strip()):
- bold = True
- # 注意:不要访问 para.style(python-docx 1.2.0 在 Python 3.13 下
- # 遍历样式枚举会崩溃);style 字段下游并未使用,只保留 text/字号/加粗
- paragraphs.append({
- "text": text,
- "font_size": _get_para_font_size(para),
- "bold": bold,
- })
- except Exception as e:
- logger.warning(f"DOCX 段落读取失败: {e}")
- return paragraphs
- def _get_para_font_size(para) -> Optional[float]:
- """获取段落的首个有效字号"""
- for run in (para.runs or []):
- if run.font.size and run.text.strip():
- return run.font.size.pt
- return None
- def _jaccard_similarity(text1: str, text2: str) -> float:
- """计算两个文本的 Jaccard 相似度(基于字符 2-gram)
- 用于段落级别的快速比对,不依赖分词。
- """
- def _bigrams(s: str) -> Set[str]:
- s = s.replace(" ", "").replace("\n", "")
- return {s[i:i + 2] for i in range(len(s) - 1)}
- b1 = _bigrams(text1)
- b2 = _bigrams(text2)
- if not b1 or not b2:
- return 0.0
- intersection = len(b1 & b2)
- union = len(b1 | b2)
- return intersection / union if union > 0 else 0.0
- def _infer_heading_level(para_info: Dict) -> int:
- """根据段落特征推断标题层级
- 推断规则(优先级从高到低):
- 1. 格式特征:字号 + 加粗组合判断
- 2. 内容特征:中文序号模式判断
- 3. 长度特征:短文本更可能是标题
- Returns:
- 0 = 正文, 1 = 章(Heading 1), 2 = 节(Heading 2), 3 = 小节(Heading 3)
- """
- text = para_info.get("text", "").strip()
- font_size = para_info.get("font_size")
- bold = para_info.get("bold", False)
- # 规则 1:由格式推断
- if font_size:
- if font_size >= 16 and bold:
- return 1 # 大字号加粗 = 章标题
- if font_size >= 14 and bold:
- return 2 # 中字号加粗 = 节标题
- if font_size >= 12 and bold:
- return 3 # 小字号加粗 = 小节标题
- # 规则 2:由中文序号模式推断
- if re.match(r"^第[一二三四五六七八九十\d]+章", text):
- return 1
- if re.match(r"^[一二三四五六七八九十]+[、,,]", text) and len(text) < 30:
- return 2
- if re.match(r"^[((][一二三四五六七八九十]+[))]", text) and len(text) < 30:
- return 3
- # 规则 3:由长度和加粗推断(标题通常较短)
- if bold and len(text) < 20:
- return 2
- if bold and len(text) < 40:
- return 3
- return 0
- # ============================================================
- # 4. 批量提取:一步完成全部表格/段落提取
- # ============================================================
- def extract_all(
- template_docx_path: str = "",
- reference_bid_path: str = "",
- ) -> Dict:
- """批量提取:模板表格 + 参考投标骨架段落
- Args:
- template_docx_path: 模板 DOCX 路径
- reference_bid_path: 参考投标文件路径
- Returns:
- {
- "template_tables": List[ExtractedTable],
- "skeleton_paragraphs": List[SkeletonParagraph],
- }
- """
- result: Dict = {
- "template_tables": [],
- "skeleton_paragraphs": [],
- }
- if template_docx_path:
- result["template_tables"] = extract_tables_from_template_docx(template_docx_path)
- if reference_bid_path:
- result["skeleton_paragraphs"] = extract_skeleton_paragraphs(
- reference_path=reference_bid_path,
- template_path=template_docx_path,
- )
- return result
- __all__ = [
- "extract_tables_from_template_docx",
- "extract_skeleton_paragraphs",
- "extract_all",
- ]
|