docx_ingest.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  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 docx.oxml.ns import qn
  17. from sqlalchemy.ext.asyncio import create_async_engine
  18. from sqlalchemy import text
  19. from processors.chunker import DrugChunker
  20. # ============================================
  21. # 配置
  22. # ============================================
  23. SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
  24. # 跳过的目录名(非药品内容:凡例、纲要、通则说明等)
  25. SKIP_DIRS = {"索引", "目录", "概述"}
  26. # 从环境变量读取
  27. PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  28. PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
  29. PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  30. PG_USER = os.environ.get("POSTGRES_USER", "postgres")
  31. PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
  32. DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
  33. IMAGES_DIR = os.environ.get("IMAGES_DIR", "/opt/pharmacopoeia-ai/static/images/drugs")
  34. IMAGE_URL_PREFIX = "/images/drugs/"
  35. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  36. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  37. EMBEDDING_MODEL = "text-embedding-v3"
  38. BATCH_SIZE = 10 # 每批向量化的文本数
  39. # Section 头部关键词 → 标准名称映射
  40. SECTION_HEADERS = OrderedDict([
  41. ("处方", "处方"), ("制法", "制法"), ("性状", "性状"),
  42. ("鉴别", "鉴别"), ("检查", "检查"), ("浸出物", "浸出物"),
  43. ("含量测定", "含量测定"), ("含量", "含量测定"),
  44. ("功能与主治", "功能主治"), ("功能", "功能主治"), ("主治", "功能主治"),
  45. ("用法与用量", "用法用量"), ("用法", "用法用量"), ("用量", "用法用量"),
  46. ("注意", "注意事项"), ("注意事项", "注意事项"),
  47. ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
  48. ("制剂", "制剂"), ("附注", "附注"),
  49. ("包装", "包装"), ("有效期", "有效期"),
  50. ("核准日期", "核准日期"), ("修订日期", "修订日期"),
  51. ("执行标准", "执行标准"), ("批准文号", "批准文号"),
  52. ])
  53. # 药典部别映射
  54. VOLUME_MAP = {"output": "一部", "output2": "二部", "output3": "三部", "output4": "四部"}
  55. # 四部子目录 → 具体分类
  56. CATEGORY_OVERRIDE = {
  57. "药用辅料": "药用辅料",
  58. "通用技术要求": "通则",
  59. "指导原则": "通则",
  60. "通用技术要求/指导原则": "通则",
  61. }
  62. def load_env():
  63. env_file = Path(__file__).resolve().parent.parent / ".env"
  64. if env_file.exists():
  65. with open(env_file, encoding="utf-8") as f:
  66. for line in f:
  67. line = line.strip()
  68. if line and not line.startswith("#") and "=" in line:
  69. key, _, val = line.partition("=")
  70. os.environ.setdefault(key.strip(), val.strip())
  71. def _extract_table_text(table) -> str:
  72. """将 python-docx Table 对象转换为可读文本。"""
  73. rows = []
  74. for row in table.rows:
  75. cells = [cell.text.strip() for cell in row.cells]
  76. # 跳过全空行
  77. if any(c for c in cells):
  78. rows.append(" | ".join(cells))
  79. return "\n".join(rows)
  80. def _extract_images(doc, drug_name):
  81. """从 DOCX 提取所有图片,保存到 IMAGES_DIR,返回 {image_rId: url} 映射"""
  82. os.makedirs(IMAGES_DIR, exist_ok=True)
  83. img_map = {}
  84. # 用药名 hash 前 8 位做前缀,避免中文 URL 编码后 nginx 找不到文件
  85. name_hash = hashlib.md5(drug_name.encode()).hexdigest()[:8]
  86. for rId, rel in doc.part.rels.items():
  87. if "image" in rel.reltype:
  88. ext = rel.target_ref.split('.')[-1]
  89. if ext.lower() not in ('jpg', 'jpeg', 'png', 'gif', 'bmp'):
  90. ext = 'png'
  91. fname = f"{name_hash}_{rId}.{ext}"
  92. fpath = os.path.join(IMAGES_DIR, fname)
  93. if not os.path.exists(fpath):
  94. with open(fpath, 'wb') as f:
  95. f.write(rel.target_part.blob)
  96. img_map[rId] = IMAGE_URL_PREFIX + fname
  97. return img_map
  98. def _get_paragraph_images(p_element, img_map):
  99. """检查段落中是否包含图片,返回对应的 img url 列表"""
  100. urls = []
  101. for run in p_element:
  102. if run.tag == qn("w:r"):
  103. for child in run:
  104. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  105. if tag == 'drawing':
  106. # 尝试从 drawing 的子元素中获取 rId
  107. for dchild in child.iter():
  108. if dchild.tag == qn("a:blip"):
  109. embed = dchild.get(qn("r:embed"))
  110. if embed and embed in img_map:
  111. urls.append(img_map[embed])
  112. elif tag == 'pict':
  113. for pchild in child.iter():
  114. if pchild.tag.endswith('}imagedata'):
  115. rid = pchild.get(qn("r:id"))
  116. if rid and rid in img_map:
  117. urls.append(img_map[rid])
  118. return urls
  119. def parse_docx(filepath: str) -> dict | None:
  120. """
  121. 解析单个 DOCX 文件为药典条目。
  122. 药品名 = 文件名(去掉 .docx)
  123. 凡例/纲要类:名称加上部别前缀
  124. 按文档顺序遍历段落和表格,表格内容归入当前 section。
  125. """
  126. filename = Path(filepath).stem
  127. volume = get_volume_group(filepath)
  128. parent_dir = Path(filepath).parent.name
  129. # 凡例/纲要/通则类:名称格式为"一部-凡例"以区分
  130. is_meta = parent_dir in ("凡例", "纲要", "通用技术要求", "指导原则")
  131. display_name = f"{volume}-{filename}" if is_meta else filename
  132. try:
  133. doc = Document(filepath)
  134. except Exception:
  135. return None
  136. # 提取图片
  137. img_map = _extract_images(doc, display_name)
  138. pinyin = ""
  139. sections = OrderedDict()
  140. current_section = "正文"
  141. current_text = []
  142. # 遍历文档 body 中的所有元素(按文档顺序,同时处理段落和表格)
  143. body = doc.element.body
  144. for child in body:
  145. # --- 表格 ---
  146. if child.tag == qn("w:tbl"):
  147. # 找到对应的 python-docx Table 对象
  148. tbl = None
  149. for t in doc.tables:
  150. if t._element is child:
  151. tbl = t
  152. break
  153. if tbl is not None:
  154. tbl_text = _extract_table_text(tbl)
  155. if tbl_text:
  156. current_text.append(f"[表格]\n{tbl_text}")
  157. continue
  158. # --- 段落 ---
  159. if child.tag != qn("w:p"):
  160. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  161. if tag in ('drawing', 'pict') and in_drug:
  162. current_text.append("[图片]")
  163. continue
  164. # 从段落 XML 中提取文本和样式
  165. p_text = ""
  166. p_style = ""
  167. has_image = False
  168. for p_child in child:
  169. if p_child.tag == qn("w:pPr"):
  170. for style_child in p_child:
  171. if style_child.tag == qn("w:pStyle"):
  172. p_style = style_child.get(qn("w:val"), "")
  173. elif p_child.tag == qn("w:r"):
  174. for r_child in p_child:
  175. if r_child.tag == qn("w:t"):
  176. t = r_child.text
  177. if t:
  178. p_text += t
  179. # 检测段落内嵌图片
  180. rt = r_child.tag.split('}')[-1] if '}' in r_child.tag else r_child.tag
  181. if rt in ('drawing', 'pict'):
  182. has_image = True
  183. text = p_text.strip()
  184. # 内嵌图片的段落
  185. if has_image:
  186. img_urls = _get_paragraph_images(child, img_map) if not p_text else []
  187. if not text and img_urls:
  188. img_tag = ' '.join(f'<img src="{u}" style="max-width:100%"/>' for u in img_urls)
  189. current_text.append(img_tag)
  190. continue
  191. elif not text and not img_urls:
  192. current_text.append("[图片]")
  193. continue
  194. if not text or text.isspace():
  195. continue
  196. # Heading 1 通常是药品名(与文件名一致),直接跳过,不加入 sections
  197. if p_style and (p_style.startswith("Heading") or "Heading" in p_style):
  198. continue
  199. # 拼音行(紧跟药名后)
  200. if not pinyin and is_pinyin_line(text):
  201. pinyin = text.strip()
  202. continue
  203. # 独立的 section 标题(短行,阈值放宽以适应较长标题)
  204. section_matched = detect_section_header(text)
  205. if section_matched and len(text) <= 15:
  206. if current_text:
  207. sections[current_section] = "\n".join(current_text).strip()
  208. current_text = []
  209. current_section = section_matched
  210. continue
  211. # 行内 section:【性状】xxx
  212. m = re.match(r'^【(.+?)】\s*(.*)', text)
  213. if m and detect_section_header(m.group(1)):
  214. if current_text:
  215. sections[current_section] = "\n".join(current_text).strip()
  216. current_text = []
  217. current_section = detect_section_header(m.group(1))
  218. if m.group(2):
  219. current_text.append(m.group(2))
  220. continue
  221. # 普通段落
  222. current_text.append(text)
  223. # 最后一个 section
  224. if current_text:
  225. sections[current_section] = '\n'.join(current_text).strip()
  226. # ============================================
  227. # 凡例/纲要特殊处理:按编号拆分(一、二、三... 或 1. 2. 3.)
  228. # ============================================
  229. if parent_dir in ("凡例", "纲要") and "正文" in sections:
  230. sections = split_numbered_content(sections["正文"])
  231. if len(sections) == 0:
  232. return None
  233. category = infer_category(filepath, parent_dir)
  234. return {
  235. "drug_id": generate_drug_id(display_name, filepath),
  236. "name": display_name,
  237. "name_en": "",
  238. "pinyin": pinyin,
  239. "category": category,
  240. "subcategory": infer_subcategory(filepath),
  241. "sections": dict(sections),
  242. "source": {
  243. "version": "2025年版",
  244. "volume": volume,
  245. "page": "",
  246. },
  247. }
  248. def split_numbered_content(text: str) -> OrderedDict:
  249. """
  250. 凡例/纲要类内容按编号拆分为独立 section。
  251. 匹配模式:
  252. - 一、二、三、... 十四、
  253. - (一)(二)...
  254. - 1. 2. 3. 或 1)2)3)
  255. """
  256. # 中文数字
  257. cn_nums = "一二三四五六七八九十百千"
  258. patterns = [
  259. # ^一、或 \n一、 开头(支持"二十一"等复合数字,最大3字)
  260. re.compile(r'(?:^|\n)([' + cn_nums + r']{1,3})[、,,]'),
  261. # (一)开头
  262. re.compile(r'(?:^|\n)(([' + cn_nums + r']{1,3}))'),
  263. # 数字编号
  264. re.compile(r'(?:^|\n)(\d{1,2})[\.\))]'),
  265. ]
  266. # 用第一个模式尝试
  267. result = OrderedDict()
  268. pattern = patterns[0]
  269. matches = list(pattern.finditer(text))
  270. if len(matches) < 2:
  271. # 尝试第二个模式
  272. pattern = patterns[1]
  273. matches = list(pattern.finditer(text))
  274. if len(matches) < 2:
  275. # 尝试第三个模式
  276. pattern = patterns[2]
  277. matches = list(pattern.finditer(text))
  278. if len(matches) < 2:
  279. # 确实没有编号结构,保留原样但限制长度
  280. if len(text) > 3000:
  281. # 按段落粗略切分
  282. paras = [p.strip() for p in text.split('\n') if p.strip()]
  283. buf = ""
  284. idx = 0
  285. for p in paras:
  286. if len(buf) + len(p) > 2000 and buf:
  287. result[f"第{idx + 1}段"] = buf
  288. idx += 1
  289. buf = p
  290. else:
  291. buf += ("\n" if buf else "") + p
  292. if buf:
  293. result[f"第{idx + 1}段"] = buf
  294. return result if result else OrderedDict([("正文", text)])
  295. return OrderedDict([("正文", text)])
  296. # 按编号切分
  297. for i, match in enumerate(matches):
  298. start = match.start()
  299. end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
  300. content = text[start:end].strip()
  301. # 编号字符串(去除换行和括号)
  302. raw_num = match.group(0).lstrip('\n').strip()
  303. section_name = f"凡例-{raw_num}"
  304. if len(content) > 50:
  305. result[section_name] = content
  306. return result if result else OrderedDict([("正文", text)])
  307. def is_pinyin_line(text: str) -> bool:
  308. """检测文本是否为拼音行。
  309. 判断逻辑:
  310. 1. 字母占比 > 30% 且长度在合理范围
  311. 2. 不含常见英文单词(如 drug, tablet, injection 等)
  312. 3. 不含化学元素符号(如 Na, K, HCl 等大写字母组合)
  313. 4. 拼音通常全小写或带声调符号
  314. """
  315. if len(text) <= 3 or len(text) >= 200:
  316. return False
  317. # 检查是否包含英文单词(排除化学药英文名)
  318. english_indicators = [
  319. "tablet", "injection", "capsule", "solution", "sodium",
  320. "chloride", "hydrochloride", "acid", "oxide", "sulfate",
  321. "phosphate", "acetate", "extract", "powder", "granule",
  322. "oral", "intravenous", "topical", "ophthalmic",
  323. "for", "and", "the", "with", "mg", "ml", "μg",
  324. "Tablet", "Injection", "Capsule", "Solution",
  325. ]
  326. text_lower = text.lower()
  327. if any(ind in text_lower for ind in english_indicators):
  328. return False
  329. # 检查是否含大写字母组合(化学式如 HCl, NaOH 等)
  330. if re.search(r'[A-Z]{2,}', text):
  331. return False
  332. # 计算字母占比
  333. alpha_count = sum(1 for c in text if c.isascii() and c.isalpha())
  334. total = len(text.replace(" ", ""))
  335. if total == 0:
  336. return False
  337. alpha_ratio = alpha_count / total
  338. # 拼音行通常字母占比 > 50% 且以空格分词
  339. if alpha_ratio < 0.5:
  340. return False
  341. # 检查是否有拼音声调标记(āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ)
  342. tone_marks = set("āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ")
  343. has_tones = any(c in tone_marks for c in text)
  344. # 拼音行特征:全小写字母 + 空格分词 + 可能带声调
  345. # 单词长度短(拼音音节通常 1-6 个字母)
  346. words = text.split()
  347. if len(words) >= 2 and all(len(w) <= 8 for w in words):
  348. if has_tones:
  349. return True
  350. # 无明确声调时提高阈值,减少误判
  351. return alpha_ratio > 0.7
  352. return has_tones
  353. def detect_section_header(text: str) -> str | None:
  354. clean = text.replace("【", "").replace("】", "").strip()
  355. if clean in SECTION_HEADERS:
  356. return SECTION_HEADERS[clean]
  357. return None
  358. def get_volume_group(filepath: str) -> str:
  359. """从目录结构判断药典部别:检查路径中是否包含 output/output2/output3/output4 作为独立目录"""
  360. for key, vol in VOLUME_MAP.items():
  361. # 用路径分隔符包围,避免 "output" 误匹配 "output2"
  362. if os.sep + key + os.sep in filepath or filepath.endswith(os.sep + key):
  363. return vol
  364. return "一部"
  365. def infer_category(filepath: str, parent_dir: str = "") -> str:
  366. path_lower = filepath.lower()
  367. # 凡例/纲要/通用技术要求
  368. if parent_dir in ("凡例", "纲要"):
  369. return "凡例与纲要"
  370. if parent_dir in ("通用技术要求", "指导原则"):
  371. return "通则"
  372. if "output2" in path_lower: return "化学药"
  373. if "output3" in path_lower: return "生物制品"
  374. if "output4" in path_lower: return _infer_category_vol4(filepath)
  375. return "中药"
  376. def _infer_category_vol4(filepath: str) -> str:
  377. """四部特殊处理"""
  378. for key, cat in CATEGORY_OVERRIDE.items():
  379. if key in filepath:
  380. return cat
  381. return "通则"
  382. def infer_subcategory(filepath: str) -> str:
  383. """从目录层级推断子分类"""
  384. parts = Path(filepath).parts
  385. # 找到 output/output2/output3/output4 之后的子目录名
  386. for i, p in enumerate(parts):
  387. if p in ("output", "output2", "output3", "output4"):
  388. if i + 1 < len(parts):
  389. sub = parts[i + 1]
  390. # 排除数字编号前缀
  391. return sub
  392. return ""
  393. def generate_drug_id(name: str, filepath: str) -> str:
  394. vol = get_volume_group(filepath)
  395. prefix = {"一部": "Z", "二部": "H", "三部": "S", "四部": "T"}.get(vol, "Z")
  396. hash_suffix = hashlib.md5(name.encode()).hexdigest()[:6].upper()
  397. return f"{prefix}2025-{hash_suffix}"
  398. def find_docx_files(root_dir: str) -> list[str]:
  399. """扫描所有 DOCX 文件,跳过非药品目录"""
  400. files = []
  401. for dirpath, dirnames, filenames in os.walk(root_dir):
  402. dir_basename = os.path.basename(dirpath)
  403. if dir_basename in SKIP_DIRS:
  404. dirnames.clear()
  405. continue
  406. for f in sorted(filenames):
  407. if f.startswith('~') or f.startswith('.'):
  408. continue
  409. if f.endswith('.docx'):
  410. files.append(os.path.join(dirpath, f))
  411. return files
  412. async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
  413. async with httpx.AsyncClient(timeout=60) as client:
  414. resp = await client.post(
  415. EMBEDDING_URL,
  416. headers={
  417. "Content-Type": "application/json",
  418. "Authorization": f"Bearer {QWEN_API_KEY}",
  419. },
  420. json={
  421. "model": EMBEDDING_MODEL,
  422. "input": {"texts": texts},
  423. "parameters": {"text_type": text_type},
  424. },
  425. )
  426. data = resp.json()
  427. if data.get("code") and data.get("code") != "" and data.get("code") is not None:
  428. raise RuntimeError(f"Embedding error: {data.get('message', data)}")
  429. return [item["embedding"] for item in data["output"]["embeddings"]]
  430. async def ingest_docx_entries(entries: list[dict], engine, start_idx: int = 0):
  431. """批量向量化 + 入库。长 section 使用 DrugChunker 切分以避免语义稀释。"""
  432. chunker = DrugChunker(chunk_size=1500, chunk_overlap=200, min_chunk_size=200)
  433. chunks = []
  434. chunk_meta = []
  435. for entry in entries:
  436. source = f"{entry['source']['version']} {entry['source']['volume']}"
  437. drug_name = entry["name"]
  438. for section_key, section_text in entry["sections"].items():
  439. if not section_text or len(section_text.strip()) < 5:
  440. continue
  441. # 短 section:直接作为 1 个 chunk
  442. if len(section_text) <= 1500:
  443. content = f"【{drug_name} - {section_key}】\n{section_text}\n\n来源:{source}"
  444. chunks.append(content)
  445. chunk_meta.append({
  446. "drug_id": entry["drug_id"],
  447. "section": section_key,
  448. "content": content,
  449. "source": source,
  450. })
  451. else:
  452. # 长 section:用 DrugChunker 切分为多个 chunk(含 overlap)
  453. source_dict = {
  454. "version": entry["source"]["version"],
  455. "volume": entry["source"]["volume"],
  456. "page": entry["source"].get("page", ""),
  457. }
  458. sub_chunks = chunker._split_long_section(
  459. section_text, drug_name, section_key, source_dict
  460. )
  461. for sub in sub_chunks:
  462. full_content = sub.content + f"\n\n来源:{source}"
  463. chunks.append(full_content)
  464. chunk_meta.append({
  465. "drug_id": entry["drug_id"],
  466. "section": section_key,
  467. "content": full_content,
  468. "source": source,
  469. })
  470. print(f" ✂️ {len(chunks)} 个 chunks,向量化中...")
  471. all_vectors = []
  472. for i in range(0, len(chunks), BATCH_SIZE):
  473. batch = chunks[i:i + BATCH_SIZE]
  474. vecs = await get_embeddings(batch, text_type="document")
  475. all_vectors.extend(vecs)
  476. n = min(i + BATCH_SIZE, len(chunks))
  477. print(f" 向量化: {n}/{len(chunks)}")
  478. print(f" ✅ 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
  479. # 写入 drug_chunks
  480. chunk_count = 0
  481. async with engine.begin() as conn:
  482. for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
  483. vec_str = f"[{','.join(str(v) for v in vec)}]"
  484. await conn.execute(
  485. text("""
  486. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
  487. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
  488. ON CONFLICT DO NOTHING
  489. """),
  490. {
  491. "drug_id": meta["drug_id"],
  492. "section": meta["section"],
  493. "content": meta["content"],
  494. "source": meta["source"],
  495. "chunk_index": start_idx + idx,
  496. "embedding": json.dumps(vec),
  497. "vec": vec_str,
  498. },
  499. )
  500. chunk_count += 1
  501. # 写入 drugs 表
  502. drug_count = 0
  503. async with engine.begin() as conn:
  504. seen = set()
  505. for entry in entries:
  506. if entry["drug_id"] in seen:
  507. continue
  508. seen.add(entry["drug_id"])
  509. await conn.execute(
  510. text("""
  511. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
  512. sections, source_version, source_volume, source_page, is_active)
  513. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
  514. :sections, :source_version, :source_volume, :source_page, TRUE)
  515. ON CONFLICT (drug_id) DO UPDATE SET
  516. sections = EXCLUDED.sections,
  517. category = EXCLUDED.category,
  518. subcategory = EXCLUDED.subcategory,
  519. source_version = EXCLUDED.source_version,
  520. source_volume = EXCLUDED.source_volume,
  521. updated_at = NOW()
  522. """),
  523. {
  524. "drug_id": entry["drug_id"],
  525. "name": entry["name"],
  526. "name_en": entry.get("name_en", ""),
  527. "pinyin": entry.get("pinyin", ""),
  528. "category": entry.get("category", ""),
  529. "subcategory": entry.get("subcategory", ""),
  530. "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
  531. "source_version": entry["source"]["version"],
  532. "source_volume": entry["source"]["volume"],
  533. "source_page": entry["source"].get("page", ""),
  534. },
  535. )
  536. drug_count += 1
  537. return drug_count, chunk_count
  538. async def dedup_old_versions(engine):
  539. """2025 年版为唯一基准,旧版数据不保留。"""
  540. pass
  541. async def main():
  542. load_env()
  543. global QWEN_API_KEY
  544. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  545. if not QWEN_API_KEY:
  546. print("❌ 未设置 QWEN_API_KEY,请在 .env 文件中配置")
  547. sys.exit(1)
  548. print("=" * 60)
  549. print("📂 扫描 DOCX 文件...")
  550. files = find_docx_files(SOURCE_DIR)
  551. print(f" 发现 {len(files)} 个 DOCX 文件")
  552. print("=" * 60)
  553. # 解析并统计
  554. entries = []
  555. skipped = 0
  556. by_category = {}
  557. for i, fp in enumerate(files):
  558. entry = parse_docx(fp)
  559. if entry:
  560. entries.append(entry)
  561. cat = entry["category"]
  562. by_category[cat] = by_category.get(cat, 0) + 1
  563. else:
  564. skipped += 1
  565. if (i + 1) % 1000 == 0:
  566. print(f" 解析进度: {i + 1}/{len(files)} (有效: {len(entries)})")
  567. print(f"\n📦 解析完成: {len(entries)} 个有效条目 (跳过 {skipped} 个)")
  568. print(" 分类分布:")
  569. for cat, cnt in sorted(by_category.items(), key=lambda x: -x[1]):
  570. print(f" {cat}: {cnt}")
  571. if not entries:
  572. print("❌ 没有有效数据")
  573. return
  574. # 连接数据库
  575. engine = create_async_engine(DB_URL)
  576. # 分批入库
  577. total_drugs = 0
  578. total_chunks = 0
  579. BATCH = 50
  580. for i in range(0, len(entries), BATCH):
  581. batch = entries[i:i + BATCH]
  582. batch_no = i // BATCH + 1
  583. total_batches = (len(entries) + BATCH - 1) // BATCH
  584. print(f"\n🚀 第 {batch_no}/{total_batches} 批 ({len(batch)} 个药品) ...")
  585. try:
  586. dc, cc = await ingest_docx_entries(batch, engine, start_idx=total_chunks)
  587. total_drugs += dc
  588. total_chunks += cc
  589. print(f" ✅ 入库: {dc} 药品, {cc} chunks")
  590. except Exception as e:
  591. print(f" ❌ 批次失败: {e}")
  592. if batch:
  593. print(f" 首个药品: {batch[0]['name']}")
  594. # 去重:标记同名旧版药品为 inactive
  595. print("\n🧹 清理旧版重复数据...")
  596. await dedup_old_versions(engine)
  597. await engine.dispose()
  598. print("\n" + "=" * 60)
  599. print(f"🎉 全部完成!新增药品 {total_drugs} 个, chunks {total_chunks} 条")
  600. print("=" * 60)
  601. if __name__ == "__main__":
  602. asyncio.run(main())