Files
PlantAI-dev/gfa_core/tools/identify_tool.py
T

355 lines
12 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
"""
Identify Tool - 封装 run_identify.py 为 LLM 可调用的 Tool
"""
import os
import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
# 项目路径配置
PROJECT_ROOT = Path(__file__).resolve().parents[2]
CLI_SCRIPT = PROJECT_ROOT / "apps" / "cli" / "run_identify.py"
INPUT_ROOT = PROJECT_ROOT / "input"
WORKSPACE_ROOT = PROJECT_ROOT / "workspace"
PFAM_DB_DIR = PROJECT_ROOT / "data" / "hmm"
# 常用基因家族的Pfam ID映射(作为RAG不可用时的fallback)
FAMILY_PFAM_MAP = {
"spl": "PF03110", # SBP domain
"sbp": "PF03110", # 同上
"myb": "PF00249", # MYB DNA-binding domain
"wrky": "PF03106", # WRKY domain
"bzip": "PF00170", # bZIP domain
"nac": "PF02365", # NAC domain
"ap2": "PF00847", # AP2 domain
"erf": "PF00847", # AP2/ERF family
"mads": "PF00319", # MADS-box
"homeobox": "PF00046", # Homeodomain
"bhlh": "PF00010", # bHLH domain
"c2h2": "PF00096", # C2H2 zinc finger
"dreb": "PF00847", # AP2/ERF subfamily
}
@dataclass
class IdentifyResult:
"""Identify执行结果"""
success: bool
message: str
manifest_path: Optional[Path] = None
final_members_count: int = 0
final_proteins_path: Optional[Path] = None
report_path: Optional[Path] = None
error: Optional[str] = None
def get_pfam_from_knowledge(family_name: str) -> Tuple[Optional[str], str]:
"""
获取基因家族对应的Pfam ID
策略:
1. 查本地知识库 (FAMILY_PFAM_MAP)
2. 如未来有RAG,可在此扩展
返回: (pfam_id, source说明)
"""
family_lower = family_name.lower().strip()
# 1. 直接匹配
if family_lower in FAMILY_PFAM_MAP:
return FAMILY_PFAM_MAP[family_lower], f"本地知识库精确匹配: {family_lower}"
# 2. 去除常见后缀再匹配 (如 SPL_family -> SPL)
for suffix in ["_family", "family", "_genes", "genes"]:
if family_lower.endswith(suffix):
base_name = family_lower[:-len(suffix)]
if base_name in FAMILY_PFAM_MAP:
return FAMILY_PFAM_MAP[base_name], f"本地知识库匹配(去除后缀): {base_name}"
# 3. 子串匹配
for known_family, pfam_id in FAMILY_PFAM_MAP.items():
if known_family in family_lower or family_lower in known_family:
return pfam_id, f"本地知识库模糊匹配: {known_family}"
return None, "未在知识库中找到,需要用户指定Pfam ID或上传HMM文件"
def parse_prefix(input_prefix: str) -> Tuple[str, str]:
"""
解析prefix,提取物种和家族名称
支持格式:
- "Ft.SPL" -> ("Ft", "SPL")
- "SPL" -> ("auto", "SPL") # 需从输入文件推断物种
- "Ft.SPL.n5" -> ("Ft", "SPL")
"""
parts = input_prefix.split('.')
if len(parts) >= 2:
# 假设第一部分是物种,第二部分是家族
species = parts[0]
family = parts[1]
return species, family
else:
# 只有家族名,物种需推断
return "auto", parts[0]
def find_input_files(job_id: str) -> Dict[str, Path]:
"""
在 input/<job_id>/ 中查找输入文件
返回: {"pep": Path, "gff": Path, "hmm": Path, "reference": Path, ...}
"""
input_dir = INPUT_ROOT / job_id
if not input_dir.exists():
raise FileNotFoundError(f"输入目录不存在: {input_dir}")
files = {"pep": None, "gff": None, "hmm": None, "reference": None, "subfamily": None}
for file_path in input_dir.iterdir():
if not file_path.is_file():
continue
name_lower = file_path.name.lower()
# 蛋白质序列: *.pep.fasta, *.protein.fa, *.faa
if any(x in name_lower for x in [".pep.", ".protein.", ".faa"]):
if name_lower.endswith(('.fasta', '.fa')):
files["pep"] = file_path
# GFF3注释
elif name_lower.endswith('.gff3') or name_lower.endswith('.gff'):
files["gff"] = file_path
# HMM文件 (排除pfam系统文件)
elif name_lower.endswith('.hmm') and 'pfam' not in name_lower:
files["hmm"] = file_path
# 亚家族分组文件
elif 'subfamily' in name_lower or '.geneid.' in name_lower:
files["subfamily"] = file_path
# 参考序列 (用于BLAST)
elif name_lower.endswith(('.fasta', '.fa', '.faa')):
if not files["reference"]: # 第一个非pep文件作为参考
# 检查是否不是pep文件
if not any(x in name_lower for x in [".pep.", ".protein."]):
files["reference"] = file_path
return files
def run_identify(
job_id: str,
prefix: str,
required_pfams: Optional[str] = None,
cpu: int = 8,
dom_i_evalue_max: float = 1e-3,
hmm_cov_min: float = 0.3,
cut_ga: bool = False,
retention_days: int = 7,
) -> IdentifyResult:
"""
执行基因家族鉴定
参数:
job_id: 任务ID (对应input/<job_id>/目录)
prefix: 分析前缀,如 "Ft.SPL"
required_pfams: Pfam ID,如 "PF03110"。如果为None,自动从知识库获取
cpu: 线程数
dom_i_evalue_max: domain i-Evalue阈值
hmm_cov_min: HMM覆盖度阈值
cut_ga: 是否使用Pfam GA阈值
retention_days: 结果保留天数
返回:
IdentifyResult对象
"""
try:
# 1. 检查输入目录
input_dir = INPUT_ROOT / job_id
if not input_dir.exists():
return IdentifyResult(
success=False,
message=f"输入目录不存在: {input_dir}",
error="INPUT_NOT_FOUND"
)
# 2. 查找输入文件
try:
input_files = find_input_files(job_id)
except Exception as e:
return IdentifyResult(
success=False,
message=f"查找输入文件失败: {e}",
error="INPUT_SCAN_ERROR"
)
if not input_files["pep"]:
return IdentifyResult(
success=False,
message=f"在 {input_dir} 中未找到蛋白质序列文件 (*.pep.fasta/*.protein.fa)",
error="PEP_FILE_NOT_FOUND"
)
# 3. 确定Pfam ID
species, family_name = parse_prefix(prefix)
pfam_source = ""
if required_pfams:
# 用户指定了Pfam ID
pfam_ids = required_pfams
pfam_source = f"用户指定: {required_pfams}"
else:
# 从知识库获取
pfam_id, pfam_source = get_pfam_from_knowledge(family_name)
if pfam_id:
pfam_ids = pfam_id
else:
# 检查是否有用户上传的HMM
if input_files["hmm"]:
pfam_ids = None # 使用用户HMM,不需要指定Pfam ID
pfam_source = f"使用用户上传HMM: {input_files['hmm'].name}"
else:
return IdentifyResult(
success=False,
message=f"无法确定基因家族 '{family_name}' 的Pfam ID。请:\n"
f"1. 指定 --required-pfams PFxxxxx\n"
f"2. 上传HMM文件到input/{job_id}/\n"
f"3. 使用已知家族名称: {list(FAMILY_PFAM_MAP.keys())}",
error="PFAM_NOT_FOUND"
)
# 4. 准备workspace
workspace_dir = WORKSPACE_ROOT / job_id
workspace_dir.mkdir(parents=True, exist_ok=True)
# 5. 构建命令
cmd = [
sys.executable,
str(CLI_SCRIPT),
"--input-dir", str(input_dir),
"--workspace", str(workspace_dir),
"--prefix", prefix,
"--cpu", str(cpu),
"--dom-i-evalue-max", str(dom_i_evalue_max),
"--hmm-cov-min", str(hmm_cov_min),
"--retention-days", str(retention_days),
]
if pfam_ids:
cmd.extend(["--required-pfams", pfam_ids])
if cut_ga:
cmd.append("--cut-ga")
# 指定新的Pfam数据库位置
cmd.extend(["--pfam-dir", str(PFAM_DB_DIR)])
# 6. 执行
print(f"[IdentifyTool] 执行命令: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=PROJECT_ROOT
)
# 7. 解析结果
if result.returncode != 0:
return IdentifyResult(
success=False,
message=f"Identify执行失败 (exit code: {result.returncode})\n"
f"stdout: {result.stdout[-2000:]}\n" # 最后2000字符
f"stderr: {result.stderr[-2000:]}",
error="EXECUTION_FAILED"
)
# 8. 读取manifest
manifest_path = workspace_dir / "final" / f"{prefix}.identify.manifest.json"
if not manifest_path.exists():
return IdentifyResult(
success=False,
message=f"执行成功但manifest未生成: {manifest_path}",
error="MANIFEST_NOT_FOUND"
)
with open(manifest_path, 'r') as f:
manifest = json.load(f)
final_members = manifest.get("metrics", {}).get("final_members", 0)
# 提取产物路径
artifacts = manifest.get("artifacts", {})
final_proteins_path = None
report_path = None
if isinstance(artifacts, dict):
if "final_proteins" in artifacts:
final_proteins_path = Path(artifacts["final_proteins"].get("full_path", ""))
if "identify_report" in artifacts:
report_path = Path(artifacts["identify_report"].get("full_path", ""))
return IdentifyResult(
success=True,
message=f"✅ Identify完成\n"
f" 家族: {family_name}\n"
f" Pfam来源: {pfam_source}\n"
f" 输入蛋白: {input_files['pep'].name}\n"
f" 最终成员数: {final_members}\n"
f" Manifest: {manifest_path}",
manifest_path=manifest_path,
final_members_count=final_members,
final_proteins_path=final_proteins_path,
report_path=report_path
)
except Exception as e:
return IdentifyResult(
success=False,
message=f"执行异常: {e}",
error="EXCEPTION"
)
def get_identify_params_help() -> str:
"""返回Identify参数说明,用于LLM理解"""
return """
Identify阶段参数说明:
- prefix: 分析前缀,格式为 "物种.家族名",如 "Ft.SPL"
- required_pfams: Pfam ID,如 "PF03110"。如不指定,自动从知识库获取
- cpu: 线程数 (默认8)
- dom_i_evalue_max: domain i-Evalue阈值 (默认1e-3)
- hmm_cov_min: HMM覆盖度阈值 (默认0.3)
- cut_ga: 使用Pfam GA阈值 (默认False)
常用家族Pfam ID:
""" + "\n".join([f" {k}: {v}" for k, v in FAMILY_PFAM_MAP.items()])
# 测试函数
if __name__ == "__main__":
# 测试知识库查询
print("=== 测试知识库查询 ===")
for test_name in ["SPL", "MYB", "WRKY", "unknown"]:
pfam, source = get_pfam_from_knowledge(test_name)
print(f"{test_name}: {pfam} ({source})")
# 测试文件查找
print("\n=== 测试文件查找 ===")
# 找一个存在的job_id测试
if INPUT_ROOT.exists():
for job_dir in INPUT_ROOT.iterdir():
if job_dir.is_dir():
files = find_input_files(job_dir.name)
print(f"Job {job_dir.name}:")
for k, v in files.items():
print(f" {k}: {v.name if v else None}")
break