Files
PlantAI-dev/gfa_core/tools/identify_tool.py
T
2026-08-03 18:42:47 +08:00

355 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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