import_from_openclaw.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. """
  2. Open Claw 药典数据 → PostgreSQL pgvector 知识库导入脚本
  3. 用法:
  4. # 仅转换格式(不连接数据库)
  5. python3 import_from_openclaw.py --convert-only
  6. # 转换 + 入库(需要PG + DashScope API Key)
  7. python3 import_from_openclaw.py
  8. # 跳过向量化(仅入库药品元数据)
  9. python3 import_from_openclaw.py --no-embedding
  10. """
  11. import json
  12. import asyncio
  13. import os
  14. import sys
  15. import re
  16. import hashlib
  17. from pathlib import Path
  18. from datetime import datetime, timezone
  19. import httpx
  20. # 项目根目录
  21. ROOT = Path(__file__).resolve().parent.parent
  22. DATA_DIR = ROOT / "data"
  23. OUTPUT_DIR = ROOT / "data" / "processed"
  24. OPENCLAW_JSON = DATA_DIR / "all_drugs_final.json"
  25. CONVERTED_JSON = OUTPUT_DIR / "converted_drugs.json"
  26. CHUNKS_JSON = OUTPUT_DIR / "drug_chunks.json"
  27. os.makedirs(OUTPUT_DIR, exist_ok=True)
  28. # ============ 配置 ============
  29. 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. DB_URL = os.environ.get(
  33. "DATABASE_URL",
  34. f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER', 'postgres')}:{os.environ.get('POSTGRES_PASSWORD', 'change-me')}@{os.environ.get('POSTGRES_HOST', 'localhost')}:{os.environ.get('POSTGRES_PORT', '5432')}/{os.environ.get('POSTGRES_DB', 'pharmacopoeia')}"
  35. )
  36. # 尝试从 .env 加载
  37. env_file = ROOT / ".env"
  38. if env_file.exists():
  39. for line in open(env_file):
  40. line = line.strip()
  41. if line and not line.startswith("#") and "=" in line:
  42. key, _, val = line.partition("=")
  43. os.environ.setdefault(key.strip(), val.strip())
  44. API_KEY = os.environ.get("QWEN_API_KEY", API_KEY)
  45. def generate_drug_id(record: dict, idx: int) -> str:
  46. """生成 drug_id"""
  47. docid = record.get("docid", "")
  48. bid = record.get("蒲标网ID", "")
  49. eid = record.get("id", "")
  50. unique = docid or bid or eid or f"{idx:05d}"
  51. category = record.get("药品类别", "")
  52. if category == "化学药":
  53. prefix = "H"
  54. elif category == "生物制品":
  55. prefix = "S"
  56. elif category == "药用辅料":
  57. prefix = "F"
  58. elif "中药" in category:
  59. prefix = "Z"
  60. else:
  61. prefix = "X"
  62. # 使用 MD5 前8位确保唯一性
  63. hash_id = hashlib.md5(str(unique).encode()).hexdigest()[:8]
  64. return f"{prefix}2025{hash_id}"
  65. def parse_source(record: dict) -> dict:
  66. """解析标准来源字段"""
  67. source_text = record.get("标准来源", "")
  68. version = "2025年版"
  69. volume = ""
  70. if "二部" in source_text:
  71. volume = "二部"
  72. elif "三部" in source_text:
  73. volume = "三部"
  74. elif "四部" in source_text:
  75. volume = "四部"
  76. elif "一部" in source_text:
  77. volume = "一部"
  78. elif "生物" in record.get("药品类别", ""):
  79. volume = "三部"
  80. elif "辅料" in record.get("药品类别", ""):
  81. volume = "四部"
  82. return {"version": version, "volume": volume, "page": ""}
  83. def extract_sections(record: dict) -> dict:
  84. """提取药品 sections"""
  85. sections = {}
  86. # 优先使用章节字段
  87. zhangjie = record.get("章节")
  88. if zhangjie and isinstance(zhangjie, dict) and len(zhangjie) > 0:
  89. for key, value in zhangjie.items():
  90. if value and isinstance(value, str) and len(value.strip()) >= 10:
  91. sections[key] = value.strip()
  92. if len(sections) > 0:
  93. return sections
  94. # 从展开字段构建
  95. field_map = {
  96. "性状": ["性状"],
  97. "功效主治": ["功效主治", "定义", "定义/描述"],
  98. "用法用量": ["用法用量"],
  99. "禁忌": ["禁忌"],
  100. "注意事项": ["注意事项"],
  101. "成分": ["成分"],
  102. }
  103. for target_key, source_keys in field_map.items():
  104. for sk in source_keys:
  105. val = record.get(sk, "")
  106. if val and isinstance(val, str) and len(val.strip()) >= 5:
  107. sections[target_key] = val.strip()
  108. break
  109. return sections
  110. def extract_subcategory(record: dict) -> str:
  111. """提取子分类"""
  112. cat = record.get("药品类别", "")
  113. if cat == "生物制品":
  114. detail = record.get("详细分类", "")
  115. sub = record.get("子分类", "")
  116. if sub and detail:
  117. return f"{detail} - {sub}"
  118. return detail or sub or "生物制品"
  119. if cat == "药用辅料":
  120. return "药用辅料"
  121. return record.get("剂型", record.get("类型", ""))
  122. def convert_record(record: dict, idx: int) -> dict:
  123. """将 Open Claw 格式转换为项目标准格式"""
  124. source = parse_source(record)
  125. sections = extract_sections(record)
  126. drug = {
  127. "drug_id": generate_drug_id(record, idx),
  128. "name": record.get("药品名称", "").strip(),
  129. "name_en": record.get("英文名", record.get("拼音", "")).strip(),
  130. "pinyin": record.get("拼音", "").strip(),
  131. "category": record.get("药品类别", "").strip(),
  132. "subcategory": extract_subcategory(record).strip(),
  133. "sections": sections,
  134. "source": source,
  135. }
  136. return drug
  137. async def get_embeddings(texts: list[str]) -> list[list[float]]:
  138. """批量获取 DashScope 嵌入向量"""
  139. async with httpx.AsyncClient(timeout=120) as client:
  140. resp = await client.post(
  141. EMBEDDING_URL,
  142. headers={
  143. "Content-Type": "application/json",
  144. "Authorization": f"Bearer {API_KEY}",
  145. },
  146. json={
  147. "model": EMBEDDING_MODEL,
  148. "input": {"texts": texts},
  149. "parameters": {"text_type": "document"},
  150. },
  151. )
  152. data = resp.json()
  153. if data.get("code") and data["code"] != "":
  154. raise RuntimeError(f"Embedding API error: {data.get('message', data)}")
  155. return [item["embedding"] for item in data["output"]["embeddings"]]
  156. def chunk_drug(drug: dict) -> list[dict]:
  157. """将一个药品切分为多个 chunk"""
  158. source_str = f"{drug['source']['version']} {drug['source']['volume']} {drug['source']['page']}".strip()
  159. chunks = []
  160. base_content = (
  161. f"药品名称:{drug['name']}\n"
  162. f"英文名:{drug['name_en']}\n"
  163. f"拼音:{drug['pinyin']}\n"
  164. f"类别:{drug['category']} / {drug['subcategory']}"
  165. )
  166. if source_str:
  167. base_content += f"\n来源:{source_str}"
  168. # 概览 chunk
  169. chunks.append({
  170. "drug_id": drug["drug_id"],
  171. "section": "概览",
  172. "content": base_content.strip(),
  173. "source": source_str,
  174. "chunk_index": 0,
  175. })
  176. # 各章节 chunk
  177. idx = 1
  178. for section_key, section_text in drug["sections"].items():
  179. if not section_text or len(section_text.strip()) < 20:
  180. continue
  181. content = f"【{drug['name']} - {section_key}】\n{section_text}\n\n来源:{source_str}"
  182. chunks.append({
  183. "drug_id": drug["drug_id"],
  184. "section": section_key,
  185. "content": content.strip(),
  186. "source": source_str,
  187. "chunk_index": idx,
  188. })
  189. idx += 1
  190. return chunks
  191. async def main(convert_only: bool = False, no_embedding: bool = False):
  192. print("=" * 60)
  193. print("Open Claw 药典数据导入工具")
  194. print("=" * 60)
  195. # 1. 读取数据
  196. print(f"\n📖 读取数据: {OPENCLAW_JSON}")
  197. with open(OPENCLAW_JSON, "r", encoding="utf-8") as f:
  198. raw_data = json.load(f)
  199. print(f" 共 {len(raw_data)} 条原始记录")
  200. # 2. 格式转换
  201. print("\n🔄 格式转换中...")
  202. drugs = []
  203. skipped = 0
  204. for idx, record in enumerate(raw_data):
  205. name = record.get("药品名称", "").strip()
  206. if not name:
  207. skipped += 1
  208. continue
  209. drug = convert_record(record, idx)
  210. drugs.append(drug)
  211. print(f" 成功转换 {len(drugs)} 条,跳过 {skipped} 条(无名称)")
  212. # 3. 保存转换结果
  213. with open(CONVERTED_JSON, "w", encoding="utf-8") as f:
  214. json.dump(drugs, f, ensure_ascii=False, indent=2)
  215. print(f" 💾 已保存到: {CONVERTED_JSON}")
  216. if convert_only:
  217. print("\n✅ 格式转换完成(--convert-only)")
  218. return
  219. # 4. 切分 chunk
  220. print("\n✂️ 切分 chunk...")
  221. all_chunks = []
  222. for drug in drugs:
  223. chunks = chunk_drug(drug)
  224. all_chunks.extend(chunks)
  225. print(f" 共切出 {len(all_chunks)} 个 chunk")
  226. with open(CHUNKS_JSON, "w", encoding="utf-8") as f:
  227. json.dump(all_chunks, f, ensure_ascii=False, indent=2)
  228. print(f" 💾 已保存到: {CHUNKS_JSON}")
  229. # 5. 向量化
  230. if no_embedding:
  231. print("\n⏭️ 跳过向量化(--no-embedding)")
  232. print("\n✅ 转换完成。数据准备就绪,可后续执行入库。")
  233. return
  234. if not API_KEY:
  235. print("\n⚠️ 未设置 QWEN_API_KEY,跳过向量化和入库")
  236. print(" 请设置后重新运行: export QWEN_API_KEY=sk-xxx")
  237. return
  238. chunk_texts = [c["content"] for c in all_chunks]
  239. print(f"\n🧬 开始向量化 ({len(chunk_texts)} 个文本)...")
  240. all_vectors = []
  241. batch_size = 10
  242. for i in range(0, len(chunk_texts), batch_size):
  243. batch = chunk_texts[i : i + batch_size]
  244. try:
  245. vectors = await get_embeddings(batch)
  246. all_vectors.extend(vectors)
  247. n = min(i + batch_size, len(chunk_texts))
  248. print(f" 进度: {n}/{len(chunk_texts)}")
  249. except Exception as e:
  250. print(f" ❌ 向量化失败 (batch {i}): {e}")
  251. print(" 请检查 QWEN_API_KEY 是否正确")
  252. return
  253. print(f" ✅ 向量化完成,维度: {len(all_vectors[0]) if all_vectors else 'N/A'}")
  254. # 6. 入库 PostgreSQL
  255. print("\n💾 写入 PostgreSQL...")
  256. try:
  257. from sqlalchemy.ext.asyncio import create_async_engine
  258. from sqlalchemy import text
  259. except ImportError:
  260. print("❌ 缺少 sqlalchemy 依赖,请安装:")
  261. print(" pip3 install sqlalchemy asyncpg")
  262. return
  263. engine = create_async_engine(DB_URL)
  264. # 写入 drug_chunks
  265. count = 0
  266. async with engine.begin() as conn:
  267. for i, (chunk, vec) in enumerate(zip(all_chunks, all_vectors)):
  268. await conn.execute(
  269. text("""
  270. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding)
  271. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding)
  272. """),
  273. {
  274. "drug_id": chunk["drug_id"],
  275. "section": chunk["section"],
  276. "content": chunk["content"],
  277. "source": chunk["source"],
  278. "chunk_index": chunk["chunk_index"],
  279. "embedding": json.dumps(vec),
  280. },
  281. )
  282. count += 1
  283. # 写入 drugs
  284. drug_count = 0
  285. async with engine.begin() as conn:
  286. for drug in drugs:
  287. await conn.execute(
  288. text("""
  289. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory, sections, source_version, source_volume, source_page, is_active)
  290. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory, :sections, :source_version, :source_volume, :source_page, TRUE)
  291. ON CONFLICT (drug_id) DO UPDATE SET
  292. sections = EXCLUDED.sections,
  293. updated_at = NOW()
  294. """),
  295. {
  296. "drug_id": drug["drug_id"],
  297. "name": drug["name"],
  298. "name_en": drug["name_en"] or None,
  299. "pinyin": drug["pinyin"] or None,
  300. "category": drug["category"] or None,
  301. "subcategory": drug["subcategory"] or None,
  302. "sections": json.dumps(drug["sections"], ensure_ascii=False),
  303. "source_version": drug["source"]["version"],
  304. "source_volume": drug["source"]["volume"] or None,
  305. "source_page": drug["source"]["page"] or None,
  306. },
  307. )
  308. drug_count += 1
  309. await engine.dispose()
  310. print(f"\n🎉 入库完成!")
  311. print(f" 药品: {drug_count} 条")
  312. print(f" Chunk: {count} 条")
  313. print(f" 转换结果: {CONVERTED_JSON}")
  314. print(f" Chunk 数据: {CHUNKS_JSON}")
  315. if __name__ == "__main__":
  316. import argparse
  317. parser = argparse.ArgumentParser(description="Open Claw 药典数据导入")
  318. parser.add_argument("--convert-only", action="store_true", help="仅转换格式,不连接数据库")
  319. parser.add_argument("--no-embedding", action="store_true", help="跳过向量化和入库")
  320. args = parser.parse_args()
  321. asyncio.run(main(convert_only=args.convert_only, no_embedding=args.no_embedding))