template_parser.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990
  1. """
  2. 模板 DOCX 解析器
  3. 策略:参考标书 diff + 模板自身结构标记
  4. 1. 与参考标书逐段模糊匹配 → 区分骨架段落(模板与参考一致的)和可变段落(需填充的)
  5. 2. 在骨架段落中查找"第X章"、分区标题等结构标记 → 划分 section
  6. 3. 扫描占位符和表格
  7. 用法:
  8. ts = parse_template(
  9. template_path="src/templates/申勤投标模板.docx",
  10. reference_bid_path="test_data/.../投标文件.docx",
  11. )
  12. """
  13. from __future__ import annotations
  14. import logging
  15. import os
  16. import re
  17. from difflib import SequenceMatcher
  18. from typing import Dict, List, Set, Tuple
  19. from docx import Document as DocxDocument
  20. from models import (
  21. TemplateStructure,
  22. TemplateSection,
  23. TemplatePlaceholder,
  24. TemplateTable,
  25. )
  26. logger = logging.getLogger(__name__)
  27. DEFAULT_TEMPLATE = "templates/申勤投标模板.docx"
  28. _PLACEHOLDER_PATTERN = re.compile(r"%%(.+?)%%")
  29. SKELETON_SIMILARITY = 0.75
  30. # 模板自身结构标记
  31. _PART_MARKERS = {"商务部分", "技术部分", "附件", "附录"}
  32. _CHAPTER_PATTERN = re.compile(r"^第[一二三四五六七八九十\d]+章[::]?\s*(.*)")
  33. _HEADING_STYLE_PATTERN = re.compile(r"^(?:heading|标题)\s*([1-4])$", re.I)
  34. def heading_level_from_style(value: str) -> int:
  35. """读取 Word Heading 1-4 的层级语义,不读取或复制其视觉格式。"""
  36. match = _HEADING_STYLE_PATTERN.match((value or "").strip())
  37. return int(match.group(1)) if match else 0
  38. def paragraph_heading_level(para) -> int:
  39. """返回 python-docx 段落的显式标题级别;未标注返回 0。"""
  40. values = []
  41. try:
  42. values.extend([para.style.name or "", para.style.style_id or ""])
  43. except Exception:
  44. pass
  45. for value in values:
  46. level = heading_level_from_style(value)
  47. if level:
  48. return level
  49. return 0
  50. def parse_template(
  51. template_path: str = "",
  52. reference_bid_path: str = "",
  53. reference_texts=None,
  54. ) -> TemplateStructure:
  55. """解析模板 DOCX
  56. Args:
  57. template_path: 模板路径(空则用默认)
  58. reference_bid_path: 参考投标文件(用于 diff 识别骨架)
  59. reference_texts: 参考文件正文段落文本(可选,传入则不再重复打开参考文件)
  60. """
  61. if not template_path:
  62. template_path = _resolve_default_template()
  63. if not os.path.exists(template_path):
  64. raise FileNotFoundError(f"模板文件不存在: {template_path}")
  65. doc = DocxDocument(template_path)
  66. all_paras = list(doc.paragraphs)
  67. # ---- 1. Diff 识别骨架/可变 ----
  68. skeleton: Set[int] = set()
  69. if reference_bid_path and os.path.exists(reference_bid_path):
  70. if reference_texts is None:
  71. from doc_reader.reader import read_docx_paragraph_texts_et
  72. reference_texts = read_docx_paragraph_texts_et(reference_bid_path)
  73. skeleton, _ = _diff_skeleton(doc, reference_texts)
  74. logger.info(f"参考标书 diff: {len(skeleton)} 骨架段落")
  75. else:
  76. logger.info("无参考标书,所有段落视为可变")
  77. # ---- 2. 在骨架段落中找章节边界 ----
  78. boundaries = _find_boundaries_in_skeleton(all_paras, skeleton)
  79. logger.info(f"章节边界: {len(boundaries)} 个")
  80. # ---- 3. 扫描占位符 ----
  81. placeholders = _scan_placeholders(doc)
  82. # ---- 4. 扫描表格 ----
  83. tables, table_para_idx = _scan_tables_fast(doc)
  84. # ---- 5. 按边界划分 section ----
  85. sections = _build_sections(
  86. all_paras, boundaries, placeholders, tables,
  87. table_para_idx, skeleton,
  88. )
  89. return TemplateStructure(
  90. file_path=os.path.abspath(template_path),
  91. sections=sections,
  92. total_paragraphs=len(all_paras),
  93. total_tables=len(doc.tables),
  94. )
  95. def _resolve_default_template() -> str:
  96. return os.path.join(
  97. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  98. DEFAULT_TEMPLATE,
  99. )
  100. # ============================================================
  101. # Diff:识别骨架段落
  102. # ============================================================
  103. def _diff_skeleton(
  104. template_doc: DocxDocument,
  105. reference_texts: List[str],
  106. ) -> Tuple[Set[int], Set[int]]:
  107. """逐段模糊匹配,划分骨架和可变段落"""
  108. # 构建参考段落索引
  109. ref_index: Dict[str, List[str]] = {}
  110. for text in reference_texts:
  111. t = _clean(text)
  112. if t and len(t) >= 8:
  113. key = _norm_prefix(t[:20])
  114. ref_index.setdefault(key, []).append(t)
  115. skeleton = set()
  116. fillable_paras = set()
  117. for i, p in enumerate(template_doc.paragraphs):
  118. tt = _clean(p.text)
  119. if not tt or len(tt) < 4:
  120. continue
  121. key = _norm_prefix(tt[:20])
  122. candidates = ref_index.get(key, [])
  123. best = 0.0
  124. for rt in candidates:
  125. score = SequenceMatcher(None, _norm(tt), _norm(rt)).ratio()
  126. if score > best:
  127. best = score
  128. if best >= SKELETON_SIMILARITY:
  129. skeleton.add(i)
  130. else:
  131. fillable_paras.add(i)
  132. return skeleton, fillable_paras
  133. def _clean(text: str) -> str:
  134. """清洗段落文本"""
  135. return re.sub(r'\d{1,3}$', '', text.strip()).strip()
  136. def _norm(s: str) -> str:
  137. """归一化:数字→#"""
  138. return re.sub(r'\d+', '#', s)
  139. def _norm_prefix(s: str) -> str:
  140. """归一化前缀"""
  141. return re.sub(r'\d+', '#', s)
  142. # ============================================================
  143. # 章节边界识别(骨架中查找)
  144. # ============================================================
  145. def _find_boundaries_in_skeleton(
  146. all_paras: list,
  147. skeleton: Set[int],
  148. ) -> List[Tuple[int, str]]:
  149. """在模板段落中查找章节边界
  150. 优先从骨架段落中查找(与参考标书一致的标题),
  151. 骨架中找不到时才从全部段落中补充。
  152. Returns:
  153. [(para_idx, title_text), ...] 按位置排序
  154. """
  155. boundaries: List[Tuple[int, str]] = []
  156. seen_positions: Set[int] = set()
  157. # 第一遍:骨架段落(与参考标书一致的章节标题)
  158. search_skeleton = sorted(skeleton) if skeleton else []
  159. for i in search_skeleton:
  160. if i >= len(all_paras):
  161. continue
  162. text = all_paras[i].text.strip()
  163. if not text:
  164. continue
  165. if text in _PART_MARKERS:
  166. boundaries.append((i, text))
  167. seen_positions.add(i)
  168. elif _CHAPTER_PATTERN.match(text) and _is_top_level_chapter(all_paras[i]):
  169. # 骨架段落中的"第X章"同样只接受顶级章节格式,避免参考标书中的
  170. # 管理制度内嵌章(如"第二章 安全生产管理制度的建立")成为边界,
  171. # 把真实章节(第四章 十二~十六)截断到章节区域之外。
  172. boundaries.append((i, text))
  173. seen_positions.add(i)
  174. # 第二遍:全部段落中的"第X章"(补充骨架中缺失的章节)
  175. # 只接受"顶级章节"格式的段落;模板内嵌的制度章节(如"第四章 安全生产工作
  176. # 例会制度""第五章 安全设施管理维护制度")是 12pt 非粗体的正文,不能作为
  177. # 章节边界,否则会把真实章节截断(如第四章 十二、创新管理 ~ 十六、服务考核
  178. # 方法和标准 会被切到章节区域之外而丢失)。
  179. for i, p in enumerate(all_paras):
  180. if i in seen_positions:
  181. continue
  182. text = p.text.strip()
  183. if not text:
  184. continue
  185. if text in _PART_MARKERS:
  186. boundaries.append((i, text))
  187. seen_positions.add(i)
  188. elif _CHAPTER_PATTERN.match(text) and _is_top_level_chapter(p):
  189. boundaries.append((i, text))
  190. seen_positions.add(i)
  191. boundaries.sort(key=lambda x: x[0])
  192. return boundaries
  193. # ============================================================
  194. # 占位符 & 表格
  195. # ============================================================
  196. def _scan_placeholders(doc: DocxDocument) -> List[TemplatePlaceholder]:
  197. """扫描 %%...%% 占位符"""
  198. result: List[TemplatePlaceholder] = []
  199. for pi, para in enumerate(doc.paragraphs):
  200. for ri, run in enumerate(para.runs):
  201. for m in _PLACEHOLDER_PATTERN.finditer(run.text):
  202. result.append(TemplatePlaceholder(
  203. key=m.group(1).strip(),
  204. full_text=m.group(0),
  205. location="paragraph",
  206. paragraph_idx=pi,
  207. run_idx=ri,
  208. ))
  209. for ti, table in enumerate(doc.tables):
  210. for row in table.rows:
  211. for cell in row.cells:
  212. for para in cell.paragraphs:
  213. for run in para.runs:
  214. for m in _PLACEHOLDER_PATTERN.finditer(run.text):
  215. result.append(TemplatePlaceholder(
  216. key=m.group(1).strip(),
  217. full_text=m.group(0),
  218. location="table_cell",
  219. paragraph_idx=ti,
  220. run_idx=-1,
  221. ))
  222. return result
  223. def _scan_tables_fast(
  224. doc: DocxDocument,
  225. ) -> Tuple[List[TemplateTable], Dict[int, int]]:
  226. """单次 body 遍历:扫描表格 + 缓存段落位置"""
  227. # 遍历 body,记录每个表格前最近的段落索引
  228. table_para_idx: Dict[int, int] = {}
  229. last_para_idx = -1
  230. para_counter = 0
  231. table_counter = 0
  232. for child in doc.element.body:
  233. if child.tag.endswith("}p"):
  234. text = "".join(
  235. n.text or ""
  236. for n in child.iter()
  237. if n.tag.endswith("}t") and n.text
  238. )
  239. if text.strip():
  240. last_para_idx = para_counter
  241. para_counter += 1
  242. elif child.tag.endswith("}tbl"):
  243. table_para_idx[table_counter] = last_para_idx
  244. table_counter += 1
  245. tables: List[TemplateTable] = []
  246. for ti, table in enumerate(doc.tables):
  247. headers = (
  248. [c.text.strip()[:50] for c in table.rows[0].cells]
  249. if table.rows else []
  250. )
  251. fillable = any(
  252. "%%" in c.text
  253. for row in table.rows
  254. for c in row.cells
  255. )
  256. tables.append(TemplateTable(
  257. table_idx=ti,
  258. caption="",
  259. headers=headers,
  260. row_count=len(table.rows),
  261. fillable=fillable,
  262. fill_strategy="replace_placeholder" if fillable else "",
  263. ))
  264. return tables, table_para_idx
  265. # ============================================================
  266. # Section 构建
  267. # ============================================================
  268. def _build_sections(
  269. all_paras: list,
  270. boundaries: List[Tuple[int, str]],
  271. placeholders: List[TemplatePlaceholder],
  272. tables: List[TemplateTable],
  273. table_para_idx: Dict[int, int],
  274. skeleton: Set[int],
  275. ) -> List[TemplateSection]:
  276. """按边界划分 section"""
  277. sections: List[TemplateSection] = []
  278. for idx in range(len(boundaries)):
  279. para_idx, title = boundaries[idx]
  280. end_idx = (
  281. boundaries[idx + 1][0] - 1
  282. if idx + 1 < len(boundaries)
  283. else len(all_paras) - 1
  284. )
  285. # 占位符
  286. sec_ph = [p for p in placeholders if para_idx <= p.paragraph_idx <= end_idx]
  287. # 表格
  288. sec_tb = [
  289. t for t in tables
  290. if para_idx <= table_para_idx.get(t.table_idx, -1) <= end_idx
  291. ]
  292. # 类型
  293. if sec_ph:
  294. stype = "placeholder"
  295. elif sec_tb:
  296. stype = "table_form"
  297. elif any(kw in title for kw in ["方案", "管理", "服务", "措施", "预案"]):
  298. stype = "prose"
  299. else:
  300. stype = "static_text"
  301. sections.append(TemplateSection(
  302. name=title,
  303. start_para=para_idx,
  304. end_para=end_idx,
  305. section_type=stype,
  306. placeholders=sec_ph,
  307. tables=sec_tb,
  308. ))
  309. return sections
  310. # ============================================================
  311. # 公开接口
  312. # ============================================================
  313. def _read_template_body_texts(file_path: str) -> List[str]:
  314. """独立流式解析正文,保留 Document.paragraphs 的索引和文本语义。"""
  315. import zipfile
  316. import xml.etree.ElementTree as ET
  317. w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
  318. texts = []
  319. with zipfile.ZipFile(file_path) as archive, archive.open("word/document.xml") as stream:
  320. depth = 0
  321. body_depth = None
  322. for event, element in ET.iterparse(stream, events=("start", "end")):
  323. if event == "start":
  324. depth += 1
  325. if element.tag == w + "body":
  326. body_depth = depth
  327. continue
  328. if body_depth is not None and depth == body_depth + 1:
  329. if element.tag == w + "p":
  330. parts = []
  331. for child in element:
  332. runs = [child] if child.tag == w + "r" else (
  333. child.findall(w + "r") if child.tag == w + "hyperlink" else []
  334. )
  335. for run in runs:
  336. for item in run:
  337. if item.tag == w + "t":
  338. parts.append(item.text or "")
  339. elif item.tag in (w + "tab", w + "ptab"):
  340. parts.append("\t")
  341. elif item.tag == w + "cr" or (
  342. item.tag == w + "br"
  343. and item.get(w + "type", "textWrapping") == "textWrapping"
  344. ):
  345. parts.append("\n")
  346. elif item.tag == w + "noBreakHyphen":
  347. parts.append("-")
  348. texts.append("".join(parts))
  349. element.clear()
  350. depth -= 1
  351. return texts
  352. def get_template_text_for_chapter(
  353. template_structure: TemplateStructure,
  354. chapter_title: str,
  355. use_llm: bool = True,
  356. ) -> str:
  357. """从模板中提取指定章节的完整文本。
  358. 匹配策略(混合):
  359. 1. 确定性打分(_titles_match_score)找出全部候选;
  360. 2. 唯一候选或最高分明显领先(≥20 分)→ 直接采用,不调 LLM;
  361. 3. 同号章节多候选且分数接近(如模板内嵌"管理制度"与真正的技术章
  362. 同名同号)→ 用 LLM 仲裁一次,提高准确率;LLM 失败则回退最高分。
  363. """
  364. candidates: List[Tuple[int, TemplateSection]] = []
  365. for section in template_structure.sections:
  366. score = _titles_match_score(section.name, chapter_title)
  367. if score > 0:
  368. candidates.append((score, section))
  369. if not candidates:
  370. return ""
  371. candidates.sort(key=lambda x: x[0], reverse=True)
  372. best_section = candidates[0][1]
  373. if (
  374. use_llm
  375. and len(candidates) > 1
  376. and candidates[0][0] - candidates[1][0] < 20
  377. ):
  378. llm_section = _llm_disambiguate_section(
  379. template_structure, chapter_title, candidates
  380. )
  381. if llm_section is not None:
  382. best_section = llm_section
  383. paragraphs = _read_template_body_texts(template_structure.file_path)
  384. parts = []
  385. for i in range(
  386. best_section.start_para,
  387. min(best_section.end_para + 1, len(paragraphs)),
  388. ):
  389. text = paragraphs[i].strip()
  390. if text:
  391. parts.append(text)
  392. return "\n".join(parts)
  393. _SECTION_DISAMBIGUATE_PROMPT = """你是投标文件结构匹配专家。给定一个章节标题和若干个候选模板区域,
  394. 请选出与该章节标题最匹配的候选区域。
  395. 输出严格 JSON:{"index": 候选序号}
  396. 规则:
  397. 1. 候选区域按序号列出,序号从 1 开始;
  398. 2. 若某个候选区域名称与章节标题一致或高度吻合(含章节编号一致、标题文字
  399. 相同/包含),选它;
  400. 3. 若所有候选都明显不匹配,输出 {"index": null};
  401. 4. 只输出 JSON。"""
  402. def _llm_disambiguate_section(
  403. template_structure: TemplateStructure,
  404. chapter_title: str,
  405. candidates: List[Tuple[int, TemplateSection]],
  406. ) -> Optional[TemplateSection]:
  407. """分数接近的同号候选章节用 LLM 仲裁(如模板内嵌管理制度与真正技术章)"""
  408. try:
  409. from llm_client import LLMClient
  410. lines = []
  411. for idx, (score, section) in enumerate(candidates, start=1):
  412. lines.append(
  413. f"{idx}. {section.name} "
  414. f"(段落 {section.start_para}~{section.end_para},"
  415. f"确定性得分 {score})"
  416. )
  417. llm = LLMClient()
  418. result = llm.extract_json(
  419. system_prompt=_SECTION_DISAMBIGUATE_PROMPT,
  420. user_prompt=(
  421. f"章节标题:{chapter_title}\n\n候选模板区域:\n"
  422. + "\n".join(lines)
  423. ),
  424. max_tokens=2048,
  425. temperature=0.0,
  426. )
  427. if not isinstance(result, dict):
  428. return None
  429. idx = result.get("index")
  430. if idx is None:
  431. return None
  432. try:
  433. idx = int(idx)
  434. except (TypeError, ValueError):
  435. return None
  436. if 1 <= idx <= len(candidates):
  437. logger.info(
  438. f"LLM 章节匹配仲裁: {chapter_title!r} → "
  439. f"{candidates[idx - 1][1].name!r}"
  440. )
  441. return candidates[idx - 1][1]
  442. except Exception as e:
  443. logger.warning(f"LLM 章节匹配仲裁失败(回退确定性最高分): {e}")
  444. return None
  445. def _titles_match_score(section_name: str, chapter_title: str) -> int:
  446. """计算模板 section 与章节标题的匹配分数(取最优,而非第一个命中)。
  447. 模板中存在"管理制度"内嵌的同号章节(如"第五章 安全设施管理维护制度"),
  448. 与真正的"第五章:各分项服务的实施安排"同号。若用宽松的"第X章"关键词
  449. 匹配,会命中错误的制度章节。因此:
  450. - 标题含"第X章"时,先要求章节编号一致;
  451. - 完整标题互相包含得分最高;
  452. - 再按关键词命中数、Jaccard 相似度逐级降分;
  453. - 分数 > 0 才算匹配。
  454. """
  455. sn = (section_name or "").strip()
  456. ct = (chapter_title or "").strip()
  457. if not sn or not ct:
  458. return -1
  459. # 提取章节编号(第X章)
  460. m_sn = re.match(r"^第\s*([一二三四五六七八九十百\d]+)\s*章", sn)
  461. m_ct = re.match(r"^第\s*([一二三四五六七八九十百\d]+)\s*章", ct)
  462. if m_ct:
  463. if not m_sn or m_sn.group(1) != m_ct.group(1):
  464. # 同号章节才可匹配(避免命中管理制度内嵌的同号章)
  465. return -1
  466. # 1. 完整标题互相包含
  467. if ct in sn or sn in ct:
  468. return 100
  469. words = [w for w in re.split(r"[、,,::\s]+", ct) if len(w) >= 2]
  470. hits_3plus = 0
  471. hits_2char = 0
  472. for w in words:
  473. if w in sn:
  474. if len(w) >= 3:
  475. hits_3plus += 1
  476. elif len(w) == 2:
  477. hits_2char += 1
  478. if hits_3plus >= 1:
  479. return 50 + hits_3plus
  480. if hits_2char >= 2:
  481. return 30 + hits_2char
  482. # Jaccard 相似度兜底
  483. sn_chars = set(sn.replace(":", "").replace(":", "").replace(" ", ""))
  484. ct_chars = set(ct.replace(":", "").replace(":", "").replace(" ", ""))
  485. if sn_chars and ct_chars:
  486. sim = len(sn_chars & ct_chars) / len(sn_chars | ct_chars)
  487. if sim >= 0.35:
  488. return int(sim * 30)
  489. return -1
  490. def get_template_text_for_section(
  491. template_structure: TemplateStructure,
  492. section_name: str,
  493. ) -> str:
  494. """按 Step 3 持久化的区域名精确获取模板文本(避免再次标题匹配)"""
  495. if not section_name:
  496. return ""
  497. for section in template_structure.sections:
  498. if section.name.strip() == section_name.strip():
  499. paragraphs = _read_template_body_texts(template_structure.file_path)
  500. parts = []
  501. for i in range(
  502. section.start_para,
  503. min(section.end_para + 1, len(paragraphs)),
  504. ):
  505. text = paragraphs[i].strip()
  506. if text:
  507. parts.append(text)
  508. return "\n".join(parts)
  509. return ""
  510. def _titles_match(section_name: str, chapter_title: str) -> bool:
  511. """判断模板 section 名称与章节标题是否匹配
  512. 多层匹配策略(由严到宽):
  513. 1. 直接包含
  514. 2. 3+ 字关键词命中(高置信度)
  515. 3. 2 字关键词命中 ≥ 2 个(组合置信度)
  516. 4. 字符级 Jaccard 相似度 ≥ 0.35(宽匹配兜底)
  517. """
  518. # 归一化
  519. sn = section_name.strip()
  520. ct = chapter_title.strip()
  521. # 1. 直接包含
  522. if ct in sn or sn in ct:
  523. return True
  524. # 拆分章节标题为词组
  525. words = [w for w in re.split(r"[、,,::\s]+", ct) if len(w) >= 2]
  526. # 统计各长度关键词命中
  527. hits_3plus = 0
  528. hits_2char = 0
  529. for w in words:
  530. if w in sn:
  531. if len(w) >= 3:
  532. hits_3plus += 1
  533. elif len(w) == 2:
  534. hits_2char += 1
  535. # 2. 任一 3+ 字词命中
  536. if hits_3plus >= 1:
  537. return True
  538. # 3. 至少 2 个 2 字词命中
  539. if hits_2char >= 2:
  540. return True
  541. # 4. 字符级 Jaccard 相似度兜底
  542. sn_chars = set(sn.replace(":", "").replace(":", ""))
  543. ct_chars = set(ct)
  544. if sn_chars and ct_chars:
  545. intersection = sn_chars & ct_chars
  546. union = sn_chars | ct_chars
  547. jaccard = len(intersection) / len(union) if union else 0
  548. if jaccard >= 0.35:
  549. return True
  550. return False
  551. def get_template_chapter_titles(
  552. template_path: str,
  553. reference_bid_path: str = "",
  554. ) -> List[Tuple[str, str]]:
  555. """从模板中提取顶级章节编号和标题
  556. 策略(V2.2 增强版):
  557. 1. 优先通过骨架 diff 获取精准边界(有参考标书时)
  558. 2. 降级:字体格式过滤(尝试检测粗体/大字号段落)
  559. 3. 终级降级:所有匹配"第X章"模式的段落(不依赖格式判断)
  560. Args:
  561. template_path: 模板 DOCX 路径
  562. reference_bid_path: 参考投标文件路径(可选,用于优先匹配骨架段落)
  563. Returns:
  564. [(chapter_id, title_text), ...] 按出现顺序排列
  565. """
  566. if not os.path.exists(template_path):
  567. return []
  568. # 先尝试通过骨架 diff 获取精准边界(有参考标书时)
  569. if reference_bid_path and os.path.exists(reference_bid_path):
  570. try:
  571. ts = parse_template(
  572. template_path=template_path,
  573. reference_bid_path=reference_bid_path,
  574. )
  575. titles: List[Tuple[str, str]] = []
  576. for section in ts.sections:
  577. m = _CHAPTER_PATTERN.match(section.name)
  578. if m:
  579. raw_id = m.group(0).split("章")[0].replace("第", "")
  580. ch_id = _cn_num_to_arabic(raw_id)
  581. title = (m.group(1) or "").strip()
  582. titles.append((ch_id, title))
  583. if titles:
  584. return _dedupe_template_chapter_titles(titles)
  585. except Exception:
  586. pass # 降级到格式过滤方案
  587. # 大文件(≥20MB,如 104MB 模板):用标准库 ET 读取(含字号/加粗),
  588. # 避免 python-docx/lxml 反复解析大文件导致的原生崩溃(0xC0000005)。
  589. if os.path.getsize(template_path) >= 20 * 1024 * 1024:
  590. try:
  591. from doc_reader.reader import read_docx_paragraphs_et
  592. paras_info = read_docx_paragraphs_et(template_path)
  593. return _dedupe_template_chapter_titles(
  594. _extract_chapter_titles_from_paras(paras_info)
  595. )
  596. except Exception as e:
  597. logger.warning(f"模板章节标题 ET 读取失败,回退 python-docx: {e}")
  598. # 降级路径:扫描所有"第X章"段落,用启发式判断顶级章节
  599. doc = DocxDocument(template_path)
  600. titles: List[Tuple[str, str]] = []
  601. seen = set()
  602. # 收集所有匹配"第X章"的段落
  603. all_chapters: list = []
  604. for para in doc.paragraphs:
  605. text = para.text.strip()
  606. m = _CHAPTER_PATTERN.match(text)
  607. if not m:
  608. continue
  609. raw_id = m.group(0).split("章")[0].replace("第", "")
  610. ch_id = _cn_num_to_arabic(raw_id)
  611. title = (m.group(1) or "").strip()
  612. is_top = _is_top_level_chapter(para)
  613. all_chapters.append((ch_id, title, is_top, para))
  614. # 策略 A:如果有任何顶级章节(粗体/大字号),只用顶级章节
  615. top_chapters = [(cid, t) for cid, t, is_top, _ in all_chapters if is_top]
  616. if top_chapters:
  617. top_nums = sorted({int(c) for c, _ in top_chapters if c.isdigit()})
  618. for ch_id, title in top_chapters:
  619. if ch_id not in seen:
  620. seen.add(ch_id)
  621. titles.append((ch_id, title))
  622. # 容错:顶层章编号应连续。若存在编号缺口(如 1,2,4 缺 3),
  623. # 说明个别章格式不一致(未加粗/字号不足)被 _is_top_level_chapter
  624. # 漏判为"子节伪装"——补收录并警告,避免章节丢失。
  625. if len(top_nums) >= 2:
  626. for ch_id, title, is_top2, _para in all_chapters:
  627. if is_top2 or ch_id in seen or not ch_id.isdigit():
  628. continue
  629. num = int(ch_id)
  630. if top_nums[0] <= num <= top_nums[-1] and num not in top_nums:
  631. seen.add(ch_id)
  632. titles.append((ch_id, title))
  633. logger.warning(
  634. f"格式不一致的顶层章已补充(编号缺口 {num}): "
  635. f"第{ch_id}章 {title[:40]}"
  636. )
  637. # 记录被格式过滤的章(多为正文内部子目录,但至少不静默)
  638. filtered = [
  639. f"第{cid}章 {t[:20]}"
  640. for cid, t, is_top2, _para in all_chapters
  641. if not is_top2 and cid not in seen
  642. ]
  643. if filtered:
  644. logger.info(
  645. "模板正文中检测到但未纳入投书顶层大纲的“第X章”标题"
  646. f"(常见于附件/制度子目录): {filtered[:10]}"
  647. )
  648. return _dedupe_template_chapter_titles(titles)
  649. # 策略 B:没有检测到格式差异,全部视为顶级章节
  650. for ch_id, title, _is_top, _para in all_chapters:
  651. if ch_id not in seen:
  652. seen.add(ch_id)
  653. titles.append((ch_id, title))
  654. return _dedupe_template_chapter_titles(titles)
  655. # 模板内嵌"管理制度"等附件章节的标题特征词(如"第五章 安全设施管理维护制度")
  656. _EMBEDDED_CHAPTER_KEYWORDS = (
  657. "总则", "安全生产", "安全责任", "安全设施", "生产安全",
  658. "考评", "预警", "档案存档", "责任追究", "制度",
  659. )
  660. def _dedupe_template_chapter_titles(
  661. titles: List[Tuple[str, str]],
  662. ) -> List[Tuple[str, str]]:
  663. """同号章节去重,并剔除模板内嵌的"管理制度"等附件章节。
  664. 模板中真正的顶层章(第1~8章)与内嵌的"管理制度"章节(第1~10章)同号,
  665. dict(titles) 后段覆盖前段会把第一章~第四章标题污染成制度标题,并把
  666. 第九章/第十章(制度章)带进大纲。规则:
  667. - 同一编号保留第一个"非制度特征"标题;
  668. - 全部候选都是制度特征标题(如第9/10章)→ 剔除该编号。
  669. """
  670. by_id: Dict[str, List[str]] = {}
  671. for ch_id, title in titles:
  672. by_id.setdefault(ch_id, []).append(title)
  673. result: List[Tuple[str, str]] = []
  674. for ch_id in sorted(by_id, key=lambda x: int(x) if x.isdigit() else 0):
  675. best = None
  676. for title in by_id[ch_id]:
  677. if not any(kw in title for kw in _EMBEDDED_CHAPTER_KEYWORDS):
  678. best = title
  679. break
  680. if best:
  681. result.append((ch_id, best))
  682. return result
  683. def _is_top_level_chapter(para) -> bool:
  684. """判断段落是否为顶级章节(非子节伪装)
  685. 增强版检测标准:
  686. - 存在 run 满足:粗体=True AND 字号 >= 12pt(放宽到 12pt)
  687. - 存在 run 满足:字号 >= 14pt(即使未显式设粗体,大字号默认是标题)
  688. - 段落的首个 run 粗体=True(即使在段样式级别设置)
  689. 子节的"第X章"通常为 12pt + 非粗体 + 有缩进。
  690. """
  691. from docx.shared import Pt
  692. explicit_level = paragraph_heading_level(para)
  693. if explicit_level:
  694. return explicit_level == 1
  695. if not para.runs:
  696. return False
  697. has_bold_large = False
  698. has_large_font = False
  699. first_run_bold = para.runs[0].bold is True
  700. for run in para.runs:
  701. # 检查粗体+大字号组合
  702. if run.bold and run.font.size and run.font.size >= Pt(12):
  703. has_bold_large = True
  704. # 检查大字号(即使没设粗体)
  705. if run.font.size and run.font.size >= Pt(14):
  706. has_large_font = True
  707. # 检查段落级别的粗体(通过 pPr)
  708. if not has_bold_large and not first_run_bold:
  709. try:
  710. from docx.oxml.ns import qn
  711. pPr = para._element.find(qn('w:pPr'))
  712. if pPr is not None:
  713. pStyle = pPr.find(qn('w:pStyle'))
  714. if pStyle is not None:
  715. style_val = pStyle.get(qn('w:val'), '')
  716. if style_val and style_val.lower().startswith('heading'):
  717. has_bold_large = True
  718. except Exception:
  719. pass
  720. return has_bold_large or has_large_font or first_run_bold
  721. def _is_top_level_chapter_from_info(info: dict) -> bool:
  722. """按 ET 提取的段落信息判断是否为顶级章节(与 _is_top_level_chapter 同规则)"""
  723. explicit_level = heading_level_from_style(info.get("style_name") or "")
  724. if explicit_level:
  725. return explicit_level == 1
  726. runs = info.get("runs") or []
  727. if not runs:
  728. return False
  729. first_run_bold = runs[0][0] is True
  730. has_bold_large = False
  731. has_large_font = False
  732. for bold, size in runs:
  733. if bold and size is not None and size >= 12:
  734. has_bold_large = True
  735. if size is not None and size >= 14:
  736. has_large_font = True
  737. if not has_bold_large and not first_run_bold:
  738. if heading_level_from_style(info.get("style") or "") == 1:
  739. has_bold_large = True
  740. return has_bold_large or has_large_font or first_run_bold
  741. def _extract_chapter_titles_from_paras(paras_info: list) -> List[Tuple[str, str]]:
  742. """从段落信息列表提取顶级章节标题(含编号缺口补充与过滤日志)"""
  743. titles: List[Tuple[str, str]] = []
  744. seen = set()
  745. all_chapters = []
  746. for info in paras_info:
  747. text = info.get("text", "").strip()
  748. m = _CHAPTER_PATTERN.match(text)
  749. if not m:
  750. continue
  751. raw_id = m.group(0).split("章")[0].replace("第", "")
  752. ch_id = _cn_num_to_arabic(raw_id)
  753. title = (m.group(1) or "").strip()
  754. is_top = _is_top_level_chapter_from_info(info)
  755. all_chapters.append((ch_id, title, is_top, text))
  756. top_chapters = [(cid, t) for cid, t, is_top, _ in all_chapters if is_top]
  757. if top_chapters:
  758. top_nums = sorted({int(c) for c, _ in top_chapters if c.isdigit()})
  759. for ch_id, title in top_chapters:
  760. if ch_id not in seen:
  761. seen.add(ch_id)
  762. titles.append((ch_id, title))
  763. if len(top_nums) >= 2:
  764. for ch_id, title, is_top2, _text in all_chapters:
  765. if is_top2 or ch_id in seen or not ch_id.isdigit():
  766. continue
  767. num = int(ch_id)
  768. if top_nums[0] <= num <= top_nums[-1] and num not in top_nums:
  769. seen.add(ch_id)
  770. titles.append((ch_id, title))
  771. logger.warning(
  772. f"格式不一致的顶层章已补充(编号缺口 {num}): "
  773. f"第{ch_id}章 {title[:40]}"
  774. )
  775. filtered = [
  776. f"第{cid}章 {t[:20]}"
  777. for cid, t, is_top2, _text in all_chapters
  778. if not is_top2 and cid not in seen
  779. ]
  780. if filtered:
  781. logger.info(
  782. "模板正文中检测到但未纳入投书顶层大纲的“第X章”标题"
  783. f"(常见于附件/制度子目录): {filtered[:10]}"
  784. )
  785. return titles
  786. def _cn_num_to_arabic(cn: str) -> str:
  787. """中文数字 → 阿拉伯数字字符串
  788. 支持任意常见范围("十"~"百"~"千"组合);无法解析时返回原串
  789. (调用方需自行过滤非数字 id)。
  790. """
  791. if cn.isdigit():
  792. return cn
  793. cn_digits = {
  794. "一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
  795. "六": 6, "七": 7, "八": 8, "九": 9,
  796. }
  797. if cn in cn_digits:
  798. return str(cn_digits[cn])
  799. # 组合数解析:"十"→10,"十六"→16,"二十"→20,
  800. # "二十一"→21,"一百零五"→105("零"忽略)
  801. total = 0
  802. section = 0
  803. number = 0
  804. for ch in cn:
  805. if ch in cn_digits:
  806. number = cn_digits[ch]
  807. elif ch == "十":
  808. section += (number or 1) * 10
  809. number = 0
  810. elif ch == "百":
  811. section += (number or 1) * 100
  812. number = 0
  813. elif ch == "千":
  814. section += (number or 1) * 1000
  815. number = 0
  816. elif ch == "万":
  817. section += number
  818. total += section * 10000
  819. section = 0
  820. number = 0
  821. elif ch == "零":
  822. number = 0 # "一百零五":百位后清零个位数字
  823. else:
  824. return cn # 无法解析,返回原串
  825. total += section + number
  826. if total <= 0:
  827. return cn
  828. return str(total)
  829. __all__ = [
  830. "parse_template",
  831. "get_template_text_for_chapter",
  832. "DEFAULT_TEMPLATE",
  833. ]