docx_ingest.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """
  2. DOCX 药典批量导入脚本
  3. 遍历指定目录下所有 DOCX 文件 → 提取药名和 sections → 向量化 → 写入 PostgreSQL
  4. 药品名以文件名(去 .docx)为准
  5. """
  6. import json
  7. import asyncio
  8. import os
  9. import sys
  10. import re
  11. import hashlib
  12. from pathlib import Path
  13. from collections import OrderedDict
  14. import httpx
  15. from docx import Document
  16. from sqlalchemy.ext.asyncio import create_async_engine
  17. from sqlalchemy import text
  18. # ============================================
  19. # 配置
  20. # ============================================
  21. SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
  22. # 跳过的目录名(非药品内容:凡例、纲要、通则说明等)
  23. SKIP_DIRS = {"索引", "目录", "概述"}
  24. # 从环境变量读取
  25. PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  26. PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
  27. PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  28. PG_USER = os.environ.get("POSTGRES_USER", "postgres")
  29. PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
  30. DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
  31. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  32. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  33. EMBEDDING_MODEL = "text-embedding-v3"
  34. BATCH_SIZE = 10 # 每批向量化的文本数
  35. # Section 头部关键词 → 标准名称映射
  36. SECTION_HEADERS = OrderedDict([
  37. ("处方", "处方"), ("制法", "制法"), ("性状", "性状"),
  38. ("鉴别", "鉴别"), ("检查", "检查"), ("浸出物", "浸出物"),
  39. ("含量测定", "含量测定"), ("含量", "含量测定"),
  40. ("功能与主治", "功能主治"), ("功能", "功能主治"), ("主治", "功能主治"),
  41. ("用法与用量", "用法用量"), ("用法", "用法用量"), ("用量", "用法用量"),
  42. ("注意", "注意事项"), ("注意事项", "注意事项"),
  43. ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
  44. ("制剂", "制剂"), ("附注", "附注"),
  45. ])
  46. # 药典部别映射
  47. VOLUME_MAP = {"output": "一部", "output2": "二部", "output3": "三部", "output4": "四部"}
  48. # 四部子目录 → 具体分类
  49. CATEGORY_OVERRIDE = {
  50. "药用辅料": "药用辅料",
  51. "通用技术要求": "通则",
  52. "指导原则": "通则",
  53. "通用技术要求/指导原则": "通则",
  54. }
  55. def load_env():
  56. env_file = Path(__file__).resolve().parent.parent / ".env"
  57. if env_file.exists():
  58. with open(env_file, encoding="utf-8") as f:
  59. for line in f:
  60. line = line.strip()
  61. if line and not line.startswith("#") and "=" in line:
  62. key, _, val = line.partition("=")
  63. os.environ.setdefault(key.strip(), val.strip())
  64. def parse_docx(filepath: str) -> dict | None:
  65. """
  66. 解析单个 DOCX 文件为药典条目。
  67. 药品名 = 文件名(去掉 .docx)
  68. 凡例/纲要类:名称加上部别前缀
  69. """
  70. filename = Path(filepath).stem
  71. volume = get_volume_group(filepath)
  72. parent_dir = Path(filepath).parent.name
  73. # 凡例/纲要/通则类:名称格式为"一部-凡例"以区分
  74. is_meta = parent_dir in ("凡例", "纲要", "通用技术要求", "指导原则")
  75. display_name = f"{volume}-{filename}" if is_meta else filename
  76. try:
  77. doc = Document(filepath)
  78. except Exception:
  79. return None
  80. pinyin = ""
  81. sections = OrderedDict()
  82. current_section = "正文"
  83. current_text = []
  84. # 收集所有段落
  85. for p in doc.paragraphs:
  86. text = p.text.strip()
  87. if not text or text.isspace():
  88. continue
  89. style = p.style.name if p.style else ""
  90. # Heading 1 通常是药品名(与文件名一致),直接跳过,不加入 sections
  91. if 'Heading 1' in style:
  92. continue
  93. # 拼音行(紧跟药名后)
  94. if not pinyin and is_pinyin_line(text):
  95. pinyin = text.strip()
  96. continue
  97. # 独立的 section 标题(2-6 字短行)
  98. section_matched = detect_section_header(text)
  99. if section_matched and len(text) <= 10:
  100. if current_text:
  101. sections[current_section] = '\n'.join(current_text).strip()
  102. current_text = []
  103. current_section = section_matched
  104. continue
  105. # 行内 section:【性状】xxx
  106. m = re.match(r'^【(.+?)】\s*(.*)', text)
  107. if m and detect_section_header(m.group(1)):
  108. if current_text:
  109. sections[current_section] = '\n'.join(current_text).strip()
  110. current_text = []
  111. current_section = detect_section_header(m.group(1))
  112. if m.group(2):
  113. current_text.append(m.group(2))
  114. continue
  115. # 普通段落
  116. current_text.append(text)
  117. # 最后一个 section
  118. if current_text:
  119. sections[current_section] = '\n'.join(current_text).strip()
  120. # ============================================
  121. # 凡例/纲要特殊处理:按编号拆分(一、二、三... 或 1. 2. 3.)
  122. # ============================================
  123. if parent_dir in ("凡例", "纲要") and "正文" in sections:
  124. sections = split_numbered_content(sections["正文"])
  125. if len(sections) == 0:
  126. return None
  127. category = infer_category(filepath, parent_dir)
  128. return {
  129. "drug_id": generate_drug_id(display_name, filepath),
  130. "name": display_name,
  131. "name_en": "",
  132. "pinyin": pinyin,
  133. "category": category,
  134. "subcategory": infer_subcategory(filepath),
  135. "sections": dict(sections),
  136. "source": {
  137. "version": "2025年版",
  138. "volume": volume,
  139. "page": "",
  140. },
  141. }
  142. def split_numbered_content(text: str) -> OrderedDict:
  143. """
  144. 凡例/纲要类内容按编号拆分为独立 section。
  145. 匹配模式:
  146. - 一、二、三、... 十四、
  147. - (一)(二)...
  148. - 1. 2. 3. 或 1)2)3)
  149. """
  150. # 中文数字
  151. cn_nums = "一二三四五六七八九十"
  152. patterns = [
  153. # ^一、或 \n一、 开头
  154. re.compile(r'(?:^|\n)([' + cn_nums + r']{1,2})[、,,]'),
  155. # (一)开头
  156. re.compile(r'(?:^|\n)(([' + cn_nums + r']{1,2}))'),
  157. # 数字编号
  158. re.compile(r'(?:^|\n)(\d{1,2})[\.\))]'),
  159. ]
  160. # 用第一个模式尝试
  161. result = OrderedDict()
  162. pattern = patterns[0]
  163. matches = list(pattern.finditer(text))
  164. if len(matches) < 2:
  165. # 尝试第二个模式
  166. pattern = patterns[1]
  167. matches = list(pattern.finditer(text))
  168. if len(matches) < 2:
  169. # 尝试第三个模式
  170. pattern = patterns[2]
  171. matches = list(pattern.finditer(text))
  172. if len(matches) < 2:
  173. # 确实没有编号结构,保留原样但限制长度
  174. if len(text) > 3000:
  175. # 按段落粗略切分
  176. paras = [p.strip() for p in text.split('\n') if p.strip()]
  177. buf = ""
  178. idx = 0
  179. for p in paras:
  180. if len(buf) + len(p) > 2000 and buf:
  181. result[f"第{idx + 1}段"] = buf
  182. idx += 1
  183. buf = p
  184. else:
  185. buf += ("\n" if buf else "") + p
  186. if buf:
  187. result[f"第{idx + 1}段"] = buf
  188. return result if result else OrderedDict([("正文", text)])
  189. return OrderedDict([("正文", text)])
  190. # 按编号切分
  191. for i, match in enumerate(matches):
  192. start = match.start()
  193. end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
  194. content = text[start:end].strip()
  195. # 编号字符串(去除换行和括号)
  196. raw_num = match.group(0).lstrip('\n').strip()
  197. section_name = f"凡例-{raw_num}"
  198. if len(content) > 50:
  199. result[section_name] = content
  200. return result if result else OrderedDict([("正文", text)])
  201. def is_pinyin_line(text: str) -> bool:
  202. alpha_count = sum(1 for c in text if c.isascii() and c.isalpha())
  203. return alpha_count > len(text) * 0.3 and len(text) > 3 and len(text) < 200
  204. def detect_section_header(text: str) -> str | None:
  205. clean = text.replace("【", "").replace("】", "").strip()
  206. if clean in SECTION_HEADERS:
  207. return SECTION_HEADERS[clean]
  208. return None
  209. def get_volume_group(filepath: str) -> str:
  210. for key, vol in VOLUME_MAP.items():
  211. if key in filepath:
  212. return vol
  213. return "一部"
  214. def infer_category(filepath: str, parent_dir: str = "") -> str:
  215. path_lower = filepath.lower()
  216. # 凡例/纲要/通用技术要求
  217. if parent_dir in ("凡例", "纲要"):
  218. return "凡例与纲要"
  219. if parent_dir in ("通用技术要求", "指导原则"):
  220. return "通则"
  221. if "output2" in path_lower: return "化学药"
  222. if "output3" in path_lower: return "生物制品"
  223. if "output4" in path_lower: return _infer_category_vol4(filepath)
  224. return "中药"
  225. def _infer_category_vol4(filepath: str) -> str:
  226. """四部特殊处理"""
  227. for key, cat in CATEGORY_OVERRIDE.items():
  228. if key in filepath:
  229. return cat
  230. return "通则"
  231. def infer_subcategory(filepath: str) -> str:
  232. """从目录层级推断子分类"""
  233. parts = Path(filepath).parts
  234. # 找到 output/output2/output3/output4 之后的子目录名
  235. for i, p in enumerate(parts):
  236. if p in ("output", "output2", "output3", "output4"):
  237. if i + 1 < len(parts):
  238. sub = parts[i + 1]
  239. # 排除数字编号前缀
  240. return sub
  241. return ""
  242. def generate_drug_id(name: str, filepath: str) -> str:
  243. vol = get_volume_group(filepath)
  244. prefix = {"一部": "Z", "二部": "H", "三部": "S", "四部": "T"}.get(vol, "Z")
  245. hash_suffix = hashlib.md5(name.encode()).hexdigest()[:6].upper()
  246. return f"{prefix}2025-{hash_suffix}"
  247. def find_docx_files(root_dir: str) -> list[str]:
  248. """扫描所有 DOCX 文件,跳过非药品目录"""
  249. files = []
  250. for dirpath, dirnames, filenames in os.walk(root_dir):
  251. dir_basename = os.path.basename(dirpath)
  252. if dir_basename in SKIP_DIRS:
  253. dirnames.clear()
  254. continue
  255. for f in sorted(filenames):
  256. if f.startswith('~') or f.startswith('.'):
  257. continue
  258. if f.endswith('.docx'):
  259. files.append(os.path.join(dirpath, f))
  260. return files
  261. async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
  262. async with httpx.AsyncClient(timeout=60) as client:
  263. resp = await client.post(
  264. EMBEDDING_URL,
  265. headers={
  266. "Content-Type": "application/json",
  267. "Authorization": f"Bearer {QWEN_API_KEY}",
  268. },
  269. json={
  270. "model": EMBEDDING_MODEL,
  271. "input": {"texts": texts},
  272. "parameters": {"text_type": text_type},
  273. },
  274. )
  275. data = resp.json()
  276. if data.get("code") and data.get("code") != "" and data.get("code") is not None:
  277. raise RuntimeError(f"Embedding error: {data.get('message', data)}")
  278. return [item["embedding"] for item in data["output"]["embeddings"]]
  279. async def ingest_docx_entries(entries: list[dict], engine, start_idx: int = 0):
  280. """批量向量化 + 入库"""
  281. chunks = []
  282. chunk_meta = []
  283. for entry in entries:
  284. source = f"{entry['source']['version']} {entry['source']['volume']}"
  285. for section_key, section_text in entry["sections"].items():
  286. if not section_text or len(section_text.strip()) < 5:
  287. continue
  288. content = f"【{entry['name']} - {section_key}】\n{section_text}\n\n来源:{source}"
  289. chunks.append(content)
  290. chunk_meta.append({
  291. "drug_id": entry["drug_id"],
  292. "section": section_key,
  293. "content": content,
  294. "source": source,
  295. })
  296. print(f" ✂️ {len(chunks)} 个 chunks,向量化中...")
  297. all_vectors = []
  298. for i in range(0, len(chunks), BATCH_SIZE):
  299. batch = chunks[i:i + BATCH_SIZE]
  300. vecs = await get_embeddings(batch, text_type="document")
  301. all_vectors.extend(vecs)
  302. n = min(i + BATCH_SIZE, len(chunks))
  303. print(f" 向量化: {n}/{len(chunks)}")
  304. print(f" ✅ 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
  305. # 写入 drug_chunks
  306. chunk_count = 0
  307. async with engine.begin() as conn:
  308. for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
  309. vec_str = f"[{','.join(str(v) for v in vec)}]"
  310. await conn.execute(
  311. text("""
  312. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
  313. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
  314. ON CONFLICT DO NOTHING
  315. """),
  316. {
  317. "drug_id": meta["drug_id"],
  318. "section": meta["section"],
  319. "content": meta["content"],
  320. "source": meta["source"],
  321. "chunk_index": start_idx + idx,
  322. "embedding": json.dumps(vec),
  323. "vec": vec_str,
  324. },
  325. )
  326. chunk_count += 1
  327. # 写入 drugs 表
  328. drug_count = 0
  329. async with engine.begin() as conn:
  330. seen = set()
  331. for entry in entries:
  332. if entry["drug_id"] in seen:
  333. continue
  334. seen.add(entry["drug_id"])
  335. await conn.execute(
  336. text("""
  337. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
  338. sections, source_version, source_volume, source_page, is_active)
  339. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
  340. :sections, :source_version, :source_volume, :source_page, TRUE)
  341. ON CONFLICT (drug_id) DO UPDATE SET
  342. sections = EXCLUDED.sections,
  343. category = EXCLUDED.category,
  344. subcategory = EXCLUDED.subcategory,
  345. source_version = EXCLUDED.source_version,
  346. source_volume = EXCLUDED.source_volume,
  347. updated_at = NOW()
  348. """),
  349. {
  350. "drug_id": entry["drug_id"],
  351. "name": entry["name"],
  352. "name_en": entry.get("name_en", ""),
  353. "pinyin": entry.get("pinyin", ""),
  354. "category": entry.get("category", ""),
  355. "subcategory": entry.get("subcategory", ""),
  356. "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
  357. "source_version": entry["source"]["version"],
  358. "source_volume": entry["source"]["volume"],
  359. "source_page": entry["source"].get("page", ""),
  360. },
  361. )
  362. drug_count += 1
  363. return drug_count, chunk_count
  364. async def dedup_old_versions(engine):
  365. """覆盖性新增:同名药品如果存在非 2025 版的旧记录,标记为 inactive"""
  366. async with engine.begin() as conn:
  367. result = await conn.execute(
  368. text("""
  369. UPDATE drugs
  370. SET is_active = FALSE, updated_at = NOW()
  371. WHERE is_active = TRUE
  372. AND source_version != '2025年版'
  373. AND name IN (
  374. SELECT name FROM drugs
  375. WHERE source_version = '2025年版' AND is_active = TRUE
  376. )
  377. """)
  378. )
  379. # asyncpg 不支持 rowcount,用另一种方式
  380. count = getattr(result, 'rowcount', 0)
  381. if count > 0:
  382. print(f" 🧹 去重: 标记 {count} 个旧版药品为 inactive")
  383. async def main():
  384. load_env()
  385. global QWEN_API_KEY
  386. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  387. if not QWEN_API_KEY:
  388. print("❌ 未设置 QWEN_API_KEY,请在 .env 文件中配置")
  389. sys.exit(1)
  390. print("=" * 60)
  391. print("📂 扫描 DOCX 文件...")
  392. files = find_docx_files(SOURCE_DIR)
  393. print(f" 发现 {len(files)} 个 DOCX 文件")
  394. print("=" * 60)
  395. # 解析并统计
  396. entries = []
  397. skipped = 0
  398. by_category = {}
  399. for i, fp in enumerate(files):
  400. entry = parse_docx(fp)
  401. if entry:
  402. entries.append(entry)
  403. cat = entry["category"]
  404. by_category[cat] = by_category.get(cat, 0) + 1
  405. else:
  406. skipped += 1
  407. if (i + 1) % 1000 == 0:
  408. print(f" 解析进度: {i + 1}/{len(files)} (有效: {len(entries)})")
  409. print(f"\n📦 解析完成: {len(entries)} 个有效条目 (跳过 {skipped} 个)")
  410. print(" 分类分布:")
  411. for cat, cnt in sorted(by_category.items(), key=lambda x: -x[1]):
  412. print(f" {cat}: {cnt}")
  413. if not entries:
  414. print("❌ 没有有效数据")
  415. return
  416. # 连接数据库
  417. engine = create_async_engine(DB_URL)
  418. # 分批入库
  419. total_drugs = 0
  420. total_chunks = 0
  421. BATCH = 50
  422. for i in range(0, len(entries), BATCH):
  423. batch = entries[i:i + BATCH]
  424. batch_no = i // BATCH + 1
  425. total_batches = (len(entries) + BATCH - 1) // BATCH
  426. print(f"\n🚀 第 {batch_no}/{total_batches} 批 ({len(batch)} 个药品) ...")
  427. try:
  428. dc, cc = await ingest_docx_entries(batch, engine, start_idx=total_chunks)
  429. total_drugs += dc
  430. total_chunks += cc
  431. print(f" ✅ 入库: {dc} 药品, {cc} chunks")
  432. except Exception as e:
  433. print(f" ❌ 批次失败: {e}")
  434. if batch:
  435. print(f" 首个药品: {batch[0]['name']}")
  436. # 去重:标记同名旧版药品为 inactive
  437. print("\n🧹 清理旧版重复数据...")
  438. await dedup_old_versions(engine)
  439. await engine.dispose()
  440. print("\n" + "=" * 60)
  441. print(f"🎉 全部完成!新增药品 {total_drugs} 个, chunks {total_chunks} 条")
  442. print("=" * 60)
  443. if __name__ == "__main__":
  444. asyncio.run(main())