""" Open Claw 药典数据 → PostgreSQL pgvector 知识库导入脚本 用法: # 仅转换格式(不连接数据库) python3 import_from_openclaw.py --convert-only # 转换 + 入库(需要PG + DashScope API Key) python3 import_from_openclaw.py # 跳过向量化(仅入库药品元数据) python3 import_from_openclaw.py --no-embedding """ import json import asyncio import os import sys import re import hashlib from pathlib import Path from datetime import datetime, timezone import httpx # 项目根目录 ROOT = Path(__file__).resolve().parent.parent DATA_DIR = ROOT / "data" OUTPUT_DIR = ROOT / "data" / "processed" OPENCLAW_JSON = DATA_DIR / "all_drugs_final.json" CONVERTED_JSON = OUTPUT_DIR / "converted_drugs.json" CHUNKS_JSON = OUTPUT_DIR / "drug_chunks.json" os.makedirs(OUTPUT_DIR, exist_ok=True) # ============ 配置 ============ API_KEY = os.environ.get("QWEN_API_KEY", "") EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding" EMBEDDING_MODEL = "text-embedding-v3" DB_URL = os.environ.get( "DATABASE_URL", 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')}" ) # 尝试从 .env 加载 env_file = ROOT / ".env" if env_file.exists(): for line in open(env_file): line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") os.environ.setdefault(key.strip(), val.strip()) API_KEY = os.environ.get("QWEN_API_KEY", API_KEY) def generate_drug_id(record: dict, idx: int) -> str: """生成 drug_id""" docid = record.get("docid", "") bid = record.get("蒲标网ID", "") eid = record.get("id", "") unique = docid or bid or eid or f"{idx:05d}" category = record.get("药品类别", "") if category == "化学药": prefix = "H" elif category == "生物制品": prefix = "S" elif category == "药用辅料": prefix = "F" elif "中药" in category: prefix = "Z" else: prefix = "X" # 使用 MD5 前8位确保唯一性 hash_id = hashlib.md5(str(unique).encode()).hexdigest()[:8] return f"{prefix}2025{hash_id}" def parse_source(record: dict) -> dict: """解析标准来源字段""" source_text = record.get("标准来源", "") version = "2025年版" volume = "" if "二部" in source_text: volume = "二部" elif "三部" in source_text: volume = "三部" elif "四部" in source_text: volume = "四部" elif "一部" in source_text: volume = "一部" elif "生物" in record.get("药品类别", ""): volume = "三部" elif "辅料" in record.get("药品类别", ""): volume = "四部" return {"version": version, "volume": volume, "page": ""} def extract_sections(record: dict) -> dict: """提取药品 sections""" sections = {} # 优先使用章节字段 zhangjie = record.get("章节") if zhangjie and isinstance(zhangjie, dict) and len(zhangjie) > 0: for key, value in zhangjie.items(): if value and isinstance(value, str) and len(value.strip()) >= 10: sections[key] = value.strip() if len(sections) > 0: return sections # 从展开字段构建 field_map = { "性状": ["性状"], "功效主治": ["功效主治", "定义", "定义/描述"], "用法用量": ["用法用量"], "禁忌": ["禁忌"], "注意事项": ["注意事项"], "成分": ["成分"], } for target_key, source_keys in field_map.items(): for sk in source_keys: val = record.get(sk, "") if val and isinstance(val, str) and len(val.strip()) >= 5: sections[target_key] = val.strip() break return sections def extract_subcategory(record: dict) -> str: """提取子分类""" cat = record.get("药品类别", "") if cat == "生物制品": detail = record.get("详细分类", "") sub = record.get("子分类", "") if sub and detail: return f"{detail} - {sub}" return detail or sub or "生物制品" if cat == "药用辅料": return "药用辅料" return record.get("剂型", record.get("类型", "")) def convert_record(record: dict, idx: int) -> dict: """将 Open Claw 格式转换为项目标准格式""" source = parse_source(record) sections = extract_sections(record) drug = { "drug_id": generate_drug_id(record, idx), "name": record.get("药品名称", "").strip(), "name_en": record.get("英文名", record.get("拼音", "")).strip(), "pinyin": record.get("拼音", "").strip(), "category": record.get("药品类别", "").strip(), "subcategory": extract_subcategory(record).strip(), "sections": sections, "source": source, } return drug async def get_embeddings(texts: list[str]) -> list[list[float]]: """批量获取 DashScope 嵌入向量""" async with httpx.AsyncClient(timeout=120) as client: resp = await client.post( EMBEDDING_URL, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", }, json={ "model": EMBEDDING_MODEL, "input": {"texts": texts}, "parameters": {"text_type": "document"}, }, ) data = resp.json() if data.get("code") and data["code"] != "": raise RuntimeError(f"Embedding API error: {data.get('message', data)}") return [item["embedding"] for item in data["output"]["embeddings"]] def chunk_drug(drug: dict) -> list[dict]: """将一个药品切分为多个 chunk""" source_str = f"{drug['source']['version']} {drug['source']['volume']} {drug['source']['page']}".strip() chunks = [] base_content = ( f"药品名称:{drug['name']}\n" f"英文名:{drug['name_en']}\n" f"拼音:{drug['pinyin']}\n" f"类别:{drug['category']} / {drug['subcategory']}" ) if source_str: base_content += f"\n来源:{source_str}" # 概览 chunk chunks.append({ "drug_id": drug["drug_id"], "section": "概览", "content": base_content.strip(), "source": source_str, "chunk_index": 0, }) # 各章节 chunk idx = 1 for section_key, section_text in drug["sections"].items(): if not section_text or len(section_text.strip()) < 20: continue content = f"【{drug['name']} - {section_key}】\n{section_text}\n\n来源:{source_str}" chunks.append({ "drug_id": drug["drug_id"], "section": section_key, "content": content.strip(), "source": source_str, "chunk_index": idx, }) idx += 1 return chunks async def main(convert_only: bool = False, no_embedding: bool = False): print("=" * 60) print("Open Claw 药典数据导入工具") print("=" * 60) # 1. 读取数据 print(f"\n📖 读取数据: {OPENCLAW_JSON}") with open(OPENCLAW_JSON, "r", encoding="utf-8") as f: raw_data = json.load(f) print(f" 共 {len(raw_data)} 条原始记录") # 2. 格式转换 print("\n🔄 格式转换中...") drugs = [] skipped = 0 for idx, record in enumerate(raw_data): name = record.get("药品名称", "").strip() if not name: skipped += 1 continue drug = convert_record(record, idx) drugs.append(drug) print(f" 成功转换 {len(drugs)} 条,跳过 {skipped} 条(无名称)") # 3. 保存转换结果 with open(CONVERTED_JSON, "w", encoding="utf-8") as f: json.dump(drugs, f, ensure_ascii=False, indent=2) print(f" 💾 已保存到: {CONVERTED_JSON}") if convert_only: print("\n✅ 格式转换完成(--convert-only)") return # 4. 切分 chunk print("\n✂️ 切分 chunk...") all_chunks = [] for drug in drugs: chunks = chunk_drug(drug) all_chunks.extend(chunks) print(f" 共切出 {len(all_chunks)} 个 chunk") with open(CHUNKS_JSON, "w", encoding="utf-8") as f: json.dump(all_chunks, f, ensure_ascii=False, indent=2) print(f" 💾 已保存到: {CHUNKS_JSON}") # 5. 向量化 if no_embedding: print("\n⏭️ 跳过向量化(--no-embedding)") print("\n✅ 转换完成。数据准备就绪,可后续执行入库。") return if not API_KEY: print("\n⚠️ 未设置 QWEN_API_KEY,跳过向量化和入库") print(" 请设置后重新运行: export QWEN_API_KEY=sk-xxx") return chunk_texts = [c["content"] for c in all_chunks] print(f"\n🧬 开始向量化 ({len(chunk_texts)} 个文本)...") all_vectors = [] batch_size = 10 for i in range(0, len(chunk_texts), batch_size): batch = chunk_texts[i : i + batch_size] try: vectors = await get_embeddings(batch) all_vectors.extend(vectors) n = min(i + batch_size, len(chunk_texts)) print(f" 进度: {n}/{len(chunk_texts)}") except Exception as e: print(f" ❌ 向量化失败 (batch {i}): {e}") print(" 请检查 QWEN_API_KEY 是否正确") return print(f" ✅ 向量化完成,维度: {len(all_vectors[0]) if all_vectors else 'N/A'}") # 6. 入库 PostgreSQL print("\n💾 写入 PostgreSQL...") try: from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text except ImportError: print("❌ 缺少 sqlalchemy 依赖,请安装:") print(" pip3 install sqlalchemy asyncpg") return engine = create_async_engine(DB_URL) # 写入 drug_chunks count = 0 async with engine.begin() as conn: for i, (chunk, vec) in enumerate(zip(all_chunks, all_vectors)): await conn.execute( text(""" INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding) VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding) """), { "drug_id": chunk["drug_id"], "section": chunk["section"], "content": chunk["content"], "source": chunk["source"], "chunk_index": chunk["chunk_index"], "embedding": json.dumps(vec), }, ) count += 1 # 写入 drugs drug_count = 0 async with engine.begin() as conn: for drug in drugs: await conn.execute( text(""" INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory, sections, source_version, source_volume, source_page, is_active) VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory, :sections, :source_version, :source_volume, :source_page, TRUE) ON CONFLICT (drug_id) DO UPDATE SET sections = EXCLUDED.sections, updated_at = NOW() """), { "drug_id": drug["drug_id"], "name": drug["name"], "name_en": drug["name_en"] or None, "pinyin": drug["pinyin"] or None, "category": drug["category"] or None, "subcategory": drug["subcategory"] or None, "sections": json.dumps(drug["sections"], ensure_ascii=False), "source_version": drug["source"]["version"], "source_volume": drug["source"]["volume"] or None, "source_page": drug["source"]["page"] or None, }, ) drug_count += 1 await engine.dispose() print(f"\n🎉 入库完成!") print(f" 药品: {drug_count} 条") print(f" Chunk: {count} 条") print(f" 转换结果: {CONVERTED_JSON}") print(f" Chunk 数据: {CHUNKS_JSON}") if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Open Claw 药典数据导入") parser.add_argument("--convert-only", action="store_true", help="仅转换格式,不连接数据库") parser.add_argument("--no-embedding", action="store_true", help="跳过向量化和入库") args = parser.parse_args() asyncio.run(main(convert_only=args.convert_only, no_embedding=args.no_embedding))