docx_ingest.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. """
  2. DOCX 药典批量导入脚本
  3. 遍历指定目录下所有 DOCX 文件 → 提取药名和 sections → 向量化 → 写入 PostgreSQL
  4. """
  5. import json
  6. import asyncio
  7. import os
  8. import sys
  9. import re
  10. import hashlib
  11. from pathlib import Path
  12. from collections import OrderedDict
  13. import httpx
  14. from docx import Document
  15. from sqlalchemy.ext.asyncio import create_async_engine
  16. from sqlalchemy import text
  17. # ============================================
  18. # 配置
  19. # ============================================
  20. SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
  21. SKIP_DIRS = {"凡例", "通则", "凡例与通则", "附录", "索引", "目录"}
  22. # 从环境变量读取
  23. PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  24. PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
  25. PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  26. PG_USER = os.environ.get("POSTGRES_USER", "postgres")
  27. PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
  28. DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
  29. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  30. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  31. EMBEDDING_MODEL = "text-embedding-v3"
  32. BATCH_SIZE = 10 # 每批向量化的文本数
  33. # Section 头部关键词 → 标准名称映射
  34. SECTION_HEADERS = OrderedDict([
  35. ("处方", "处方"),
  36. ("制法", "制法"),
  37. ("性状", "性状"),
  38. ("鉴别", "鉴别"),
  39. ("检查", "检查"),
  40. ("浸出物", "浸出物"),
  41. ("含量测定", "含量测定"),
  42. ("含量", "含量测定"),
  43. ("功能与主治", "功能主治"),
  44. ("功能", "功能主治"),
  45. ("主治", "功能主治"),
  46. ("用法与用量", "用法用量"),
  47. ("用法", "用法用量"),
  48. ("用量", "用法用量"),
  49. ("注意", "注意事项"),
  50. ("注意事项", "注意事项"),
  51. ("规格", "规格"),
  52. ("贮藏", "贮藏"),
  53. ("类别", "类别"),
  54. ("制剂", "制剂"),
  55. ("附注", "附注"),
  56. ])
  57. # 药典部别推断
  58. VOLUME_MAP = {"output": "一部", "output2": "二部", "output3": "三部", "output4": "四部"}
  59. def load_env():
  60. """加载 .env 文件"""
  61. env_file = Path(__file__).resolve().parent.parent / ".env"
  62. if env_file.exists():
  63. with open(env_file, encoding="utf-8") as f:
  64. for line in f:
  65. line = line.strip()
  66. if line and not line.startswith("#") and "=" in line:
  67. key, _, val = line.partition("=")
  68. os.environ.setdefault(key.strip(), val.strip())
  69. def normalize_name(name: str) -> str:
  70. """清理药名,去掉拼音行、空格等"""
  71. # 移除拼音行(包含英文字母的行)
  72. lines = name.split('\n')
  73. result = []
  74. for line in lines:
  75. line = line.strip()
  76. if not line:
  77. continue
  78. # 跳过纯拼音行(大量英文字母)
  79. if sum(1 for c in line if c.isascii() and c.isalpha()) > len(line) * 0.4:
  80. continue
  81. # 跳过纯数字行
  82. if line.replace('.', '').replace(' ', '').isdigit():
  83. continue
  84. result.append(line)
  85. return result[0] if result else name.strip()
  86. def parse_docx(filepath: str) -> dict | None:
  87. """解析单个 DOCX 文件为药典条目"""
  88. try:
  89. doc = Document(filepath)
  90. except Exception:
  91. return None
  92. drug_name = None
  93. pinyin = ""
  94. sections = OrderedDict()
  95. current_section = "正文" # 第一个 heading 之前的内容
  96. current_text = []
  97. # 检测并提取 section 头部的正则
  98. section_pattern = re.compile(
  99. r'^【(.+?)】' # 【性状】
  100. r'|^(\S{2,4})$' # 性状, 鉴别 等单独的 section header
  101. )
  102. # 第一次 pass:收集所有段落文本
  103. all_paras = []
  104. for p in doc.paragraphs:
  105. text = p.text.strip()
  106. if not text or text.isspace():
  107. continue
  108. all_paras.append((p.style.name, text))
  109. for style, text in all_paras:
  110. # Heading 1 = 药品名
  111. if 'Heading 1' in style and drug_name is None:
  112. drug_name = normalize_name(text)
  113. continue
  114. # 检测拼音行(紧跟药名之后,单独一段包含字母)
  115. if drug_name and not pinyin and is_pinyin_line(text):
  116. pinyin = text.strip()
  117. continue
  118. # 检测 section 头部
  119. section_matched = detect_section_header(text)
  120. if section_matched and len(text) <= 10:
  121. # 保存上一个 section
  122. if current_text:
  123. sections[current_section] = '\n'.join(current_text).strip()
  124. current_text = []
  125. current_section = section_matched
  126. continue
  127. # 检测行内 section 头部:【性状】xxx 格式
  128. m = re.match(r'^【(.+?)】\s*(.*)', text)
  129. if m:
  130. header = m.group(1)
  131. rest = m.group(2)
  132. # 看看 header 是否匹配已知 section
  133. matched = detect_section_header(f"【{header}】")
  134. if matched:
  135. if current_text:
  136. sections[current_section] = '\n'.join(current_text).strip()
  137. current_text = []
  138. current_section = matched
  139. if rest:
  140. current_text.append(rest)
  141. continue
  142. # 普通段落,追加到当前 section
  143. current_text.append(text)
  144. # 最后一个 section
  145. if current_text:
  146. sections[current_section] = '\n'.join(current_text).strip()
  147. if not drug_name or len(sections) == 0:
  148. return None
  149. # 推断分类和部别
  150. parent_dir = Path(filepath).parent.name
  151. volume = get_volume_group(filepath)
  152. return {
  153. "drug_id": generate_drug_id(drug_name, filepath),
  154. "name": drug_name,
  155. "name_en": "",
  156. "pinyin": pinyin,
  157. "category": infer_category(filepath),
  158. "subcategory": parent_dir if parent_dir not in ("output", "output2", "output3", "output4") else "",
  159. "sections": dict(sections),
  160. "source": {
  161. "version": "2025年版",
  162. "volume": volume,
  163. "page": "",
  164. },
  165. }
  166. def is_pinyin_line(text: str) -> bool:
  167. """判断是否为拼音行"""
  168. alpha_count = sum(1 for c in text if c.isascii() and c.isalpha())
  169. return alpha_count > len(text) * 0.3 and len(text) > 3 and len(text) < 200
  170. def detect_section_header(text: str) -> str | None:
  171. """检测是否为 section 标题行,返回标准 section 名称"""
  172. clean = text.replace("【", "").replace("】", "").strip()
  173. if clean in SECTION_HEADERS:
  174. return SECTION_HEADERS[clean]
  175. return None
  176. def get_volume_group(filepath: str) -> str:
  177. """推断药典部别"""
  178. for key, vol in VOLUME_MAP.items():
  179. if key in filepath:
  180. return vol
  181. return "一部"
  182. def infer_category(filepath: str) -> str:
  183. """推断药品分类"""
  184. path_lower = filepath.lower()
  185. if "output2" in path_lower:
  186. return "化学药"
  187. if "output3" in path_lower:
  188. return "生物制品"
  189. return "中药"
  190. def generate_drug_id(name: str, filepath: str) -> str:
  191. """生成 drug_id"""
  192. vol = get_volume_group(filepath)
  193. prefix = {"一部": "Z", "二部": "H", "三部": "S", "四部": "T"}.get(vol, "Z")
  194. hash_suffix = hashlib.md5(name.encode()).hexdigest()[:6].upper()
  195. return f"{prefix}2025-{hash_suffix}"
  196. def find_docx_files(root_dir: str) -> list[str]:
  197. """扫描所有 DOCX 文件,跳过非药品目录"""
  198. files = []
  199. for dirpath, dirnames, filenames in os.walk(root_dir):
  200. # 跳过非药品目录
  201. dir_basename = os.path.basename(dirpath)
  202. if dir_basename in SKIP_DIRS:
  203. dirnames.clear()
  204. continue
  205. # 跳过临时文件
  206. for f in sorted(filenames):
  207. if f.startswith('~') or f.startswith('.'):
  208. continue
  209. if f.endswith('.docx'):
  210. files.append(os.path.join(dirpath, f))
  211. return files
  212. async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
  213. """调用 Qwen Embedding API"""
  214. async with httpx.AsyncClient(timeout=60) as client:
  215. resp = await client.post(
  216. EMBEDDING_URL,
  217. headers={
  218. "Content-Type": "application/json",
  219. "Authorization": f"Bearer {QWEN_API_KEY}",
  220. },
  221. json={
  222. "model": EMBEDDING_MODEL,
  223. "input": {"texts": texts},
  224. "parameters": {"text_type": text_type},
  225. },
  226. )
  227. data = resp.json()
  228. if data.get("code") and data.get("code") != "" and data.get("code") is not None:
  229. raise RuntimeError(f"Embedding error: {data.get('message', data)}")
  230. return [item["embedding"] for item in data["output"]["embeddings"]]
  231. async def ingest_docx_entries(entries: list[dict], engine, start_idx: int = 0):
  232. """批量向量化 + 入库"""
  233. # 构建 chunk 文本
  234. chunks = []
  235. chunk_meta = []
  236. for entry in entries:
  237. source = f"{entry['source']['version']} {entry['source']['volume']}"
  238. for section_key, section_text in entry["sections"].items():
  239. if not section_text or len(section_text.strip()) < 5:
  240. continue
  241. content = f"【{entry['name']} - {section_key}】\n{section_text}\n\n来源:{source}"
  242. chunks.append(content)
  243. chunk_meta.append({
  244. "drug_id": entry["drug_id"],
  245. "section": section_key,
  246. "content": content,
  247. "source": source,
  248. })
  249. print(f" ✂️ {len(chunks)} 个 chunks,向量化中...")
  250. # 分批向量化
  251. all_vectors = []
  252. for i in range(0, len(chunks), BATCH_SIZE):
  253. batch = chunks[i:i + BATCH_SIZE]
  254. vecs = await get_embeddings(batch, text_type="document")
  255. all_vectors.extend(vecs)
  256. n = min(i + BATCH_SIZE, len(chunks))
  257. print(f" 向量化: {n}/{len(chunks)}")
  258. print(f" ✅ 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
  259. # 写入 drug_chunks
  260. chunk_count = 0
  261. async with engine.begin() as conn:
  262. for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
  263. vec_str = f"[{','.join(str(v) for v in vec)}]"
  264. await conn.execute(
  265. text("""
  266. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
  267. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
  268. ON CONFLICT DO NOTHING
  269. """),
  270. {
  271. "drug_id": meta["drug_id"],
  272. "section": meta["section"],
  273. "content": meta["content"],
  274. "source": meta["source"],
  275. "chunk_index": start_idx + idx,
  276. "embedding": json.dumps(vec),
  277. "vec": vec_str,
  278. },
  279. )
  280. chunk_count += 1
  281. # 写入 drugs 表
  282. drug_count = 0
  283. async with engine.begin() as conn:
  284. seen = set()
  285. for entry in entries:
  286. if entry["drug_id"] in seen:
  287. continue
  288. seen.add(entry["drug_id"])
  289. await conn.execute(
  290. text("""
  291. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
  292. sections, source_version, source_volume, source_page, is_active)
  293. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
  294. :sections, :source_version, :source_volume, :source_page, TRUE)
  295. ON CONFLICT (drug_id) DO UPDATE SET
  296. sections = EXCLUDED.sections,
  297. category = EXCLUDED.category,
  298. updated_at = NOW()
  299. """),
  300. {
  301. "drug_id": entry["drug_id"],
  302. "name": entry["name"],
  303. "name_en": entry.get("name_en", ""),
  304. "pinyin": entry.get("pinyin", ""),
  305. "category": entry.get("category", ""),
  306. "subcategory": entry.get("subcategory", ""),
  307. "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
  308. "source_version": entry["source"]["version"],
  309. "source_volume": entry["source"]["volume"],
  310. "source_page": entry["source"].get("page", ""),
  311. },
  312. )
  313. drug_count += 1
  314. return drug_count, chunk_count
  315. async def main():
  316. load_env()
  317. global QWEN_API_KEY
  318. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  319. if not QWEN_API_KEY:
  320. print("❌ 未设置 QWEN_API_KEY,请在 .env 文件中配置")
  321. sys.exit(1)
  322. print("=" * 60)
  323. print("📂 扫描 DOCX 文件...")
  324. files = find_docx_files(SOURCE_DIR)
  325. print(f" 发现 {len(files)} 个 DOCX 文件")
  326. print("=" * 60)
  327. # 解析所有文件
  328. entries = []
  329. parse_errors = 0
  330. for i, fp in enumerate(files):
  331. entry = parse_docx(fp)
  332. if entry:
  333. entries.append(entry)
  334. if (i + 1) % 500 == 0:
  335. print(f" 解析进度: {i + 1}/{len(files)} (有效: {len(entries)})")
  336. print(f"\n📦 解析完成: {len(entries)} 个有效药品条目 (跳过 {len(files) - len(entries)} 个)")
  337. if not entries:
  338. print("❌ 没有有效数据")
  339. return
  340. # 连接数据库,分批入库
  341. engine = create_async_engine(DB_URL)
  342. total_drugs = 0
  343. total_chunks = 0
  344. # 分批处理(每批 50 个药品,避免 Qwen API 超时)
  345. BATCH = 50
  346. for i in range(0, len(entries), BATCH):
  347. batch = entries[i:i + BATCH]
  348. batch_no = i // BATCH + 1
  349. total_batches = (len(entries) + BATCH - 1) // BATCH
  350. print(f"\n🚀 第 {batch_no}/{total_batches} 批 ({len(batch)} 个药品) ...")
  351. try:
  352. dc, cc = await ingest_docx_entries(batch, engine, start_idx=total_chunks)
  353. total_drugs += dc
  354. total_chunks += cc
  355. print(f" ✅ 入库: {dc} 药品, {cc} chunks")
  356. except Exception as e:
  357. print(f" ❌ 批次失败: {e}")
  358. # 打印第一个药品名用于调试
  359. if batch:
  360. print(f" 首个药品: {batch[0]['name']}")
  361. await engine.dispose()
  362. print("\n" + "=" * 60)
  363. print(f"🎉 全部完成!药品 {total_drugs} 个, chunks {total_chunks} 条")
  364. print("=" * 60)
  365. if __name__ == "__main__":
  366. asyncio.run(main())