Files
PlantAI-dev/gfa_core/gene_family_scope.py
potaserandClaude Opus 5 e641c8b9ab feat: 轻量拓展分析 + WGS2GWAS 阶段支持 + 结果区域重构 + 物种角色解析增强 + 会话 Job 隔离
【轻量拓展分析】
- 新增 gfa_core/extension_stage_lightweight/ 核心模块,支持用户上传代码/数据/原始命令
- 前端卡片式交互流程:候选核对 → 验证 → 结果审查 → 授权发布 → 运行
- Celery 不受限任务 + 轮询机制 + sessionStorage 操作恢复
- 启动脚本新增 BBAV5_LIGHTWEIGHT_* 环境变量

【WGS2GWAS 支持】
- 新增 13 个 WGS 阶段定义(QC → MAF/Missing Filter)
- email_haplotype_region.py 新增 LTR-RT 分析功能

【结果区域重构】
- 单层折叠 → 三组双层折叠:RNA-seq / 基因家族 / WGS2GWAS
- 结果按执行时间排序,保留最新尝试

【物种角色解析增强】
- 新增 _resolve_subfamily_request_species_roles()
- 支持 reference 角色的识别
- 直接 stage 请求保留 intent 中的物种角色

【会话 Job 隔离】
- rebind_job() 跨 job 时创建新会话,返回新 conversation_id

【前端交互增强】
- RAG 文献问答模式
- NL 触发 config/samples 自动填写、亚家族确认表、基因家族成员确认表
- 执行状态从后端 resource_tasks 恢复

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:40:45 +08:00

568 lines
21 KiB
Python
Executable File

"""Gene-family analysis scope resolution.
The legacy pipeline uses ``prefix`` as both an output namespace and an
analysis-range identifier. This module keeps ``prefix`` for compatibility and
adds an explicit scope object that records family, species roles, selected
species and resolved input files.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
import hashlib
import json
import os
import re
from typing import Any, Iterable
ROLE_TARGET = "target"
ROLE_REFERENCE = "reference"
ROLE_COMPARATIVE = "comparative"
ROLE_SEED_REFERENCE = "seed_reference"
@dataclass
class ScopeFile:
original_file_name: str
species: str
file_type: str
role: str
file_scope: str = ""
gene_family: str = ""
ref: bool = False
reference_use: str = ""
path: str = ""
exists: bool = False
size: int | None = None
mtime: float | None = None
@dataclass
class GeneFamilyScope:
family: str
prefix: str
scope_id: str
scope_mode: str
target_species: list[str] = field(default_factory=list)
reference_species: list[str] = field(default_factory=list)
primary_reference_species: list[str] = field(default_factory=list)
identify_blast_reference_species: list[str] = field(default_factory=list)
comparative_species: list[str] = field(default_factory=list)
selected_species: list[str] = field(default_factory=list)
config_path: str = ""
files: dict[str, dict[str, list[dict[str, Any]]]] = field(default_factory=dict)
analysis: dict[str, str] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def clean_text(value: Any) -> str:
if value is None:
return ""
text = str(value).strip()
if text.lower() in {"none", "nan", "null"}:
return ""
return text
def split_csv(value: Any) -> list[str]:
text = clean_text(value)
if not text:
return []
items = re.split(r"[,;,、\s]+", text)
return unique_keep_order(item.strip() for item in items if item.strip())
def unique_keep_order(items: Iterable[str]) -> list[str]:
seen: set[str] = set()
ordered: list[str] = []
for item in items:
if item and item not in seen:
seen.add(item)
ordered.append(item)
return ordered
def parse_bool(value: Any) -> bool:
text = clean_text(value).lower()
return text in {"1", "true", "ture", "yes", "y", "ref"}
def sanitize_token(value: str) -> str:
token = re.sub(r"[^A-Za-z0-9_.-]+", "-", clean_text(value))
token = token.strip(".-_")
return token or "NA"
def normalize_reference_use(value: Any) -> str:
text = clean_text(value).lower().replace("-", "_").replace(" ", "_")
aliases = {
"blast": "identify_blast",
"identify": "identify_blast",
"identify_reference": "identify_blast",
"identify_blast_reference": "identify_blast",
"homology_search": "identify_blast",
"primary": "downstream_primary",
"main_reference": "downstream_primary",
"primary_reference": "downstream_primary",
"downstream": "downstream_primary",
"downstream_reference": "downstream_primary",
"all": "both",
"both_reference": "both",
"no": "none",
"false": "none",
}
return aliases.get(text, text)
def reference_use_includes(value: Any, purpose: str) -> bool:
normalized = normalize_reference_use(value)
if normalized == "both":
return True
if purpose == "identify_blast":
return normalized in {"identify_blast"}
if purpose == "downstream_primary":
return normalized in {"downstream_primary"}
return False
def normalize_role(role: Any, ref: Any = None, file_scope: Any = None) -> str:
text = clean_text(role).lower().replace("-", "_")
scope = clean_text(file_scope).lower().replace("-", "_")
if text in {"target", "targets"}:
return ROLE_TARGET
if text in {"comparative", "comparison", "compare", "outgroup"}:
return ROLE_COMPARATIVE
if text in {"seed_reference", "seed", "known_reference"}:
return ROLE_SEED_REFERENCE
if text in {"ref", "reference", "references"}:
if scope == "known_family":
return ROLE_SEED_REFERENCE
return ROLE_REFERENCE
if parse_bool(ref):
if scope == "known_family":
return ROLE_SEED_REFERENCE
return ROLE_REFERENCE
return ""
def normalize_file_type(file_type: Any, file_scope: Any = None) -> str:
text = clean_text(file_type).lower().replace("-", "_")
scope = clean_text(file_scope).lower().replace("-", "_")
aliases = {
"protein": "pep",
"all_pep": "pep",
"proteome": "pep",
"faa": "pep",
"fa_pep": "pep",
"fasta_pep": "pep",
"annotation": "gff",
"gff3": "gff",
"gtf": "gff",
"dna": "genome",
"genomic": "genome",
"cdna": "cds",
}
text = aliases.get(text, text)
if text == "pep" and scope == "known_family":
return "family_pep"
if text == "cds" and scope == "known_family":
return "family_cds"
return text
def find_config_file(input_dir: Path) -> Path | None:
candidates = [input_dir / "config.xlsx", input_dir / "config.xlsm", input_dir / "config.xls"]
for candidate in candidates:
if candidate.exists():
return candidate
matches = sorted(input_dir.glob("*config*.xlsx"))
return matches[0] if matches else None
def read_config_workbook(config_path: Path) -> tuple[list[dict[str, Any]], dict[str, str]]:
try:
from openpyxl import load_workbook
except Exception as exc: # pragma: no cover - dependency issue is environment-specific.
raise RuntimeError("openpyxl is required to read config.xlsx") from exc
wb = load_workbook(config_path, data_only=True)
rows: list[dict[str, Any]] = []
analysis: dict[str, str] = {}
if "Analysis" in wb.sheetnames:
ws = wb["Analysis"]
headers = [clean_text(cell.value) for cell in next(ws.iter_rows(min_row=1, max_row=1))]
key_idx = headers.index("Key") if "Key" in headers else 0
value_idx = headers.index("Value") if "Value" in headers else 1
for row in ws.iter_rows(min_row=2, values_only=True):
if len(row) <= key_idx:
continue
key = clean_text(row[key_idx])
if not key:
continue
value = clean_text(row[value_idx] if len(row) > value_idx else "")
analysis[key] = value
sheet_name = "config" if "config" in wb.sheetnames else wb.sheetnames[0]
ws = wb[sheet_name]
header_values = [clean_text(cell.value) for cell in next(ws.iter_rows(min_row=1, max_row=1))]
for raw in ws.iter_rows(min_row=2, values_only=True):
row = {header_values[idx]: raw[idx] if idx < len(raw) else None for idx in range(len(header_values))}
original_name = Path(clean_text(row.get("Original_File_Name") or "")).name
if original_name.startswith("Ref_combined."):
continue
if any(clean_text(value) for value in row.values()):
rows.append(row)
return rows, analysis
def resolve_existing_path(input_dir: Path, workspace: Path | None, species: str, family: str, row: dict[str, Any], file_type: str) -> Path | None:
original = clean_text(row.get("Original_File_Name") or row.get("File_Name") or row.get("filename"))
names: list[str] = []
if original:
names.extend([
original,
Path(original).name,
str(Path(species) / Path(original).name),
])
if species:
if file_type == "pep":
names.extend([f"{species}.pep.fasta", f"{species}.pep.fa", f"{species}.protein.fasta"])
elif file_type == "family_pep":
names.extend([
f"{species}.{family}.pep.fa",
f"{species}.{family}.pep.fasta",
str(Path(species) / f"{species}.{family}.pep.fasta"),
str(Path(species) / f"{species}.{family}.pep.fa"),
])
elif file_type == "cds":
names.extend([f"{species}.cds.fasta", f"{species}.cds.fa"])
elif file_type == "family_cds":
names.extend([f"{species}.{family}.cds.fa", f"{species}.{family}.cds.fasta"])
elif file_type == "gff":
names.extend([f"{species}_final.gff3", f"{species}.gff3", f"{species}.gff"])
elif file_type == "genome":
names.extend([f"{species}.genome.fasta", f"{species}.genome.fa", f"{species}.fasta"])
search_dirs = [input_dir]
if workspace:
search_dirs.extend([workspace / "final", workspace])
for base in search_dirs:
for name in unique_keep_order(names):
candidate = base / name
if candidate.exists():
return candidate.resolve()
for base in reference_search_roots(input_dir, workspace):
for name in unique_keep_order(names):
filename = Path(name).name
if not filename:
continue
candidate = base / filename
if candidate.exists():
return candidate.resolve()
try:
matches = sorted(path for path in base.rglob(filename) if path.is_file())
except Exception:
matches = []
if matches:
return matches[0].resolve()
return None
def reference_search_roots(input_dir: Path, workspace: Path | None = None) -> list[Path]:
"""Local reference-data roots used to fill config paths without user prompts."""
candidates: list[Path] = []
for base in [input_dir, *list(input_dir.parents)[:4]]:
candidates.extend([
base / "ref_data",
base / "reference",
base / "references",
base / "data" / "ref_data",
])
if workspace:
for base in [workspace, *list(workspace.parents)[:4]]:
candidates.extend([
base / "ref_data",
base / "reference",
base / "references",
base / "data" / "ref_data",
])
configured_ref = os.environ.get("PLANTAI_REF_DATA_ROOT")
if configured_ref:
candidates.append(Path(configured_ref).expanduser())
roots: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
try:
resolved = candidate.resolve()
except Exception:
continue
key = str(resolved)
if key in seen or not resolved.exists() or not resolved.is_dir():
continue
seen.add(key)
roots.append(resolved)
return roots
def file_record(path: Path | None) -> tuple[str, bool, int | None, float | None]:
if not path or not path.exists():
return "", False, None, None
stat = path.stat()
return str(path), True, stat.st_size, stat.st_mtime
def infer_family(prefix: str, analysis: dict[str, str], rows: list[dict[str, Any]], family_override: str = "") -> str:
if clean_text(family_override):
return clean_text(family_override)
for key in ("Family", "Gene_Family", "family", "gene_family"):
if clean_text(analysis.get(key)):
return clean_text(analysis.get(key))
if "." in clean_text(prefix):
return clean_text(prefix).split(".", 1)[1]
for row in rows:
family = clean_text(row.get("Gene_Family"))
if family:
return family
return ""
def default_prefix(prefix: str, family: str, target_species: list[str]) -> str:
provided = clean_text(prefix)
if provided:
return provided
if family and len(target_species) == 1:
return f"{target_species[0]}.{family}"
if family:
return f"Target.{family}"
return "Target"
def build_scope_id(family: str, targets: list[str], refs: list[str], comps: list[str], mode: str) -> str:
parts = [sanitize_token(family or "family")]
if targets:
parts.append("target-" + "-".join(sanitize_token(x) for x in targets))
if refs:
parts.append("ref-" + "-".join(sanitize_token(x) for x in refs))
if comps:
parts.append("comp-" + "-".join(sanitize_token(x) for x in comps))
if mode and mode != "custom":
parts.append(sanitize_token(mode))
return ".".join(parts)
def scope_hash(scope_id: str, length: int = 8) -> str:
text = sanitize_token(scope_id)
return hashlib.sha1(text.encode("utf-8")).hexdigest()[: max(4, int(length or 8))]
def summarize_species_list(role: str, species: list[str], max_items: int = 3) -> str:
clean = [sanitize_token(x) for x in species if clean_text(x)]
if not clean:
return ""
head = clean[:max_items]
suffix = f"-plus{len(clean) - max_items}" if len(clean) > max_items else ""
return f"{sanitize_token(role)}-" + "-".join(head) + suffix
def build_scope_summary(
family: str,
targets: list[str],
refs: list[str],
comps: list[str],
mode: str,
max_items: int = 3,
) -> str:
"""Human-readable, bounded scope label for filenames/UI.
The full machine scope remains in ``scope_id`` and scope JSON. This
summary intentionally avoids listing every species when users analyze many
species.
"""
parts: list[str] = []
for role, values in (("target", targets), ("ref", refs), ("comp", comps)):
part = summarize_species_list(role, values, max_items=max_items)
if part:
parts.append(part)
if not parts:
parts.append(sanitize_token(family or "family"))
if mode and mode != "custom":
parts.append(sanitize_token(mode))
return ".".join(parts)
def selected_species_for_mode(targets: list[str], refs: list[str], comps: list[str], mode: str) -> list[str]:
mode = clean_text(mode) or "custom"
if mode == "target_only":
return unique_keep_order(targets)
if mode == "target_plus_reference":
return unique_keep_order([*targets, *refs])
if mode == "target_plus_comparative":
return unique_keep_order([*targets, *comps])
if mode == "target_plus_reference_plus_comparative":
return unique_keep_order([*targets, *refs, *comps])
return unique_keep_order([*targets, *refs, *comps])
def build_gene_family_scope(
input_dir: str | Path,
workspace: str | Path | None = None,
prefix: str = "",
family: str = "",
target_species: Iterable[str] | str | None = None,
reference_species: Iterable[str] | str | None = None,
comparative_species: Iterable[str] | str | None = None,
scope_mode: str = "custom",
) -> GeneFamilyScope:
input_path = Path(input_dir).resolve()
workspace_path = Path(workspace).resolve() if workspace else None
config_path = find_config_file(input_path)
rows: list[dict[str, Any]] = []
analysis: dict[str, str] = {}
warnings: list[str] = []
if config_path:
rows, analysis = read_config_workbook(config_path)
else:
warnings.append(f"No config.xlsx found in {input_path}")
family_value = infer_family(prefix, analysis, rows, family)
role_species: dict[str, list[str]] = {
ROLE_TARGET: [],
ROLE_REFERENCE: [],
ROLE_COMPARATIVE: [],
ROLE_SEED_REFERENCE: [],
}
scope_files: dict[str, dict[str, list[dict[str, Any]]]] = {}
for row in rows:
species = clean_text(row.get("Species_Code") or row.get("Species") or row.get("species"))
if not species:
continue
file_scope = clean_text(row.get("File_Scope"))
role = normalize_role(row.get("Species_Role"), row.get("Ref"), file_scope)
reference_use = normalize_reference_use(row.get("Reference_Use"))
if role:
role_species.setdefault(role, [])
if species not in role_species[role]:
role_species[role].append(species)
file_type = normalize_file_type(row.get("File_Type"), file_scope)
if not file_type:
continue
row_family = clean_text(row.get("Gene_Family"))
if row_family and family_value and row_family.lower() != family_value.lower() and file_type in {"family_pep", "family_cds"}:
continue
path = resolve_existing_path(input_path, workspace_path, species, family_value, row, file_type)
path_text, exists, size, mtime = file_record(path)
rec = ScopeFile(
original_file_name=clean_text(row.get("Original_File_Name")),
species=species,
file_type=file_type,
role=role,
file_scope=file_scope,
gene_family=row_family,
ref=parse_bool(row.get("Ref")),
reference_use=reference_use,
path=path_text,
exists=exists,
size=size,
mtime=mtime,
)
scope_files.setdefault(species, {}).setdefault(file_type, []).append(asdict(rec))
analysis_targets = split_csv(analysis.get("Target_Species"))
analysis_refs = split_csv(analysis.get("Reference_Species"))
analysis_primary_refs = split_csv(analysis.get("Primary_Reference_Species"))
analysis_identify_blast_refs = split_csv(analysis.get("Identify_Blast_Reference_Species"))
analysis_comps = split_csv(analysis.get("Comparative_Species"))
target_provided = target_species is not None
ref_provided = reference_species is not None
comp_provided = comparative_species is not None
target_list = split_csv(target_species) if isinstance(target_species, str) else unique_keep_order(target_species or [])
ref_list = split_csv(reference_species) if isinstance(reference_species, str) else unique_keep_order(reference_species or [])
comp_list = split_csv(comparative_species) if isinstance(comparative_species, str) else unique_keep_order(comparative_species or [])
if not target_provided and not target_list:
target_list = analysis_targets or role_species.get(ROLE_TARGET, [])
explicit_primary_refs = analysis_primary_refs or analysis_refs
if not ref_provided and not ref_list:
row_primary_refs = unique_keep_order(
row.get("Species_Code") or row.get("Species") or row.get("species")
for row in rows
if reference_use_includes(row.get("Reference_Use"), "downstream_primary")
)
ref_list = explicit_primary_refs or row_primary_refs or role_species.get(ROLE_REFERENCE, [])
identify_blast_ref_list = analysis_identify_blast_refs
if not identify_blast_ref_list:
identify_blast_ref_list = unique_keep_order(
row.get("Species_Code") or row.get("Species") or row.get("species")
for row in rows
if reference_use_includes(row.get("Reference_Use"), "identify_blast")
)
if not identify_blast_ref_list:
identify_blast_ref_list = unique_keep_order([*role_species.get(ROLE_SEED_REFERENCE, [])])
if analysis_identify_blast_refs and ref_list:
extra = [sp for sp in analysis_identify_blast_refs if sp not in ref_list]
if extra:
warnings.append("Identify_Blast_Reference_Species is used only for identify BLAST and will not be added to downstream reference_species: " + ",".join(extra))
if not analysis_primary_refs and analysis_refs:
warnings.append("Using legacy Analysis.Reference_Species as downstream primary reference; prefer Primary_Reference_Species for new projects.")
if not comp_provided and not comp_list:
comp_list = analysis_comps or role_species.get(ROLE_COMPARATIVE, [])
prefix_value = default_prefix(prefix, family_value, target_list)
selected = selected_species_for_mode(target_list, ref_list, comp_list, scope_mode)
scope_id = build_scope_id(family_value, target_list, ref_list, comp_list, clean_text(scope_mode) or "custom")
return GeneFamilyScope(
family=family_value,
prefix=prefix_value,
scope_id=scope_id,
scope_mode=clean_text(scope_mode) or "custom",
target_species=target_list,
reference_species=ref_list,
primary_reference_species=ref_list,
identify_blast_reference_species=identify_blast_ref_list,
comparative_species=comp_list,
selected_species=selected,
config_path=str(config_path.resolve()) if config_path else "",
files=scope_files,
analysis=analysis,
warnings=warnings,
)
def write_scope(scope: GeneFamilyScope, output_path: str | Path) -> Path:
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(scope.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
return path
def read_scope(scope_path: str | Path) -> dict[str, Any]:
return json.loads(Path(scope_path).read_text(encoding="utf-8"))
def scope_manifest_path(workspace: str | Path, prefix: str, scope_id: str) -> Path:
return Path(workspace) / "final" / f"{sanitize_token(prefix)}.{sanitize_token(scope_id)}.scope.json"
def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
hasher = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(chunk_size), b""):
hasher.update(chunk)
return hasher.hexdigest()