# web/pipeline_backend.py import csv import http.client import json import os import re import shlex import shutil import subprocess import sys import time import traceback import urllib.error import urllib.parse import urllib.request import zipfile from dataclasses import dataclass from datetime import datetime, timedelta from functools import lru_cache from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple # 可复现性支持 sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from gfa_core.project_paths import ( INPUT_ROOT, PROJECT_ROOT, WORKSPACE_ROOT, get_conda_env_bin, get_conda_env_rscript, get_configured_path, get_wgs_bcftools, get_wgs_java, ) from gfa_core.reproducibility_v2 import ReproducibilityManager from gfa_core.gene_family_scope import build_gene_family_scope, build_scope_id, read_scope, sanitize_token, write_scope from gfa_core.gene_family_stage_context import apply_stage_context, build_stage_context, member_set_id_for, user_output_prefix_for from gfa_core.mas.effective_members import MemberResolutionError, resolve_effective_members from gfa_core.ppi_string_policy import ( build_ppi_species_diagnostic, recommended_string_reference_species, reference_protein_candidate_paths, species_to_string_code as _shared_species_to_string_code, string_taxon_id, ) from gfa_core.ppi_provenance import ( apply_ppi_provenance, build_ppi_provenance, record_ppi_preflight_decision, ) from gfa_core.string_species_registry import ( ARABIDOPSIS_TAXON_ID, StringSnapshotError, load_string_snapshot, resolve_ppi_string_species, ) from gfa_core.string_rate_gate import ( StringRateGate, is_authoritative_string_https_url, ) from gfa_core.rnaseq_context import ( find_rnaseq_hisat2_index, find_rnaseq_reference_file, resolve_rnaseq_project_context, ) ASAW_BIN = get_conda_env_bin("ASAW") ASAW_RSCRIPT = get_conda_env_rscript("ASAW") TASSEL_PIPELINE = get_configured_path("TASSEL_PIPELINE", "run_pipeline.pl") GWAS_EMMAX_BIN = get_configured_path("GWAS_EMMAX_BIN", "emmax") WGS_BEAGLE_JAR = get_configured_path("WGS_BEAGLE_JAR") # API 客户端支持 from gfa_core.api_clients import ( MultiAPIQuery, PlantCAREClient, PfamClient, NCBIClient, EnsemblPlantsClient, ) # 你要求:上传落到项目 input/;输出在 workspace/ ATH_FAMILY_ROOT = Path("/home/xzq/wutx/data/raw_genome/ath_family") INPUT_ROOT.mkdir(parents=True, exist_ok=True) WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True) RETENTION_DAYS = 7 IMAGE_SUFFIX = {".png", ".jpg", ".jpeg", ".svg", ".webp"} PDF_SUFFIX = {".pdf"} HTML_SUFFIX = {".html", ".htm"} PREPARE_SUBSTAGES = { "prepare_organize": "organize", "prepare_normalize_gff": "normalize_gff", "prepare_longest_transcript": "longest_transcript", "prepare_extract_pep": "extract_pep", "prepare_extract_cds": "extract_cds", "prepare_filter_fasta": "filter_fasta", "prepare_simplify_ncbi": "simplify_ncbi", } CUSTOM_PHYLOGENY_ROUTES = {"custom_phylogeny", "comparative_phylogeny"} NCBI_SPECIES_DEFAULTS = { "at": {"species": "Arabidopsis thaliana", "taxid": "3702"}, "ath": {"species": "Arabidopsis thaliana", "taxid": "3702"}, "os": {"species": "Oryza sativa", "taxid": "4530"}, "zm": {"species": "Zea mays", "taxid": "4577"}, "sl": {"species": "Solanum lycopersicum", "taxid": "4081"}, "ft": {"species": "Fagopyrum tataricum", "taxid": "62330"}, } POPULATION_STAGES = {"pop_pca", "pop_phylogeny", "pop_structure", "pop_ld_decay", "pop_snp_density"} def _resolve_population_vcf(*, explicit: str, input_dir: Path, final_dir: Path) -> Path: """Resolve one population VCF without guessing between competing inputs.""" requested = str(explicit or "").strip() if requested: raw_path = Path(requested).expanduser() candidates = [raw_path] if raw_path.is_absolute() else [ raw_path, PROJECT_ROOT / raw_path, input_dir / raw_path, ] for path in candidates: if path.is_file(): return path.resolve() raise ValueError(f"Population VCF does not exist: {requested}") input_candidates = sorted( path.resolve() for path in input_dir.iterdir() if path.is_file() and path.name.lower().endswith((".vcf", ".vcf.gz")) ) if input_dir.is_dir() else [] if len(input_candidates) == 1: return input_candidates[0] if len(input_candidates) > 1: raise ValueError("Multiple population VCFs found in input; specify populationVcf explicitly") manifest_candidates: list[Path] = [] if final_dir.is_dir(): for manifest_path in sorted(final_dir.glob("*.manifest.json")): try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue if data.get("status") != "success" or data.get("stage") not in POPULATION_STAGES: continue for role, record in (data.get("artifacts") or {}).items(): if role not in {"population_vcf", "vcf", "genotype_vcf"} or not isinstance(record, dict): continue raw = str(record.get("full_path") or "").strip() if not raw: raw_path = str(record.get("path") or "").strip() raw = str(final_dir.parent / raw_path) if raw_path else "" candidate = Path(raw).expanduser() if candidate.is_file(): manifest_candidates.append(candidate.resolve()) unique = sorted(set(manifest_candidates)) if len(unique) == 1: return unique[0] if len(unique) > 1: raise ValueError("Multiple population VCFs declared by final Manifests; specify populationVcf explicitly") raise ValueError("Population VCF not found in input or completed population Manifests") def _population_cli_command( *, stage: str, workspace: Path, job_id: str, prefix: str, vcf: Path, sample_group: str, cpu: int, params: Dict[str, Any], ) -> list[str]: if stage not in POPULATION_STAGES: raise ValueError(f"Unsupported population stage: {stage}") command = [ sys.executable, "-m", f"apps.cli.run_{stage}", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(vcf), ] if sample_group and stage in {"pop_pca", "pop_ld_decay"}: command.extend(["--sample-group", sample_group]) mappings = { "pop_pca": (("plink", "--plink"), ("ld_window_variants", "--ld-window-variants"), ("ld_step_variants", "--ld-step-variants"), ("ld_r2", "--ld-r2"), ("requested_plot_pcs", "--requested-plot-pcs"), ("tassel_covariate_pcs", "--tassel-covariate-pcs")), "pop_phylogeny": (("tassel", "--tassel"), ("fasttree", "--fasttree"), ("java_memory", "--java-memory")), "pop_structure": (("plink", "--plink"), ("admixture", "--admixture"), ("ld_window_variants", "--ld-window-variants"), ("ld_step_variants", "--ld-step-variants"), ("ld_r2", "--ld-r2"), ("k_min", "--k-min"), ("k_max", "--k-max"), ("cv_folds", "--cv-folds"), ("random_seed", "--random-seed"), ("gwas_k", "--gwas-k")), "pop_ld_decay": (("bcftools", "--bcftools"), ("poplddecay", "--poplddecay"), ("plot_one", "--plot-one"), ("plot_multi", "--plot-multi"), ("max_dist_kb", "--max-dist-kb"), ("maf", "--maf"), ("missing", "--missing"), ("bin1", "--bin1"), ("bin2", "--bin2"), ("min_group_samples", "--min-group-samples"), ("invalid_group_policy", "--invalid-group-policy")), "pop_snp_density": (("chromosome_policy", "--chromosome-policy"), ("chromosomes", "--chromosomes"), ("coarse_window_bp", "--coarse-window-bp"), ("fine_window_bp", "--fine-window-bp"), ("rscript", "--rscript")), } for key, flag in mappings[stage]: if params.get(key) not in (None, "", []): command.extend([flag, str(params[key])]) if stage == "pop_structure": command.extend(["--threads", str(cpu)]) return command def _gwas_fmt_cli_command( *, workspace: Path, job_id: str, prefix: str, vcf: Path, tassel_pipeline: str, xmx: str, unsupported_marker_policy: str = "mask", log_file: Optional[Path] = None, ) -> list[str]: """Build the canonical import-safe command used by backend execution.""" command = [ sys.executable, "-m", "apps.cli.run_gwas_tassel", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(vcf), "--tassel-pipeline", str(tassel_pipeline or "run_pipeline.pl"), "--xmx", str(xmx or "10g"), "--unsupported-marker-policy", str(unsupported_marker_policy or "mask"), ] if log_file is not None: command.extend(["--log-file", str(log_file)]) return command def _resolve_gwas_kinship_input( *, workspace: Path, prefix: str, genotype_scope_id: str = "", ) -> Dict[str, Any]: from gfa_core.gwas_upstream_manifest import resolve_upstream_artifact return resolve_upstream_artifact( workspace=workspace, prefix=prefix, producer_stage="gwas_fmt", artifact_role="sorted_vcf", expected_scopes={"genotype_scope_id": genotype_scope_id} if genotype_scope_id else None, ) def _gwas_kinship_cli_command( *, workspace: Path, job_id: str, prefix: str, sorted_vcf: Path, genotype_scope_id: str, tassel_pipeline: str, xmx: str, log_file: Optional[Path] = None, ) -> list[str]: command = [ sys.executable, "-m", "apps.cli.run_gwas_kinship", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(sorted_vcf), "--genotype-scope-id", genotype_scope_id, "--tassel-pipeline", str(tassel_pipeline or "run_pipeline.pl"), "--xmx", str(xmx or "10g"), ] if log_file is not None: command.extend(["--log-file", str(log_file)]) return command def _gwas_trait_cli_command( *, workspace: Path, job_id: str, prefix: str, phenotype: Path, sample_column: str, trait_column: str, trait_name: str, log_file: Optional[Path] = None, ) -> list[str]: command = [ sys.executable, "-m", "apps.cli.run_gwas_trait", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--phenotype", str(phenotype), "--sample-column", sample_column, "--trait-column", trait_column, "--trait-name", trait_name, ] if log_file is not None: command.extend(["--log-file", str(log_file)]) return command def _gwas_cov_cli_command( *, workspace: Path, job_id: str, prefix: str, q_file: Path, fam_file: Path, k: int, genotype_scope_id: str, log_file: Optional[Path] = None, ) -> list[str]: command = [ sys.executable, "-m", "apps.cli.run_gwas_covariate", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--q", str(q_file), "--fam", str(fam_file), "--k", str(k), "--source-genotype-scope-id", genotype_scope_id, ] if log_file is not None: command.extend(["--log-file", str(log_file)]) return command def _gwas_mlm_cli_command( *, workspace: Path, job_id: str, prefix: str, sorted_vcf: Path, kinship: Path, trait: Path, covariate: Path, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, tassel_pipeline: str, xmx: str, log_file: Optional[Path] = None, ) -> list[str]: command = [ sys.executable, "-m", "apps.cli.run_gwas_tassel_mlm", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(sorted_vcf), "--kinship", str(kinship), "--trait", str(trait), "--covariate", str(covariate), "--genotype-scope-id", genotype_scope_id, "--phenotype-scope-id", phenotype_scope_id, "--covariate-scope-id", covariate_scope_id, "--tassel-pipeline", str(tassel_pipeline or "run_pipeline.pl"), "--xmx", str(xmx or "20g"), ] if log_file is not None: command.extend(["--log-file", str(log_file)]) return command def _resolve_gwas_mlm_inputs( *, workspace: Path, prefix: str, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, ) -> Dict[str, Dict[str, Any]]: from gfa_core.gwas_upstream_manifest import resolve_upstream_artifact requests = { "sorted_vcf": ("gwas_fmt", "sorted_vcf", {"genotype_scope_id": genotype_scope_id}), "kinship_matrix": ("gwas_kinship", "kinship_matrix", {"genotype_scope_id": genotype_scope_id}), "trait_table": ("gwas_trait", "trait_table", {"phenotype_scope_id": phenotype_scope_id}), "covariate_table": ("gwas_cov", "covariate_table", { "genotype_scope_id": genotype_scope_id, "covariate_scope_id": covariate_scope_id, }), } return { role: resolve_upstream_artifact( workspace=workspace, prefix=prefix, producer_stage=producer, artifact_role=artifact_role, expected_scopes=scopes, ) for role, (producer, artifact_role, scopes) in requests.items() } def _gwas_plot_cli_command( *, workspace: Path, job_id: str, prefix: str, mlm_pvalues: Path, cmlm_pvalues: Path, mlm_tested_hypotheses: int, cmlm_tested_hypotheses: int, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, source_model_scope_id: str, rscript: str, log_file: Path, ) -> list[str]: return [ sys.executable, "-m", "apps.cli.run_gwas_plot", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--mlm-pvalues", str(mlm_pvalues), "--cmlm-pvalues", str(cmlm_pvalues), "--genotype-scope-id", genotype_scope_id, "--phenotype-scope-id", phenotype_scope_id, "--covariate-scope-id", covariate_scope_id, "--source-model-scope-id", source_model_scope_id, "--mlm-tested-hypotheses", str(mlm_tested_hypotheses), "--cmlm-tested-hypotheses", str(cmlm_tested_hypotheses), "--rscript", str(rscript), "--log-file", str(log_file), ] def _resolve_gwas_plot_inputs( *, workspace: Path, prefix: str, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, source_model_scope_id: str, ) -> Dict[str, Any]: from gfa_core.gwas_upstream_manifest import resolve_gwas_plot_upstream return resolve_gwas_plot_upstream( workspace=workspace, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, source_model_scope_id=source_model_scope_id, ) def _gwas_emmax_cli_command( *, workspace: Path, job_id: str, prefix: str, sorted_vcf: Path, trait: Path, vcf_manifest: Path, trait_manifest: Path, genotype_scope_id: str, phenotype_scope_id: str, plink: str, emmax: str, rscript: str, requested_pcs: int, threads: int, log_file: Path, ) -> list[str]: return [ sys.executable, "-m", "apps.cli.run_gwas_emmax", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(sorted_vcf), "--trait", str(trait), "--vcf-manifest", str(vcf_manifest), "--trait-manifest", str(trait_manifest), "--genotype-scope-id", genotype_scope_id, "--phenotype-scope-id", phenotype_scope_id, "--plink", str(plink), "--emmax", str(emmax), "--rscript", str(rscript), "--requested-pcs", str(requested_pcs), "--threads", str(threads), "--log-file", str(log_file), ] def _resolve_gwas_emmax_inputs( *, workspace: Path, prefix: str, genotype_scope_id: str, phenotype_scope_id: str, ) -> Dict[str, Any]: from gfa_core.gwas_upstream_manifest import resolve_gwas_emmax_upstream return resolve_gwas_emmax_upstream( workspace=workspace, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, ) def _gwas_emplot_cli_command( *, workspace: Path, job_id: str, prefix: str, emmax_pvalues: Path, emmax_manifest: Path, genotype_scope_id: str, phenotype_scope_id: str, source_model_scope_id: str, emmax_tested_hypotheses: int, rscript: str, log_file: Path, ) -> list[str]: return [ sys.executable, "-m", "apps.cli.run_gwas_emmax_plot", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--emmax-pvalues", str(emmax_pvalues), "--emmax-manifest", str(emmax_manifest), "--genotype-scope-id", genotype_scope_id, "--phenotype-scope-id", phenotype_scope_id, "--source-model-scope-id", source_model_scope_id, "--emmax-tested-hypotheses", str(emmax_tested_hypotheses), "--rscript", str(rscript), "--log-file", str(log_file), ] def _resolve_gwas_emplot_inputs( *, workspace: Path, prefix: str, genotype_scope_id: str, phenotype_scope_id: str, source_model_scope_id: str, ) -> Dict[str, Any]: from gfa_core.gwas_upstream_manifest import resolve_gwas_emplot_upstream return resolve_gwas_emplot_upstream( workspace=workspace, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, source_model_scope_id=source_model_scope_id, ) def _gwas_glm_cli_command( *, workspace: Path, job_id: str, prefix: str, sorted_vcf: Path, trait: Path, covariate: Path, vcf_manifest: Path, trait_manifest: Path, covariate_manifest: Path, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, tassel_pipeline: str, xmx: str, log_file: Path, ) -> list[str]: return [ sys.executable, "-m", "apps.cli.run_gwas_tassel_glm", "--workspace", str(workspace), "--job-id", job_id, "--prefix", prefix, "--vcf", str(sorted_vcf), "--trait", str(trait), "--covariate", str(covariate), "--vcf-manifest", str(vcf_manifest), "--trait-manifest", str(trait_manifest), "--covariate-manifest", str(covariate_manifest), "--genotype-scope-id", genotype_scope_id, "--phenotype-scope-id", phenotype_scope_id, "--covariate-scope-id", covariate_scope_id, "--tassel-pipeline", str(tassel_pipeline or "run_pipeline.pl"), "--xmx", str(xmx or "10g"), "--log-file", str(log_file), ] def _resolve_gwas_glm_inputs( *, workspace: Path, prefix: str, genotype_scope_id: str, phenotype_scope_id: str, covariate_scope_id: str, ) -> Dict[str, Any]: from gfa_core.gwas_upstream_manifest import resolve_gwas_glm_upstream return resolve_gwas_glm_upstream( workspace=workspace, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, ) def _run_child_owned_log(command: list[str], cwd: Optional[Path] = None) -> int: """Run a CLI that exclusively owns the explicit standard log it receives.""" env = os.environ.copy() project_root = str(PROJECT_ROOT) pythonpath = [item for item in env.get("PYTHONPATH", "").split(os.pathsep) if item] if project_root not in pythonpath: env["PYTHONPATH"] = os.pathsep.join([project_root, *pythonpath]) completed = subprocess.run( command, cwd=str(cwd) if cwd else None, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) return completed.returncode def _ncbi_species_defaults_from_prefix(prefix: str) -> Dict[str, str]: token = str(prefix or "").strip().split(".", 1)[0].lower() return dict(NCBI_SPECIES_DEFAULTS.get(token) or {}) def phylogeny_route_output_suffix(analysis_route: str) -> str: route = str(analysis_route or "").strip() if route in CUSTOM_PHYLOGENY_ROUTES: return sanitize_token(route) return "" def stage_output_prefix(stage: str, prefix: str, scope_data: Optional[Dict[str, Any]] = None, analysis_route: str = "", member_set_id: str = "") -> str: scope_data = scope_data or {} if not str(scope_data.get("scope_id") or "").strip(): return prefix return user_output_prefix_for( stage, prefix, scope_data, analysis_route=analysis_route, member_set_id=member_set_id, ) def stage_manifest_candidates(final_dir: Path, stage: str, prefix: str, scope_data: Optional[Dict[str, Any]] = None, analysis_route: str = "", member_set_id: str = "") -> List[Path]: scope_data = scope_data or {} candidates: List[Path] = [] output_prefix = stage_output_prefix(stage, prefix, scope_data, analysis_route, member_set_id) candidates.append(final_dir / f"{output_prefix}.{stage}.manifest.json") scope_id = str(scope_data.get("scope_id") or "").strip() if scope_id: candidates.append(final_dir / f"{prefix}.{sanitize_token(scope_id)}.{stage}.manifest.json") candidates.append(final_dir / f"{prefix}.{stage}.manifest.json") deduped: List[Path] = [] seen = set() for item in candidates: key = str(item) if key not in seen: seen.add(key) deduped.append(item) return deduped def first_existing_stage_manifest(final_dir: Path, stage: str, prefix: str, scope_data: Optional[Dict[str, Any]] = None, analysis_route: str = "", member_set_id: str = "") -> Path | None: for candidate in stage_manifest_candidates(final_dir, stage, prefix, scope_data, analysis_route, member_set_id): if candidate.exists() and candidate.stat().st_size > 0: return candidate return None def _float_or_none(value: Any) -> Optional[float]: try: if value is None or value == "": return None return float(value) except (TypeError, ValueError): return None def _int_or_zero(value: Any) -> int: try: if value is None or value == "": return 0 number = int(float(value)) return number if number > 0 else 0 except (TypeError, ValueError): return 0 def _summarize_kaks_results_csv(path: Path) -> Dict[str, Any]: rows: List[Dict[str, Any]] = [] try: with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle) for row in reader: rows.append(row) except Exception: return {} if not rows: return {} ratios = [ value for value in (_float_or_none(row.get("Ka/Ks") or row.get("Ka.Ks")) for row in rows) if value is not None ] ks_values = [ value for value in (_float_or_none(row.get("Ks")) for row in rows) if value is not None ] if not ratios: return {"successful_pairs": len(rows), "total_pairs": len(rows)} return { "total_pairs": len(rows), "successful_pairs": len(rows), "mean_ka_ks": sum(ratios) / len(ratios), "min_ka_ks": min(ratios), "max_ka_ks": max(ratios), "mean_ks": (sum(ks_values) / len(ks_values)) if ks_values else None, "positive_selection_count": sum(1 for value in ratios if value > 1), "purifying_selection_count": sum(1 for value in ratios if value < 1), } def _aggregate_kaks_run_metrics(run_records: List[Dict[str, Any]]) -> Dict[str, Any]: run_summaries: List[Dict[str, Any]] = [] total_pairs = 0 successful_pairs = 0 positive_selection_count = 0 purifying_selection_count = 0 neutral_selection_count = 0 weighted_mean_ka_ks = 0.0 weighted_mean_ks = 0.0 mean_ks_weight = 0 min_ka_ks: Optional[float] = None max_ka_ks: Optional[float] = None for record in run_records: manifest_path = Path(str(record.get("manifest") or "")) result_path = Path(str(record.get("results") or "")) child_manifest: Dict[str, Any] = {} if manifest_path.exists(): try: child_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: child_manifest = {} metrics = child_manifest.get("metrics") if isinstance(child_manifest.get("metrics"), dict) else {} if not metrics and result_path.exists(): metrics = _summarize_kaks_results_csv(result_path) run_total = _int_or_zero(metrics.get("total_pairs")) or _int_or_zero(record.get("pair_count")) run_success = _int_or_zero(metrics.get("successful_pairs")) mean_ka_ks = _float_or_none(metrics.get("mean_ka_ks")) mean_ks = _float_or_none(metrics.get("mean_ks")) run_min = _float_or_none(metrics.get("min_ka_ks")) run_max = _float_or_none(metrics.get("max_ka_ks")) total_pairs += run_total successful_pairs += run_success positive_selection_count += _int_or_zero(metrics.get("positive_selection_count")) purifying_selection_count += _int_or_zero(metrics.get("purifying_selection_count")) neutral_selection_count += _int_or_zero(metrics.get("neutral_selection_count")) if mean_ka_ks is not None and run_success: weighted_mean_ka_ks += mean_ka_ks * run_success if mean_ks is not None and run_success: weighted_mean_ks += mean_ks * run_success mean_ks_weight += run_success if run_min is not None: min_ka_ks = run_min if min_ka_ks is None else min(min_ka_ks, run_min) if run_max is not None: max_ka_ks = run_max if max_ka_ks is None else max(max_ka_ks, run_max) run_summaries.append({ "target_species": record.get("target_species"), "reference_species": record.get("reference_species"), "prefix": record.get("prefix"), "total_pairs": run_total, "successful_pairs": run_success, "mean_ka_ks": mean_ka_ks, "mean_ks": mean_ks, "positive_selection_count": _int_or_zero(metrics.get("positive_selection_count")), "purifying_selection_count": _int_or_zero(metrics.get("purifying_selection_count")), "manifest": str(manifest_path) if manifest_path else "", "results": str(result_path) if result_path else "", }) aggregate: Dict[str, Any] = { "species_runs": len(run_records), "input_pairs": total_pairs, "total_pairs": total_pairs, "successful_pairs": successful_pairs, "positive_selection_count": positive_selection_count, "purifying_selection_count": purifying_selection_count, "neutral_selection_count": neutral_selection_count, "run_summaries": run_summaries, } if successful_pairs: aggregate["mean_ka_ks"] = round(weighted_mean_ka_ks / successful_pairs, 6) if mean_ks_weight: aggregate["mean_ks"] = round(weighted_mean_ks / mean_ks_weight, 6) if min_ka_ks is not None: aggregate["min_ka_ks"] = min_ka_ks if max_ka_ks is not None: aggregate["max_ka_ks"] = max_ka_ks return aggregate def _clean_prefix(value) -> str: if value is None: return "" value = str(value).strip() return value if value and value.lower() not in {"none", "null", "undefined", "nan", "-"} else "" def _truthy(value) -> bool: if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "y", "on"} return bool(value) def _bool_with_default(value, default: bool = False) -> bool: if value is None or value == "": return default if isinstance(value, str): text = value.strip().lower() if text in {"1", "true", "yes", "y", "on"}: return True if text in {"0", "false", "no", "n", "off"}: return False return default return bool(value) def should_disable_cli_subfamily_auto(analysis_route: str = "", label_source: str = "") -> bool: source = str(label_source or "").strip().lower() route = str(analysis_route or "").strip() if source in {"none", "skip", "disabled"}: return True return route == "subfamily_evidence" and source in {"", "auto"} def resolve_subfamily_for_plotting( final_dir: Path, input_dir: Path, prefix: str, scope_id: str = "", label_source: str = "", ) -> Path | None: """Resolve subfamily labels for downstream plots. Empty/auto/default modes are deliberately conservative: formal downstream stages may only consume confirmed labels. Prediction tables are accepted only when a caller explicitly asks for a prediction source. """ source = str(label_source or "").strip() source_lower = source.lower() known_sources = { "auto", "confirmed", "confirmed_subfamily", "confirmed_subfamily.tsv", "confirmed_or_uploaded_subfamily", "uploaded", "uploaded_subfamily", "subfamily", "prediction", "predicted", "reference_classification", "reference_classification.subfamily.tsv", "geneid.subfamily", "geneid_subfamily", } if source_lower in {"none", "skip", "disabled"}: return None explicit_path = Path(source).expanduser() if source and source_lower not in known_sources else None if explicit_path: if not explicit_path.is_absolute(): for base in (final_dir, input_dir, final_dir.parent, Path(".")): candidate = base / explicit_path if candidate.exists() and candidate.stat().st_size > 0: return candidate if explicit_path.exists() and explicit_path.stat().st_size > 0: return explicit_path return None scoped_prefix = f"{prefix}.{sanitize_token(scope_id)}" if scope_id else "" prediction_sources = { "prediction", "predicted", "reference_classification", "reference_classification.subfamily.tsv", "geneid.subfamily", "geneid_subfamily", } prediction_candidates = [ final_dir / f"{scoped_prefix}.reference_classification.subfamily.tsv" if scoped_prefix else None, final_dir / f"{scoped_prefix}.geneID.subfamily" if scoped_prefix else None, input_dir / f"{scoped_prefix}.geneID.subfamily" if scoped_prefix else None, final_dir / f"{prefix}.target_plus_reference.reference_classification.subfamily.tsv", final_dir / f"{prefix}.target_plus_reference.geneID.subfamily", input_dir / f"{prefix}.target_plus_reference.geneID.subfamily", final_dir / f"{prefix}.reference_classification.subfamily.tsv", final_dir / f"{prefix}.geneID.subfamily", input_dir / f"{prefix}.geneID.subfamily", ] if source_lower in prediction_sources: return next((path for path in prediction_candidates if path is not None and path.exists() and path.stat().st_size > 0), None) confirmed_candidates = [ final_dir / f"{scoped_prefix}.confirmed_subfamily.tsv" if scoped_prefix else None, input_dir / f"{scoped_prefix}.confirmed_subfamily.tsv" if scoped_prefix else None, final_dir / f"{prefix}.target_plus_reference.confirmed_subfamily.tsv", input_dir / f"{prefix}.target_plus_reference.confirmed_subfamily.tsv", final_dir / f"{prefix}.confirmed_subfamily.tsv", input_dir / f"{prefix}.confirmed_subfamily.tsv", final_dir / f"{prefix}.subfamily", input_dir / f"{prefix}.subfamily", final_dir / f"{prefix}.subfamily.tsv", input_dir / f"{prefix}.subfamily.tsv", ] return next((path for path in confirmed_candidates if path is not None and path.exists() and path.stat().st_size > 0), None) def materialize_compact_combo_outputs(final_dir: Path, species_prefix: str, combo_manifest: Path | None) -> Dict[str, Path]: """Copy scoped combo outputs to the compact per-species names used by review UI.""" if not combo_manifest or not combo_manifest.exists(): return {} try: manifest = json.loads(combo_manifest.read_text(encoding="utf-8")) except Exception: return {} outputs: Dict[str, Path] = {} for artifact in (manifest.get("artifacts") or {}).values(): if not isinstance(artifact, dict) or artifact.get("artifact") != "combo_plot": continue raw_path = str(artifact.get("full_path") or artifact.get("path") or "").strip() if not raw_path: continue source = Path(raw_path) if not source.is_absolute(): source = final_dir.parent / source if not source.exists() or source.stat().st_size <= 0: continue suffix = source.suffix.lower() if suffix not in {".png", ".pdf"}: continue target = final_dir / f"{species_prefix}.target_plus_reference.combo{suffix}" if source.resolve() != target.resolve(): shutil.copy2(source, target) outputs[suffix] = target return outputs def _read_subfamily_rows(path: Path) -> list[tuple[str, str]]: try: lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() except Exception: return [] rows = [line for line in lines if line.strip() and not line.lstrip().startswith("#")] if not rows: return [] delimiter = "\t" if "\t" in rows[0] else "," first = [part.strip() for part in rows[0].split(delimiter)] lowered = [part.lower() for part in first] has_header = any(key in lowered for key in ["gene_id", "gene", "id", "subfamily", "confirmed_subfamily", "predicted_subfamily"]) parsed: list[tuple[str, str]] = [] if has_header: gene_idx = next((i for i, name in enumerate(lowered) if name in {"gene_id", "gene", "id", "target_gene", "label"}), 0) sub_idx = next((i for i, name in enumerate(lowered) if name in {"confirmed_subfamily", "subfamily", "assigned_subfamily", "predicted_subfamily", "prediction"}), 1 if len(first) > 1 else 0) data_lines = rows[1:] else: gene_idx = 0 sub_idx = 1 data_lines = rows for line in data_lines: parts = [part.strip() for part in line.split(delimiter)] if len(parts) <= max(gene_idx, sub_idx): continue gene_id = parts[gene_idx] subfamily = parts[sub_idx] if gene_id and subfamily: parsed.append((gene_id, subfamily)) return parsed def _read_subfamily_metadata_for_source(path: Path) -> dict[str, dict[str, str]]: """Return per-gene confidence metadata associated with a subfamily table.""" candidates: list[Path] = [] name = path.name for old, new in [ (".confirmed_subfamily.tsv", ".confirmed_subfamily.review.tsv"), (".reference_classification.subfamily.tsv", ".reference_classification.confidence.tsv"), (".reference_classification.subfamily.tsv", ".reference_classification.assignment.tsv"), (".subfamily.tsv", ".assignment.tsv"), (".subfamily.tsv", ".confidence.tsv"), ]: if name.endswith(old): candidates.append(path.with_name(name[: -len(old)] + new)) out: dict[str, dict[str, str]] = {} for candidate in candidates: if not candidate.exists() or candidate.stat().st_size == 0: continue try: with candidate.open(encoding="utf-8", errors="ignore", newline="") as handle: sample = handle.read(2048) handle.seek(0) first_line = sample.splitlines()[0] if sample.splitlines() else "" delimiter = "\t" if "\t" in first_line else "," reader = csv.DictReader(handle, delimiter=delimiter) for row in reader: lowered = {str(k or "").strip().lower(): str(v or "").strip() for k, v in row.items()} gene_id = lowered.get("gene_id") or lowered.get("target_gene") or lowered.get("gene") or lowered.get("id") or lowered.get("label") if not gene_id: continue out[gene_id] = { "confidence": lowered.get("confidence") or lowered.get("score") or "", "note": lowered.get("note") or lowered.get("classification_note") or lowered.get("status") or "", "predicted_subfamily": lowered.get("predicted_subfamily") or lowered.get("assigned_subfamily") or lowered.get("subfamily") or "", } except Exception as exc: print(f"[WARN] Failed to read subfamily confidence metadata {candidate}: {exc}") return out def merge_subfamily_for_scope(final_dir: Path, input_dir: Path, prefix: str, scope_data: dict, label_source: str = "") -> Path | None: """Create one de-duplicated confirmed subfamily table for multi-species plotting. Target prediction tables are only consumed when the caller explicitly asks for a prediction source. This keeps subfamily evidence plots from silently reusing unconfirmed reference_classification output. """ family = str(scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip() scope_id = str(scope_data.get("scope_id") or "").strip() species = [] for group in ("target_species", "reference_species", "comparative_species"): for item in scope_data.get(group) or []: item = str(item or "").strip() if item and item not in species: species.append(item) if not family or not species: return None source_lower = str(label_source or "").strip().lower() allow_prediction = source_lower in { "prediction", "predicted", "reference_classification", "reference_classification.subfamily.tsv", "geneid.subfamily", "geneid_subfamily", } labels: dict[str, tuple[str, int, str]] = {} labels_meta: dict[str, dict[str, str]] = {} used_sources: list[str] = [] for sp in species: candidates: list[tuple[Path, int]] = [ (final_dir / f"{sp}.{family}.confirmed_subfamily.tsv", 0), (final_dir / f"{sp}.{family}.target_plus_reference.confirmed_subfamily.tsv", 0), (input_dir / f"{sp}.{family}.confirmed_subfamily.tsv", 0), (input_dir / f"{sp}.{family}.target_plus_reference.confirmed_subfamily.tsv", 0), (final_dir / f"{sp}.{family}.subfamily", 0), (input_dir / f"{sp}.{family}.subfamily", 0), (final_dir / f"{sp}.{family}.subfamily.tsv", 0), (input_dir / f"{sp}.{family}.subfamily.tsv", 0), ] for pattern, rank in [ (f"{sp}.{family}*target_plus_reference*confirmed_subfamily.tsv", 0), (f"*target-{sp}*target_plus_reference*confirmed_subfamily.tsv", 0), ]: candidates.extend((path, rank) for path in final_dir.glob(pattern)) candidates.extend((path, rank) for path in input_dir.glob(pattern)) if allow_prediction: candidates.extend([ (final_dir / f"{sp}.{family}.reference_classification.subfamily.tsv", 2), (final_dir / f"{sp}.{family}.target_plus_reference.reference_classification.subfamily.tsv", 2), (final_dir / f"{sp}.{family}.geneID.subfamily", 3), (input_dir / f"{sp}.{family}.geneID.subfamily", 3), ]) for pattern, rank in [ (f"{sp}.{family}*target_plus_reference*reference_classification.subfamily.tsv", 2), (f"*target-{sp}*target_plus_reference*reference_classification.subfamily.tsv", 2), ]: candidates.extend((path, rank) for path in final_dir.glob(pattern)) candidates.extend((path, rank) for path in input_dir.glob(pattern)) seen_paths: set[Path] = set() for candidate, rank in sorted(candidates, key=lambda item: (item[1], str(item[0]))): if candidate in seen_paths or not candidate.exists() or candidate.stat().st_size == 0: continue seen_paths.add(candidate) rows = _read_subfamily_rows(candidate) if not rows: continue source_meta = _read_subfamily_metadata_for_source(candidate) used_sources.append(str(candidate)) for gene_id, subfamily in rows: current = labels.get(gene_id) if current is None or rank < current[1]: labels[gene_id] = (subfamily, rank, str(candidate)) labels_meta[gene_id] = source_meta.get(gene_id, {}) if not labels: return None out = final_dir / f"{prefix}.{sanitize_token(scope_id)}.merged_subfamily.tsv" if scope_id else final_dir / f"{prefix}.merged_subfamily.tsv" out.parent.mkdir(parents=True, exist_ok=True) with out.open("w", encoding="utf-8") as handle: handle.write("gene_id\tsubfamily\n") for gene_id in sorted(labels): handle.write(f"{gene_id}\t{labels[gene_id][0]}\n") review_out = out.with_name(out.name.replace(".merged_subfamily.tsv", ".merged_subfamily.review.tsv")) with review_out.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter( handle, fieldnames=["gene_id", "subfamily", "confidence", "note", "predicted_subfamily", "source"], delimiter="\t", ) writer.writeheader() for gene_id in sorted(labels): subfamily, _rank, source = labels[gene_id] meta = labels_meta.get(gene_id, {}) writer.writerow({ "gene_id": gene_id, "subfamily": subfamily, "confidence": meta.get("confidence", ""), "note": meta.get("note", ""), "predicted_subfamily": meta.get("predicted_subfamily", ""), "source": source, }) print(f"[INFO] Merged subfamily labels for phylogeny: {out} ({len(labels)} genes; {len(set(used_sources))} sources)") return out def ensure_protein_fasta_without_stop_markers(protein_fasta: Path, final_dir: Path, prefix: str) -> Path: """Return a job-local FASTA without '*' stop markers when needed.""" if not protein_fasta.exists(): return protein_fasta try: has_stop = False with open(protein_fasta, "r", encoding="utf-8", errors="ignore") as handle: for line in handle: if line.startswith(">"): continue if "*" in line: has_stop = True break if not has_stop: return protein_fasta default_fasta = final_dir / f"{prefix}.final_members.pep.fa" try: is_default_fasta = protein_fasta.resolve() == default_fasta.resolve() except Exception: is_default_fasta = protein_fasta == default_fasta if is_default_fasta: clean_fasta = final_dir / f"{prefix}.final_members.pep.clean.fa" else: clean_name = re.sub(r"(\.fa(?:sta)?)$", r".clean\1", protein_fasta.name, flags=re.IGNORECASE) if clean_name == protein_fasta.name: clean_name = protein_fasta.name + ".clean.fa" clean_fasta = protein_fasta.with_name(clean_name) if clean_fasta.exists() and clean_fasta.stat().st_mtime >= protein_fasta.stat().st_mtime: print(f"[INFO] Using existing stop-marker-clean FASTA: {clean_fasta}") return clean_fasta clean_fasta.parent.mkdir(parents=True, exist_ok=True) seq_count = 0 modified = 0 removed = 0 current_changed = False with open(protein_fasta, "r", encoding="utf-8", errors="ignore") as src, open(clean_fasta, "w", encoding="utf-8") as dst: for line in src: if line.startswith(">"): if seq_count and current_changed: modified += 1 seq_count += 1 current_changed = False dst.write(line) continue count = line.count("*") if count: removed += count current_changed = True line = line.replace("*", "") dst.write(line) if seq_count and current_changed: modified += 1 meta = { "source": str(protein_fasta), "clean_fasta": str(clean_fasta), "sequence_count": seq_count, "modified_sequences": modified, "removed_asterisks": removed, "created_at": datetime.now().isoformat(), "policy": "job-local derived FASTA; original file is unchanged", } clean_fasta.with_suffix(clean_fasta.suffix + ".repair.json").write_text( json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8", ) print(f"[INFO] Removed {removed} '*' stop markers from {modified} sequences: {clean_fasta}") return clean_fasta except Exception as exc: print(f"[WARN] Failed to prepare stop-marker-clean FASTA for {protein_fasta}: {exc}") return protein_fasta def resolve_scope_path(scope_file: str, in_dir: Path, ws: Path) -> Path: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): return candidate return scope_path def resolve_scoped_protein_fasta_from_phylogeny( ws: Path, final_dir: Path, prefix: str, scope_path: Path, in_dir: Optional[Path] = None, ) -> tuple[Path, dict]: scope_data = read_scope(scope_path) manifest_candidates = stage_manifest_candidates(final_dir, "phylogeny", prefix, scope_data) for manifest_file in manifest_candidates: if not manifest_file.exists(): continue try: manifest_data = json.loads(manifest_file.read_text(encoding="utf-8")) except Exception: continue fasta_text = manifest_data.get("parameters", {}).get("scope_input_fasta") if fasta_text: protein_file = Path(fasta_text) if protein_file.exists() and protein_file.stat().st_size > 0: return ensure_protein_fasta_without_stop_markers(protein_file, final_dir, prefix), scope_data fallback = resolve_identify_protein_fasta_for_scope(ws, final_dir, in_dir or INPUT_ROOT / ws.name, prefix, scope_data) if fallback: return fallback, scope_data raise FileNotFoundError( "Cannot find scoped protein FASTA from phylogeny manifest. " "Run phylogeny with the same scope first, or use target_only scope after Identify." ) def resolve_scoped_tree_from_phylogeny( ws: Path, final_dir: Path, prefix: str, scope_path: Path, explicit_tree_file: str = "", ) -> tuple[Path, dict]: scope_data = read_scope(scope_path) if explicit_tree_file: selected_tree = Path(explicit_tree_file).expanduser() if not selected_tree.is_absolute(): selected_tree = ws / selected_tree if selected_tree.exists() and selected_tree.stat().st_size > 0: return selected_tree.resolve(), scope_data raise FileNotFoundError(f"Specified scoped tree file not found: {explicit_tree_file}") manifest_candidates = stage_manifest_candidates(final_dir, "phylogeny", prefix, scope_data) for manifest_file in manifest_candidates: if not manifest_file.exists(): continue try: manifest_data = json.loads(manifest_file.read_text(encoding="utf-8")) except Exception: continue artifacts = manifest_data.get("artifacts", {}) or {} tree_item = artifacts.get("tree") if isinstance(artifacts, dict) else None tree_text = "" if isinstance(tree_item, dict): tree_text = str(tree_item.get("full_path") or tree_item.get("path") or "") elif isinstance(tree_item, str): tree_text = tree_item if tree_text: tree_file = Path(tree_text) if not tree_file.is_absolute(): tree_file = ws / tree_file if tree_file.exists() and tree_file.stat().st_size > 0: return tree_file.resolve(), scope_data scope_id = str(scope_data.get("scope_id") or "") fallback = final_dir / f"{prefix}.tree.nwk" if not scope_id and fallback.exists() and fallback.stat().st_size > 0: return fallback.resolve(), scope_data raise FileNotFoundError( "Cannot find scoped tree from phylogeny manifest. " "Run phylogeny with the same scope before reference classification." ) def annotate_pep_manifest_with_scope(manifest_path: Path, prefix: str, protein_file: Path, scope_path: Path, scope_data: dict, extra_parameters: Optional[dict] = None) -> None: if not manifest_path.exists(): return try: manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return scope_fields = { "scope_file": str(scope_path), "scope_id": scope_data.get("scope_id", ""), "scope_mode": scope_data.get("scope_mode", ""), "target_species": scope_data.get("target_species", []), "reference_species": scope_data.get("reference_species", []), "comparative_species": scope_data.get("comparative_species", []), "selected_species": scope_data.get("selected_species", []), "family": scope_data.get("family", ""), } manifest_data.update(scope_fields) params = manifest_data.setdefault("parameters", {}) if isinstance(params, dict): params.update(scope_fields) params["protein_fasta"] = str(protein_file) if extra_parameters: params.update(extra_parameters) if extra_parameters: manifest_data.update({k: v for k, v in extra_parameters.items() if k not in manifest_data}) input_data = manifest_data.setdefault("input", {}) if isinstance(input_data, dict): input_data["protein_fasta"] = str(protein_file) manifest_path.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") if scope_fields["scope_id"]: scoped_manifest = manifest_path.parent / f"{prefix}.{sanitize_token(str(scope_fields['scope_id']))}.pep.manifest.json" manifest_data["scope_manifest"] = str(scoped_manifest.absolute()) scoped_manifest.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") def resolve_single_target_gene_id_file(ws: Path, in_dir: Path, final_dir: Path, species: str, prefix: str) -> Optional[Path]: family = prefix.split(".")[-1] try: resolved = resolve_effective_members( workspace=ws, final_dir=final_dir, input_dir=in_dir, prefix=f"Target.{species}.{family}" if species and family else prefix, phase="auto", require="id", species=species, family=family, ) if resolved.path_id: if resolved.warnings: print(f"[WARN] {'; '.join(resolved.warnings)}") return resolved.path_id except MemberResolutionError as exc: print(f"[WARN] Effective member ID resolution failed for {prefix}: {exc}") if "Missing confirmed final members" in str(exc): return None candidates = [ final_dir / f"Target.{species}.{family}.curated_members.id", in_dir / f"Target.{species}.{family}.curated_members.id", final_dir / f"{species}.{family}.curated_members.id", in_dir / f"{species}.{family}.curated_members.id", final_dir / f"{prefix}.curated_members.id", in_dir / f"{prefix}.curated_members.id", final_dir / f"Target.{species}.{family}.final_members.id", in_dir / f"Target.{species}.{family}.final_members.id", final_dir / f"{species}.{family}.final_members.id", in_dir / f"{species}.{family}.final_members.id", final_dir / f"{prefix}.final_members.id", in_dir / f"{prefix}.final_members.id", ws / "identify" / species / "final" / f"{species}.final_gfmembers.id", final_dir / f"{species}.final_gfmembers.id", in_dir / f"{species}.final_gfmembers.id", ] for candidate in candidates: if candidate.exists() and candidate.stat().st_size > 0: return candidate return None def find_identify_manifest_for_target(final_dir: Path, prefix: str, species: str, family: str = "") -> Optional[Path]: species = str(species or "").strip() family = str(family or (prefix.split(".")[-1] if "." in prefix else prefix)).strip() if not species: return None candidates = [] exact = final_dir / f"{prefix}.identify.manifest.json" if exact.exists(): candidates.append(exact) candidates.extend(sorted(final_dir.glob("*.identify.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)) seen = set() for candidate in candidates: if candidate in seen: continue seen.add(candidate) try: data = json.loads(candidate.read_text(encoding="utf-8")) except Exception: continue if data.get("stage") != "identify" or data.get("status") not in {"success", "partial_success"}: continue manifest_family = str(data.get("gene_family") or data.get("family") or "").strip() if family and manifest_family and manifest_family != family: continue target_species = {str(x).strip() for x in data.get("target_species") or [] if str(x).strip()} result_species = { str(item.get("species") or "").strip() for item in data.get("species_results") or [] if isinstance(item, dict) and str(item.get("species") or "").strip() } if species in target_species or species in result_species: return candidate return None def first_existing_scope_file(scope_data: dict, species: str, file_types: list[str]) -> Optional[Path]: files_by_species = scope_data.get("files", {}) species_files = files_by_species.get(species, {}) if isinstance(files_by_species, dict) else {} for file_type in file_types: entries = species_files.get(file_type, []) if not isinstance(entries, list): continue for entry in entries: if not isinstance(entry, dict): continue path_text = str(entry.get("path") or "").strip() if not path_text: continue path = Path(path_text) if path.exists() and path.stat().st_size > 0: return path return None def resolve_species_family_pep(ws: Path, final_dir: Path, in_dir: Path, species: str, family: str) -> Optional[Path]: prefix = f"Target.{species}.{family}" if species and family else f"{species}.{family}".strip(".") try: resolved = resolve_effective_members( workspace=ws, final_dir=final_dir, input_dir=in_dir, prefix=prefix, phase="auto", require="pep", species=species, family=family, ) if resolved.path_pep: if resolved.warnings: print(f"[WARN] {'; '.join(resolved.warnings)}") return resolved.path_pep except MemberResolutionError as exc: print(f"[WARN] Effective member FASTA resolution failed for {prefix}: {exc}") if "Missing confirmed final members" in str(exc): return None candidates = [ final_dir / f"Target.{species}.{family}.final_members.pep.fa", in_dir / f"Target.{species}.{family}.final_members.pep.fa", final_dir / f"{species}.final_gfmembers.pep.fa", ws / "identify" / species / "final" / f"{species}.final_gfmembers.pep.fa", final_dir / f"{species}.{family}.final_members.pep.fa", in_dir / f"{species}.{family}.final_members.pep.fa", final_dir / f"{species}.{family}.pep.fa", final_dir / f"{species}.{family}.pep.fasta", in_dir / f"{species}.{family}.pep.fa", in_dir / f"{species}.{family}.pep.fasta", in_dir / f"{species}.pep.fa", in_dir / f"{species}.pep.fasta", ] for candidate in candidates: if candidate.exists() and candidate.stat().st_size > 0: return candidate return None def resolve_member_protein_for_stage_route( *, ws: Path, final_dir: Path, in_dir: Path, prefix: str, analysis_route: str = "", species: str = "", family: str = "", member_set_id: str = "", ) -> tuple[Optional[Path], Optional[str]]: route = str(analysis_route or "").strip() phase = "subfamily_evidence_required" if route == "subfamily_evidence" else "post_confirm_required" try: resolved = resolve_effective_members( workspace=ws, final_dir=final_dir, input_dir=in_dir, prefix=prefix, phase=phase, require="pep", species=species, family=family, member_set_id=member_set_id, ) except MemberResolutionError as exc: if phase == "subfamily_evidence_required": return None, f"❌ 亚家族证据阶段需要先完成 identify 成员初筛确认,生成 curated_members 后才能继续:{exc}" return None, f"❌ 常规基因家族分析需要先完成亚家族确认,生成 final_members 后才能继续:{exc}" if resolved.path_pep: if resolved.warnings: print(f"[WARN] {'; '.join(resolved.warnings)}") print( f"[INFO] {route or 'regular'} member protein source: " f"{resolved.source_kind} ({resolved.member_count} sequences)" ) return ensure_protein_fasta_without_stop_markers(resolved.path_pep, final_dir, prefix), None return None, "❌ 未找到可用的成员蛋白 FASTA。" def resolve_identify_protein_fasta_for_scope(ws: Path, final_dir: Path, in_dir: Path, prefix: str, scope_data: dict) -> Optional[Path]: scope_mode = str((scope_data or {}).get("scope_mode") or "").strip() if scope_mode not in {"", "target_only"}: return None try: resolved = resolve_effective_members( workspace=ws, final_dir=final_dir, input_dir=in_dir, prefix=prefix, phase="auto", require="pep", family=str((scope_data or {}).get("family") or (prefix.split(".")[-1] if "." in prefix else prefix)).strip(), ) if resolved.path_pep: if resolved.warnings: print(f"[WARN] {'; '.join(resolved.warnings)}") return ensure_protein_fasta_without_stop_markers(resolved.path_pep, final_dir, prefix) except MemberResolutionError as exc: print(f"[WARN] Effective identify protein FASTA resolution failed for {prefix}: {exc}") if "Missing confirmed final members" in str(exc): return None candidates = [ final_dir / f"{prefix}.final_members.pep.fa", in_dir / f"{prefix}.final_members.pep.fa", ] family = str((scope_data or {}).get("family") or (prefix.split(".")[-1] if "." in prefix else prefix)).strip() targets = [str(x).strip() for x in (scope_data or {}).get("target_species") or [] if str(x).strip()] if len(targets) == 1: single = resolve_species_family_pep(ws, final_dir, in_dir, targets[0], family) if single: candidates.insert(0, single) for candidate in candidates: if candidate.exists() and candidate.stat().st_size > 0: return ensure_protein_fasta_without_stop_markers(candidate, final_dir, prefix) return None def species_to_string_code(species: str, fallback: str = "ath") -> str: return _shared_species_to_string_code(species, fallback) def resolve_ppi_execution_species(species: str) -> str: """Return a legacy STRING code or a snapshot-verified numeric TaxID.""" raw = str(species or "").strip() if raw.isdigit(): snapshot = load_string_snapshot(STRING_SPECIES_SNAPSHOT_ROOT) if raw not in snapshot.registry: raise ValueError(f"STRING TaxID {raw} is not in the fixed snapshot registry") return raw code = species_to_string_code(raw, "") if not code: raise ValueError(f"Unsupported or empty STRING species: {species!r}") return code def _normalize_species_alias_token(value: str) -> str: return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower()) def _preferred_species_code(code: str) -> str: raw = str(code or "").strip() preferred = { "at": "At", "ath": "At", "ft": "Ft", "os": "Os", "osa": "Os", "zm": "Zm", "zma": "Zm", "gm": "Gm", "gma": "Gm", "sl": "Sl", "sly": "Sl", } return preferred.get(raw.lower(), raw) @lru_cache(maxsize=32) def _species_alias_map_for_input_dir(input_dir_text: str) -> Dict[str, str]: aliases: Dict[str, str] = {} def add(alias: Any, code: str) -> None: code = _preferred_species_code(str(code or "").strip()) alias_text = str(alias or "").strip() if not code or not alias_text: return aliases[alias_text.lower()] = code aliases[_normalize_species_alias_token(alias_text)] = code alias_file = PROJECT_ROOT / "gfa_core" / "config" / "species_aliases.json" if alias_file.exists(): try: data = json.loads(alias_file.read_text(encoding="utf-8")) if isinstance(data, dict): for code, names in data.items(): add(code, code) if isinstance(names, list): for name in names: add(name, code) except Exception as exc: print(f"[WARN] Failed to read species aliases: {exc}") input_dir = Path(input_dir_text) config_xlsx = input_dir / "config.xlsx" if config_xlsx.exists(): try: from openpyxl import load_workbook wb = load_workbook(config_xlsx, read_only=True, data_only=True) for sheet_name in ("config", "Analysis", "_autofill_review"): if sheet_name not in wb.sheetnames: continue ws = wb[sheet_name] rows = ws.iter_rows(values_only=True) headers = [str(x or "").strip() for x in (next(rows, []) or [])] if "Species_Code" not in headers: continue code_idx = headers.index("Species_Code") name_indexes = [ idx for idx, header in enumerate(headers) if header in {"Species_Name", "Scientific_Name", "Latin_Name", "Species"} ] for row in rows: code = str(row[code_idx] or "").strip() if code_idx < len(row) else "" if not code: continue add(code, code) for idx in name_indexes: if idx < len(row): add(row[idx], code) except Exception as exc: print(f"[WARN] Failed to read config species aliases from {config_xlsx}: {exc}") return aliases def canonicalize_species_code_for_job(input_dir: Path, species: str) -> str: raw = str(species or "").strip() if not raw: return "" aliases = _species_alias_map_for_input_dir(str(input_dir)) return aliases.get(raw.lower()) or aliases.get(_normalize_species_alias_token(raw)) or raw def canonicalize_species_codes_for_job(input_dir: Path, species_values: list[str]) -> list[str]: seen = set() out = [] for value in species_values or []: mapped = canonicalize_species_code_for_job(input_dir, str(value)) if mapped and mapped not in seen: out.append(mapped) seen.add(mapped) return out _PPI_IDENTITY_NAME_COLUMNS = ( "Scientific_Name", "Latin_Name", "Species_Name", ) STRING_SPECIES_SNAPSHOT_ROOT = PROJECT_ROOT / "data" / "string" / "v12.0" PPI_REFERENCE_POLICY_ID = "string_species_tree_v12_topology_v1" _PPI_IDENTITY_TAXON_COLUMNS = ( "Taxon_ID", "TaxID", "NCBI_TaxID", ) def _normalize_ppi_scientific_name(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "").strip().replace("_", " ")) def _normalize_ppi_taxon_id(value: Any) -> str: raw = str(value or "").strip() if raw.endswith(".0") and raw[:-2].isdigit(): raw = raw[:-2] return raw if raw.isdigit() else "" def _read_job_species_identity(input_dir: Path, species_code: str) -> Dict[str, Any]: config_path = Path(input_dir) / "config.xlsx" scientific_names: Dict[str, str] = {} taxon_ids: set[str] = set() if not config_path.exists(): return {"scientific_names": [], "taxon_ids": []} try: from openpyxl import load_workbook workbook = load_workbook(config_path, read_only=True, data_only=True) try: for sheet_name in ("config", "Analysis", "_autofill_review"): if sheet_name not in workbook.sheetnames: continue rows = workbook[sheet_name].iter_rows(values_only=True) headers = [str(item or "").strip() for item in (next(rows, []) or [])] if "Species_Code" not in headers: continue code_index = headers.index("Species_Code") name_indexes = [headers.index(name) for name in _PPI_IDENTITY_NAME_COLUMNS if name in headers] taxon_indexes = [headers.index(name) for name in _PPI_IDENTITY_TAXON_COLUMNS if name in headers] for row in rows: row_code = str(row[code_index] or "").strip() if code_index < len(row) else "" if row_code.casefold() != str(species_code or "").strip().casefold(): continue for index in name_indexes: name = _normalize_ppi_scientific_name(row[index] if index < len(row) else "") if name: scientific_names.setdefault(name.casefold(), name) for index in taxon_indexes: taxon_id = _normalize_ppi_taxon_id(row[index] if index < len(row) else "") if taxon_id: taxon_ids.add(taxon_id) break finally: workbook.close() except Exception as exc: return { "scientific_names": [], "taxon_ids": [], "read_error": str(exc), } response = { "scientific_names": list(scientific_names.values()), "taxon_ids": sorted(taxon_ids, key=lambda value: int(value)), } return response def resolve_ppi_target_identity( input_dir: Path, species_code: str, *, explicit_scientific_name: str = "", explicit_tree_taxon_id: str = "", ) -> Dict[str, Any]: """Resolve job-local target identity without making a STRING support decision.""" code = str(species_code or "").strip() configured = _read_job_species_identity(Path(input_dir), code) configured_names = configured.get("scientific_names") or [] configured_taxa = configured.get("taxon_ids") or [] config_name = configured_names[0] if len(configured_names) == 1 else "" config_taxon_id = configured_taxa[0] if len(configured_taxa) == 1 else "" explicit_name = _normalize_ppi_scientific_name(explicit_scientific_name) explicit_taxon_raw = str(explicit_tree_taxon_id or "").strip() explicit_taxon_id = _normalize_ppi_taxon_id(explicit_taxon_raw) base = { "species_code": code, "scientific_name": "", "tree_taxon_id": "", "identity_source": "", "explicit_scientific_name": explicit_name, "explicit_tree_taxon_id": explicit_taxon_raw, "config_scientific_name": config_name, "config_tree_taxon_id": config_taxon_id, } if configured.get("read_error"): return { **base, "status": "unresolved", "issue_code": "target_identity_config_unreadable", "message": f"Cannot read target species identity from config.xlsx: {configured['read_error']}", } if len(configured_names) > 1 or len(configured_taxa) > 1: return { **base, "status": "conflict", "issue_code": "target_species_identity_ambiguous", "message": "Target species has multiple scientific names or TaxIDs in config.xlsx.", } if explicit_taxon_raw and not explicit_taxon_id: return { **base, "status": "unresolved", "issue_code": "target_taxon_id_invalid", "message": "The explicit target tree TaxID must contain digits only.", } if explicit_taxon_id and config_taxon_id and explicit_taxon_id != config_taxon_id: return { **base, "status": "conflict", "issue_code": "target_taxon_id_conflict", "message": "Explicit target tree TaxID conflicts with the unique TaxID in config.xlsx.", } if explicit_name and config_name and explicit_name.casefold() != config_name.casefold(): return { **base, "status": "conflict", "issue_code": "target_scientific_name_conflict", "message": "Explicit target scientific name conflicts with the unique scientific name in config.xlsx.", } if explicit_taxon_id: return { **base, "status": "resolved", "issue_code": "", "message": "Target identity uses the explicit tree TaxID.", "scientific_name": explicit_name or config_name, "tree_taxon_id": explicit_taxon_id, "identity_source": "explicit_taxon_id", } if config_taxon_id: return { **base, "status": "resolved", "issue_code": "", "message": "Target identity uses the unique TaxID in config.xlsx.", "scientific_name": explicit_name or config_name, "tree_taxon_id": config_taxon_id, "identity_source": "job_taxon_id", } if explicit_name or config_name: return { **base, "status": "resolved", "issue_code": "", "message": "Target identity uses a unique scientific name.", "scientific_name": explicit_name or config_name, "tree_taxon_id": "", "identity_source": "explicit_scientific_name" if explicit_name else "job_scientific_name", } from gfa_core.string_species_registry import PROJECT_SPECIES_ALIASES compatibility_taxon_id = PROJECT_SPECIES_ALIASES.get(code.casefold(), "") if compatibility_taxon_id: return { **base, "status": "resolved", "issue_code": "", "message": "Target identity uses an existing curated project alias.", "tree_taxon_id": compatibility_taxon_id, "identity_source": "project_alias", } return { **base, "status": "unresolved", "issue_code": "target_string_tree_unresolved", "message": "Provide a unique target scientific name or NCBI TaxID; the species code is not guessed.", } def build_ppi_gene_ids_from_ortholog_mapping(mapping_file: Path, output_file: Path, selected_species: list[str] | None = None) -> int: selected = {str(x) for x in (selected_species or []) if str(x)} seen = set() output_file.parent.mkdir(parents=True, exist_ok=True) with mapping_file.open("r", encoding="utf-8", errors="ignore") as src, output_file.open("w", encoding="utf-8") as dst: for raw in src: line = raw.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if not parts: continue source_id = parts[0].strip() source_species = parts[3].strip() if len(parts) >= 4 else "" if selected and source_species and source_species not in selected: continue if source_id and source_id not in seen: dst.write(source_id + "\n") seen.add(source_id) return len(seen) def infer_string_species_from_ortholog_mapping(mapping_file: Path, fallback: str = "") -> str: """Infer the STRING species to query from an ortholog mapping target column.""" try: with mapping_file.open("r", encoding="utf-8", errors="ignore") as src: for raw in src: line = raw.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if len(parts) >= 5: inferred = species_to_string_code(parts[4], "") if inferred: return inferred break except Exception: pass return fallback def infer_reference_species_from_ortholog_mapping(mapping_file: Path, fallback: str = "") -> str: """Infer the reference species label from the first data row of an ortholog mapping.""" try: with mapping_file.open("r", encoding="utf-8", errors="ignore") as src: for raw in src: line = raw.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if len(parts) >= 5 and parts[4].strip(): return parts[4].strip() break except Exception: pass return fallback def ortholog_mapping_candidates( final_dir: Path, prefix: str, *, dependency_owner: str = "", scope_id: str = "", ) -> list[Path]: from gfa_core.ortholog_materialize import ortholog_output_stem output_stem = ortholog_output_stem(prefix, dependency_owner) candidates: list[Path] = [] if scope_id: scoped_stem = f"{output_stem}.{sanitize_token(scope_id)}" candidates.extend([ final_dir / f"{scoped_stem}.orthologs.txt", final_dir / f"{scoped_stem}.ortholog_pairs.txt", ]) candidates.extend([ final_dir / f"{output_stem}.orthologs.txt", final_dir / f"{output_stem}.ortholog_pairs.txt", ]) return candidates def _reference_species_from_manifest_data(data: dict[str, Any]) -> str: for key in ("reference_species", "referenceSpecies"): value = data.get(key) if isinstance(value, list) and value: return str(value[0] or "").strip() if isinstance(value, str) and value.strip(): return value.strip() for section_key in ("parameters", "stage_context"): section = data.get(section_key) if not isinstance(section, dict): continue for key in ("reference_species", "referenceSpecies"): value = section.get(key) if isinstance(value, list) and value: return str(value[0] or "").strip() if isinstance(value, str) and value.strip(): return value.strip() return "" def _recent_ppi_kaks_reference_species(final_dir: Path, prefix: str, preferred: Optional[list[str]] = None) -> str: """Recover the technical reference species used by prior Ortholog/PPI runs.""" candidates: list[Path] = [] for stage in ("ppi", "ortholog"): candidates.extend(final_dir.glob(f"{prefix}*.{stage}.manifest.json")) candidates = sorted( [path for path in candidates if path.exists() and path.stat().st_size > 0], key=lambda path: path.stat().st_mtime, reverse=True, ) records: list[tuple[str, str, Path]] = [] for path in candidates: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: continue if str(data.get("status") or "").lower() not in {"success", "completed"}: continue species = _reference_species_from_manifest_data(data) code = species_to_string_code(species, "") if species and code: records.append((species, code.lower(), path)) preferred_codes = [ species_to_string_code(item, item).lower() for item in (preferred or []) if str(item or "").strip() ] for preferred_code in preferred_codes: if preferred_code == "ath": continue for species, code, _path in records: if code == preferred_code: return species for species, code, _path in records: if code != "ath": return species if records: return records[0][0] return "" def _downloaded_reference_bundle_species(in_dir: Path, preferred: list[str]) -> str: preferred_codes = [species_to_string_code(item, item).lower() for item in preferred if str(item or "").strip()] manifests = sorted( in_dir.glob("*/*.reference_bundle_download.manifest.json"), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True, ) records: list[tuple[str, Path]] = [] for manifest in manifests: try: data = json.loads(manifest.read_text(encoding="utf-8")) except Exception: continue code = species_to_string_code(str(data.get("species_code") or manifest.parent.name), "") if not code: continue pep = manifest.parent / f"{code}.pep.fasta" if not pep.exists() or pep.stat().st_size <= 0: continue records.append((code, manifest)) for preferred_code in preferred_codes: for code, _manifest in records: if code.lower() == preferred_code: return code return records[0][0] if records else "" def _normalized_dependency_stages(requested_stages: Optional[list[str]]) -> list[str]: values = [str(x or "").strip().lower() for x in (requested_stages or []) if str(x or "").strip()] if not values: values = ["ppi"] return list(dict.fromkeys(values)) def _reference_species_matches(expected: str, observed: str) -> bool: expected_text = str(expected or "").strip() observed_text = str(observed or "").strip() if not expected_text or not observed_text: return True expected_code = species_to_string_code(expected_text, "") observed_code = species_to_string_code(observed_text, "") if expected_code or observed_code: return bool(expected_code and observed_code and expected_code == observed_code) return expected_text.lower().replace("_", " ") == observed_text.lower().replace("_", " ") def _reference_annotation_candidate_paths( *, in_dir: Path, species: str, sequence_type: str, roots: Optional[list[Path]] = None, ) -> list[Path]: raw = str(species or "").strip() code = species_to_string_code(raw, raw.lower()) tokens = [] for token in [raw, raw.lower(), raw.replace(" ", "_"), code]: if token and token not in tokens: tokens.append(token) roots = roots or [in_dir, Path("/data/ref_genome"), Path("/data")] suffixes_by_type = { "gff3": ["{token}.gff3", "{token}.gff", "{token}.gtf", "{token}_final.gff3"], "genome": ["{token}.genome.fasta", "{token}.genome.fa", "{token}.fasta", "{token}.fa", "{token}.fna"], } suffixes = suffixes_by_type.get(sequence_type, []) candidates: list[Path] = [] for root in roots: for token in tokens: for suffix in suffixes: name = suffix.format(token=token) candidates.extend([ root / name, root / token / name, root / "genome_prepare" / name, ]) seen = set() unique: list[Path] = [] for path in candidates: text = str(path) if text not in seen: unique.append(path) seen.add(text) return unique def resolve_reference_annotation_file( *, in_dir: Path, species: str, sequence_type: str, ) -> Optional[Path]: for candidate in _reference_annotation_candidate_paths(in_dir=in_dir, species=species, sequence_type=sequence_type): if candidate.exists() and candidate.stat().st_size > 0: return candidate return None NCBI_DATASETS_DOWNLOAD_BASE = "https://api.ncbi.nlm.nih.gov/datasets/v2" NCBI_REFERENCE_ANNOTATION_TYPES = { "pep": "PROT_FASTA", "cds": "CDS_FASTA", "genome": "GENOME_FASTA", "gff3": "GENOME_GFF", } def _reference_bundle_file_plan(requested_stages: Optional[list[str]], include_recommended: bool = False) -> list[str]: # PPI owns reference preparation. Download PEP and CDS together so the # later Ka/Ks choice can reuse the exact same bundle without a second job. files = ["pep", "cds"] if include_recommended: for item in ("gff3", "genome"): if item not in files: files.append(item) return files def _urlopen_json_with_retry(url: str, *, timeout_sec: int = 30, attempts: int = 3) -> dict[str, Any]: last_error: Exception | None = None for attempt in range(1, max(1, attempts) + 1): try: with urllib.request.urlopen(url, timeout=timeout_sec) as response: return json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, OSError) as exc: last_error = exc if attempt < attempts: time.sleep(min(8, 2 ** (attempt - 1))) continue break raise RuntimeError(f"NCBI request failed after {attempts} attempts: {last_error}") def _select_ncbi_reference_assembly(*, taxon_id: str = "", species_name: str = "", timeout_sec: int = 30) -> dict[str, str]: taxon = str(taxon_id or species_name or "").strip() if not taxon: raise ValueError("taxon_id or species_name is required for NCBI reference bundle download") url = ( f"{NCBI_DATASETS_DOWNLOAD_BASE}/genome/taxon/" f"{urllib.parse.quote(taxon, safe='')}/dataset_report?page_size=10" ) payload = _urlopen_json_with_retry(url, timeout_sec=timeout_sec, attempts=3) reports = payload.get("reports") or [] if not reports: raise RuntimeError(f"NCBI Datasets returned no assemblies for taxon/species: {taxon}") def score(report: dict[str, Any]) -> tuple[int, int, int]: source = str(report.get("source_database") or "") accession = str(report.get("current_accession") or report.get("accession") or "") assembly_info = report.get("assembly_info") if isinstance(report.get("assembly_info"), dict) else {} level = str(assembly_info.get("assembly_level") or "") annotated = bool(assembly_info.get("annotation_name")) return ( 1 if accession.startswith("GCF_") or "REFSEQ" in source else 0, 1 if annotated else 0, 1 if level.lower() in {"chromosome", "complete genome"} else 0, ) selected = sorted(reports, key=score, reverse=True)[0] assembly_info = selected.get("assembly_info") if isinstance(selected.get("assembly_info"), dict) else {} organism = selected.get("organism") if isinstance(selected.get("organism"), dict) else {} return { "accession": str(selected.get("current_accession") or selected.get("accession") or ""), "organism_name": str(organism.get("organism_name") or species_name or ""), "taxon_id": str(organism.get("tax_id") or taxon_id or ""), "assembly_name": str(assembly_info.get("assembly_name") or ""), "assembly_level": str(assembly_info.get("assembly_level") or ""), "annotation_name": str(assembly_info.get("annotation_name") or ""), } def _download_url_to_file( url: str, output_path: Path, timeout_sec: int = 300, attempts: int = 3, progress_callback: Optional[Callable[[dict[str, Any]], None]] = None, ) -> int: output_path.parent.mkdir(parents=True, exist_ok=True) temp_path = output_path.with_suffix(output_path.suffix + ".part") last_error: Exception | None = None for attempt in range(1, max(1, attempts) + 1): total = 0 try: if temp_path.exists(): temp_path.unlink() with urllib.request.urlopen(url, timeout=timeout_sec) as response, temp_path.open("wb") as dst: total_bytes = int(response.headers.get("Content-Length") or 0) while True: chunk = response.read(1024 * 1024) if not chunk: break dst.write(chunk) total += len(chunk) if progress_callback: progress_callback({ "phase": "download", "bytes_downloaded": total, "total_bytes": total_bytes, "percentage": round(total * 100 / total_bytes, 1) if total_bytes else None, "indeterminate": not bool(total_bytes), }) if total <= 0: raise RuntimeError("downloaded file is empty") temp_path.replace(output_path) return total except (http.client.IncompleteRead, urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc: last_error = exc if temp_path.exists(): try: temp_path.unlink() except OSError: pass if attempt < attempts: time.sleep(min(15, 2 ** (attempt - 1))) continue break raise RuntimeError(f"NCBI reference bundle download failed after {attempts} attempts: {last_error}") def _ncbi_dataset_download_url(accession: str, file_types: list[str]) -> str: params = [ ("include_annotation_type", NCBI_REFERENCE_ANNOTATION_TYPES[file_type]) for file_type in file_types if file_type in NCBI_REFERENCE_ANNOTATION_TYPES ] if not params: raise ValueError("No supported reference bundle file types requested") return ( f"{NCBI_DATASETS_DOWNLOAD_BASE}/genome/accession/" f"{urllib.parse.quote(str(accession), safe='')}/download?" + urllib.parse.urlencode(params) ) def _extract_ncbi_reference_bundle_zip( *, zip_path: Path, destination_dir: Path, species_code: str, requested_file_types: list[str], ) -> dict[str, str]: destination_dir.mkdir(parents=True, exist_ok=True) patterns = { "pep": ("protein.faa", "_protein.faa", ".faa"), "cds": ("cds_from_genomic.fna", "_cds_from_genomic.fna"), "genome": ("genomic.fna", "_genomic.fna"), "gff3": ("genomic.gff", "_genomic.gff", ".gff"), } output_names = { "pep": f"{species_code}.pep.fasta", "cds": f"{species_code}.cds.fasta", "genome": f"{species_code}.genome.fasta", "gff3": f"{species_code}.gff3", } extracted: dict[str, str] = {} with zipfile.ZipFile(zip_path) as zf: members = [name for name in zf.namelist() if not name.endswith("/")] for file_type in requested_file_types: suffixes = patterns.get(file_type, ()) match = next( ( name for name in members if any(Path(name).name.endswith(suffix) for suffix in suffixes) ), "", ) if not match: continue output_path = destination_dir / output_names[file_type] with zf.open(match) as src, output_path.open("wb") as dst: shutil.copyfileobj(src, dst) if file_type == "cds": _normalize_ncbi_cds_headers_to_protein_ids(output_path) if output_path.exists() and output_path.stat().st_size > 0: extracted[file_type] = str(output_path) return extracted def _normalize_ncbi_cds_headers_to_protein_ids(cds_path: Path) -> None: """Make NCBI CDS FASTA IDs match the corresponding protein FASTA accessions.""" text = cds_path.read_text(encoding="utf-8", errors="ignore") lines = [] changed = False for line in text.splitlines(): if not line.startswith(">"): lines.append(line) continue header = line[1:].strip() protein_id = "" match = re.search(r"\[protein_id=([^\]\s]+)\]", header) if match: protein_id = match.group(1) if not protein_id: match = re.search(r"_cds_([A-Z]{2}_[0-9]+\.[0-9]+)", header) if match: protein_id = match.group(1) if protein_id: lines.append(f">{protein_id} {header}") changed = True else: lines.append(line) if changed: cds_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _fasta_header_index(fasta_path: Path | str | None) -> dict[str, dict[str, str]]: path = Path(fasta_path) if fasta_path else None if not path or not path.exists() or path.stat().st_size <= 0: return {} records: dict[str, dict[str, str]] = {} with path.open("r", encoding="utf-8", errors="ignore") as handle: for line in handle: if not line.startswith(">"): continue header = line[1:].strip() seq_id = header.split()[0] if header else "" if not seq_id or seq_id in records: continue gene_match = re.search(r"\[gene=([^\]]+)\]", header) if not gene_match: gene_match = re.search(r"(?:^|\s)gene:([^\s]+)", header) ncbi_gene_match = re.search(r"\[db_xref=GeneID:(\d+)\]", header) protein_match = re.search(r"\[protein_id=([^\]]+)\]", header) records[seq_id] = { "id": seq_id, "header": header, "gene_id": gene_match.group(1) if gene_match else "", "ncbi_gene_id": ncbi_gene_match.group(1) if ncbi_gene_match else "", "protein_id": protein_match.group(1) if protein_match else "", } return records def _build_reference_bundle_id_map( *, species_code: str, pep_path: Path | str | None, cds_path: Path | str | None, output_path: Path, ) -> dict[str, Any]: """Write a provenance TSV proving downloaded PEP/CDS IDs are aligned.""" pep_records = _fasta_header_index(pep_path) cds_records = _fasta_header_index(cds_path) all_ids = sorted(set(pep_records) | set(cds_records)) output_path.parent.mkdir(parents=True, exist_ok=True) with output_path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter( handle, fieldnames=[ "species_code", "protein_id", "pep_id", "cds_id", "gene_id", "ncbi_gene_id", "pep_present", "cds_present", "pep_header", "cds_header", ], delimiter="\t", ) writer.writeheader() for seq_id in all_ids: pep = pep_records.get(seq_id, {}) cds = cds_records.get(seq_id, {}) writer.writerow({ "species_code": species_code, "protein_id": seq_id, "pep_id": pep.get("id", ""), "cds_id": cds.get("id", ""), "gene_id": cds.get("gene_id", "") or pep.get("gene_id", ""), "ncbi_gene_id": cds.get("ncbi_gene_id", "") or pep.get("ncbi_gene_id", ""), "pep_present": "1" if seq_id in pep_records else "0", "cds_present": "1" if seq_id in cds_records else "0", "pep_header": pep.get("header", ""), "cds_header": cds.get("header", ""), }) overlap = sorted(set(pep_records) & set(cds_records)) pep_only = sorted(set(pep_records) - set(cds_records)) cds_only = sorted(set(cds_records) - set(pep_records)) return { "species_code": species_code, "pep_records": len(pep_records), "cds_records": len(cds_records), "pep_cds_overlap": len(overlap), "pep_only_count": len(pep_only), "cds_only_count": len(cds_only), "pep_cds_overlap_ratio": (len(overlap) / len(pep_records)) if pep_records else 0, "id_map_path": str(output_path), "pep_only_examples": pep_only[:10], "cds_only_examples": cds_only[:10], } def download_reference_annotation_bundle( *, job: "JobInfo", species_code: str, species_name: str = "", taxon_id: str = "", requested_stages: Optional[list[str]] = None, include_recommended: bool = False, accession: str = "", timeout_sec: int = 300, progress_callback: Optional[Callable[[dict[str, Any]], None]] = None, ) -> dict[str, Any]: """Download a job-local NCBI reference annotation bundle for PPI/KaKs fallback.""" def emit(progress: dict[str, Any]) -> None: if progress_callback: progress_callback({"stage": "reference_bundle_download", **progress}) in_dir = Path(job.input_dir) ws = Path(job.workspace_dir) code = species_to_string_code(species_code or species_name, species_code or "").strip() or str(species_code or "").strip() code = code or "reference" required_file_types = _reference_bundle_file_plan(requested_stages, include_recommended=False) file_types = _reference_bundle_file_plan( requested_stages, include_recommended=include_recommended, ) assembly = {"accession": accession} emit({"phase": "select_assembly", "message": "Resolving the recommended reference assembly", "percentage": 5, "indeterminate": True}) if not accession: assembly = _select_ncbi_reference_assembly(taxon_id=taxon_id, species_name=species_name, timeout_sec=min(timeout_sec, 60)) accession = assembly.get("accession", "") if not accession: raise RuntimeError("No NCBI assembly accession resolved for reference bundle download") download_url = _ncbi_dataset_download_url(accession, file_types) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") work_dir = ws / "reference_bundle_download" / f"{code}_{stamp}" zip_path = work_dir / f"{code}.ncbi_dataset.zip" emit({"phase": "download", "message": "Downloading reference PEP and CDS from NCBI", "percentage": 10, "indeterminate": True}) download_kwargs = {"timeout_sec": timeout_sec} if progress_callback: download_kwargs["progress_callback"] = lambda event: emit({"message": "Downloading reference PEP and CDS from NCBI", **event}) bytes_downloaded = _download_url_to_file(download_url, zip_path, **download_kwargs) if not zipfile.is_zipfile(zip_path): raise RuntimeError( f"NCBI reference bundle download returned an invalid zip archive: {zip_path}" ) species_dir = in_dir / code emit({"phase": "extract", "message": "Extracting reference PEP and CDS", "percentage": 80, "indeterminate": True}) extracted = _extract_ncbi_reference_bundle_zip( zip_path=zip_path, destination_dir=species_dir, species_code=code, requested_file_types=file_types, ) emit({"phase": "validate_ids", "message": "Validating PEP/CDS identifier correspondence", "percentage": 90, "indeterminate": True}) id_validation = _build_reference_bundle_id_map( species_code=code, pep_path=extracted.get("pep"), cds_path=extracted.get("cds"), output_path=species_dir / f"{code}.pep_cds_idmap.tsv", ) if Path(id_validation["id_map_path"]).exists(): extracted["pep_cds_idmap"] = id_validation["id_map_path"] missing_required = [item for item in required_file_types if item not in extracted] manifest = { "manifest_version": "1.0", "stage": "reference_bundle_download", "status": "success" if not missing_required else "incomplete", "job_id": job.job_id, "species_code": code, "species_name": species_name or assembly.get("organism_name", ""), "taxon_id": taxon_id or assembly.get("taxon_id", ""), "requested_stages": _normalized_dependency_stages(requested_stages), "requested_file_types": file_types, "include_recommended": include_recommended, "assembly": assembly, "download_url": download_url, "zip_path": str(zip_path), "bytes_downloaded": bytes_downloaded, "files": extracted, "id_validation": id_validation, "missing_required": missing_required, "created_at": datetime.now().isoformat(), "provenance": { "source": "NCBI Datasets v2", "storage": "job_input", "destination_dir": str(species_dir), }, } manifest_path = species_dir / f"{code}.reference_bundle_download.manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8") manifest["manifest_path"] = str(manifest_path) emit({ "phase": "completed", "message": "PPI and Ka/Ks reference species data are ready", "percentage": 100, "indeterminate": False, "bytes_downloaded": bytes_downloaded, "files": extracted, }) return manifest def _ppi_only_preflight_response( *, status: str, issue_code: str, message: str, decision: str, target_species: list[str], target_identity: dict[str, Any], species_resolution: dict[str, Any], requested_ppi_species: str, selected_taxon_id: str = "", selected_string_species: str = "", reference_storage_key: str = "", reference_pep: str = "", reference_cds: str = "", mapping_file: Optional[Path] = None, mapping_reference_species: str = "", required_next_stage: str = "", missing_required: Optional[list[str]] = None, dependency_owner: str = "ppi", ) -> dict[str, Any]: candidates = list(species_resolution.get("candidates") or []) selection_mode = str(species_resolution.get("selection_mode") or "unresolved") required_files = ["pep", "cds"] if reference_storage_key else [] bundle_files = {} if reference_pep: bundle_files["pep"] = reference_pep if reference_cds: bundle_files["cds"] = reference_cds reference_bundle_status = { "required_files": required_files, "recommended_files": [], "files": bundle_files, "missing_required": list(missing_required or []), "missing_recommended": [], "mode": ( "complete" if not required_files or (reference_pep and reference_cds) else "not_selected" if status in {"confirmation_required", "unresolved", "conflict"} else "unavailable" if status == "resource_error" else "incomplete" ), "kaks_ready": (not required_files) or bool(reference_pep and reference_cds), } diagnostics = { "issue_code": issue_code, "severity": "info" if status == "ready" else "blocking", "layer": "resource" if status == "resource_error" else "input_contract", "required_next_stage": required_next_stage, "recommendation": message, "dependency_owner_stages": ["ppi"], "required_bundle_files": required_files, "reference_bundle_status": reference_bundle_status, "ortholog_dependency_role": "producer" if required_next_stage == "ortholog" else "not_requested", "reference_recommendations": candidates, } response = { "status": status, "issue_code": issue_code, "message": message, "target_species": target_species, "target_identity": target_identity, "target_string_supported": bool(species_resolution.get("target_string_supported")), "requested_stages": ["ppi"], "requested_ppi_species": requested_ppi_species or "auto", "selected_string_species": selected_string_species, "selected_taxon_id": selected_taxon_id, "reference_species": reference_storage_key, "reference_data_source": reference_pep, "reference_bundle_status": reference_bundle_status, "mapping_reference_species": mapping_reference_species, "ortholog_mapping_source": str(mapping_file) if mapping_file else "", "required_next_stage": required_next_stage, "decision": decision, "selection_mode": selection_mode, "candidates": candidates, "requires_user_confirmation": bool(species_resolution.get("requires_user_confirmation")), "snapshot_id": str(species_resolution.get("snapshot_id") or ""), "ppi_reference_policy_id": PPI_REFERENCE_POLICY_ID, "ppi_reference_storage_key": reference_storage_key, "dependency_owner": dependency_owner or "ppi", "species_resolution": species_resolution, "diagnostics": diagnostics, } response["ppi_provenance"] = build_ppi_provenance( raw_target_species=target_species, target_identity=target_identity, species_resolution=species_resolution, selected_string_taxon_id=selected_taxon_id, selection_mode=selection_mode, user_confirmed=bool(species_resolution.get("reference_user_confirmed")), mapping_reference_species=mapping_reference_species or reference_storage_key, mapping_path=str(mapping_file) if mapping_file else "", normalization_species_label=(target_species[0] if target_species else selected_string_species), snapshot_root=STRING_SPECIES_SNAPSHOT_ROOT, status=status, ) return response def _effective_ppi_owner_scope_id( *, family: str, selected_targets: list[str], scope_data: dict[str, Any], reference_storage_key: str, ) -> str: return build_scope_id( family, selected_targets, [], [], "target_only", ) def _ppi_mapping_identity_matches_current_inputs( *, mapping_file: Path, final_dir: Path, in_dir: Path, ws: Path, prefix: str, family: str, selected_targets: list[str], scope_data: dict[str, Any], reference_taxon_id: str, reference_storage_key: str, reference_pep: Path, ) -> tuple[bool, str]: if len(selected_targets) != 1: return False, "ppi_owner_identity_requires_single_target" query_fasta = resolve_species_family_pep(ws, final_dir, in_dir, selected_targets[0], family) if not query_fasta: return False, "ppi_owner_target_pep_missing" effective_scope_id = _effective_ppi_owner_scope_id( family=family, selected_targets=selected_targets, scope_data=scope_data, reference_storage_key=reference_storage_key, ) from gfa_core.ortholog_materialize import build_ortholog_run_identity, ortholog_run_identity_matches expected = build_ortholog_run_identity( query_species=selected_targets[0], reference_taxon_id=reference_taxon_id, reference_storage_key=reference_storage_key, query_fasta=query_fasta, reference_pep=reference_pep, scope_id=effective_scope_id, member_set_id="", mapping_path=mapping_file, ) if not ortholog_run_identity_matches(mapping_file, expected): return False, "ppi_owner_mapping_sidecar_identity_mismatch" manifest_candidates = sorted( final_dir.glob(f"{prefix}*.ortholog.manifest.json"), key=lambda path: path.stat().st_mtime, reverse=True, ) identity_keys = ( "reference_taxon_id", "reference_storage_key", "reference_pep_sha256", "target_identity", "member_identity", "mapping_identity", "mapping_path", ) for manifest_path in manifest_candidates: try: payload = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: continue params = payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {} if all(params.get(key) == expected.get(key) for key in identity_keys): return True, "" return False, "ppi_owner_mapping_manifest_identity_mismatch" def _resolve_ppi_only_snapshot_preflight( *, job: "JobInfo", prefix: str, family: str, selected_targets: list[str], scope_data: dict[str, Any], scope_id: str, target_identity: dict[str, Any], requested_ppi_species: str, ignore_existing_ortholog: bool, ppi_reference_taxon_id: str, ppi_reference_storage_key: str, ppi_reference_selection_mode: str, ppi_reference_user_confirmed: bool, dependency_owner: str, ) -> dict[str, Any]: identity_status = str(target_identity.get("status") or "unresolved") if identity_status in {"conflict", "unresolved"}: issue_code = str(target_identity.get("issue_code") or "target_string_tree_unresolved") species_resolution = { "selection_mode": "unresolved", "target_tree_taxon_id": "", "target_tree_name": "", "selected_string_taxon_id": "", "selected_string_name": "", "target_string_supported": False, "requires_user_confirmation": False, "resolution_source": str(target_identity.get("identity_source") or "target_identity"), "candidate_source": "", "candidates": [], "issue_code": issue_code, "string_version": "", "snapshot_id": "", "ppi_reference_policy_id": PPI_REFERENCE_POLICY_ID, } return _ppi_only_preflight_response( status=identity_status, issue_code=issue_code, message=str(target_identity.get("message") or "Target STRING tree identity is unresolved."), decision="target_identity_conflict" if identity_status == "conflict" else "target_identity_unresolved", target_species=selected_targets, target_identity=target_identity, species_resolution=species_resolution, requested_ppi_species=requested_ppi_species, dependency_owner=dependency_owner, ) try: snapshot = load_string_snapshot(STRING_SPECIES_SNAPSHOT_ROOT) species_resolution = resolve_ppi_string_species( snapshot, selected_targets[0] if len(selected_targets) == 1 else "", target_scientific_name=str(target_identity.get("scientific_name") or ""), ppi_target_tree_taxon_id=str(target_identity.get("tree_taxon_id") or ""), ) except StringSnapshotError as exc: species_resolution = { "selection_mode": "resource_error", "target_tree_taxon_id": str(target_identity.get("tree_taxon_id") or ""), "target_tree_name": str(target_identity.get("scientific_name") or ""), "selected_string_taxon_id": "", "selected_string_name": "", "target_string_supported": False, "requires_user_confirmation": False, "resolution_source": str(target_identity.get("identity_source") or ""), "candidate_source": "", "candidates": [], "issue_code": exc.code, "string_version": "", "snapshot_id": "", "ppi_reference_policy_id": PPI_REFERENCE_POLICY_ID, } return _ppi_only_preflight_response( status="resource_error", issue_code=exc.code, message=str(exc), decision="string_registry_error", target_species=selected_targets, target_identity=target_identity, species_resolution=species_resolution, requested_ppi_species=requested_ppi_species, dependency_owner=dependency_owner, ) species_resolution = { **species_resolution, "ppi_reference_policy_id": PPI_REFERENCE_POLICY_ID, } selection_mode = str(species_resolution.get("selection_mode") or "unresolved") if selection_mode == "exact_string": selected_taxon_id = str(species_resolution.get("selected_string_taxon_id") or "") compatibility_code = species_to_string_code( selected_targets[0] if len(selected_targets) == 1 else "", "", ) return _ppi_only_preflight_response( status="ready", issue_code="", message="Target species is directly supported by the fixed STRING snapshot.", decision="ready_target_string", target_species=selected_targets, target_identity=target_identity, species_resolution=species_resolution, requested_ppi_species=requested_ppi_species, selected_taxon_id=selected_taxon_id, selected_string_species=compatibility_code, dependency_owner=dependency_owner, ) if selection_mode != "tree_nearest": return _ppi_only_preflight_response( status="unresolved", issue_code=str(species_resolution.get("issue_code") or "target_string_tree_unresolved"), message="Target species could not be resolved in the fixed STRING species tree.", decision="target_identity_unresolved", target_species=selected_targets, target_identity=target_identity, species_resolution=species_resolution, requested_ppi_species=requested_ppi_species, dependency_owner=dependency_owner, ) chosen_taxon_id = str(ppi_reference_taxon_id or "").strip() chosen = next( ( row for row in species_resolution.get("candidates") or [] if str(row.get("selected_string_taxon_id") or "") == chosen_taxon_id ), None, ) explicit_model_fallback = bool( str(ppi_reference_selection_mode or "").strip().lower() == "model_fallback" and chosen_taxon_id == ARABIDOPSIS_TAXON_ID and chosen_taxon_id in snapshot.registry ) if explicit_model_fallback: chosen = { "selected_string_taxon_id": chosen_taxon_id, "selected_string_name": snapshot.nodes[chosen_taxon_id].taxon_name, } if not (ppi_reference_user_confirmed and chosen): return _ppi_only_preflight_response( status="confirmation_required", issue_code="ppi_reference_confirmation_required", message="Select and confirm one of the nearest STRING reference species before downloading PEP or running Ortholog.", decision="confirmation_required", target_species=selected_targets, target_identity=target_identity, species_resolution=species_resolution, requested_ppi_species=requested_ppi_species, dependency_owner=dependency_owner, ) if explicit_model_fallback: species_resolution = { **species_resolution, "selection_mode": "model_fallback", "candidate_source": "explicit_model_fallback", "issue_code": "", } storage_key = str(ppi_reference_storage_key or "").strip() or f"string_taxon_{chosen_taxon_id}" selected_name = str(chosen.get("selected_string_name") or "") ws = Path(job.workspace_dir) in_dir = Path(job.input_dir) final_dir = ws / "final" reference_pep_path = resolve_species_sequence_file( ws, final_dir, in_dir, scope_data, storage_key, family, "pep", family_only=False ) reference_pep = str(reference_pep_path) if reference_pep_path else "" reference_cds_path = resolve_species_sequence_file( ws, final_dir, in_dir, scope_data, storage_key, family, "cds", family_only=False ) reference_cds = str(reference_cds_path) if reference_cds_path else "" mapping_candidates = ortholog_mapping_candidates( final_dir, prefix, dependency_owner="ppi", scope_id=scope_id, ) mapping_file = None if ignore_existing_ortholog else next( (path for path in mapping_candidates if path.exists() and path.stat().st_size > 0), None, ) mapping_reference_species = infer_reference_species_from_ortholog_mapping(mapping_file, "") if mapping_file else "" normalized_mapping = mapping_reference_species.lower().replace("_", " ") allowed_mapping_references = { chosen_taxon_id.lower(), storage_key.lower().replace("_", " "), selected_name.lower().replace("_", " "), } mapping_matches = not mapping_reference_species or normalized_mapping in allowed_mapping_references mapping_identity_matches = False mapping_identity_reason = "" if mapping_file and mapping_matches and reference_pep_path: mapping_identity_matches, mapping_identity_reason = _ppi_mapping_identity_matches_current_inputs( mapping_file=mapping_file, final_dir=final_dir, in_dir=in_dir, ws=ws, prefix=prefix, family=family, selected_targets=selected_targets, scope_data=scope_data, reference_taxon_id=chosen_taxon_id, reference_storage_key=storage_key, reference_pep=reference_pep_path, ) species_resolution = { **species_resolution, "selected_string_taxon_id": chosen_taxon_id, "selected_string_name": selected_name, "requires_user_confirmation": False, "reference_selection_mode": str(ppi_reference_selection_mode or selection_mode), "reference_user_confirmed": True, "reference_storage_key": storage_key, "reference_pep_path": reference_pep, "ortholog_mapping_path": str(mapping_file) if mapping_file else "", } common = { "target_species": selected_targets, "target_identity": target_identity, "species_resolution": species_resolution, "requested_ppi_species": requested_ppi_species, "selected_taxon_id": chosen_taxon_id, "selected_string_species": species_to_string_code(selected_name, ""), "reference_storage_key": storage_key, "reference_pep": reference_pep, "reference_cds": reference_cds, "mapping_file": mapping_file, "mapping_reference_species": mapping_reference_species, "dependency_owner": dependency_owner, } missing_reference_files = [ file_type for file_type, file_path in (("pep", reference_pep), ("cds", reference_cds)) if not file_path ] if missing_reference_files: return _ppi_only_preflight_response( status="needs_reference_data", issue_code="ppi_reference_data_missing", message="The confirmed STRING reference requires matching PEP and CDS before Ortholog.", decision="needs_reference_bundle", required_next_stage="", missing_required=missing_reference_files, **common, ) if mapping_file and not mapping_matches: return _ppi_only_preflight_response( status="needs_ortholog", issue_code="stale_ortholog_reference_conflict", message="Existing Ortholog mapping does not match the confirmed STRING reference TaxID.", decision="needs_reference_ortholog", required_next_stage="ortholog", **common, ) if mapping_file and not mapping_identity_matches: return _ppi_only_preflight_response( status="needs_ortholog", issue_code="stale_ortholog_identity_conflict", message=f"Existing Ortholog mapping identity is stale or unverifiable: {mapping_identity_reason}.", decision="needs_reference_ortholog", required_next_stage="ortholog", **common, ) if not mapping_file: return _ppi_only_preflight_response( status="needs_ortholog", issue_code="ppi_requires_ortholog_mapping", message="Reference PEP is available; run Ortholog for the confirmed STRING reference.", decision="needs_reference_ortholog", required_next_stage="ortholog", **common, ) return _ppi_only_preflight_response( status="ready", issue_code="", message="Confirmed STRING reference PEP and matching Ortholog mapping are available.", decision="ready_reference_mapping", required_next_stage="", **common, ) def resolve_ppi_species_resolution( *, job: "JobInfo", prefix: str, ppi_species: str = "auto", scope_file: str = "", target_species: Optional[list[str]] = None, reference_species: Optional[list[str]] = None, family: str = "", ignore_existing_ortholog: bool = False, requested_stages: Optional[list[str]] = None, target_scientific_name: str = "", ppi_target_tree_taxon_id: str = "", ppi_reference_taxon_id: str = "", ppi_reference_storage_key: str = "", ppi_reference_selection_mode: str = "", ppi_reference_user_confirmed: bool = False, dependency_owner: str = "", ) -> Dict[str, Any]: """Read-only PPI/KaKs dependency preflight for UI and API callers.""" ws = Path(job.workspace_dir) in_dir = Path(job.input_dir) final_dir = ws / "final" requested = str(ppi_species or "auto").strip().lower() explicit_ppi_species = bool(requested and requested != "auto") dependency_stages = _normalized_dependency_stages(requested_stages) needs_ppi = "ppi" in dependency_stages needs_kaks = "kaks" in dependency_stages required_bundle_files = ["pep", "cds"] recommended_bundle_files = ["gff3", "genome"] if needs_kaks else [] selected_targets = canonicalize_species_codes_for_job(in_dir, [str(x).strip() for x in (target_species or []) if str(x).strip()]) references = canonicalize_species_codes_for_job(in_dir, [str(x).strip() for x in (reference_species or []) if str(x).strip()]) scope_data: dict[str, Any] = {} scope_id = "" if scope_file: scope_path = resolve_scope_path(scope_file, in_dir, ws) if scope_path.exists(): scope_data = read_scope(scope_path) selected_targets = canonicalize_species_codes_for_job( in_dir, [str(x).strip() for x in scope_data.get("target_species", []) if str(x).strip()], ) or selected_targets references = canonicalize_species_codes_for_job( in_dir, [str(x).strip() for x in scope_data.get("reference_species", []) if str(x).strip()], ) or references scope_id = str(scope_data.get("scope_id") or "") if not selected_targets: prefix_species = prefix.split(".", 1)[0] if "." in prefix else "" if prefix_species: selected_targets = [prefix_species] family = str(family or scope_data.get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix)).strip() selected_target_identity = selected_targets[0] if len(selected_targets) == 1 else "" inferred_scientific_name = ( selected_target_identity if " " in selected_target_identity.strip() and not target_scientific_name else target_scientific_name ) target_identity = resolve_ppi_target_identity( in_dir, selected_target_identity, explicit_scientific_name=inferred_scientific_name, explicit_tree_taxon_id=ppi_target_tree_taxon_id, ) if needs_ppi and not needs_kaks: scope_id = _effective_ppi_owner_scope_id( family=family, selected_targets=selected_targets, scope_data=scope_data, reference_storage_key=str(ppi_reference_storage_key or ""), ) scope_data = { **scope_data, "family": family, "scope_id": scope_id, "scope_mode": "target_only", "target_species": list(selected_targets), "reference_species": [], "comparative_species": [], "selected_species": list(selected_targets), } result = _resolve_ppi_only_snapshot_preflight( job=job, prefix=prefix, family=family, selected_targets=selected_targets, scope_data=scope_data, scope_id=scope_id, target_identity=target_identity, requested_ppi_species=requested or "auto", ignore_existing_ortholog=ignore_existing_ortholog, ppi_reference_taxon_id=ppi_reference_taxon_id, ppi_reference_storage_key=ppi_reference_storage_key, ppi_reference_selection_mode=ppi_reference_selection_mode, ppi_reference_user_confirmed=ppi_reference_user_confirmed, dependency_owner=dependency_owner or "ppi", ) decision_path = record_ppi_preflight_decision(ws, prefix, result) result["preflight_decision_record"] = str(decision_path) return result target_string_code = species_to_string_code(selected_targets[0], "") if len(selected_targets) == 1 else "" reference_recommendations = recommended_string_reference_species(selected_targets[0]) if len(selected_targets) == 1 else [] ppi_reference_species = references[0] if references else "" if not target_string_code: recommended_codes = [ str(item.get("species_code") or "").strip() for item in reference_recommendations if str(item.get("species_code") or "").strip() ] downloaded_reference = _downloaded_reference_bundle_species(in_dir, recommended_codes) recent_reference = _recent_ppi_kaks_reference_species(final_dir, prefix, recommended_codes) downloaded_reference_code = species_to_string_code(downloaded_reference, "") recovered_reference = ( downloaded_reference if downloaded_reference_code and downloaded_reference_code != "ath" else recent_reference or downloaded_reference ) current_reference_code = species_to_string_code(ppi_reference_species, "") recovered_reference_code = species_to_string_code(recovered_reference, "") preserve_explicit_arabidopsis_reference = bool( explicit_ppi_species and species_to_string_code(requested, requested) == "ath" and current_reference_code == "ath" ) if ( recovered_reference_code and recovered_reference_code != "ath" and not preserve_explicit_arabidopsis_reference and ( not ppi_reference_species or current_reference_code == "ath" or current_reference_code != recovered_reference_code ) ): ppi_reference_species = recovered_reference selected_string_species = "" if explicit_ppi_species: selected_string_species = species_to_string_code(requested, requested) elif target_string_code: selected_string_species = target_string_code elif ppi_reference_species: selected_string_species = species_to_string_code(ppi_reference_species, "") use_target_string_directly = bool( len(selected_targets) == 1 and target_string_code and selected_string_species == target_string_code ) mapping_candidates: list[Path] = [] if not use_target_string_directly or needs_kaks: mapping_owner = str(dependency_owner or "").strip().lower() mapping_candidates = ortholog_mapping_candidates( final_dir, prefix, dependency_owner=mapping_owner if mapping_owner in {"ppi", "kaks"} else "", scope_id=scope_id, ) mapping_file = None if ignore_existing_ortholog else next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None) reference_data_source = "" reference_bundle_files: dict[str, str] = {} reference_bundle_missing: list[str] = [] reference_bundle_recommended_missing: list[str] = [] if ppi_reference_species: ref_pep = resolve_species_sequence_file( ws, final_dir, in_dir, scope_data, ppi_reference_species, family, "pep", family_only=False, ) reference_data_source = str(ref_pep) if ref_pep else "" if ref_pep: reference_bundle_files["pep"] = str(ref_pep) if "cds" in required_bundle_files: ref_cds = resolve_species_sequence_file( ws, final_dir, in_dir, scope_data, ppi_reference_species, family, "cds", family_only=False, ) if ref_cds: reference_bundle_files["cds"] = str(ref_cds) for optional_type in recommended_bundle_files: optional_path = resolve_reference_annotation_file( in_dir=in_dir, species=ppi_reference_species, sequence_type=optional_type, ) if optional_path: reference_bundle_files[optional_type] = str(optional_path) else: reference_bundle_recommended_missing.append(optional_type) reference_bundle_missing = [item for item in required_bundle_files if item not in reference_bundle_files] reference_bundle_status = { "required_files": required_bundle_files, "recommended_files": recommended_bundle_files, "files": reference_bundle_files, "missing_required": reference_bundle_missing, "missing_recommended": reference_bundle_recommended_missing, "mode": "complete" if not reference_bundle_missing else ("pep_only" if reference_bundle_files.get("pep") and "cds" in reference_bundle_missing else "incomplete"), "kaks_ready": (not needs_kaks) or ("pep" in reference_bundle_files and "cds" in reference_bundle_files), } mapping_reference_species = infer_reference_species_from_ortholog_mapping(mapping_file, "") if mapping_file else "" mapping_reference_conflict = bool( mapping_file and ppi_reference_species and mapping_reference_species and not _reference_species_matches(ppi_reference_species, mapping_reference_species) ) if use_target_string_directly and not needs_kaks: status = "ready" issue_code = "" message = "Target species is directly supported by STRING; PPI can query target STRING IDs." required_next_stage = "" decision = "ready_target_string" elif not ppi_reference_species: status = "needs_reference_data" issue_code = "kaks_reference_data_missing" if needs_kaks else "ppi_reference_data_missing" message = ( "PPI/KaKs dependency preflight requires a reference species before Ortholog can generate target-to-reference mappings." if (needs_ppi and needs_kaks) else "KaKs requires a reference species with CDS and PEP before Ortholog and Ka/Ks execution." if needs_kaks else "Target species is not directly supported by STRING; provide/select a reference protein FASTA before Ortholog." ) required_next_stage = "ortholog" decision = "needs_reference_species" elif mapping_reference_conflict and not (needs_kaks and reference_bundle_missing): status = "needs_ortholog" issue_code = "stale_ortholog_reference_conflict" message = ( f"Existing Ortholog mapping targets {mapping_reference_species}, but current PPI/KaKs dependency policy " f"selects {ppi_reference_species}; rerun Ortholog with the selected reference species." ) required_next_stage = "ortholog" decision = "needs_reference_ortholog" elif mapping_file: inferred = infer_string_species_from_ortholog_mapping(mapping_file, "") if needs_kaks and reference_bundle_missing: status = "needs_reference_data" issue_code = "kaks_reference_cds_missing" if needs_kaks and "cds" in reference_bundle_missing else "reference_bundle_incomplete" message = ( "Ortholog mapping exists, but the selected reference annotation bundle is incomplete for requested PPI/KaKs stages: " + ", ".join(reference_bundle_missing) ) required_next_stage = "ortholog" decision = "needs_reference_bundle" elif needs_ppi and not (selected_string_species or inferred): status = "invalid_mapping" issue_code = "ppi_invalid_ortholog_mapping" message = "Ortholog mapping exists, but its reference species cannot be resolved to a supported STRING species." required_next_stage = "ortholog" decision = "failed_invalid_mapping" else: if inferred: selected_string_species = selected_string_species or inferred status = "ready" issue_code = "" message = ( "Ortholog mapping and reference annotation bundle are available for requested PPI/KaKs stages." if needs_kaks else "Ortholog mapping is available; PPI can query the mapped reference STRING species." ) required_next_stage = "" decision = "ready_reference_mapping" elif reference_bundle_missing: status = "needs_reference_data" issue_code = "kaks_reference_cds_missing" if needs_kaks and "cds" in reference_bundle_missing else "ppi_reference_data_missing" message = ( "Reference annotation bundle is incomplete for requested PPI/KaKs stages; missing: " + ", ".join(reference_bundle_missing) + ". Upload or download a complete reference annotation bundle before Ortholog." ) required_next_stage = "ortholog" decision = "needs_reference_bundle" elif selected_string_species and (explicit_ppi_species or ppi_reference_species): status = "needs_ortholog" issue_code = "ppi_requires_ortholog_mapping" message = ( "PPI/KaKs dependency data are available; run Ortholog first to generate target-to-reference mappings." if needs_kaks else "Reference STRING species selected; run Ortholog before PPI so target IDs are mapped to reference IDs." ) required_next_stage = "ortholog" decision = "needs_reference_ortholog" elif not needs_ppi and needs_kaks: status = "needs_ortholog" issue_code = "ppi_requires_ortholog_mapping" message = "KaKs reference CDS/PEP are available; run Ortholog first to generate target-to-reference pairs." required_next_stage = "ortholog" decision = "needs_reference_ortholog" elif reference_data_source: status = "needs_ortholog" issue_code = "ppi_requires_ortholog_mapping" message = "Reference protein data exists; run Ortholog before PPI fallback." required_next_stage = "ortholog" decision = "needs_reference_ortholog" else: status = "needs_reference_data" issue_code = "ppi_reference_data_missing" message = "Target species is not directly supported by STRING; provide/select a reference protein FASTA before Ortholog." required_next_stage = "ortholog" decision = "needs_reference_data" selected_taxon_id = string_taxon_id(selected_string_species, "") if selected_string_species else "" diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested or "auto", target_species=",".join(selected_targets), target_string_supported=bool(target_string_code), reference_species=ppi_reference_species, reference_data_source=reference_data_source, ortholog_mapping_used=bool(mapping_file), fallback_level=status, fallback_reason=issue_code or "", selected_string_species=selected_string_species, selected_taxon_id=selected_taxon_id, prefix=prefix, ) diagnostics = diagnostic.get("diagnostics", {}) if issue_code: diagnostics["issue_code"] = issue_code diagnostics["required_next_stage"] = required_next_stage diagnostics["reference_recommendations"] = reference_recommendations diagnostics["dependency_owner_stages"] = dependency_stages diagnostics["required_bundle_files"] = required_bundle_files diagnostics["reference_bundle_status"] = reference_bundle_status diagnostics["ortholog_dependency_role"] = "producer" if status == "ready": diagnostics = { "issue_code": "", "severity": "info", "layer": "input_contract", "required_next_stage": "", "recommendation": message, "dependency_owner_stages": dependency_stages, "required_bundle_files": required_bundle_files, "reference_bundle_status": reference_bundle_status, "ortholog_dependency_role": "producer", "recommended_actions": [ { "id": "run_ppi", "label": "Run PPI with the resolved STRING species and mapping", "requires_confirmation": False, "risk": "uses_existing_ortholog_mapping" if mapping_file else "direct_target_string", } ], } return { "status": status, "issue_code": issue_code, "message": message, "target_species": selected_targets, "target_identity": target_identity, "target_string_supported": bool(target_string_code), "requested_stages": dependency_stages, "requested_ppi_species": requested or "auto", "selected_string_species": selected_string_species, "selected_taxon_id": selected_taxon_id, "reference_species": ppi_reference_species, "reference_data_source": reference_data_source, "reference_bundle_status": reference_bundle_status, "mapping_reference_species": mapping_reference_species, "ortholog_mapping_source": str(mapping_file) if mapping_file else "", "required_next_stage": required_next_stage, "decision": decision, "diagnostics": diagnostics, } def write_ppi_failed_manifest( final_dir: Path, prefix: str, diagnostic: Dict[str, Any], ) -> Path: final_dir.mkdir(parents=True, exist_ok=True) manifest_path = final_dir / f"{prefix}.ppi.manifest.json" data = dict(diagnostic) data["prefix"] = prefix data["timestamp"] = datetime.now().isoformat() manifest_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") return manifest_path def resolve_species_sequence_file( ws: Path, final_dir: Path, in_dir: Path, scope_data: Optional[dict], species: str, family: str, sequence_type: str, family_only: bool = False, ) -> Optional[Path]: # Prefer genome_prepare/standardized representative files over raw scope # uploads. Raw NCBI CDS/PEP often use XP/NP headers while prepared family # members and ortholog pairs use transcript-like primary IDs. standardized_candidates: list[Path] = [] if sequence_type == "pep" and family_only: family_pep = resolve_species_family_pep(ws, final_dir, in_dir, species, family) if family_pep: return family_pep if sequence_type == "cds": standardized_candidates.extend([ in_dir / f"{species}.cds.fasta", in_dir / f"{species}.cds.fa", in_dir / species / f"{species}.cds.fasta", in_dir / species / f"{species}.cds.fa", in_dir / "genome_prepare" / f"{species}_extracted.cds.fa", in_dir / "genome_prepare" / f"{species}.cds.fasta", in_dir / "genome_prepare" / f"{species}.cds.fa", ]) elif sequence_type == "pep": standardized_candidates.extend([ in_dir / f"{species}.pep.fasta", in_dir / f"{species}.pep.fa", in_dir / species / f"{species}.pep.fasta", in_dir / species / f"{species}.pep.fa", in_dir / "genome_prepare" / f"{species}_extracted.pep.fa", in_dir / "genome_prepare" / f"{species}.pep.fasta", in_dir / "genome_prepare" / f"{species}.pep.fa", ]) for candidate in standardized_candidates: if candidate.exists() and candidate.stat().st_size > 0: return candidate if scope_data: file_types = ["family_pep", "pep"] if sequence_type == "pep" and family_only else [sequence_type] scoped = first_existing_scope_file(scope_data, species, file_types) if scoped: return scoped candidates = [] if sequence_type == "cds": candidates.extend([ in_dir / species / f"{species}.cds.fasta.gz", ]) if species.lower() in {"at", "ath"}: candidates.extend([ in_dir / "At.cds.fasta", in_dir / "Ath.cds.fasta", Path("/home/xzq/wutx/data/raw_genome/ath/Arabidopsis_thaliana.TAIR10.cds.all.fa"), ]) elif sequence_type == "pep": if family_only: candidates.extend([ in_dir / f"{species}.{family}.pep.fa", in_dir / f"{species}.{family}.pep.fasta", final_dir / f"{species}.{family}.pep.fa", final_dir / f"{species}.{family}.pep.fasta", ]) candidates.extend([ in_dir / species / f"{species}.pep.fasta.gz", ]) if species.lower() in {"at", "ath"}: candidates.extend([ in_dir / "At.pep.fasta", in_dir / "Ath.pep.fasta", Path("/home/xzq/wutx/data/raw_genome/ath/Arabidopsis_thaliana.TAIR10.pep.all.fa"), ]) candidates.extend(reference_protein_candidate_paths(species)) for candidate in candidates: if candidate.exists() and candidate.stat().st_size > 0: return candidate return None def write_species_filtered_pairs( mapping_file: Path, output_file: Path, source_species: str, target_species: str, ) -> int: output_file.parent.mkdir(parents=True, exist_ok=True) count = 0 with mapping_file.open("r", encoding="utf-8", errors="ignore") as src, output_file.open("w", encoding="utf-8") as dst: dst.write("# Source_ID\tTarget_ID\tIdentity(%)\tSource_Species\tTarget_Species\n") for raw in src: line = raw.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if len(parts) < 2: continue src_species = parts[3] if len(parts) >= 4 else "" tgt_species = parts[4] if len(parts) >= 5 else "" if src_species and src_species != source_species: continue if tgt_species and tgt_species != target_species: continue dst.write("\t".join(parts[:5]) + "\n") count += 1 return count def annotate_stage_manifest_with_scope( manifest_path: Path, prefix: str, stage_name: str, scope_path: Path, scope_data: dict, extra_parameters: Optional[dict] = None, ) -> None: if not manifest_path.exists(): return try: manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return scope_fields = { "scope_file": str(scope_path), "scope_id": scope_data.get("scope_id", ""), "scope_mode": scope_data.get("scope_mode", ""), "target_species": scope_data.get("target_species", []), "reference_species": scope_data.get("reference_species", []), "comparative_species": scope_data.get("comparative_species", []), "selected_species": scope_data.get("selected_species", []), "family": scope_data.get("family", ""), } manifest_data.update(scope_fields) params = manifest_data.setdefault("parameters", {}) if isinstance(params, dict): params.update(scope_fields) if extra_parameters: params.update(extra_parameters) if extra_parameters: manifest_data.update({k: v for k, v in extra_parameters.items() if k not in manifest_data}) route = str((extra_parameters or {}).get("analysis_route") or manifest_data.get("analysis_route") or "").strip() member = str((extra_parameters or {}).get("member_set_id") or manifest_data.get("member_set_id") or "").strip() try: stage_context = build_stage_context( stage=stage_name, prefix=prefix, scope_data=scope_data, scope_file=scope_path, analysis_route=route, member_set_id=member, ) manifest_data = apply_stage_context(manifest_data, stage_context) except Exception as exc: print(f"[WARN] Failed to apply stage context to {stage_name} manifest: {exc}") manifest_path.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") if scope_fields["scope_id"]: scoped_manifest = manifest_path.parent / f"{prefix}.{sanitize_token(str(scope_fields['scope_id']))}.{stage_name}.manifest.json" manifest_data["scope_manifest"] = str(scoped_manifest.absolute()) scoped_manifest.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") def _read_prefix_from_manifest(path: Path) -> str: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: return "" return _clean_prefix(data.get("prefix")) def _gene_family_from_config_xlsx(config_path: Path) -> str: if not config_path.exists(): return "" try: import pandas as pd sheets = pd.read_excel(config_path, sheet_name=None) except Exception as exc: print(f"[WARN] Failed to read Gene_Family from {config_path}: {exc}") return "" for sheet_name, df in sheets.items(): if str(sheet_name).lower() in {"analysis", "meta", "metadata"}: for _, row in df.fillna("").iterrows(): cells = [str(v).strip() for v in row.tolist()] for idx, cell in enumerate(cells[:-1]): if cell.lower() in {"family", "gene_family", "analysis_family", "project_family"}: gene_family = _clean_prefix(cells[idx + 1]) if gene_family: return gene_family if "Gene_Family" in df.columns: for value in df["Gene_Family"].tolist(): gene_family = _clean_prefix(value) if gene_family: return gene_family return "" def _species_prefix_from_job_resources(in_dir: Path) -> str: context = resolve_rnaseq_project_context(input_dir=in_dir) return context.get("output_prefix", "") def _is_gene_family_prefix(value: str) -> bool: value = str(value or "").strip() return "." in value or value.lower() in {"target", "multi"} RNA_SEQ_STAGES = { "rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc", "rnaseq_de", "annotation", "rnaseq_enrich", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family", "rnaseq_family_score", "rnaseq_report", } GENE_FAMILY_SCOPE_STAGES = { "identify", "phylogeny", "reference_classification", "structure", "motif", "domain", "combo", } def resolve_effective_prefix_for_job(job, prefix: str, stage: str) -> str: prefix = _clean_prefix(prefix) in_dir = Path(job.input_dir) ws = Path(job.workspace_dir) def normalize_gene_family_prefix(value: str) -> str: value = _clean_prefix(value) if stage in GENE_FAMILY_SCOPE_STAGES and value.startswith("Multi."): family = _clean_prefix(value.split(".", 1)[1]) if family: return f"Target.{family}" return value if stage in RNA_SEQ_STAGES: rnaseq_context = resolve_rnaseq_project_context( input_dir=in_dir, final_dir=ws / "final", requested_prefix=prefix, ) resolved = rnaseq_context.get("output_prefix", "") if resolved: if resolved != prefix: print( f"[INFO] Using RNA-seq output prefix {resolved} for {stage} " f"(requested {prefix or ''}, source={rnaseq_context.get('source', '')})" ) return resolved if prefix: return normalize_gene_family_prefix(prefix) if stage in GENE_FAMILY_SCOPE_STAGES: gene_family = _gene_family_from_config_xlsx(in_dir / "config.xlsx") if gene_family: return f"Target.{gene_family}" species_config = in_dir / "genome_prepare" / "species_config.json" try: if species_config.exists(): data = json.loads(species_config.read_text(encoding="utf-8")) target = data.get("target") if isinstance(data, dict) else None if isinstance(target, dict): resolved = _clean_prefix(target.get("prefix")) if resolved and resolved.lower() != "multi_species": return normalize_gene_family_prefix(resolved) except Exception as exc: print(f"[WARN] Failed to read prefix from {species_config}: {exc}") for root in [in_dir / "final", ws / "final"]: if not root.exists(): continue for path in sorted(root.glob("*.genome_prepare.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True): resolved = _read_prefix_from_manifest(path) if resolved: return normalize_gene_family_prefix(resolved) for path in sorted(root.glob("Target.*.identify.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True): resolved = _read_prefix_from_manifest(path) or path.name[:-len(".identify.manifest.json")] if resolved: return normalize_gene_family_prefix(resolved) for path in sorted(root.glob("Multi.*.identify.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True): resolved = _read_prefix_from_manifest(path) or path.name[:-len(".identify.manifest.json")] if resolved: return normalize_gene_family_prefix(resolved) for path in sorted(root.glob("Target.*.final_members.pep.fa")): resolved = path.name[:-len(".final_members.pep.fa")] if resolved: return normalize_gene_family_prefix(resolved) for path in sorted(root.glob("Multi.*.final_members.pep.fa")): resolved = path.name[:-len(".final_members.pep.fa")] if resolved: return normalize_gene_family_prefix(resolved) gene_family = _gene_family_from_config_xlsx(in_dir / "config.xlsx") if gene_family: return f"Target.{gene_family}" for root in [in_dir, in_dir / "final", ws / "final"]: if not root.exists(): continue for path in sorted(root.glob("Ref_combined.*.pep.fasta")): gene_family = path.name[len("Ref_combined."):-len(".pep.fasta")] gene_family = _clean_prefix(gene_family) if gene_family: return f"Target.{gene_family}" return "" def _has_non_comment_data(path: Path) -> bool: """Return True if a text table has at least one non-empty, non-comment row.""" try: if not path or not path.exists() or path.stat().st_size == 0: return False with open(path, "r", encoding="utf-8", errors="ignore") as handle: for raw in handle: line = raw.strip() if line and not line.startswith("#"): return True except Exception: return False return False def _write_rnaseq_family_score_manifest( final_dir: Path, prefix: str, job_id: str, workspace: Path, input_dir: Path, *, status: str, reason: str, missing: Optional[List[str]] = None, log_path: Optional[Path] = None, returncode: Optional[int] = None, trigger: str = "", ) -> Path: manifest_path = final_dir / f"{prefix}.rnaseq_family_score.manifest.json" payload = { "manifest_version": "1.0", "stage": "rnaseq_family_score", "status": status, "prefix": prefix, "job_id": job_id, "timestamp": datetime.now().isoformat(timespec="seconds"), "triggered_by": trigger, "workspace": str(workspace), "input_dir": str(input_dir), "reason": reason, "missing_inputs": missing or [], "returncode": returncode, "log": str(log_path) if log_path else "", "artifacts": {}, } final_dir.mkdir(parents=True, exist_ok=True) manifest_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return manifest_path def _resolve_rnaseq_family_score_inputs(ws: Path, in_dir: Path, final_dir: Path, prefix: str) -> tuple[bool, Dict[str, Path], List[str]]: de_files = [p for p in final_dir.glob(f"{prefix}.rnaseq_de.*.DE_results.txt") if p.exists() and p.stat().st_size > 0] tpm_candidates = [ final_dir / f"{prefix}.salmon.tpm_matrix.txt", final_dir / f"{prefix}.tpm_matrix.txt", *sorted(final_dir.glob("*.salmon.tpm_matrix.txt")), *sorted(final_dir.glob("*.tpm_matrix.txt")), ] sample_candidates = [ in_dir / "samples.yaml", *sorted(in_dir.glob("*.yaml")), final_dir / "samples.yaml", ] annotation_analysis_dir = ws / "annotation" / "analysis" candidate_analysis_dir = ws / "rnaseq_candidate_family" / "analysis" interpro_candidates = [ ws / "annotation" / "analysis" / f"{prefix}.interproscan.tsv", ws / "rnaseq_candidate_family" / "analysis" / f"{prefix}.rnaseq_candidate_family.interproscan.tsv", *sorted(final_dir.glob("*interproscan*.tsv")), ] if annotation_analysis_dir.exists(): interpro_candidates.extend(sorted(annotation_analysis_dir.glob("*interproscan*.tsv"))) if candidate_analysis_dir.exists(): interpro_candidates.extend(sorted(candidate_analysis_dir.glob("*interproscan*.tsv"))) gene_annotation = final_dir / f"{prefix}.rnaseq_candidate_family.gene_annotations.tsv" inputs: Dict[str, Path] = {} missing: List[str] = [] if de_files: inputs["de_results"] = de_files[0] else: missing.append(f"{prefix}.rnaseq_de.*.DE_results.txt") tpm_matrix = next((p for p in tpm_candidates if p.exists() and p.stat().st_size > 0), None) if tpm_matrix: inputs["tpm_matrix"] = tpm_matrix else: missing.append(f"{prefix}.salmon.tpm_matrix.txt or {prefix}.tpm_matrix.txt") sample_config = next((p for p in sample_candidates if p.exists() and p.stat().st_size > 0), None) if sample_config: inputs["sample_config"] = sample_config else: missing.append("samples.yaml") interpro_tsv = next( ( p for p in interpro_candidates if p.exists() and p.stat().st_size > 0 and "final_members.interproscan" not in p.name.lower() and "curated_members.interproscan" not in p.name.lower() and "candidate_members.interproscan" not in p.name.lower() ), None, ) if interpro_tsv: inputs["interproscan_tsv"] = interpro_tsv elif gene_annotation.exists() and gene_annotation.stat().st_size > 0: inputs["gene_annotations"] = gene_annotation else: missing.append(f"{prefix}.interproscan.tsv or {prefix}.rnaseq_candidate_family.gene_annotations.tsv") return not missing, inputs, missing def _run_rnaseq_family_score_if_ready( ws: Path, in_dir: Path, final_dir: Path, prefix: str, job_id: str, cpu: int, base_log_file: Path, *, trigger: str, ) -> tuple[str, Path]: score_manifest = final_dir / f"{prefix}.rnaseq_family_score.manifest.json" score_script = Path("apps/cli/run_rnaseq_family_score.py") score_log = base_log_file.with_name(f"{base_log_file.stem}.rnaseq_family_score.log") if not score_script.exists(): manifest_path = _write_rnaseq_family_score_manifest( final_dir, prefix, job_id, ws, in_dir, status="skipped", reason="rnaseq_family_score script not found", missing=[str(score_script)], log_path=score_log, trigger=trigger, ) return f" Family score skipped: script not found ({score_script}).", manifest_path ready, inputs, missing = _resolve_rnaseq_family_score_inputs(ws, in_dir, final_dir, prefix) if not ready: manifest_path = _write_rnaseq_family_score_manifest( final_dir, prefix, job_id, ws, in_dir, status="skipped", reason="required inputs are not complete", missing=missing, log_path=score_log, trigger=trigger, ) return f" Family score skipped: missing {', '.join(missing)}.", manifest_path score_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", str(score_script), "--workspace", str(ws), "--input-dir", str(in_dir), "--final-dir", str(final_dir), "-p", prefix, "-j", job_id, "-t", str(cpu), "--sample-config", str(inputs["sample_config"]), "--tpm-matrix", str(inputs["tpm_matrix"]), ] if inputs.get("interproscan_tsv"): score_cmd.extend(["--interproscan-tsv", str(inputs["interproscan_tsv"])]) print(f"[RNA-seq Family Score] Auto post-processing ({trigger}): {' '.join(score_cmd)}") code = run_cmd_logged(score_cmd, score_log, cwd=Path(".")) if code == 0 and score_manifest.exists(): return f" Family score generated: {score_manifest.name}.", score_manifest manifest_path = _write_rnaseq_family_score_manifest( final_dir, prefix, job_id, ws, in_dir, status="failed", reason="rnaseq_family_score command failed or manifest missing", log_path=score_log, returncode=code, trigger=trigger, ) return f" Family score failed but RNA-seq results are kept; see log: {score_log}.", manifest_path RAP_TRANSCRIPT_ID_RE = re.compile(r"^(Os\d{2})t(\d+)(?:-\d+)?$", re.IGNORECASE) def _normalize_enrichment_gene_id_for_match(value: str) -> str: token = (value or "").strip().strip('"').lstrip(">") token = re.sub(r"^[a-z][a-z0-9]{2,4}:", "", token) token = re.split(r"\s+", token, maxsplit=1)[0] previous = None suffix_patterns = [ re.compile(r"(?i)([._-](?:mrna|rna|transcript|cds|protein|pep|peptide)\d*[A-Za-z]*)$"), re.compile(r"(?i)([._-][tp]\d+[A-Za-z]?)$"), re.compile(r"(?i)([._-][rp][A-Z])$"), re.compile(r"(\.\d+)$"), ] while token and token != previous: previous = token for pattern in suffix_patterns: token = pattern.sub("", token) match = RAP_TRANSCRIPT_ID_RE.match(token) if match: token = f"{match.group(1)}g{match.group(2)}" return token def _read_enrichment_first_column(path: Path, *, skip_header: bool = False) -> tuple[set[str], set[str]]: raw_ids: set[str] = set() normalized_ids: set[str] = set() try: with path.open("r", encoding="utf-8", errors="ignore") as handle: for idx, raw in enumerate(handle): line = raw.strip() if not line or line.startswith("#"): continue if skip_header and idx == 0: continue gene_id = re.split(r"[\t, ]+", line, maxsplit=1)[0].strip() normalized = _normalize_enrichment_gene_id_for_match(gene_id) if normalized and normalized.lower() not in {"gene", "gene_id", "geneid", "id", "x"}: raw_ids.add(gene_id) normalized_ids.add(normalized) except Exception: return set(), set() return raw_ids, normalized_ids def _rnaseq_de_background_reference_set(final_dir: Path) -> tuple[set[str], set[str]]: raw_ids: set[str] = set() normalized_ids: set[str] = set() for de_file in sorted(final_dir.glob("*.rnaseq_de.*.DE_genes.txt")): raw, normalized = _read_enrichment_first_column(de_file, skip_header=True) raw_ids.update(raw) normalized_ids.update(normalized) return raw_ids, normalized_ids def _filter_gene2go_files_by_de_overlap(gene2go_files: list[Path], final_dir: Path) -> tuple[list[Path], list[dict[str, Any]]]: de_raw, de_norm = _rnaseq_de_background_reference_set(final_dir) if not de_raw: return gene2go_files, [] compatible: list[Path] = [] reports: list[dict[str, Any]] = [] for gene2go in gene2go_files: bg_raw, bg_norm = _read_enrichment_first_column(gene2go) raw_overlap = len(de_raw & bg_raw) normalized_overlap = len(de_norm & bg_norm) report = { "path": str(gene2go), "de_genes": len(de_raw), "background_genes": len(bg_raw), "raw_overlap": raw_overlap, "normalized_overlap": normalized_overlap, "de_examples": sorted(de_raw)[:5], "background_examples": sorted(bg_raw)[:5], } reports.append(report) if raw_overlap > 0 or normalized_overlap > 0: compatible.append(gene2go) return compatible, reports def _json_file(path: Path) -> dict: try: if path.exists() and path.stat().st_size > 0: with path.open("r", encoding="utf-8") as handle: data = json.load(handle) if isinstance(data, dict): return data except Exception: return {} return {} def _annotation_manifest_success(path: Path) -> bool: return _json_file(path).get("status") == "success" def _annotation_progress_running(path: Path) -> bool: return _json_file(path).get("status") == "running" def _annotation_process_active(ws: Path, scope: str, prefix: str) -> bool: """Best-effort check for a live local annotation process for this workspace.""" analysis_dir = ws / "annotation" / "analysis" markers = [ str(ws.resolve()), str(analysis_dir.resolve()), str((analysis_dir / f"{prefix}.{scope}.annotation_safe.pep.fasta").resolve()), ] if scope == "go": markers.extend(["interproscan", f"{prefix}.interproscan"]) elif scope == "kegg": markers.extend(["exec_annotation", "kofamscan", "hmmscan", f"{prefix}.kofamscan"]) else: markers.extend(["run_annotation.py", "interproscan", "exec_annotation"]) try: for proc in Path("/proc").iterdir(): if not proc.name.isdigit(): continue try: cmdline = (proc / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore") except Exception: continue if not cmdline: continue if "run_annotation.py" in cmdline and any(marker in cmdline for marker in markers[:3]): return True if scope in {"go", "kegg"} and any(marker in cmdline for marker in markers[3:]) and any(marker in cmdline for marker in markers[:2]): return True except Exception: return False return False def _annotation_progress_active(path: Path, ws: Path, scope: str, prefix: str) -> bool: if not _annotation_progress_running(path): return False if _annotation_process_active(ws, scope, prefix): return True progress = _json_file(path) updated_raw = str(progress.get("updated_at") or "") try: updated = datetime.fromisoformat(updated_raw) age_sec = (datetime.now() - updated).total_seconds() except Exception: age_sec = 999999 return age_sec < 60 def _wait_for_annotation_reuse( annotation_manifest: Path, progress_file: Path, log_file: Path, timeout_sec: int = 172800, scope: str = "annotation", ws: Path | None = None, prefix: str = "", ) -> bool: """Wait for an already-running background annotation instead of starting a duplicate.""" start = time.time() log_file.parent.mkdir(parents=True, exist_ok=True) with log_file.open("a", encoding="utf-8") as lg: lg.write( f"\nWAIT: detected existing {scope} annotation run for this workspace; " "waiting to reuse its background outputs.\n" ) lg.flush() while time.time() - start < timeout_sec: if _annotation_manifest_success(annotation_manifest): lg.write(f"WAIT_DONE: {scope} annotation manifest succeeded at {datetime.now():%Y-%m-%d %H:%M:%S}\n") return True progress = _json_file(progress_file) status = progress.get("status") if status == "failed": lg.write(f"WAIT_FAILED: {scope} annotation progress failed: {progress}\n") return False if status == "running" and ws is not None and not _annotation_progress_active(progress_file, ws, scope, prefix): lg.write(f"WAIT_STALE: {scope} annotation progress is running but no live process was detected: {progress}\n") return False if status and status != "running": time.sleep(5) if _annotation_manifest_success(annotation_manifest): lg.write(f"WAIT_DONE: {scope} annotation manifest succeeded at {datetime.now():%Y-%m-%d %H:%M:%S}\n") return True lg.write(f"WAIT_STOPPED: {scope} annotation no longer running, status={status}, manifest not successful\n") return False time.sleep(30) lg.write(f"WAIT_TIMEOUT: {scope} annotation reuse wait timed out\n") return False def _annotation_state_paths(ws: Path, final_dir: Path, prefix: str, scope: str) -> dict[str, Path]: if scope not in {"go", "kegg"}: return { "manifest": final_dir / f"{prefix}.annotation.manifest.json", "progress": ws / "annotation" / "analysis" / "progress.json", "lock": ws / "annotation" / "analysis" / ".annotation.lock", } return { "manifest": final_dir / f"{prefix}.annotation_{scope}.manifest.json", "progress": ws / "annotation" / "analysis" / f"{scope}.progress.json", "lock": ws / "annotation" / "analysis" / f".annotation.{scope}.lock", } def _resolve_sequence_ids_file(job: "JobInfo", value: str) -> Optional[Path]: text = str(value or "").strip() if not text: return None path = Path(text).expanduser() allowed_suffixes = {".ids", ".id", ".list", ".txt", ".tsv", ".csv"} if path.suffix.lower() not in allowed_suffixes: return None if path.is_absolute(): return path if path.exists() and path.is_file() else None search_roots = [Path(job.input_dir), Path(job.workspace_dir), ATH_FAMILY_ROOT] candidate_names = [path] compact_name = path.name.replace("-", "") if compact_name != path.name: candidate_names.append(path.with_name(compact_name)) for root in search_roots: for candidate_name in candidate_names: candidate = root / candidate_name if candidate.exists() and candidate.is_file(): return candidate return None def _resolve_job_ids_file(job: "JobInfo", prefix: str) -> Optional[Path]: prefix = str(prefix or "").strip() if not prefix: return None for suffix in (".ids", ".id", ".list", ".txt"): candidate = _resolve_sequence_ids_file(job, f"{prefix}{suffix}") if candidate: return candidate return None def _now_tag() -> str: return datetime.now().strftime("%Y%m%d_%H%M%S") def cleanup_old_jobs(retention_days: int = RETENTION_DAYS) -> str: """清理 input/ 和 workspace/ 中超过保留期的任务目录""" cutoff = datetime.now() - timedelta(days=retention_days) removed = [] for root in [INPUT_ROOT, WORKSPACE_ROOT]: if not root.exists(): continue for d in root.iterdir(): if not d.is_dir(): continue try: mtime = datetime.fromtimestamp(d.stat().st_mtime) except Exception: continue if mtime < cutoff: try: shutil.rmtree(d, ignore_errors=True) removed.append(str(d)) except Exception: pass if not removed: return f"✅ 清理完成:未发现超过 {retention_days} 天的任务目录" return "✅ 清理完成,已删除:\n" + "\n".join(f"- {p}" for p in removed) @dataclass class JobInfo: job_id: str input_dir: str workspace_dir: str logs_dir: str created_at: str def new_job() -> JobInfo: jid = f"{_now_tag()}_{os.getpid()}_{os.urandom(3).hex()}" in_dir = (INPUT_ROOT / jid) ws_dir = (WORKSPACE_ROOT / jid) logs_dir = (ws_dir / "logs") in_dir.mkdir(parents=True, exist_ok=True) ws_dir.mkdir(parents=True, exist_ok=True) logs_dir.mkdir(parents=True, exist_ok=True) return JobInfo( job_id=jid, input_dir=str(in_dir.resolve()), workspace_dir=str(ws_dir.resolve()), logs_dir=str(logs_dir.resolve()), created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ) def _as_path(f) -> Path: # 兼容 gradio 不同版本的 file 返回形式 if isinstance(f, str): return Path(f) if isinstance(f, dict): if "path" in f: return Path(f["path"]) if "name" in f: return Path(f["name"]) if hasattr(f, "name"): return Path(f.name) raise ValueError(f"Unknown file object: {type(f)}") def upload_to_job(files, job: JobInfo) -> Tuple[str, List[List[str]]]: if not files: return "请选择文件", list_input_table(job) in_dir = Path(job.input_dir) added = [] for f in files: src = _as_path(f) dest = in_dir / src.name if dest.exists(): dest = in_dir / f"{src.stem}_{datetime.now().strftime('%H%M%S')}{src.suffix}" shutil.copy(src, dest) added.append(dest.name) return f"✅ 已上传到 input/{job.job_id}/: {', '.join(added)}", list_input_table(job) def list_input_table(job: JobInfo) -> List[List[str]]: """[文件名, 大小, mtime, 完整路径]""" in_dir = Path(job.input_dir) rows = [] if not in_dir.exists(): return rows for p in sorted(in_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): if not p.is_file(): continue size = p.stat().st_size if size >= 1024 * 1024: size_str = f"{size / 1024 / 1024:.1f} MB" elif size >= 1024: size_str = f"{size / 1024:.1f} KB" else: size_str = f"{size} B" mtime = datetime.fromtimestamp(p.stat().st_mtime).strftime("%m-%d %H:%M") rows.append([p.name, size_str, mtime, str(p.resolve())]) return rows def _strip_gz(name: str) -> str: return name[:-3] if name.lower().endswith(".gz") else name def detect_role(p: Path) -> str: name = _strip_gz(p.name.lower()) suf = Path(name).suffix.lower() if suf in [".fa", ".fasta", ".faa", ".pep"]: return "fasta" if suf in [".gff", ".gff3"]: return "gff3" if suf in [".hmm"]: return "hmm_db" if name.endswith(".domtblout") or "domtblout" in name: return "hmmer_domtblout" if suf in [".id"]: return "id_list" if suf in [".tsv", ".txt", ".csv"]: return "table_or_txt" if suf in [".zip"]: return "zip" return "unknown" def infer_species_code_from_names(files: List[Path]) -> Optional[str]: # 尝试从文件名开头提取类似 Ft / At / Os 这样的物种缩写 # 例如 FtChromosomeV2... -> Ft for p in files: m = re.match(r"^([A-Z][a-z])", p.name) if m: return m.group(1) return None def l1_advise(job: JobInfo) -> str: in_dir = Path(job.input_dir) files = [p for p in in_dir.iterdir() if p.is_file()] if in_dir.exists() else [] if not files: return ( "⚠️ 还没有上传文件。\n\n" "建议先上传(至少一种):\n" "- 蛋白序列(*.fa/*.fasta/*.faa)\n" "- 注释(*.gff3,可选)\n\n" "若你希望做基因家族鉴定(identify),通常还需要:\n" "- 候选ID列表(*.id)或 hmmer domtblout 结果(*.domtblout)\n" "-(系统已预埋)Pfam-A.hmm 等资源\n" ) roles: Dict[str, List[str]] = {} for p in files: roles.setdefault(detect_role(p), []).append(p.name) species = infer_species_code_from_names(files) or "Unknown" lines = [] lines.append("### 📦 已识别到的数据类型(L1)") lines.append(f"- 推测物种缩写:**{species}**(可在前端手动改)") for k, v in roles.items(): lines.append(f"- **{k}**: {', '.join(v)}") #NV| # 检测表达量文件 has_expression = False expression_files = [] for p in files: if p.suffix == ".txt" or p.suffix == ".tsv": name_lower = p.name.lower() if "fpkm" in name_lower or "tpm" in name_lower or "expression" in name_lower or "exp" in name_lower: has_expression = True expression_files.append(p.name) # 检测样本信息文件 has_sample_info = any(p.name.lower() == "sample.txt" for p in files) # 检测基因ID文件 has_gene_id = any("geneid" in p.name.lower() for p in files) has_fasta = "fasta" in roles has_domtbl = "hmmer_domtblout" in roles has_id = "id_list" in roles #ZN| lines.append("\n### ✅ 基于当前上传,你可以直接做的分析") #JB| if has_fasta: #RT| lines.append("- **Phylogeny**:MAFFT/MUSCLE → IQ-TREE(你机器上是 iqtree)") #YN| lines.append("- **MSA figure**:mafft 绘图法(主)+ 保守区二次绘图") #XW| lines.append("- **Motif(MEME)**:MEME + seqlogo(若本机已装 MEME Suite)") #ZT| lines.append("- **Summary**:汇总报告 + 关键文件索引") #ZR| else: #JK| lines.append("- 目前缺少 FASTA(蛋白序列),先上传 fasta 才能继续。") #XJ| #XZ| # Expression 差异表达分析建议 #XB| if has_expression and has_gene_id and has_sample_info: #PQ| lines.append("- **Expression**:差异表达分析(热图、直方图、相关性分析)") #XT| elif has_expression: #KM| lines.append("- **Expression**:差异表达分析(还需:基因ID列表 + sample.txt)") if has_fasta: lines.append("- **Phylogeny**:MAFFT/MUSCLE → IQ-TREE(你机器上是 iqtree)") lines.append("- **MSA figure**:mafft 绘图法(主)+ 保守区二次绘图") lines.append("- **Motif(MEME)**:MEME + seqlogo(若本机已装 MEME Suite)") lines.append("- **Summary**:汇总报告 + 关键文件索引") else: lines.append("- 目前缺少 FASTA(蛋白序列),先上传 fasta 才能继续。") lines.append("\n### 🧩 若要做 Identify(基因家族鉴定)通常还需要") if not has_fasta: lines.append("- 必需:蛋白序列 fasta(pep/protein)") if not (has_domtbl or has_id): lines.append("- 候选证据:hmmer domtblout 或 候选ID列表(*.id)") lines.append("-(项目内置)Pfam-A.hmm:data/hmm/Pfam-A.hmm") lines.append("\n> 说明:Identify 这一步你现有代码的“输入映射规则”需要在 runner 里固定下来(见下方 runner 的 identify 入口)。") lines.append(f"\n### ⏳ 结果保留策略\n- 本地结果默认保留 **{RETENTION_DAYS} 天**,到期会自动清理(前端会提示)。") return "\n".join(lines) def identify_hmm_audit_failure(manifest_path: Path | None) -> tuple[bool, str]: if not manifest_path or not manifest_path.exists(): return False, "" try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception as exc: return False, f"identify manifest unreadable for HMM audit: {exc}" if str(data.get("stage") or "") != "identify": return False, "" hmm_models = data.get("hmm_models") if isinstance(data.get("hmm_models"), dict) else {} missing = hmm_models.get("missing_requested_pfams") or [] status = str(hmm_models.get("status") or "").lower() if missing or status == "failed": missing_text = ",".join(str(x) for x in missing) if missing else "unknown" return True, f"Identify HMM audit failed: missing requested Pfam model(s): {missing_text}" return False, "" def _read_manifest(manifest_path: Path) -> Optional[dict]: try: return json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return None def normalize_manifest(d: dict, manifest_path: Path) -> dict: """标准化 manifest:补 workspace_root;统一 artifacts 格式;优先 relpath_or_hint""" if not d: return {} out = dict(d) # workspace_root:优先字段,否则从路径推断 workspace// if not out.get("workspace_root"): try: out["workspace_root"] = str(manifest_path.parents[1].resolve()) except Exception: out["workspace_root"] = str(manifest_path.parent.resolve()) # workspace:保留兼容(通常是 final/) if not out.get("workspace"): out["workspace"] = str(manifest_path.parent.resolve()) # artifacts:处理三种格式 arts = out.get("artifacts", {}) or {} if isinstance(arts, list): # 格式1:list[dict] 每个 dict 有 artifact 字段 arts_dict = {} for item in arts: if isinstance(item, dict): # 提取 artifact 名称 art_name = item.get("artifact") if not art_name: # 如果没有 artifact 字段,尝试从其他字段推断 if "file" in item: art_name = Path(item["file"]).stem else: art_name = f"artifact_{len(arts_dict)}" # 确保每个条目都有 artifact 字段 v = dict(item) if "artifact" not in v: v["artifact"] = art_name # 统一路径字段 if not v.get("path") and v.get("relpath_or_hint"): v["path"] = v["relpath_or_hint"] arts_dict[art_name] = v arts = arts_dict elif isinstance(arts, dict): # 格式2:dict[art_name, dict] arts2 = {} for art_name, item in arts.items(): if isinstance(item, dict): v = dict(item) # 确保有 artifact 字段 if "artifact" not in v: v["artifact"] = art_name # 统一路径字段 if not v.get("path") and v.get("relpath_or_hint"): v["path"] = v["relpath_or_hint"] arts2[art_name] = v else: # 格式3:dict[art_name, str](直接是文件路径) arts2[art_name] = { "artifact": art_name, "path": item, "full_path": str(Path(item).resolve()) if not Path(item).is_absolute() else item, } arts = arts2 out["artifacts"] = arts return out def find_latest_manifest(workspace_dir: Path) -> Optional[Path]: cands = sorted(workspace_dir.rglob("*.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True) for path in cands: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: return path if data.get("stage") == "minimal": continue return path return None def stage_allows_minimal_manifest(stage: str) -> bool: """Only protein-family downstream views may use an uploaded FASTA as a minimal input.""" return stage in {"phylogeny", "motif", "domain", "structure", "combo", "pep", "msa", "msa_region_plot", "synteny_plot_revise"} def ensure_min_manifest(workspace_dir: Path, prefix: str, final_fa: Path) -> Path: # 用于用户只上传了 fasta,但也想跑 phylogeny/motif/msa_figure/summary 的场景 out_dir = workspace_dir / "final" out_dir.mkdir(parents=True, exist_ok=True) mp = out_dir / f"{prefix}.minimal.manifest.json" # helper:尽量生成“可用的相对路径”,失败就退回文件名 def _safe_rel(p: Path, base: Path) -> str: try: return str(p.resolve().relative_to(base.resolve())) except Exception: return p.name if mp.exists() and mp.stat().st_size > 0: # 如果已存在,确保包含必要字段 try: existing = json.loads(mp.read_text(encoding="utf-8")) # 补齐 workspace_root / workspace / input_dir(兼容旧manifest) if "workspace_root" not in existing: existing["workspace_root"] = str(workspace_dir.resolve()) if "workspace" not in existing: existing["workspace"] = str(out_dir.resolve()) if "input_dir" not in existing: existing["input_dir"] = str(final_fa.parent.resolve()) # 兼容 artifacts 可能是 list 的情况(你前面 key_results 已做过,这里再保险一次) artifacts = existing.get("artifacts", {}) or {} if isinstance(artifacts, list): artifacts_dict = {} for item in artifacts: if isinstance(item, dict) and item.get("artifact"): artifacts_dict[item["artifact"]] = item artifacts = artifacts_dict existing["artifacts"] = artifacts mp.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8") return mp except Exception: pass # 如果解析失败,重新创建 # 生成 minimal manifest(注意:final_fa 在 input_dir,不在 workspace_dir) fa_abs = final_fa.resolve() ws_root = workspace_dir.resolve() in_dir = final_fa.parent.resolve() fa_art = { "artifact": "final_fa", "path": _safe_rel(fa_abs, in_dir), # 相对 input_dir 更合理 "full_path": str(fa_abs), "type": "fasta", "size": fa_abs.stat().st_size if fa_abs.exists() else 0, "sha256": _sha256_file(fa_abs), "source": "uploaded", } d = { "manifest_version": "1.0", "stage": "minimal", "prefix": prefix, "status": "created", "timestamp": datetime.now().isoformat(), # 关键:同时写 workspace_root + workspace "workspace_root": str(ws_root), "workspace": str(out_dir.resolve()), "input_dir": str(in_dir), # 最小也给 python 版本(后续 identify 真跑会覆盖成完整 tool_versions) "tool_versions": {"python": f"{os.sys.version_info.major}.{os.sys.version_info.minor}.{os.sys.version_info.micro}"}, # 兼容两种 key:final_fa / final_proteins "artifacts": { "final_fa": fa_art, "final_proteins": {**fa_art, "artifact": "final_proteins"}, "manifest_json": { "artifact": "manifest_json", "path": _safe_rel(mp.resolve(), ws_root), "full_path": str(mp.resolve()), "type": "json", "size": 0, }, }, # 兼容你旧代码可能用 params "params": {}, "parameters": {}, "steps": [ { "agent": "min_manifest", "timestamp": datetime.now().isoformat(), "operation": "create_minimal_manifest", "out": {"final_fa": str(fa_abs)}, } ], } mp.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8") return mp def normalize_manifest(manifest_data: dict, manifest_path: Path) -> dict: """标准化 manifest 数据格式""" if not manifest_data: return {} # 1. 确保 workspace_root 存在 if 'workspace_root' not in manifest_data: # 推断:final/xxx.manifest.json → workspace/ manifest_data['workspace_root'] = str(manifest_path.parents[1]) # 2. 标准化 artifacts 为字典格式 artifacts = manifest_data.get('artifacts', {}) if isinstance(artifacts, list): artifacts_dict = {} for item in artifacts: if isinstance(item, dict) and 'artifact' in item: artifacts_dict[item['artifact']] = item manifest_data['artifacts'] = artifacts_dict return manifest_data def key_results_from_manifest(manifest_path: Path) -> Tuple[List[List[str]], List[str]]: """返回:关键文件表格 + 图片列表(用于 gallery)""" d = _read_manifest(manifest_path) if not d: return [], [] d = normalize_manifest(d, manifest_path) ws_root = Path(d["workspace_root"]) artifacts = d.get("artifacts", {}) or {} key_paths = [] for k, v in artifacts.items(): file_path = None rel_hint = None # ========== 新增:提取 caption ========== caption = None # ========== 新增结束 ========== if isinstance(v, dict): file_path = v.get("full_path") or v.get("path") or v.get("relpath_or_hint") or v.get("file") rel_hint = v.get("relpath_or_hint") or v.get("path") # ========== 新增:从 artifact 字典中提取 caption ========== caption = v.get("caption") # ========== 新增结束 ========== elif isinstance(v, str): file_path = v if not file_path: continue p = Path(file_path) if not p.is_absolute(): p = ws_root / file_path if p.exists() and p.is_file(): # key_paths.append((k, str(p.resolve()), rel_hint)) # ========== 修改:传递 caption ========== key_paths.append((k, str(p.resolve()), rel_hint, caption)) # ========== 修改结束 ========== # 置顶(把你的 identify_report 也加进去) priority = [ "identify_report", "report_md", "report_html", "summary_md", "summary_files_tsv", "tree_nwk", "meme_html", "meme_txt" ] key_paths.sort(key=lambda kv: (0 if kv[0] in priority else 1, kv[0])) table = [] images = [] # for name, abs_path, rel_hint in key_paths: # ========== 修改:解包时包含 caption ========== for name, abs_path, rel_hint, caption in key_paths: # ========== 修改结束 ========== p = Path(abs_path) # 优先用 relpath_or_hint(你 manifest 里已经写好了) if rel_hint: rel = rel_hint else: rel = str(p.relative_to(ws_root)) if p.is_relative_to(ws_root) else str(p) ext = p.suffix.lower() if ext in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]: # 使用 caption 或自动生成标题 display_caption = caption or name.replace("_", " ").title() # Gradio Gallery 期望 List[Tuple[str, str]] 格式: (path, caption) images.append((str(p), display_caption)) table.append([name, rel, ext]) return table, images def list_all_results(workspace_dir: Path, limit: int = 200) -> List[List[str]]: """列出 workspace// 下所有文件(完整路径)""" rows = [] if not workspace_dir.exists(): return rows files = [p for p in workspace_dir.rglob("*") if p.is_file() and not p.name.startswith(".")] files.sort(key=lambda p: p.stat().st_mtime, reverse=True) for p in files[:limit]: size = p.stat().st_size if size >= 1024 * 1024: size_str = f"{size / 1024 / 1024:.1f} MB" elif size >= 1024: size_str = f"{size / 1024:.1f} KB" else: size_str = f"{size} B" mtime = datetime.fromtimestamp(p.stat().st_mtime).strftime("%m-%d %H:%M") rows.append([p.name, size_str, mtime, str(p.resolve())]) return rows def preview_text(path: str, max_chars: int = 20000) -> str: p = Path(path) if not p.exists(): return "文件不存在" try: content = p.read_text(encoding="utf-8", errors="ignore")[:max_chars] if p.stat().st_size > max_chars: content += "\n\n... (文件较大,仅显示部分)" return content except Exception: return "无法预览此文件(可能是二进制/图片/PDF)" # ======== 新增:文件hash工具函数(给最小manifest用)======== import hashlib def _sha256_file(p: Path, chunk_size: int = 1024 * 1024) -> Optional[str]: try: h = hashlib.sha256() with p.open("rb") as f: while True: b = f.read(chunk_size) if not b: break h.update(b) return h.hexdigest() except Exception: return None # ======== 新增结束 ======== def download_string_network_image( string_url: str, output_file: Path, *, http_client: Any = None, rate_gate: Any = None, ) -> Optional[Path]: """Download a static STRING network image for frontend display.""" if not string_url or "string-db.org/cgi/network?" not in string_url: return None import requests from urllib.parse import parse_qs, urlparse parsed = urlparse(string_url) params = {key: values[-1] for key, values in parse_qs(parsed.query, keep_blank_values=True).items()} params.setdefault("network_flavor", "confidence") params.setdefault("hide_disconnected_nodes", "0") params["caller_identity"] = "PlantAI" client = http_client or requests shared_rate_gate = rate_gate or StringRateGate() attempts = [ ("https://string-db.org/api/highres_image/network", output_file, "STRING highres_image API", (8, 30), 40), ("https://string-db.org/api/image/network", output_file, "STRING image API", (5, 20), 30), ("https://string-db.org/api/svg/network", output_file.with_suffix(".svg"), "STRING svg API", (5, 20), 30), ] errors: List[str] = [] for request_url, target_file, source_label, timeout_value, max_seconds in attempts: try: target_file.parent.mkdir(parents=True, exist_ok=True) if target_file.exists(): target_file.unlink() expected_svg = target_file.suffix.lower() == ".svg" expected_token = "svg" if expected_svg else "image" if expected_svg: params.setdefault("block_structure_pics_in_bubbles", "0") start_time = time.time() shared_rate_gate.wait() response = client.post( request_url, data=params, timeout=timeout_value, stream=True, allow_redirects=False, ) response_url = getattr(response, "url", None) if not is_authoritative_string_https_url(response_url): raise RuntimeError(f"unexpected response authority: {response_url}") status_code = getattr(response, "status_code", None) if ( isinstance(status_code, bool) or not isinstance(status_code, int) or not 200 <= status_code < 300 ): raise RuntimeError(f"unexpected HTTP status: {status_code!r}") response.raise_for_status() content_type = response.headers.get("content-type", "").lower() if expected_token not in content_type: raise RuntimeError(f"unexpected content-type: {content_type or 'unknown'}") chunks = [] deadline = start_time + max_seconds for chunk in response.iter_content(chunk_size=1024 * 256): if time.time() > deadline: raise TimeoutError(f"{source_label} download exceeded {max_seconds} seconds") if chunk: chunks.append(chunk) content = b"".join(chunks) if not content: raise RuntimeError("empty response body") target_file.write_bytes(content) if target_file.exists() and target_file.stat().st_size > 0: return target_file except Exception as exc: errors.append(f"{source_label}: {exc}") continue if errors: print(f"[PPI] STRING network image download skipped: {'; '.join(errors)}") return None _UNIPROT_ACCESSION_RE = re.compile( r"^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9][A-Z][A-Z0-9]{2}[0-9]|A0A[A-Z0-9]{7})$" ) def _alphafold_lookup_id(identifier: str) -> Tuple[str, str]: """Return a conservative AlphaFold DB lookup token and link type.""" token = str(identifier or "").strip() if not token: return "", "unresolved" if "." in token: suffix = token.rsplit(".", 1)[-1] if _UNIPROT_ACCESSION_RE.match(suffix): return suffix, "direct_entry" if "_" in token: head = token.split("_", 1)[0] if _UNIPROT_ACCESSION_RE.match(head): return head, "direct_entry" if _UNIPROT_ACCESSION_RE.match(token): return token, "direct_entry" return token, "search" def create_alphafold2_links( id_resolution_file: Path, network_file: Path, output_file: Path, ) -> Optional[Path]: """Create AlphaFold DB/Fold2 single-protein structure links from PPI outputs.""" from urllib.parse import quote candidates: List[str] = [] if network_file.exists() and network_file.stat().st_size > 0: with network_file.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") for row in reader: for key in ("protein1", "protein2"): value = str(row.get(key) or "").strip() if value: candidates.append(value) if id_resolution_file.exists() and id_resolution_file.stat().st_size > 0: with id_resolution_file.open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") for row in reader: for key in ("string_id", "selected_query_id", "normalized_id", "raw_id"): value = str(row.get(key) or "").strip() if value: candidates.append(value) break seen: set[str] = set() rows: List[Dict[str, str]] = [] for source_id in candidates: if source_id in seen: continue seen.add(source_id) lookup_id, link_type = _alphafold_lookup_id(source_id) if not lookup_id: continue if link_type == "direct_entry": url = f"https://alphafold.ebi.ac.uk/entry/{quote(lookup_id)}" note = "direct accession-style lookup" else: url = f"https://alphafold.ebi.ac.uk/search/text/{quote(lookup_id)}" note = "search link; accession not confirmed" rows.append({ "source_id": source_id, "lookup_id": lookup_id, "link_type": link_type, "alphafold_db_url": url, "structure_type": "AlphaFold DB/Fold2 single protein", "note": note, }) if not rows: return None output_file.parent.mkdir(parents=True, exist_ok=True) with output_file.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter( handle, fieldnames=[ "source_id", "lookup_id", "link_type", "alphafold_db_url", "structure_type", "note", ], delimiter="\t", ) writer.writeheader() writer.writerows(rows) return output_file if output_file.exists() and output_file.stat().st_size > 0 else None def sync_ppi_manifest_final_artifacts(manifest_file: Path, final_dir: Path, prefix: str) -> None: """Point copied PPI skill manifest artifacts at the final/ copies.""" if not manifest_file.exists(): return final_artifacts = { "network": (final_dir / f"{prefix}.ppi_network.tsv", "table", "tsv"), "report": (final_dir / f"{prefix}.ppi_report.md", "markdown", "markdown"), "id_resolution": (final_dir / f"{prefix}.ppi_id_resolution.tsv", "table", "tsv"), "string_url": (final_dir / f"{prefix}.ppi_url.txt", "link", "txt"), } try: with manifest_file.open("r", encoding="utf-8") as handle: data = json.load(handle) artifacts = data.setdefault("artifacts", {}) changed = False for key, (path, artifact_type, artifact_format) in final_artifacts.items(): if not path.exists(): continue record = artifacts.setdefault(key, {}) record.update({ "artifact": key, "path": f"final/{path.name}", "full_path": str(path.absolute()), "type": artifact_type, "format": artifact_format, "size": path.stat().st_size, "sha256": _sha256_file(path), }) changed = True if changed: with manifest_file.open("w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) except Exception as exc: print(f"[PPI] Could not sync PPI manifest artifact paths: {exc}") def _read_gene_id_lines(path: Path) -> List[str]: if not path.exists(): return [] seen: set[str] = set() values: List[str] = [] for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): token = line.strip() if not token or token.startswith("#") or token in seen: continue seen.add(token) values.append(token) return values def _next_ppi_reset_dir(final_dir: Path) -> Tuple[str, Path]: reset_root = final_dir / "ppi_reset" reset_root.mkdir(parents=True, exist_ok=True) used: set[int] = set() for path in reset_root.glob("reset_*"): if not path.is_dir(): continue match = re.fullmatch(r"reset_(\d{3})", path.name) if match: used.add(int(match.group(1))) next_index = 1 while next_index in used: next_index += 1 reset_id = f"reset_{next_index:03d}" reset_dir = reset_root / reset_id reset_dir.mkdir(parents=False, exist_ok=False) return reset_id, reset_dir def prepare_ppi_reset_inputs( final_dir: Path, prefix: str, source_gene_id_file: Path, submitted_gene_ids: List[str], instruction: str = "", ppi_species: str = "auto", ppi_score: int = 200, ) -> Dict[str, Any]: """Create isolated PPI reset input/diff/manifest files without touching original PPI outputs.""" original_ids = _read_gene_id_lines(source_gene_id_file) submitted_seen: set[str] = set() clean_submitted: List[str] = [] for item in submitted_gene_ids: token = str(item or "").strip() if not token or token in submitted_seen: continue submitted_seen.add(token) clean_submitted.append(token) if not clean_submitted: raise ValueError("PPI reset requires at least one gene ID") reset_id, reset_dir = _next_ppi_reset_dir(final_dir) input_file = reset_dir / f"{prefix}.ppi_reset_input.id" diff_file = reset_dir / f"{prefix}.ppi_reset_diff.tsv" manifest_file = reset_dir / f"{prefix}.ppi_reset.manifest.json" input_file.write_text("\n".join(clean_submitted) + "\n", encoding="utf-8") original_set = set(original_ids) submitted_set = set(clean_submitted) deleted_ids = [gid for gid in original_ids if gid not in submitted_set] added_ids = [gid for gid in clean_submitted if gid not in original_set] kept_ids = [gid for gid in clean_submitted if gid in original_set] with diff_file.open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter="\t") writer.writerow(["gene_id", "action"]) for gene_id in kept_ids: writer.writerow([gene_id, "kept"]) for gene_id in deleted_ids: writer.writerow([gene_id, "deleted"]) for gene_id in added_ids: writer.writerow([gene_id, "added"]) manifest = { "manifest_version": "1.0", "stage": "ppi_reset", "status": "prepared", "prefix": prefix, "reset_id": reset_id, "created_at": datetime.now().isoformat(), "source_gene_id_file": str(source_gene_id_file), "input_gene_id_file": str(input_file), "diff_file": str(diff_file), "instruction": str(instruction or ""), "input_gene_count": len(clean_submitted), "source_gene_count": len(original_ids), "kept_count": len(kept_ids), "deleted_count": len(deleted_ids), "added_count": len(added_ids), "parameters": { "ppi_species": ppi_species, "ppi_score": int(ppi_score), }, "artifacts": { "reset_input": { "artifact": "reset_input", "path": str(input_file), "type": "table", "format": "id", "size": input_file.stat().st_size, "sha256": _sha256_file(input_file), }, "reset_diff": { "artifact": "reset_diff", "path": str(diff_file), "type": "table", "format": "tsv", "size": diff_file.stat().st_size, "sha256": _sha256_file(diff_file), }, }, } manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") return { "reset_id": reset_id, "reset_dir": reset_dir, "input_file": input_file, "diff_file": diff_file, "manifest_file": manifest_file, "gene_ids": clean_submitted, "deleted_ids": deleted_ids, "added_ids": added_ids, } def _record_reset_artifact(reset_dir: Path, reset_id: str, path: Path, artifact: str, artifact_type: str, artifact_format: str) -> Dict[str, Any]: return { "artifact": artifact, "path": f"final/ppi_reset/{reset_id}/{path.name}", "full_path": str(path.absolute()), "type": artifact_type, "format": artifact_format, "size": path.stat().st_size, "sha256": _sha256_file(path), } def run_ppi_reset_analysis( job: JobInfo, prefix: str, source_gene_id_file: Path, submitted_gene_ids: List[str], instruction: str = "", ppi_species: str = "auto", ppi_score: int = 200, ) -> Dict[str, Any]: """Run a derived PPI reset analysis into final/ppi_reset/reset_###.""" ws = Path(job.workspace_dir) final_dir = ws / "final" logs_dir = Path(job.logs_dir) final_dir.mkdir(parents=True, exist_ok=True) logs_dir.mkdir(parents=True, exist_ok=True) prepared = prepare_ppi_reset_inputs( final_dir=final_dir, prefix=prefix, source_gene_id_file=source_gene_id_file, submitted_gene_ids=submitted_gene_ids, instruction=instruction, ppi_species=ppi_species, ppi_score=ppi_score, ) reset_id = str(prepared["reset_id"]) reset_dir = Path(prepared["reset_dir"]) input_file = Path(prepared["input_file"]) skill_output_dir = reset_dir / "skill_output" skill_output_dir.mkdir(parents=True, exist_ok=True) skill_script = Path("skills/ppi/run.py") if not skill_script.exists(): raise FileNotFoundError("skills/ppi/run.py not found") cmd = [ sys.executable, str(skill_script), "--gene-ids", str(input_file), "--species", ppi_species, "--prefix", prefix, "--output", str(skill_output_dir), "--score", str(int(ppi_score)), ] log_file = logs_dir / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{prefix}.ppi_reset.{reset_id}.log" code = run_cmd_logged(cmd, log_file, cwd=Path(".")) manifest_file = Path(prepared["manifest_file"]) try: manifest_data = json.loads(manifest_file.read_text(encoding="utf-8")) except Exception: manifest_data = {} manifest_data["status"] = "success" if code == 0 else "failed" manifest_data["log_file"] = str(log_file) manifest_data.setdefault("parameters", {})["ppi_species"] = ppi_species manifest_data.setdefault("parameters", {})["ppi_score"] = int(ppi_score) artifacts = manifest_data.setdefault("artifacts", {}) if code == 0: mappings = { f"{prefix}_network.tsv": (f"{prefix}.ppi_network.tsv", "network", "table", "tsv"), f"{prefix}_report.md": (f"{prefix}.ppi_report.md", "report", "markdown", "markdown"), f"{prefix}_url.txt": (f"{prefix}.ppi_url.txt", "string_url", "link", "txt"), f"{prefix}_id_resolution.tsv": (f"{prefix}.ppi_id_resolution.tsv", "id_resolution", "table", "tsv"), f"{prefix}.manifest.json": (f"{prefix}.ppi_skill.manifest.json", "skill_manifest", "json", "json"), } for src_name, (dst_name, artifact, artifact_type, artifact_format) in mappings.items(): src = skill_output_dir / src_name if not src.exists(): continue dst = reset_dir / dst_name shutil.copy2(src, dst) artifacts[artifact] = _record_reset_artifact(reset_dir, reset_id, dst, artifact, artifact_type, artifact_format) url_file = reset_dir / f"{prefix}.ppi_url.txt" string_url = "" if url_file.exists(): for line in url_file.read_text(encoding="utf-8", errors="ignore").splitlines(): match = re.search(r"(https?://\S+)", line) if match: string_url = match.group(1) break if string_url: manifest_data["string_url"] = string_url image_file = download_string_network_image(string_url, reset_dir / f"{prefix}.ppi_network.string.png") if image_file: fmt = image_file.suffix.lower().lstrip(".") or "png" artifacts["string_network_image"] = _record_reset_artifact(reset_dir, reset_id, image_file, "string_network_image", fmt, fmt) fold2_file = create_alphafold2_links( id_resolution_file=reset_dir / f"{prefix}.ppi_id_resolution.tsv", network_file=reset_dir / f"{prefix}.ppi_network.tsv", output_file=reset_dir / f"{prefix}.ppi_alphafold2_links.tsv", ) if fold2_file: fold2_record = _record_reset_artifact(reset_dir, reset_id, fold2_file, "alphafold2_links", "table", "tsv") fold2_record["source"] = "AlphaFold DB links derived from STRING PPI reset outputs" fold2_record["structure_type"] = "AlphaFold DB/Fold2 single protein" artifacts["alphafold2_links"] = fold2_record manifest_file.write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2), encoding="utf-8") return { "success": code == 0, "exit_code": code, "reset_id": reset_id, "reset_dir": reset_dir, "manifest_file": manifest_file, "log_file": log_file, } def _get_free_mem_gb() -> float: # 不依赖 psutil:直接读 /proc/meminfo try: meminfo = Path("/proc/meminfo").read_text() m = re.search(r"MemAvailable:\s+(\d+)\s+kB", meminfo) if not m: return 0.0 kb = float(m.group(1)) return kb / 1024 / 1024 except Exception: return 0.0 def can_start_new_task(min_free_gb: float = 20.0) -> Tuple[bool, str]: free_gb = _get_free_mem_gb() if free_gb <= 0: return True, "⚠️ 无法读取 MemAvailable,允许启动(请自行关注内存)" if free_gb < min_free_gb: return False, f"❌ 当前可用内存约 {free_gb:.1f} GB < {min_free_gb} GB,建议稍后再运行或减少并发" return True, f"✅ 当前可用内存约 {free_gb:.1f} GB,允许启动任务" def run_cmd_logged(cmd: List[str], log_path: Path, cwd: Optional[Path] = None) -> int: log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("w", encoding="utf-8") as lg: lg.write("CMD: " + " ".join(cmd) + "\n") lg.write("CWD: " + (str(cwd) if cwd else os.getcwd()) + "\n") lg.write("START: " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n\n") lg.flush() # 设置环境变量,包含 conda 环境的 PATH env = os.environ.copy() conda_bin = "/home/xzq/miniconda3/envs/test4py311/bin" conda_lib = "/home/xzq/miniconda3/envs/test4py311/lib" if conda_bin not in env.get("PATH", ""): env["PATH"] = conda_bin + ":" + env.get("PATH", "") if conda_lib not in env.get("LD_LIBRARY_PATH", ""): env["LD_LIBRARY_PATH"] = conda_lib + ":" + env.get("LD_LIBRARY_PATH", "") project_root = str(PROJECT_ROOT) pythonpath_parts = [p for p in env.get("PYTHONPATH", "").split(os.pathsep) if p] if project_root not in pythonpath_parts: env["PYTHONPATH"] = os.pathsep.join([project_root] + pythonpath_parts) p = subprocess.Popen(cmd, cwd=str(cwd) if cwd else None, stdout=lg, stderr=subprocess.STDOUT, text=True, env=env) return p.wait() def _species_arg_list(value) -> List[str]: if not value: return [] if isinstance(value, str): return [item.strip() for item in value.split(',') if item.strip()] return [str(item).strip() for item in value if str(item).strip()] def _workspace_url_to_path(value: str, workspace: Path) -> Optional[Path]: raw = str(value or "").strip() if not raw: return None if raw.startswith("/workspace/"): parts = raw.strip("/").split("/", 2) if len(parts) == 3: return (WORKSPACE_ROOT / parts[1] / parts[2]).resolve() path = Path(raw).expanduser() if path.is_absolute(): return path.resolve() if raw.startswith("final/"): return (workspace / raw).resolve() return (workspace / raw).resolve() def _resolve_msa_file_for_region_plot(workspace: Path, prefix: str, msa_file: str = "", source_image: str = "") -> Optional[Path]: explicit = _workspace_url_to_path(msa_file, workspace) if explicit and explicit.exists() and explicit.is_file(): return explicit image_path = _workspace_url_to_path(source_image, workspace) final_dir = workspace / "final" manifest_candidates: List[Path] = [] if image_path and image_path.exists(): stem = image_path.name for suffix in ("_msa.png", "_msa.pdf", ".png", ".pdf", ".jpg", ".jpeg", ".svg"): if stem.lower().endswith(suffix): manifest_candidates.append(image_path.with_name(stem[: -len(suffix)] + ".manifest.json")) if final_dir.exists(): manifest_candidates.extend(sorted(final_dir.glob(f"{prefix}.msa_region_*.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)) if image_path and image_path.exists(): image_name = image_path.name image_stem = image_name for suffix in (".png", ".jpg", ".jpeg", ".svg", ".pdf"): if image_stem.lower().endswith(suffix): image_stem = image_stem[: -len(suffix)] break msa_stems = [] msa_plot_match = re.match(r"^(.+?)\.msa_plot(?:-\d+)?$", image_stem, flags=re.IGNORECASE) if msa_plot_match: msa_stems.append(msa_plot_match.group(1)) region_match = re.match(r"^(.+?)\.msa_region_\d+_\d+(?:_msa)?$", image_stem, flags=re.IGNORECASE) if region_match: msa_stems.append(region_match.group(1)) for stem_prefix in msa_stems: candidate = final_dir / f"{stem_prefix}.pep.msa.fa" if candidate.exists() and candidate.is_file(): return candidate.resolve() seen = set() for manifest_path in manifest_candidates: if manifest_path in seen or not manifest_path.exists(): continue seen.add(manifest_path) try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: continue artifacts = data.get("artifacts") or {} source_msa = artifacts.get("source_msa") or artifacts.get("msa") or "" if isinstance(source_msa, dict): source_msa = source_msa.get("full_path") or source_msa.get("path") or "" resolved = _workspace_url_to_path(str(source_msa), workspace) if resolved and resolved.exists() and resolved.is_file(): return resolved fallback = final_dir / f"{prefix}.pep.msa.fa" return fallback.resolve() if fallback.exists() else None def _tree_prefix_from_image_name(name: str) -> str: stem = str(name or "") for suffix in (".png", ".jpg", ".jpeg", ".svg", ".pdf"): if stem.lower().endswith(suffix): stem = stem[: -len(suffix)] break if stem.lower().endswith(".circular_tree"): stem = stem[: -len(".circular_tree")] lower = stem.lower() replot_match = re.match(r"^(.+?)\.tree_replot_[^.]+$", stem, flags=re.IGNORECASE) if replot_match: stem = replot_match.group(1) + ".tree" lower = stem.lower() if lower.endswith(".motif.tree_motif"): stem = stem[: -len(".motif.tree_motif")] lower = stem.lower() if lower.endswith(".combo"): stem = stem[: -len(".combo")] lower = stem.lower() return stem def _resolve_tree_file_for_replot(workspace: Path, prefix: str, tree_file: str = "", source_image: str = "") -> Optional[Path]: explicit = _workspace_url_to_path(tree_file, workspace) if explicit and explicit.exists() and explicit.is_file(): return explicit final_dir = workspace / "final" image_path = _workspace_url_to_path(source_image, workspace) if image_path and image_path.exists(): image_stem = _tree_prefix_from_image_name(image_path.name) candidates = [ final_dir / f"{image_stem}.nwk", final_dir / f"{image_stem}.tree.nwk" if not image_stem.endswith(".tree") else final_dir / f"{image_stem}.nwk", ] if image_stem.endswith(".tree"): base_stem = image_stem[: -len(".tree")] candidates.append(final_dir / f"{base_stem}.tree.nwk") else: candidates.append(final_dir / f"{image_stem}.tree.nwk") if ".reference_classification" not in image_stem: # Combo and tree-motif images may use a shorter public prefix; prefer # exact tree files first, then any scoped tree derived from that prefix. candidates.extend(sorted(final_dir.glob(f"{image_stem}*.tree.nwk"), key=lambda p: p.stat().st_mtime, reverse=True)) for candidate in candidates: if candidate.exists() and candidate.is_file(): return candidate.resolve() fallback = final_dir / f"{prefix}.tree.nwk" if fallback.exists(): return fallback.resolve() matches = sorted(final_dir.glob(f"{prefix}*.tree.nwk"), key=lambda p: p.stat().st_mtime, reverse=True) return matches[0].resolve() if matches else None def normalize_combo_panels(raw_panels) -> List[str]: allowed = ["motif", "structure", "domain"] aliases = { "gene_structure": "structure", "gene-structure": "structure", "genestructure": "structure", "domains": "domain", "motifs": "motif", "all": "all", } if isinstance(raw_panels, str): items = raw_panels.replace(";", ",").split(",") elif isinstance(raw_panels, (list, tuple, set)): items = list(raw_panels) else: items = [] selected: List[str] = [] for token in items: key = str(token or "").strip().lower().replace(" ", "_") if not key: continue key = aliases.get(key, key) if key == "all": return allowed if key not in allowed: raise ValueError(f"Unsupported combo panel: {token}. Allowed: {', '.join(allowed)}") if key not in selected: selected.append(key) selected = selected or allowed if len(selected) < 2: raise ValueError("Combo redraw requires at least two data panels; single-panel views already exist in motif/structure/domain stages.") return selected def _manifest_combo_output_label(manifest: Dict[str, Any]) -> str: params = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {} return str(params.get("output_label") or manifest.get("output_label") or "").strip() def _is_standard_combo_manifest(path: Path) -> bool: try: manifest = json.loads(path.read_text(encoding="utf-8")) except Exception: return True if _manifest_combo_output_label(manifest): return False artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} for key in artifacts: if str(key or "").startswith("combo_replot"): return False return True def infer_completed_combo_panels( final_dir: Path, prefix: str, scope_data: Optional[Dict[str, Any]] = None, analysis_route: str = "", member_set_id: str = "", ) -> List[str]: completed: List[str] = [] for panel in ["motif", "structure", "domain"]: manifest_path = first_existing_stage_manifest( final_dir, panel, prefix, scope_data or {}, analysis_route, member_set_id, ) if manifest_path: completed.append(panel) return completed if len(completed) >= 2 else [] def _resolve_rnaseq_sample_config( input_dir: Path, workspace_dir: Path, requested: str = "", ) -> Optional[Path]: """Resolve the RNA-seq sample table without inferring read roles from filenames.""" candidates: List[Path] = [] requested_text = str(requested or "").strip() if requested_text: requested_path = Path(requested_text).expanduser() if requested_path.is_absolute(): candidates.append(requested_path) else: candidates.extend([input_dir / requested_path, workspace_dir / requested_path, requested_path]) candidates.extend([input_dir / "samples.yaml", workspace_dir / "final" / "samples.yaml"]) for candidate in candidates: if candidate.is_file(): return candidate return None def _resolve_wgs_sample_config(input_dir: Path, workspace_dir: Path, requested: str = "") -> Path: """Resolve exactly one explicit WGS sample configuration; never infer reads.""" def _resolve_wgs_sample_config(input_dir: Path, workspace_dir: Path, requested: str = "") -> Path: candidates: list[Path] = [] if str(requested or "").strip(): path = Path(str(requested).strip()).expanduser() candidates.append(path if path.is_absolute() else input_dir / path) else: candidates.extend([input_dir / "wgs_samples.yaml", workspace_dir / "wgs_samples.yaml"]) existing = [path for path in candidates if path.is_file()] if len(existing) != 1: raise ValueError("wgs_qc requires exactly one wgs_samples.yaml configuration") return existing[0] def _resolve_wgs_reference_fasta(input_dir: Path, requested: str = "") -> Path: """Resolve only the explicit Stage 02 root reference; never infer one.""" text = str(requested or "").strip() if not text: raise ValueError("wgs_index requires an explicit reference FASTA") candidate = Path(text).expanduser() candidate = candidate if candidate.is_absolute() else input_dir / candidate if not candidate.is_file() or candidate.stat().st_size <= 0: raise ValueError("wgs_index reference FASTA must be an existing non-empty regular file") return candidate def _valid_wgs_qc_manifest(data: dict, *, job_id: str, prefix: str, sample_ids: set[str]) -> bool: if data.get("stage") != "wgs_qc" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix: return False if not str(data.get("wgs_sample_set_id") or "") or not str(data.get("wgs_analysis_id") or ""): return False artifacts = data.get("artifacts") if isinstance(data.get("artifacts"), dict) else {} config_artifact = artifacts.get("wgs_sample_config") if not isinstance(config_artifact, dict): return False if str(config_artifact.get("role") or config_artifact.get("artifact") or "") != "wgs_sample_config": return False required = {"clean_reads_r1", "clean_reads_r2", "fastp_html", "fastp_json"} found: dict[str, set[str]] = {} for item in artifacts.values(): if isinstance(item, dict) and item.get("sample_id"): found.setdefault(str(item["sample_id"]), set()).add(str(item.get("role") or "")) return set(found) == sample_ids and all(found[sample_id] == required for sample_id in sample_ids) def _valid_wgs_index_manifest(data: dict, *, job_id: str, prefix: str) -> bool: if data.get("stage") != "wgs_index" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix: return False if not str(data.get("wgs_reference_id") or "") or not str(data.get("wgs_analysis_id") or ""): return False artifacts = data.get("artifacts") if isinstance(data.get("artifacts"), dict) else {} required = {"reference_fasta", "reference_fai", "reference_dict", *{f"bwa_mem2_index_component.{component}" for component in (".0123", ".amb", ".ann", ".bwt.2bit.64", ".pac")}} if set(artifacts) != required: return False return all(isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == ("bwa_mem2_index_component" if key.startswith("bwa_mem2_index_component.") else key) and (not key.startswith("bwa_mem2_index_component.") or item.get("component") == key.removeprefix("bwa_mem2_index_component.")) for key, item in artifacts.items()) from gfa_core.wgs_upstream_manifest import resolve_wgs_artifacts def _explicit_wgs_authority(requested: str, workspace: Path, prefix: str, stage: str) -> Path: if not str(requested or "").strip(): raise ValueError(f"{stage} requires an explicit authoritative Manifest path") path = Path(requested) if not path.is_absolute(): path = workspace / path if path.is_symlink() or not path.is_file(): raise ValueError(f"{stage} authoritative Manifest must be a regular non-symlink file") path = path.resolve() if stage in {"wgs_variant_select", "wgs_variant_filter"}: try: snapshot = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError( "wgs_variant_select authoritative Manifest is unreadable" ) from exc actual_method = str(snapshot.get("method") or "") if ( actual_method not in {"gatk", "bcftools"} or (snapshot.get("parameters") or {}).get("method") != actual_method ): raise ValueError( "wgs_variant_select authoritative Manifest method mismatch" ) expected = ( workspace.resolve() / "final" / f"{prefix}.{stage}.{actual_method}.manifest.json" ) else: expected = workspace.resolve() / "final" / f"{prefix}.{stage}.manifest.json" if path != expected: raise ValueError( f"{stage} authoritative Manifest path/prefix/method mismatch" ) return path def _valid_wgs_align_manifest(data: dict, *, job_id: str, prefix: str) -> bool: if data.get("stage") != "wgs_align" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix: return False if not all(str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id")): return False inputs = (data.get("metrics") or {}).get("input_sample_ids") outputs = (data.get("metrics") or {}).get("output_sample_ids") if not isinstance(inputs, list) or not inputs or inputs != outputs or len(set(inputs)) != len(inputs): return False artifacts = data.get("artifacts") if not isinstance(artifacts, dict): return False expected = {(role, sample) for sample in inputs for role in ("sorted_bam", "sorted_bam_index", "align_log")} observed = set() for item in artifacts.values(): if not isinstance(item, dict): return False role, sample = str(item.get("role") or item.get("artifact") or ""), str(item.get("sample_id") or "") if (role, sample) in observed: return False observed.add((role, sample)) return observed == expected def _valid_wgs_markdup_manifest(data: dict, *, job_id: str, prefix: str) -> bool: if data.get("stage") != "wgs_markdup" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix: return False if not all(str(data.get(key) or "") for key in ( "wgs_sample_set_id", "wgs_reference_id", "upstream_wgs_analysis_id", "wgs_analysis_id")): return False inputs = (data.get("metrics") or {}).get("input_sample_ids") outputs = (data.get("metrics") or {}).get("output_sample_ids") if not isinstance(inputs, list) or not inputs or inputs != outputs or len(set(inputs)) != len(inputs): return False artifacts = data.get("artifacts") if not isinstance(artifacts, dict): return False roles = ("markdup_bam", "markdup_bam_index", "markdup_metrics", "markdup_log") expected = {(role, sample) for sample in inputs for role in roles} observed = set() for item in artifacts.values(): if not isinstance(item, dict): return False role = str(item.get("role") or item.get("artifact") or "") sample = str(item.get("sample_id") or "") if (role, sample) in observed: return False observed.add((role, sample)) return observed == expected def _valid_wgs_bam_qc_manifest(data: dict, *, job_id: str, prefix: str) -> bool: if data.get("stage") != "wgs_bam_qc" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix: return False if not all(str(data.get(key) or "") for key in ( "wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id")): return False upstream = data.get("upstream_wgs_analysis_ids") if not isinstance(upstream, dict) or set(upstream) != {"wgs_markdup", "wgs_index"} or not all(str(value or "") for value in upstream.values()): return False metrics = data.get("metrics") or {} inputs, outputs = metrics.get("input_sample_ids"), metrics.get("output_sample_ids") if not isinstance(inputs, list) or not inputs or inputs != outputs or len(set(inputs)) != len(inputs): return False if (data.get("parameters") or {}).get("metric_name") != "coverage_ge_1x": return False artifacts = data.get("artifacts") if not isinstance(artifacts, dict): return False expected = {(role, sample) for sample in inputs for role in ("bam_flagstat", "bam_depth_summary")} expected.update({("bam_qc_summary", ""), ("bam_qc_report", "")}) observed = set() for item in artifacts.values(): if not isinstance(item, dict): return False identity = ( str(item.get("role") or item.get("artifact") or ""), str(item.get("sample_id") or ""), ) if identity in observed: return False observed.add(identity) return observed == expected def _valid_wgs_haplotype_calling_manifest( data: dict, *, job_id: str, prefix: str ) -> bool: if ( data.get("stage") != "wgs_haplotype_calling" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_markdup", "wgs_bam_qc", "wgs_index"} or not all(str(value or "") for value in upstream.values()) ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") outputs = metrics.get("output_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != outputs or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") if not isinstance(artifacts, dict): return False expected = { (role, sample) for sample in inputs for role in ("gvcf", "gvcf_index", "haplotype_log") } observed = set() for item in artifacts.values(): if not isinstance(item, dict): return False identity = ( str(item.get("role") or item.get("artifact") or ""), str(item.get("sample_id") or ""), ) if identity in observed: return False observed.add(identity) return observed == expected def _valid_wgs_gvcf_combine_manifest( data: dict, *, job_id: str, prefix: str ) -> bool: if ( data.get("stage") != "wgs_gvcf_combine" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_haplotype_calling", "wgs_index"} or not all(str(value or "") for value in upstream.values()) ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") outputs = metrics.get("output_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != outputs or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") if not isinstance(artifacts, dict) or set(artifacts) != { "combined_gvcf", "combined_gvcf_index", "combine_log", }: return False return all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) def _valid_wgs_joint_genotyping_manifest( data: dict, *, job_id: str, prefix: str ) -> bool: if ( data.get("stage") != "wgs_joint_genotyping" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_gvcf_combine", "wgs_index"} or not all(str(value or "") for value in upstream.values()) ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != metrics.get("output_sample_ids") or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") expected = { "joint_vcf", "joint_vcf_index", "joint_summary", "joint_log", } return ( isinstance(artifacts, dict) and set(artifacts) == expected and all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) ) def _valid_wgs_variant_select_manifest( data: dict, *, job_id: str, prefix: str, method: str ) -> bool: if ( data.get("stage") != "wgs_variant_select" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix or data.get("method") != method or (data.get("parameters") or {}).get("method") != method ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_joint_genotyping"} or not str(upstream.get("wgs_joint_genotyping") or "") ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != metrics.get("output_sample_ids") or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") expected = { "selected_snp_vcf", "selected_snp_vcf_index", "selected_indel_vcf", "selected_indel_vcf_index", "variant_select_report", } return ( isinstance(artifacts, dict) and set(artifacts) == expected and all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) ) def _valid_wgs_variant_filter_manifest( data: dict, *, job_id: str, prefix: str, method: str ) -> bool: if ( data.get("stage") != "wgs_variant_filter" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix or data.get("method") != method or (data.get("parameters") or {}).get("method") != method ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_variant_select"} or not str(upstream.get("wgs_variant_select") or "") ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != metrics.get("output_sample_ids") or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") expected = { "filtered_snp_vcf", "filtered_snp_vcf_index", "pass_snp_vcf", "pass_snp_vcf_index", "filtered_indel_vcf", "filtered_indel_vcf_index", "pass_indel_vcf", "pass_indel_vcf_index", "variant_filter_report", } return ( isinstance(artifacts, dict) and set(artifacts) == expected and all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) ) def _valid_wgs_impute_manifest( data: dict, *, job_id: str, prefix: str ) -> bool: if ( data.get("stage") != "wgs_impute" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix or data.get("method") != "beagle" ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_variant_filter"} or not str(upstream.get("wgs_variant_filter") or "") or data.get("upstream_method") not in {"gatk", "bcftools"} ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != metrics.get("output_sample_ids") or metrics.get("input_site_keys") != metrics.get("output_site_keys") or not metrics.get("input_site_keys") or len(set(inputs)) != len(inputs) ): return False artifacts = data.get("artifacts") expected = { "imputed_snp_vcf", "imputed_snp_vcf_index", "impute_report", "impute_log", } return ( isinstance(artifacts, dict) and set(artifacts) == expected and all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) ) def _valid_wgs_maf_missing_filter_manifest( data: dict, *, job_id: str, prefix: str ) -> bool: if ( data.get("stage") != "wgs_maf_missing_filter" or data.get("status") != "success" or data.get("job_id") != job_id or data.get("prefix") != prefix or data.get("method") != "bcftools" ): return False if not all( str(data.get(key) or "") for key in ("wgs_sample_set_id", "wgs_reference_id", "wgs_analysis_id") ): return False upstream = data.get("upstream_wgs_analysis_ids") if ( not isinstance(upstream, dict) or set(upstream) != {"wgs_impute"} or not str(upstream.get("wgs_impute") or "") ): return False parameters = data.get("parameters") or {} if ( parameters.get("method") != "bcftools" or parameters.get("maf") != 0.05 or parameters.get("max_missing") != 0.5 or parameters.get("max_missing_semantics") != "maximum_missing_fraction" or parameters.get("maf_semantics") != "second_most_common_observed_allele_frequency" ): return False metrics = data.get("metrics") or {} inputs = metrics.get("input_sample_ids") if ( not isinstance(inputs, list) or not inputs or inputs != metrics.get("output_sample_ids") or len(set(inputs)) != len(inputs) or not isinstance(metrics.get("retained_sites"), int) or isinstance(metrics.get("retained_sites"), bool) or metrics.get("retained_sites") < 1 ): return False artifacts = data.get("artifacts") expected = { "final_snp_vcf", "final_snp_vcf_index", "maf_missing_report", "maf_missing_stats", } return ( isinstance(artifacts, dict) and set(artifacts) == expected and all( isinstance(item, dict) and str(item.get("role") or item.get("artifact") or "") == key and not item.get("sample_id") for key, item in artifacts.items() ) ) def run_stage( stage: str, job: JobInfo, prefix: str, cpu: int = 8, required_pfams: Optional[str] = None, ref_species: Optional[List[str]] = None, retention_days: int = 7, # 高级参数 id_evalue: float = 0.001, id_coverage: float = 0.3, phy_mafft: str = None, phy_iqtree: str = None, phy_tree_layout: str = "both", msa_start_col: int = 0, msa_end_col: int = 0, msa_auto_conserved: bool = False, msa_window_len: int = 60, msa_min_nongap: float = 0.6, msa_file: str = "", msa_source_image: str = "", msa_reference_id: str = "", msa_reference_start_residue: int = 0, msa_reference_end_residue: int = 0, tree_file: str = "", tree_source_image: str = "", tree_source_stage: str = "", tree_output_label: str = "", tree_label_offset: float = 0.4, tree_tip_label_size: float = 3.0, tree_branch_color: str = "black", tree_branch_width: float = 0.5, tree_show_bootstrap: bool = True, tree_bootstrap_size: float = 2.0, tree_tip_point_size: float = 2.0, tree_target_shape: str = "triangle", tree_reference_shape: str = "circle", tree_marker_mode: str = "auto", tree_subfamily_arc_enabled: bool = True, tree_subfamily_arc_gap: float = 1.2, tree_subfamily_arc_label_gap: float = 0.6, tree_subfamily_arc_label_size: float = 0.0, synteny_source_image: str = "", synteny_source_stage: str = "synteny", synteny_output_label: str = "synteny_replot_circos", synteny_gene_label_size: float = 38.0, synteny_gene_track_r0: float = 1.03, synteny_gene_track_r1: float = 1.30, synteny_chrom_label_radius: str = "1r - 60p", synteny_chrom_label_size: float = 40.0, synteny_ideogram_thickness: float = 90.0, tfbs_upstream_length: int = 2000, tfbs_motif_db_mode: str = "auto", tfbs_fimo_pvalue: float = 1e-4, tfbs_fimo_qvalue: float = 0.05, tfbs_background_mode: str = "none", tfbs_max_nearest_rank: str = "order", tfbs_top_motifs: int = 30, tfbs_scan_both_strands: bool = True, combo_panels: Optional[List[str]] = None, combo_output_label: str = "", reference_subfamily_file: str = "", subfamily_confirm_file: str = "", reference_target_prefix: str = "", reference_prefix: str = "At", reference_overwrite_gene_subfamily: bool = False, motif_nmotifs: int = 10, motif_minw: int = 6, motif_maxw: int = 200, dom_evalue: float = 1e-5, dom_coverage: float = None, blast_evalue: float = 1e-5, blast_identity: float = 30.0, id_transform: str = "none", geo_id: str = "", # RNA-seq 参数 rnaseq_mode: str = "upload", species_name: str = "", top_pathways: int = 20, enrich_background_source: str = "auto", enrich_species: str = "", enrich_key_type: str = "auto", enrich_type: str = "go", kegg_organism: str = "", rnaseq_foreground_mode: str = "auto", annotation_applications: Optional[List[str]] = None, rnaseq_sample_config: str = "", wgs_sample_config: str = "", wgs_fastp: str = str(ASAW_BIN / "fastp"), wgs_threads: int = 1, wgs_max_jobs: int = 1, wgs_reference_fasta: str = "", wgs_samtools: str = str(ASAW_BIN / "samtools"), wgs_bwa_mem2: str = str(ASAW_BIN / "bwa-mem2"), wgs_gatk: str = str(ASAW_BIN / "gatk"), wgs_qc_manifest: str = "", wgs_index_manifest: str = "", wgs_align_manifest: str = "", wgs_markdup_manifest: str = "", wgs_bam_qc_manifest: str = "", wgs_haplotype_manifest: str = "", wgs_combine_manifest: str = "", wgs_joint_manifest: str = "", wgs_select_manifest: str = "", wgs_filter_manifest: str = "", wgs_impute_manifest: str = "", wgs_sample_set_id: str = "", wgs_reference_id: str = "", wgs_analysis_id: str = "", wgs_index_analysis_id: str = "", wgs_coverage_threshold: int = 1, wgs_markdup_analysis_id: str = "", wgs_bam_qc_analysis_id: str = "", wgs_haplotype_analysis_id: str = "", wgs_combine_analysis_id: str = "", wgs_joint_analysis_id: str = "", wgs_select_analysis_id: str = "", wgs_filter_analysis_id: str = "", wgs_impute_analysis_id: str = "", wgs_native_pair_hmm_threads: int = 1, wgs_max_alternate_alleles: int = 6, wgs_calling_threshold: int = 10, wgs_include_non_variant_sites: bool = False, wgs_method: str = "gatk", wgs_bcftools: str = get_wgs_bcftools(), wgs_java: str = get_wgs_java(), wgs_beagle_jar: str = WGS_BEAGLE_JAR, wgs_memory: str = "8g", wgs_window: float = 40.0, wgs_overlap: float = 2.0, wgs_ne: int = 100000, wgs_iterations: int = 12, wgs_phase_states: int = 280, wgs_imp_states: int = 1600, wgs_cluster: float = 0.005, wgs_maf: float = 0.05, wgs_max_missing: float = 0.5, wgs_platform: str = "illumina", rnaseq_sample_parallel_jobs: int = 0, rnaseq_threads_per_sample: int = 0, rnaseq_project_max_cpu: int = 0, rnaseq_resource_decision: Optional[Dict[str, Any]] = None, rnaseq_qc_tool: str = "fastp", rnaseq_fastp_bin: str = "tools/fastp", rnaseq_fastp_detect_adapter_for_pe: bool = True, rnaseq_fastp_cut_front: bool = True, rnaseq_fastp_cut_tail: bool = True, rnaseq_fastp_cut_window_size: int = 4, rnaseq_fastp_cut_mean_quality: int = 20, rnaseq_fastp_qualified_quality_phred: int = 20, rnaseq_fastp_unqualified_percent_limit: int = 30, rnaseq_fastp_n_base_limit: int = 5, rnaseq_fastp_length_required: int = 30, rnaseq_splice_site_source: str = "auto", rnaseq_known_splice_site: str = "", rnaseq_quant_mode: str = "dual", rnaseq_abundance_engine: str = "auto", rnaseq_transcriptome_fasta: str = "", rnaseq_tx2gene: str = "", rnaseq_salmon_libtype: str = "A", rnaseq_tximport_counts_from_abundance: str = "no", rnaseq_kallisto_bias: bool = False, rnaseq_de_padj: float = 0.05, rnaseq_de_module: str = "", rnaseq_de_log2fc: float = 1.0, enrich_pvalue_cutoff: float = 0.05, enrich_qvalue_cutoff: float = 0.05, go_obo: str = "data/go/go-basic.obo", kegg_level: str = "pathway", kegg_filter_scope: str = "plant", kegg_highlight_mode: str = "auto", kegg_no_network: bool = False, kegg_skip_pathview: bool = False, enrich_plot_type: str = "r_plot", rnaseq_count_matrix: str = "", gwas_vcf: str = "", tassel_pipeline: str = TASSEL_PIPELINE, tassel_xmx: str = "10g", gwas_unsupported_marker_policy: str = "mask", genotype_scope_id: str = "", phenotype_file: str = "", phenotype_sample_column: str = "", phenotype_trait_column: str = "", phenotype_trait_name: str = "", gwas_q_file: str = "", gwas_fam_file: str = "", gwas_q_k: int = 2, gwas_sorted_vcf: str = "", gwas_kinship_file: str = "", gwas_trait_file: str = "", gwas_covariate_file: str = "", phenotype_scope_id: str = "", covariate_scope_id: str = "", gwas_mlm_pvalues: str = "", gwas_cmlm_pvalues: str = "", gwas_mlm_tested_hypotheses: Optional[int] = None, gwas_cmlm_tested_hypotheses: Optional[int] = None, gwas_rscript: str = str(ASAW_RSCRIPT), source_model_scope_id: str = "", gwas_plink: str = str(ASAW_BIN / "plink"), gwas_emmax: str = GWAS_EMMAX_BIN, gwas_requested_pcs: int = 3, gwas_emmax_pvalues: str = "", gwas_emmax_manifest: str = "", gwas_emmax_tested_hypotheses: Optional[int] = None, gwas_vcf_manifest: str = "", gwas_trait_manifest: str = "", gwas_covariate_manifest: str = "", rnaseq_expression_matrix: str = "", rnaseq_sample_table: str = "", fgsea_comparison: str = "", fgsea_min_size: int = 15, fgsea_max_size: int = 500, fgsea_nperm: int = 10000, fgsea_seed: int = 1, wgcna_min_mean: float = 1.0, wgcna_max_genes: int = 8000, wgcna_min_module_size: int = 30, wgcna_merge_cut_height: float = 0.25, wgcna_powers: str = "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20", candidate_max_deg_proteins: int = 500, candidate_run_interproscan: str = "auto", # NCBI fetch 参数 ncbi_query: str = "", ncbi_gene_ids: str = "", ncbi_mode: str = "gene", ncbi_id_type: str = "auto", ncbi_taxid: str = "", ncbi_species: str = "", ncbi_seq_types: str = "both", ncbi_transcript_policy: str = "refseq_preferred", ncbi_allow_family_download: bool = False, ncbi_email: str = "", # Local sequence extraction parameters sequence_extract_fasta: str = "", sequence_extract_ids_file: str = "", sequence_extract_ids: str = "", sequence_extract_seq_type: str = "auto", sequence_extract_rename_mode: str = "keep", sequence_extract_clean_headers: bool = False, # PPI 参数 ppi_species: str = "auto", normalization_species_label: str = "", ppi_reference_taxon_id: str = "", ppi_reference_storage_key: str = "", ppi_reference_selection_mode: str = "", ppi_reference_user_confirmed: bool = False, dependency_owner: str = "", ortholog_mapping_owner: str = "", ppi_score: int = 200, # Genome Prepare 参数 genome_prepare_type: str = "auto", # KaKs 参数 kaks_neutral_rate: float = 7.08e-9, **kwargs, ) -> Tuple[str, Optional[Path]]: # 函数入口debug print(f"[FUNC ENTRY DEBUG] stage={stage}, rnaseq_mode={rnaseq_mode}, species_name={species_name}") ok, msg = can_start_new_task() if not ok: return msg, None ws = Path(job.workspace_dir) in_dir = Path(job.input_dir) logs = Path(job.logs_dir) final_dir = ws / "final" # 定义 final_dir 供所有阶段使用 valid_annotation_apps = {"Pfam", "PANTHER", "SMART", "ProSiteProfiles", "ProSitePatterns"} selected_annotation_apps = annotation_applications or kwargs.get("annotationApplications") or ["Pfam", "ProSiteProfiles"] if isinstance(selected_annotation_apps, str): selected_annotation_apps = [x.strip() for x in selected_annotation_apps.split(",") if x.strip()] selected_annotation_apps = [x for x in selected_annotation_apps if x in valid_annotation_apps] if not selected_annotation_apps: selected_annotation_apps = ["Pfam", "ProSiteProfiles"] annotation_applications_arg = ",".join(selected_annotation_apps) target_species = kwargs.get("target_species") interspecies_ref_species = kwargs.get("interspecies_ref_species") scope_file = str(kwargs.get("scope_file") or kwargs.get("scopeFile") or "").strip() scope_id = str(kwargs.get("scope_id") or kwargs.get("scopeId") or "").strip() analysis_route = str(kwargs.get("analysis_route") or kwargs.get("analysisRoute") or "").strip() analysis_mode = str(kwargs.get("analysis_mode") or kwargs.get("analysisMode") or "").strip() member_set_id = str(kwargs.get("member_set_id") or kwargs.get("memberSetId") or "").strip() combo_output_label = str(combo_output_label or kwargs.get("comboOutputLabel") or kwargs.get("combo_output_label") or "").strip() subfamily_label_source = str(kwargs.get("subfamily_label_source") or kwargs.get("subfamilyLabelSource") or "").strip().lower() requires_confirmed_subfamily = _truthy(kwargs.get("requires_confirmed_subfamily") or kwargs.get("requiresConfirmedSubfamily")) if requires_confirmed_subfamily and subfamily_label_source in {"", "auto"}: subfamily_label_source = "confirmed_or_uploaded_subfamily" interspecies_blast_engine = str(kwargs.get("interspecies_blast_engine") or "diamond").strip().lower() if interspecies_blast_engine not in {"diamond", "blastp"}: interspecies_blast_engine = "diamond" interspecies_plot_mode = str(kwargs.get("interspecies_plot_mode") or kwargs.get("interspeciesPlotMode") or "both").strip().lower() if interspecies_plot_mode not in {"multi", "pairwise", "both"}: interspecies_plot_mode = "both" resolved_prefix = resolve_effective_prefix_for_job(job, prefix, stage) if resolved_prefix != _clean_prefix(prefix): print(f"[INFO] Resolved empty prefix for {stage}: {resolved_prefix or ''}") prefix = resolved_prefix # 初始化可复现性管理器 repro_manager = None try: repro_scope_id = "" if scope_file: try: scope_path_for_repro = Path(scope_file) if not scope_path_for_repro.is_absolute(): scope_path_for_repro = Path(__file__).resolve().parents[2] / scope_path_for_repro if scope_path_for_repro.exists(): repro_scope_id = str(read_scope(scope_path_for_repro).get("scope_id") or "") except Exception: repro_scope_id = "" repro_manager = ReproducibilityManager( job_id=job.job_id, prefix=prefix, workspace_root=str(WORKSPACE_ROOT), scope_file=scope_file, scope_id=repro_scope_id, ) except Exception as e: print(f"[WARN] ReproducibilityManager init failed: {e}") manifest = find_latest_manifest(ws) if not manifest: fasta_files = [] if in_dir.exists(): for p in sorted(in_dir.iterdir()): if p.is_file() and detect_role(p) == "fasta": fasta_files.append(p) if fasta_files and stage_allows_minimal_manifest(stage): manifest = ensure_min_manifest(ws, prefix=prefix, final_fa=fasta_files[0]) elif stage in {"expression", "genome_prepare", "gwas_fmt", "gwas_kinship", "gwas_trait", "gwas_cov", "gwas_mlm", "gwas_plot", "gwas_emmax", *POPULATION_STAGES} or stage.startswith("rnaseq"): manifest = None elif not stage_allows_minimal_manifest(stage): manifest = None else: return "No fasta found to create minimal manifest for this stage", None log_file = logs / f"{_now_tag()}_{stage}.log" if stage in POPULATION_STAGES: population_vcf = str(kwargs.get("population_vcf") or kwargs.get("populationVcf") or gwas_vcf or "").strip() try: vcf_path = _resolve_population_vcf( explicit=population_vcf, input_dir=in_dir, final_dir=final_dir, ) except ValueError as exc: return f"❌ {exc}", None population_params = dict(kwargs.get("population_params") or {}) sample_group = str(kwargs.get("population_sample_group") or "").strip() if sample_group: group_path = Path(sample_group).expanduser() if not group_path.is_absolute(): for candidate in (group_path, PROJECT_ROOT / group_path, in_dir / group_path): if candidate.is_file(): group_path = candidate.resolve() break sample_group = str(group_path) command = _population_cli_command( stage=stage, workspace=ws, job_id=job.job_id, prefix=prefix, vcf=vcf_path.resolve(), sample_group=sample_group, cpu=cpu, params=population_params, ) print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) candidates = sorted(final_dir.glob(f"{prefix}.{stage}.*.manifest.json"), key=lambda path: path.stat().st_mtime_ns) stage_manifest = candidates[-1] if candidates else None if code == 0 and stage_manifest is not None: return f"✅ {stage} completed.", stage_manifest return f"❌ {stage} failed (exit code: {code}). Log: {log_file}", stage_manifest if stage == "gwas_fmt": external_vcf = Path(str(gwas_vcf or "")).expanduser() if not gwas_vcf or not external_vcf.is_file(): return "❌ GWAS format conversion requires an explicitly selected external VCF.", manifest command = _gwas_fmt_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, vcf=external_vcf, tassel_pipeline=tassel_pipeline, xmx=tassel_xmx, unsupported_marker_policy=gwas_unsupported_marker_policy, log_file=log_file, ) print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) gwas_manifest = final_dir / f"{prefix}.gwas_fmt.manifest.json" if code == 0: return "✅ GWAS VCF sorting and TASSEL HapMap conversion completed.", gwas_manifest return f"❌ GWAS format conversion failed (exit code: {code}). Log: {log_file}", gwas_manifest if gwas_manifest.exists() else manifest if stage == "gwas_kinship": try: decision = _resolve_gwas_kinship_input( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, ) except Exception as exc: return f"❌ GWAS kinship requires a valid gwas_fmt Manifest: {exc}", manifest command = _gwas_kinship_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, sorted_vcf=Path(decision["path"]), genotype_scope_id=str(decision["genotype_scope_id"]), tassel_pipeline=tassel_pipeline, xmx=tassel_xmx, log_file=log_file, ) print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) manifests = sorted(final_dir.glob(f"{prefix}.gwas_kinship.*.manifest.json"), key=lambda path: path.stat().st_mtime_ns) kinship_manifest = manifests[-1] if manifests else None if code == 0 and kinship_manifest is not None: return "✅ GWAS TASSEL centered IBS kinship completed.", kinship_manifest return f"❌ GWAS kinship failed (exit code: {code}). Log: {log_file}", kinship_manifest or manifest if stage == "gwas_trait": command = _gwas_trait_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, phenotype=Path(phenotype_file), sample_column=phenotype_sample_column, trait_column=phenotype_trait_column, trait_name=phenotype_trait_name, log_file=log_file, ) print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) manifests = sorted( final_dir.glob(f"{prefix}.gwas_trait.*.manifest.json"), key=lambda path: path.stat().st_mtime_ns, ) trait_manifest = manifests[-1] if manifests else None if code == 0 and trait_manifest is not None: return "✅ GWAS phenotype validation and TASSEL trait preparation completed.", trait_manifest return f"❌ GWAS trait preparation failed (exit code: {code}). Log: {log_file}", trait_manifest or manifest if stage == "gwas_cov": command = _gwas_cov_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, q_file=Path(gwas_q_file), fam_file=Path(gwas_fam_file), k=int(gwas_q_k), genotype_scope_id=genotype_scope_id, log_file=log_file, ) print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) manifests = sorted( final_dir.glob(f"{prefix}.gwas_cov.*.manifest.json"), key=lambda path: path.stat().st_mtime_ns, ) cov_manifest = manifests[-1] if manifests else None if code == 0 and cov_manifest is not None: return "✅ GWAS ADMIXTURE covariate preparation completed.", cov_manifest return f"❌ GWAS covariate preparation failed (exit code: {code}). Log: {log_file}", cov_manifest or manifest if stage == "gwas_mlm": if not all((genotype_scope_id, phenotype_scope_id, covariate_scope_id)): return "❌ GWAS MLM requires explicit genotype, phenotype, and covariate scopes.", None try: decisions = _resolve_gwas_mlm_inputs( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, ) supplied = { "sorted_vcf": gwas_sorted_vcf, "kinship_matrix": gwas_kinship_file, "trait_table": gwas_trait_file, "covariate_table": gwas_covariate_file, } for role, supplied_path in supplied.items(): if supplied_path and Path(supplied_path).resolve() != Path(decisions[role]["path"]).resolve(): raise ValueError(f"{role} does not match its authoritative upstream Manifest") except Exception as exc: return f"❌ GWAS MLM requires four exact upstream Manifests: {exc}", None command = _gwas_mlm_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, sorted_vcf=Path(decisions["sorted_vcf"]["path"]), kinship=Path(decisions["kinship_matrix"]["path"]), trait=Path(decisions["trait_table"]["path"]), covariate=Path(decisions["covariate_table"]["path"]), genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, tassel_pipeline=tassel_pipeline, xmx=tassel_xmx, ) before = { path.resolve(): (path.stat().st_mtime_ns, path.stat().st_size) for path in final_dir.glob(f"{prefix}.gwas_mlm.*.manifest.json") } print(f"[CMD] {shlex.join(command)}") code = run_cmd_logged(command, log_file, cwd=PROJECT_ROOT) changed = [] for path in final_dir.glob(f"{prefix}.gwas_mlm.*.manifest.json"): identity = (path.stat().st_mtime_ns, path.stat().st_size) if before.get(path.resolve()) != identity: try: candidate = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue expected_identity = { "stage": "gwas_mlm", "prefix": prefix, "genotype_scope_id": genotype_scope_id, "phenotype_scope_id": phenotype_scope_id, "covariate_scope_id": covariate_scope_id, } if any(str(candidate.get(key) or "") != value for key, value in expected_identity.items()): continue if code == 0 and ( candidate.get("status") != "success" or not candidate.get("model_scope_id") or not candidate.get("analysis_scope_id") ): continue changed.append(path) mlm_manifest = max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None if code == 0 and mlm_manifest is not None: return "✅ GWAS TASSEL MLM and CMLM completed.", mlm_manifest return f"❌ GWAS MLM failed (exit code: {code}). Log: {log_file}", mlm_manifest if stage == "gwas_plot": if not all((genotype_scope_id, phenotype_scope_id, covariate_scope_id, source_model_scope_id)): return "❌ GWAS plot requires explicit genotype, phenotype, covariate, and source-model scopes.", None try: decisions = _resolve_gwas_plot_inputs( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, source_model_scope_id=source_model_scope_id, ) supplied_paths = { "mlm_pvalues": gwas_mlm_pvalues, "cmlm_pvalues": gwas_cmlm_pvalues, } for role, supplied_path in supplied_paths.items(): if supplied_path and Path(supplied_path).resolve() != Path(decisions[role]["path"]).resolve(): raise ValueError(f"{role} does not match the authoritative gwas_mlm Manifest") authoritative_count = decisions["candidate_marker_count"] for model, supplied_count in ( ("mlm", gwas_mlm_tested_hypotheses), ("cmlm", gwas_cmlm_tested_hypotheses), ): if isinstance(supplied_count, bool): raise ValueError(f"{model} tested hypotheses is invalid") if supplied_count is not None and int(supplied_count) != authoritative_count: raise ValueError(f"{model} tested hypotheses does not match candidate_marker_count") except Exception as exc: return f"❌ GWAS plot requires one exact gwas_mlm Manifest: {exc}", None command = _gwas_plot_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, mlm_pvalues=Path(decisions["mlm_pvalues"]["path"]), cmlm_pvalues=Path(decisions["cmlm_pvalues"]["path"]), mlm_tested_hypotheses=authoritative_count, cmlm_tested_hypotheses=authoritative_count, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, source_model_scope_id=source_model_scope_id, rscript=gwas_rscript, log_file=log_file, ) before = { path.resolve(): (path.stat().st_mtime_ns, path.stat().st_size) for path in final_dir.glob(f"{prefix}.gwas_plot*.manifest.json") } print(f"[CMD] {shlex.join(command)}") code = _run_child_owned_log(command, cwd=PROJECT_ROOT) changed: list[Path] = [] for path in final_dir.glob(f"{prefix}.gwas_plot*.manifest.json"): if before.get(path.resolve()) == (path.stat().st_mtime_ns, path.stat().st_size): continue try: candidate = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue expected_identity = { "stage": "gwas_plot", "prefix": prefix, "genotype_scope_id": genotype_scope_id, "phenotype_scope_id": phenotype_scope_id, "covariate_scope_id": covariate_scope_id, "source_model_scope_id": source_model_scope_id, } if any(str(candidate.get(key) or "") != value for key, value in expected_identity.items()): continue if code == 0: from gfa_core.gwas_upstream_manifest import validated_result_artifacts records = validated_result_artifacts(candidate, workspace=ws, expected_scopes=expected_identity) required = { "mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq", "report", "environment", } if ( candidate.get("status") != "success" or not candidate.get("analysis_scope_id") or {str(item.get("role") or "") for item in records} != required ): continue changed.append(path) plot_manifest = max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None if code == 0 and plot_manifest is not None: return "✅ GWAS MLM/CMLM Manhattan and QQ plots completed.", plot_manifest return f"❌ GWAS plot failed (exit code: {code}). Log: {log_file}", plot_manifest if stage == "gwas_emmax": if not all((genotype_scope_id, phenotype_scope_id)): return "❌ GWAS EMMAX requires explicit genotype and phenotype scopes.", None try: decisions = _resolve_gwas_emmax_inputs( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, ) supplied = {"sorted_vcf": gwas_sorted_vcf, "trait_table": gwas_trait_file} for role, supplied_path in supplied.items(): if supplied_path and Path(supplied_path).resolve() != Path(decisions[role]["path"]).resolve(): raise ValueError(f"{role} does not match its authoritative upstream Manifest") except Exception as exc: return f"❌ GWAS EMMAX requires exact gwas_fmt and gwas_trait Manifests: {exc}", None command = _gwas_emmax_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, sorted_vcf=Path(decisions["sorted_vcf"]["path"]), trait=Path(decisions["trait_table"]["path"]), vcf_manifest=Path(decisions["sorted_vcf"]["source_manifest"]), trait_manifest=Path(decisions["trait_table"]["source_manifest"]), genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, plink=gwas_plink, emmax=gwas_emmax, rscript=gwas_rscript, requested_pcs=int(gwas_requested_pcs), threads=int(cpu), log_file=log_file, ) before = { path.resolve(): (path.stat().st_mtime_ns, path.stat().st_size) for path in final_dir.glob(f"{prefix}.gwas_emmax.*.manifest.json") } print(f"[CMD] {shlex.join(command)}") code = _run_child_owned_log(command, cwd=PROJECT_ROOT) changed: list[Path] = [] for path in final_dir.glob(f"{prefix}.gwas_emmax.*.manifest.json"): if before.get(path.resolve()) == (path.stat().st_mtime_ns, path.stat().st_size): continue try: candidate = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue expected_identity = { "stage": "gwas_emmax", "prefix": prefix, "genotype_scope_id": genotype_scope_id, "phenotype_scope_id": phenotype_scope_id, } if any(str(candidate.get(key) or "") != value for key, value in expected_identity.items()): continue if code == 0: from gfa_core.gwas_upstream_manifest import validated_result_artifacts records = validated_result_artifacts(candidate, workspace=ws, expected_scopes=expected_identity) if ( candidate.get("status") != "success" or not candidate.get("model_scope_id") or not candidate.get("analysis_scope_id") or {str(item.get("role") or "") for item in records} != {"emmax_full", "emmax_pvalues", "report", "environment"} ): continue changed.append(path) emmax_manifest = max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None if code == 0 and emmax_manifest is not None: return "✅ GWAS PLINK-backed EMMAX association completed.", emmax_manifest return f"❌ GWAS EMMAX failed (exit code: {code}). Log: {log_file}", emmax_manifest if stage == "gwas_emplot": if not all((genotype_scope_id, phenotype_scope_id, source_model_scope_id)): return "❌ GWAS EMMAX plot requires explicit genotype, phenotype, and source-model scopes.", None try: decisions = _resolve_gwas_emplot_inputs( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, source_model_scope_id=source_model_scope_id, ) authoritative_path = Path(decisions["emmax_pvalues"]["path"]) authoritative_manifest = Path(decisions["source_manifest"]) authoritative_count = decisions["emmax_tested_count"] if gwas_emmax_pvalues and Path(gwas_emmax_pvalues).resolve() != authoritative_path.resolve(): raise ValueError("emmax_pvalues does not match the authoritative gwas_emmax Manifest") if gwas_emmax_manifest and Path(gwas_emmax_manifest).resolve() != authoritative_manifest.resolve(): raise ValueError("emmax Manifest does not match the authoritative gwas_emmax Manifest") if gwas_emmax_tested_hypotheses not in (None, "") and ( isinstance(gwas_emmax_tested_hypotheses, bool) or int(gwas_emmax_tested_hypotheses) != authoritative_count ): raise ValueError("tested hypotheses does not match emmax_tested_count") except Exception as exc: return f"❌ GWAS EMMAX plot requires one exact gwas_emmax Manifest: {exc}", None command = _gwas_emplot_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, emmax_pvalues=authoritative_path, emmax_manifest=authoritative_manifest, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, source_model_scope_id=source_model_scope_id, emmax_tested_hypotheses=authoritative_count, rscript=gwas_rscript, log_file=log_file, ) before = { path.resolve(): (path.stat().st_mtime_ns, path.stat().st_size) for path in final_dir.glob(f"{prefix}.gwas_emplot.*.manifest.json") } print(f"[CMD] {shlex.join(command)}") code = _run_child_owned_log(command, cwd=PROJECT_ROOT) changed: list[Path] = [] for path in final_dir.glob(f"{prefix}.gwas_emplot.*.manifest.json"): if before.get(path.resolve()) == (path.stat().st_mtime_ns, path.stat().st_size): continue try: candidate = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue expected_identity = { "stage": "gwas_emplot", "prefix": prefix, "genotype_scope_id": genotype_scope_id, "phenotype_scope_id": phenotype_scope_id, "source_model_scope_id": source_model_scope_id, } if any(str(candidate.get(key) or "") != value for key, value in expected_identity.items()): continue if code == 0: from gfa_core.gwas_upstream_manifest import validated_result_artifacts records = validated_result_artifacts(candidate, workspace=ws, expected_scopes=expected_identity) if ( candidate.get("status") != "success" or not candidate.get("analysis_scope_id") or {str(item.get("role") or "") for item in records} != {"emmax_manhattan", "emmax_qq", "report", "environment"} ): continue changed.append(path) plot_manifest = max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None if code == 0 and plot_manifest is not None: return "✅ GWAS EMMAX Manhattan and QQ plots completed.", plot_manifest return f"❌ GWAS EMMAX plot failed (exit code: {code}). Log: {log_file}", plot_manifest if stage == "gwas_glm": if not all((genotype_scope_id, phenotype_scope_id, covariate_scope_id)): return "❌ GWAS GLM requires explicit genotype, phenotype, and covariate scopes.", None try: decisions = _resolve_gwas_glm_inputs( workspace=ws, prefix=prefix, genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, ) supplied = { "sorted_vcf": gwas_sorted_vcf, "trait_table": gwas_trait_file, "covariate_table": gwas_covariate_file, } manifest_supplied = { "sorted_vcf": gwas_vcf_manifest, "trait_table": gwas_trait_manifest, "covariate_table": gwas_covariate_manifest, } for role, supplied_path in supplied.items(): if supplied_path and Path(supplied_path).resolve() != Path(decisions[role]["path"]).resolve(): raise ValueError(f"{role} does not match its authoritative upstream Manifest") source_manifest = Path(decisions[role]["source_manifest"]) if manifest_supplied[role] and Path(manifest_supplied[role]).resolve() != source_manifest.resolve(): raise ValueError(f"{role} producer Manifest does not match the authoritative upstream Manifest") except Exception as exc: return f"❌ GWAS GLM requires three exact upstream Manifests: {exc}", None command = _gwas_glm_cli_command( workspace=ws, job_id=job.job_id, prefix=prefix, sorted_vcf=Path(decisions["sorted_vcf"]["path"]), trait=Path(decisions["trait_table"]["path"]), covariate=Path(decisions["covariate_table"]["path"]), vcf_manifest=Path(decisions["sorted_vcf"]["source_manifest"]), trait_manifest=Path(decisions["trait_table"]["source_manifest"]), covariate_manifest=Path(decisions["covariate_table"]["source_manifest"]), genotype_scope_id=genotype_scope_id, phenotype_scope_id=phenotype_scope_id, covariate_scope_id=covariate_scope_id, tassel_pipeline=tassel_pipeline, xmx=tassel_xmx, log_file=log_file, ) before = { path.resolve(): (path.stat().st_mtime_ns, path.stat().st_size) for path in final_dir.glob(f"{prefix}.gwas_glm.*.manifest.json") } print(f"[CMD] {shlex.join(command)}") code = _run_child_owned_log(command, cwd=PROJECT_ROOT) changed: list[Path] = [] for path in final_dir.glob(f"{prefix}.gwas_glm.*.manifest.json"): if before.get(path.resolve()) == (path.stat().st_mtime_ns, path.stat().st_size): continue try: candidate = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue expected_identity = { "stage": "gwas_glm", "prefix": prefix, "genotype_scope_id": genotype_scope_id, "phenotype_scope_id": phenotype_scope_id, "covariate_scope_id": covariate_scope_id, } if any(str(candidate.get(key) or "") != value for key, value in expected_identity.items()): continue if code == 0: from gfa_core.gwas_upstream_manifest import validated_result_artifacts records = validated_result_artifacts(candidate, workspace=ws, expected_scopes=expected_identity) if ( candidate.get("status") != "success" or not candidate.get("model_scope_id") or not candidate.get("analysis_scope_id") or {str(item.get("role") or "") for item in records} != { "glm_full", "glm_structure_full", "glm_pvalues", "glm_structure_pvalues", "log", "report", "environment", } ): continue changed.append(path) glm_manifest = max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None if code == 0 and glm_manifest is not None: return "✅ GWAS TASSEL fixed-effect GLM completed.", glm_manifest return f"❌ GWAS GLM failed (exit code: {code}). Log: {log_file}", glm_manifest if stage == "promoter_plot_revise": elements = str(kwargs.get("promoter_elements") or kwargs.get("promoterElements") or "").strip() categories = str(kwargs.get("promoter_categories") or kwargs.get("promoterCategories") or "").strip() match_mode = str(kwargs.get("promoter_match_mode") or kwargs.get("promoterMatchMode") or "exact").strip().lower() plot_modes = str(kwargs.get("promoter_plot_modes") or kwargs.get("promoterPlotModes") or "position_distribution,heatmap").strip() color_by = str(kwargs.get("promoter_color_by") or kwargs.get("promoterColorBy") or "auto").strip().lower() output_label = str(kwargs.get("promoter_output_label") or kwargs.get("promoterOutputLabel") or "").strip() top_n = str(kwargs.get("promoter_top_n") or kwargs.get("promoterTopN") or "30").strip() promoter_file = str(kwargs.get("promoter_file") or kwargs.get("promoterFile") or "").strip() promoter_tree_file = str(kwargs.get("promoter_tree_file") or kwargs.get("promoterTreeFile") or tree_file or "").strip() promoter_subfamily_file = str(kwargs.get("promoter_subfamily_file") or kwargs.get("promoterSubfamilyFile") or "").strip() if not elements and not categories: return "❌ 启动子自定义绘图需要至少选择一个元件或类别。", manifest if match_mode not in {"exact", "contains", "regex"}: return f"❌ 启动子自定义绘图 match_mode 无效: {match_mode}", manifest if color_by not in {"auto", "element", "category"}: return f"❌ 启动子自定义绘图 color_by 无效: {color_by}", manifest script = Path("apps/cli/run_promoter_plot_revise.py") if not script.exists(): return "❌ Promoter custom plot script not found: apps/cli/run_promoter_plot_revise.py", manifest cmd = [ sys.executable, str(script), "--workspace", str(ws), "--prefix", prefix, "--elements", elements, "--categories", categories, "--match-mode", match_mode, "--plot-modes", plot_modes, "--top-n", top_n, "--color-by", color_by, ] if output_label: cmd.extend(["--output-label", output_label]) if promoter_file: cmd.extend(["--promoter-file", promoter_file]) if promoter_tree_file: cmd.extend(["--tree", promoter_tree_file]) if promoter_subfamily_file: cmd.extend(["--subfamily", promoter_subfamily_file]) if scope_file: cmd.extend(["--scope-file", scope_file]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) promoter_replot_manifest = final_dir / f"{prefix}.promoter_plot_revise.manifest.json" if code == 0 and promoter_replot_manifest.exists(): return f"✅ 启动子自定义绘图完成。Log: {log_file}", promoter_replot_manifest if promoter_replot_manifest.exists(): return f"❌ 启动子自定义绘图失败 (exit code: {code}). Log: {log_file}", promoter_replot_manifest return f"❌ 启动子自定义绘图失败 (exit code: {code}). Log: {log_file}", manifest if stage == "ncbi_fetch_sequences": ncbi_defaults = _ncbi_species_defaults_from_prefix(prefix) if not ncbi_species: ncbi_species = ncbi_defaults.get("species", "") if not ncbi_taxid: ncbi_taxid = ncbi_defaults.get("taxid", "") ncbi_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_ncbi_fetch_sequences.py", "--workspace", str(ws), "-p", prefix or "NCBI.fetch", "-j", job.job_id, "--mode", str(ncbi_mode or "gene"), "--id-type", str(ncbi_id_type or "auto"), "--seq-types", str(ncbi_seq_types or "both"), "--transcript-policy", str(ncbi_transcript_policy or "refseq_preferred"), ] if ncbi_taxid: ncbi_cmd.extend(["--taxid", str(ncbi_taxid)]) if ncbi_species: ncbi_cmd.extend(["--species", str(ncbi_species)]) if ncbi_query: ncbi_cmd.extend(["--query", str(ncbi_query)]) resolved_ids_file = None if ncbi_gene_ids: ncbi_gene_ids_text = str(ncbi_gene_ids).strip() provided_gene_id_file = Path(ncbi_gene_ids_text).expanduser() if not provided_gene_id_file.is_absolute(): for candidate in (Path(job.input_dir) / provided_gene_id_file, ws / provided_gene_id_file): if candidate.exists() and candidate.is_file(): provided_gene_id_file = candidate break if provided_gene_id_file.exists() and provided_gene_id_file.is_file(): resolved_ids_file = provided_gene_id_file else: gene_id_file = ws / "ncbi_fetch_sequences" / "analysis" / f"{prefix or 'NCBI.fetch'}.input_ids.txt" gene_id_file.parent.mkdir(parents=True, exist_ok=True) items = [item.strip() for item in re.split(r"[\s,;]+", ncbi_gene_ids_text) if item.strip()] gene_id_file.write_text("\n".join(items) + "\n", encoding="utf-8") resolved_ids_file = gene_id_file elif prefix: resolved_ids_file = _resolve_job_ids_file(job, prefix) if resolved_ids_file: ncbi_cmd.extend(["--gene-ids", str(resolved_ids_file)]) if ncbi_allow_family_download: ncbi_cmd.append("--allow-family-download") if ncbi_email: ncbi_cmd.extend(["--email", str(ncbi_email)]) print(f"[CMD] {' '.join(ncbi_cmd)}") code = run_cmd_logged(ncbi_cmd, log_file, cwd=Path(".")) ncbi_manifest = final_dir / f"{prefix or 'NCBI.fetch'}.ncbi_fetch.manifest.json" if code == 0: return "✅ NCBI sequence fetch completed.", ncbi_manifest return f"❌ NCBI sequence fetch failed (exit code: {code}). Log: {log_file}", ncbi_manifest if ncbi_manifest.exists() else manifest if stage == "sequence_extract_by_id": extract_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_sequence_extract_by_id.py", "--workspace", str(ws), "-p", prefix or "sequence_extract", "-j", job.job_id, "--seq-type", str(sequence_extract_seq_type or "auto"), "--rename-mode", str(sequence_extract_rename_mode or "keep"), ] if sequence_extract_clean_headers: extract_cmd.append("--clean-all-headers") if sequence_extract_fasta: for item in re.split(r"[,;,;]+", str(sequence_extract_fasta)): item = item.strip() if item: extract_cmd.extend(["--fasta", item]) ids_file_text = str(sequence_extract_ids_file or "").strip() if ids_file_text: ids_path = _resolve_sequence_ids_file(job, ids_file_text) if ids_path: extract_cmd.extend(["--ids-file", str(ids_path)]) else: # Treat a non-existing ids_file value as inline IDs; this avoids # failing when the parser captured a single gene ID into this field. sequence_extract_ids = f"{sequence_extract_ids} {ids_file_text}".strip() elif prefix: resolved_ids_file = _resolve_job_ids_file(job, prefix) if resolved_ids_file: extract_cmd.extend(["--ids-file", str(resolved_ids_file)]) if sequence_extract_ids: extract_cmd.extend(["--ids", str(sequence_extract_ids)]) print(f"[CMD] {' '.join(extract_cmd)}") code = run_cmd_logged(extract_cmd, log_file, cwd=Path(".")) extract_manifest = final_dir / f"{prefix or 'sequence_extract'}.sequence_extract.manifest.json" if code == 0: return "✅ Sequence extraction by ID completed.", extract_manifest if extract_manifest.exists(): try: data = json.loads(extract_manifest.read_text(encoding="utf-8")) if data.get("status") == "needs_user_confirmation": return f"❌ 序列提取需要用户确认源FASTA文件。候选文件已写入manifest。Log: {log_file}", extract_manifest except Exception: pass return f"❌ Sequence extraction by ID failed (exit code: {code}). Log: {log_file}", extract_manifest if extract_manifest.exists() else manifest if stage == "msa_region_plot": auto_conserved = bool(msa_auto_conserved or kwargs.get("msaAutoConserved")) def _as_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def _as_float(value, default=0.6): try: return float(value) except (TypeError, ValueError): return default start_col = _as_int(msa_start_col or kwargs.get("msaStartCol"), 0) end_col = _as_int(msa_end_col or kwargs.get("msaEndCol"), 0) window_len = _as_int(msa_window_len or kwargs.get("msaWindowLen"), 60) min_nongap = _as_float(msa_min_nongap or kwargs.get("msaMinNongap"), 0.6) reference_id = (msa_reference_id or kwargs.get("msaReferenceId") or "").strip() reference_start = _as_int(msa_reference_start_residue or kwargs.get("msaReferenceStartResidue"), 0) reference_end = _as_int(msa_reference_end_residue or kwargs.get("msaReferenceEndResidue"), 0) reference_mode = bool(reference_id or reference_start or reference_end) region_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_msa_region_plot.py", "--workspace", str(ws), "--prefix", prefix, "--force", ] resolved_msa = _resolve_msa_file_for_region_plot( workspace=ws, prefix=prefix, msa_file=msa_file or kwargs.get("msaFile", ""), source_image=msa_source_image or kwargs.get("msaSourceImage", ""), ) if resolved_msa: region_cmd.extend(["--msa-file", str(resolved_msa)]) if scope_file: region_cmd.extend(["--scope-file", scope_file]) if analysis_route: region_cmd.extend(["--analysis-route", analysis_route]) if member_set_id: region_cmd.extend(["--member-set-id", member_set_id]) if auto_conserved: region_cmd.extend(["--auto-conserved", "--window-len", str(window_len), "--min-nongap", str(min_nongap)]) elif reference_mode: if not reference_id or reference_start < 1 or reference_end < 1: return "❌ 参考序列编号绘图需要填写参考基因、起始残基和终止残基。", manifest region_cmd.extend([ "--reference-id", reference_id, "--reference-start-residue", str(reference_start), "--reference-end-residue", str(reference_end), ]) else: if start_col < 1 or end_col < 1: return "❌ MSA区间绘图需要填写 alignment 起止列,或选择自动保守窗口。", manifest region_cmd.extend(["--start-col", str(start_col), "--end-col", str(end_col)]) print(f"[CMD] {' '.join(region_cmd)}") code = run_cmd_logged(region_cmd, log_file, cwd=Path(".")) region_manifest_candidates = list(final_dir.glob(f"{prefix}.msa_region_*.manifest.json")) region_manifest_candidates.extend(final_dir.glob(f"{prefix}.msa_conserved_region_*.manifest.json")) region_manifests = sorted( region_manifest_candidates, key=lambda p: p.stat().st_mtime, reverse=True, ) region_manifest = region_manifests[0] if region_manifests else manifest if code == 0: return "✅ MSA区间绘图完成。", region_manifest return f"❌ MSA区间绘图失败 (exit code: {code}). Log: {log_file}", region_manifest if stage == "synteny_plot_revise": from apps.cli.run_synteny import run_circos_prepare, compute_file_hash def _as_float_synteny(value, default): try: return float(value) except (TypeError, ValueError): return default def _clean_label(value: str, default: str) -> str: label_value = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value or default).strip()) return label_value.strip("._-") or default def _infer_synteny_prefix_from_image_name(name: str) -> str: stem = Path(str(name or "")).name stem = re.sub(r"\.(png|jpg|jpeg|svg|pdf)$", "", stem, flags=re.I) for suffix in (".synteny_replot_circos", ".synteny_replot.circos", ".synteny_replot", ".circos_png", ".circos_svg", ".circos.circos", ".circos"): if stem.lower().endswith(suffix): stem = stem[: -len(suffix)] break scoped = re.match(r"^([^.]+\.[^.]+)\.[^.]+\.target-", stem, flags=re.I) if scoped: stem = scoped.group(1) return _clean_prefix(stem or prefix) def _first_existing(paths): for candidate in paths: if candidate and Path(candidate).exists() and Path(candidate).is_file(): return Path(candidate).resolve() return None source_image_value = synteny_source_image or kwargs.get("syntenySourceImage") or "" source_stage = str(synteny_source_stage or kwargs.get("syntenySourceStage") or "synteny").strip() or "synteny" source_image_path = _workspace_url_to_path(source_image_value, ws) if source_image_value else None inferred_prefix = _infer_synteny_prefix_from_image_name(Path(source_image_path).name if source_image_path else source_image_value) if inferred_prefix: prefix = inferred_prefix species_prefix = prefix.split(".", 1)[0] if prefix else "" analysis_dir = ws / "synteny" / "analysis" if not analysis_dir.exists(): return "❌ 未找到 synteny/analysis 目录,无法重绘种内共线性 Circos 图。", manifest gff_file = _first_existing([ analysis_dir / f"{species_prefix}.gff", analysis_dir / f"{prefix}.gff", *(sorted(analysis_dir.glob("*.gff"), key=lambda p: p.stat().st_mtime, reverse=True)), ]) collinearity_file = _first_existing([ analysis_dir / f"{species_prefix}.collinearity", analysis_dir / f"{prefix}.collinearity", *(sorted(analysis_dir.glob("*.collinearity"), key=lambda p: p.stat().st_mtime, reverse=True)), ]) gene_id_file = _first_existing([ final_dir / f"{prefix}.final_members.id", final_dir / f"{species_prefix}.final_members.id", *(sorted(final_dir.glob(f"{prefix}*.final_members.id"), key=lambda p: p.stat().st_mtime, reverse=True)), *(sorted(final_dir.glob("*.final_members.id"), key=lambda p: p.stat().st_mtime, reverse=True)), ]) genome_file = _first_existing([ in_dir / species_prefix / f"{species_prefix}.genome.fasta", in_dir / species_prefix / f"{species_prefix}.genome.fa", in_dir / f"{species_prefix}.genome.fasta", in_dir / f"{species_prefix}.genome.fa", *(sorted(in_dir.glob(f"**/{species_prefix}.genome.fasta"), key=lambda p: p.stat().st_mtime, reverse=True) if in_dir.exists() and species_prefix else []), *(sorted(in_dir.glob(f"**/{species_prefix}.genome.fa"), key=lambda p: p.stat().st_mtime, reverse=True) if in_dir.exists() and species_prefix else []), ]) missing = [] for label_name, path_value in (("GFF", gff_file), ("collinearity", collinearity_file), ("gene ID", gene_id_file), ("genome", genome_file)): if not path_value: missing.append(label_name) if missing: return f"❌ 缺少重绘所需文件: {', '.join(missing)}。请先完成 synteny 阶段。", manifest output_label = _clean_label(synteny_output_label or kwargs.get("syntenyOutputLabel"), "synteny_replot_circos") analysis_output_prefix = analysis_dir / f"{prefix}.synteny_replot" requested_gene_label_size = _as_float_synteny(synteny_gene_label_size or kwargs.get("syntenyGeneLabelSize"), 38.0) applied_gene_label_size = max(12.0, min(requested_gene_label_size, 80.0)) requested_gene_track_r1 = _as_float_synteny(synteny_gene_track_r1 or kwargs.get("syntenyGeneTrackR1"), 1.30) # Large Circos text labels need a taller text track; otherwise Circos may # silently mark all labels as not_placed. Keep r0 as the user's distance # control and expand only the outer boundary when the font is enlarged. min_gene_track_r1 = 1.30 + max(0.0, applied_gene_label_size - 38.0) * 0.02 applied_gene_track_r1 = max(requested_gene_track_r1, min(min_gene_track_r1, 2.10)) plot_params = { "gene_label_size": applied_gene_label_size, "gene_track_r0": _as_float_synteny(synteny_gene_track_r0 or kwargs.get("syntenyGeneTrackR0"), 1.03), "gene_track_r1": applied_gene_track_r1, "chrom_label_radius": str(synteny_chrom_label_radius or kwargs.get("syntenyChromLabelRadius") or "1r - 60p"), "chrom_label_size": max(20.0, min(_as_float_synteny(synteny_chrom_label_size or kwargs.get("syntenyChromLabelSize"), 40.0), 80.0)), "ideogram_thickness": _as_float_synteny(synteny_ideogram_thickness or kwargs.get("syntenyIdeogramThickness"), 90.0), } start_time = time.time() result = run_circos_prepare( gff_file=gff_file, collinearity_file=collinearity_file, gene_id_file=gene_id_file, output_prefix=analysis_output_prefix, genome_file=genome_file, **plot_params, ) final_outputs = [] for suffix in ("svg", "png"): src = analysis_dir / f"{analysis_output_prefix.name}.circos.{suffix}" if src.exists(): dest = final_dir / f"{prefix}.{output_label}.{suffix}" shutil.copy2(src, dest) final_outputs.append(dest) success = bool(result.get("success")) and bool(final_outputs) artifacts = { "source_gff": str(gff_file), "source_collinearity": str(collinearity_file), "source_gene_ids": str(gene_id_file), "source_genome": str(genome_file), } for out in final_outputs: suffix = out.suffix.lower().lstrip(".") artifacts[f"synteny_replot_{suffix}"] = { "artifact": "synteny_replot_circos" if suffix in {"png", "svg"} else "synteny_replot_file", "path": str(out.relative_to(ws)), "full_path": str(out.resolve()), "type": "image" if suffix in {"png", "svg", "jpg", "jpeg"} else "file", "format": suffix, "size": out.stat().st_size, "sha256": compute_file_hash(out), "description": "种内共线性 Circos 重绘图", } circos_conf = result.get("circos_conf") if circos_conf and Path(circos_conf).exists(): conf_path = Path(circos_conf) artifacts["synteny_replot_circos_conf"] = { "artifact": "synteny_replot_config", "path": str(conf_path.relative_to(ws)) if conf_path.is_relative_to(ws) else str(conf_path), "full_path": str(conf_path.resolve()), "type": "config", "format": "conf", "size": conf_path.stat().st_size, "sha256": compute_file_hash(conf_path), } metrics = { "circos_plotting_success": success, "elapsed_sec": round(time.time() - start_time, 3), "circos_gene_label_size": plot_params["gene_label_size"], "circos_gene_label_size_requested": requested_gene_label_size, "circos_gene_label_size_was_clamped": requested_gene_label_size != plot_params["gene_label_size"], "circos_gene_track_r1_requested": requested_gene_track_r1, "circos_gene_track_r1_auto_expanded": requested_gene_track_r1 != plot_params["gene_track_r1"], "circos_gene_track_r0": plot_params["gene_track_r0"], "circos_gene_track_r1": plot_params["gene_track_r1"], "circos_chrom_label_radius": plot_params["chrom_label_radius"], "circos_chrom_label_size": plot_params["chrom_label_size"], "circos_ideogram_thickness": plot_params["ideogram_thickness"], } for key in ("circos_chromosomes_total", "circos_chromosomes_plotted", "circos_chromosomes_filtered", "circos_chromosomes_filter_mode", "circos_chromosomes_filtered_names"): if key in result: metrics[key] = result.get(key) safe_result = json.loads(json.dumps(result, ensure_ascii=False, default=str)) manifest_data = { "manifest_version": "1.0", "stage": "synteny_plot_revise", "status": "success" if success else "failed", "prefix": prefix, "source_stage": source_stage, "display_stage": "synteny", "created_at": datetime.now().isoformat(), "parameters": { "output_label": output_label, "source_stage": source_stage, "display_stage": "synteny", "source_image": source_image_value, **plot_params, }, "artifacts": artifacts, "metrics": metrics, "plot": safe_result, "note": "圈图保留主核染色体背景,过滤掉疑似细胞器/非主序列。", } synteny_manifest = final_dir / f"{prefix}.{output_label}.manifest.json" canonical_manifest = final_dir / f"{prefix}.synteny_plot_revise.manifest.json" synteny_manifest.write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2), encoding="utf-8") canonical_manifest.write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2), encoding="utf-8") if success: return "✅ 种内共线性 Circos 图修订完成。", canonical_manifest return f"❌ 种内共线性 Circos 图修订失败:{result.get('stderr') or '未生成SVG/PNG'}", canonical_manifest if stage == "tree_plot_revise": layout = (phy_tree_layout or kwargs.get("treeLayout") or "both").lower() if layout not in {"rectangular", "circular", "both"}: return f"❌ 树图布局无效:{layout}", manifest resolved_tree = _resolve_tree_file_for_replot( workspace=ws, prefix=prefix, tree_file=tree_file or kwargs.get("treeFile", ""), source_image=tree_source_image or kwargs.get("treeSourceImage", ""), ) if not resolved_tree: return "❌ 未找到可重绘的 Newick 树文件。", manifest from apps.cli.run_phylogeny import run_tree_plotting, compute_file_hash source_stage = str(tree_source_stage or kwargs.get("treeSourceStage") or "").strip() source_image_name = Path(str(tree_source_image or kwargs.get("treeSourceImage") or "")).name.lower() allowed_display_stages = { "phylogeny", "reference_classification", "motif", "domain", "structure", "interspecies_synteny", "combo", "tree_plot_revise", } display_stage = source_stage if source_stage in allowed_display_stages else "" if display_stage == "tree_plot_revise": display_stage = "" if not display_stage: if ".motif.tree_motif." in source_image_name: display_stage = "motif" elif ".reference_classification.tree" in source_image_name: display_stage = "reference_classification" elif ".interspecies_tree" in source_image_name: display_stage = "interspecies_synteny" elif ".combo." in source_image_name: display_stage = "combo" else: display_stage = "phylogeny" base = resolved_tree.stem if base.endswith(".tree"): base = base[:-5] def _as_float_tree(value, default): try: return float(value) except (TypeError, ValueError): return default def _as_bool_tree(value, default=True): if isinstance(value, bool): return value if value is None or value == "": return default return str(value).lower() in {"1", "true", "yes", "y"} plot_params = { "label_offset": _as_float_tree(tree_label_offset or kwargs.get("treeLabelOffset"), 0.4), "tip_label_size": _as_float_tree(tree_tip_label_size or kwargs.get("treeTipLabelSize"), 3.0), "branch_color": str(tree_branch_color or kwargs.get("treeBranchColor") or "black"), "branch_width": _as_float_tree(tree_branch_width or kwargs.get("treeBranchWidth"), 0.5), "show_bootstrap": _as_bool_tree(tree_show_bootstrap if tree_show_bootstrap is not None else kwargs.get("treeShowBootstrap"), True), "bootstrap_size": _as_float_tree(tree_bootstrap_size or kwargs.get("treeBootstrapSize"), 2.0), "tip_point_size": _as_float_tree(tree_tip_point_size or kwargs.get("treeTipPointSize"), 2.0), "target_shape": str(tree_target_shape or kwargs.get("treeTargetShape") or "triangle"), "reference_shape": str(tree_reference_shape or kwargs.get("treeReferenceShape") or "circle"), "gene_shape": str(tree_target_shape or kwargs.get("treeTargetShape") or "triangle"), "marker_mode": str(tree_marker_mode or kwargs.get("treeMarkerMode") or "auto"), "subfamily_arc_enabled": _as_bool_tree(tree_subfamily_arc_enabled if tree_subfamily_arc_enabled is not None else kwargs.get("treeSubfamilyArcEnabled"), True), "subfamily_arc_gap": _as_float_tree(tree_subfamily_arc_gap or kwargs.get("treeSubfamilyArcGap"), 1.2), "subfamily_arc_label_gap": _as_float_tree(tree_subfamily_arc_label_gap or kwargs.get("treeSubfamilyArcLabelGap"), 0.6), "subfamily_arc_label_size": _as_float_tree(tree_subfamily_arc_label_size or kwargs.get("treeSubfamilyArcLabelSize"), 0.0), } label = (tree_output_label or kwargs.get("treeOutputLabel") or f"tree_replot_{layout}").strip() safe_label = re.sub(r"[^A-Za-z0-9_.-]+", "_", label) output_prefix = f"{base}.{safe_label}" def _first_existing(paths): for candidate in paths: if candidate and Path(candidate).exists() and Path(candidate).is_file(): return Path(candidate) return None def _manifest_species_values(manifest_data: dict, key: str) -> list[str]: params_data = manifest_data.get("parameters") if isinstance(manifest_data.get("parameters"), dict) else {} value = params_data.get(key) or manifest_data.get(key) or (manifest_data.get("metrics") or {}).get(key) if isinstance(value, list): return [str(v) for v in value if str(v)] if isinstance(value, str) and value: return [v] return [] def _read_interspecies_manifest() -> dict: candidates = [final_dir / f"{prefix}.interspecies_synteny.manifest.json"] candidates.extend(sorted(final_dir.glob(f"{prefix}*.interspecies_synteny.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)) tree_base = resolved_tree.name.replace(".nwk", "") if tree_base.endswith(".interspecies_tree"): root_prefix = tree_base[: -len(".interspecies_tree")] candidates.append(final_dir / f"{root_prefix}.interspecies_synteny.manifest.json") candidates.extend(sorted(final_dir.glob(f"{root_prefix}*.interspecies_synteny.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)) seen_paths = set() for candidate in candidates: if candidate in seen_paths or not candidate.exists(): continue seen_paths.add(candidate) try: data = json.loads(candidate.read_text(encoding="utf-8")) if isinstance(data, dict): return data except Exception: continue return {} def _run_interspecies_tree_replot() -> dict: inter_manifest = _read_interspecies_manifest() artifacts_data = inter_manifest.get("artifacts") if isinstance(inter_manifest.get("artifacts"), dict) else {} gene_map_value = artifacts_data.get("gene_to_species") gene_map_path = None if isinstance(gene_map_value, dict): gene_map_path = _workspace_url_to_path(gene_map_value.get("full_path") or gene_map_value.get("path") or "", ws) if not gene_map_path or not gene_map_path.exists(): tree_base = resolved_tree.name.replace(".interspecies_tree.nwk", "") gene_map_path = _first_existing([ ws / "interspecies_synteny" / "analysis" / f"{prefix}.gene_to_species.tsv", ws / "interspecies_synteny" / "analysis" / f"{tree_base}.gene_to_species.tsv", ]) target_species_values = _manifest_species_values(inter_manifest, "target_species") ref_species_values = _manifest_species_values(inter_manifest, "ref_species") if gene_map_path and gene_map_path.exists() and (not target_species_values or not ref_species_values): try: with gene_map_path.open("r", encoding="utf-8", errors="ignore") as handle: header = handle.readline().rstrip("\n").split("\t") idx_species = header.index("species") if "species" in header else 1 idx_group = header.index("group") if "group" in header else 2 target_seen, ref_seen = [], [] for raw in handle: parts = raw.rstrip("\n").split("\t") if len(parts) <= max(idx_species, idx_group): continue species_value = parts[idx_species] group_value = parts[idx_group] if group_value == "target" and species_value not in target_seen: target_seen.append(species_value) if group_value == "ref" and species_value not in ref_seen: ref_seen.append(species_value) target_species_values = target_species_values or target_seen ref_species_values = ref_species_values or ref_seen except Exception: pass target_species_label = ",".join(target_species_values) if target_species_values else "Target" ref_species_label = ",".join(ref_species_values) if ref_species_values else "Reference" temp_dir = ws / "tmp" / f"tree_plot_revise_{safe_label}" if temp_dir.exists(): shutil.rmtree(temp_dir) temp_dir.mkdir(parents=True, exist_ok=True) def _read_tree_tip_labels(tree_path: Path) -> set[str]: try: text = tree_path.read_text(encoding="utf-8", errors="ignore") except Exception: return set() return {m.group(1).strip().strip("'").strip('"') for m in re.finditer(r"(?<=[(,])([^():,;]+):", text) if m.group(1).strip()} def _merge_matching_subfamily_files(candidates: list[Path], tips: set[str]) -> Path | None: if not tips: return None merged: dict[str, str] = {} used_files: list[str] = [] seen_candidates: set[Path] = set() for candidate in candidates: if candidate in seen_candidates or not candidate.exists() or not candidate.is_file(): continue seen_candidates.add(candidate) matched_here = False try: with candidate.open("r", encoding="utf-8", errors="ignore") as handle: for raw in handle: parts = raw.strip().split() if len(parts) < 2: continue gene_id, subfamily = parts[0], parts[1] if gene_id in tips and gene_id not in merged: merged[gene_id] = subfamily matched_here = True except Exception: continue if matched_here: used_files.append(str(candidate)) if not merged: return None merged_path = temp_dir / f"{tree_root_prefix}.merged.geneID.subfamily" with merged_path.open("w", encoding="utf-8") as handle: for gene_id in sorted(merged): handle.write(f"{gene_id}\t{merged[gene_id]}\n") return merged_path tree_root_prefix = resolved_tree.name.replace(".interspecies_tree.nwk", "") subfamily_candidates = [] for stem in (prefix, tree_root_prefix): subfamily_candidates.extend([ final_dir / f"{stem}.geneID.subfamily", final_dir / f"{stem}.target_plus_reference.geneID.subfamily", in_dir / f"{stem}.geneID.subfamily", ]) subfamily_candidates.extend(sorted(final_dir.glob(f"{stem}*.geneID.subfamily"), key=lambda p: p.stat().st_mtime, reverse=True)) for species_value in [*target_species_values, *ref_species_values]: species_stem = str(species_value).strip() if not species_stem: continue subfamily_candidates.extend([ final_dir / f"{species_stem}.SPL.target_plus_reference.geneID.subfamily", final_dir / f"{species_stem}.SPL.geneID.subfamily", ]) subfamily_candidates.extend(sorted(final_dir.glob(f"{species_stem}.*.geneID.subfamily"), key=lambda p: p.stat().st_mtime, reverse=True)) subfamily_file = _first_existing(subfamily_candidates) tip_labels = _read_tree_tip_labels(resolved_tree) merged_subfamily_file = _merge_matching_subfamily_files(subfamily_candidates, tip_labels) if merged_subfamily_file: subfamily_file = merged_subfamily_file r_script = Path("scripts/interspecies_tree.R") cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/Rscript", str(r_script), str(resolved_tree), str(temp_dir), target_species_label, ref_species_label, str(subfamily_file) if subfamily_file else "", "", str(gene_map_path) if gene_map_path and gene_map_path.exists() else "", str(plot_params["label_offset"]), str(plot_params["tip_label_size"]), str(plot_params["branch_color"]), str(plot_params["branch_width"]), str(plot_params["target_shape"]), str(plot_params["reference_shape"]), str(plot_params["tip_point_size"]), str(plot_params["bootstrap_size"]), "true" if plot_params.get("subfamily_arc_enabled", True) else "false", str(plot_params.get("subfamily_arc_gap", 1.2)), str(plot_params.get("subfamily_arc_label_gap", 0.6)), str(plot_params.get("subfamily_arc_label_size", 0.0) or ""), ] start_time = time.time() proc = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=180) output_files = [] outputs = { "rect_pdf": temp_dir / "interspecies_tree.pdf", "rect_png": temp_dir / "interspecies_tree.png", "circ_pdf": temp_dir / "interspecies_tree.circular_tree.pdf", "circ_png": temp_dir / "interspecies_tree.circular_tree.png", } wanted = [] if layout in {"rectangular", "both"}: wanted.extend(["rect_pdf", "rect_png"]) if layout in {"circular", "both"}: wanted.extend(["circ_pdf", "circ_png"]) for key in wanted: src = outputs.get(key) if not src or not src.exists(): continue suffix = ".circular_tree" if key.startswith("circ_") else "" dest = final_dir / f"{output_prefix}{suffix}{src.suffix}" shutil.copy2(src, dest) output_files.append(dest) return { "success": proc.returncode == 0 and bool(output_files), "output_files": [str(p) for p in output_files], "elapsed_sec": round(time.time() - start_time, 3), "stdout": proc.stdout, "stderr": proc.stderr, "returncode": proc.returncode, "command": cmd, "interspecies_tree_replot": True, "gene_to_species": str(gene_map_path) if gene_map_path else "", "subfamily_file": str(subfamily_file) if subfamily_file else "", "target_species": target_species_values, "ref_species": ref_species_values, } if display_stage == "interspecies_synteny" or ".interspecies_tree" in source_image_name or resolved_tree.name.endswith(".interspecies_tree.nwk"): result = _run_interspecies_tree_replot() else: result = run_tree_plotting( tree_file=resolved_tree, output_prefix=output_prefix, tree_layout=layout, **plot_params, ) tree_manifest = final_dir / f"{output_prefix}.manifest.json" artifacts = {} for out in result.get("output_files", []) or []: path = Path(out) if not path.exists(): continue suffix = path.suffix.lower().lstrip(".") artifacts[path.name] = { "artifact": "tree_replot" if suffix == "png" else "tree_replot_file", "path": str(path.relative_to(ws)), "full_path": str(path.resolve()), "type": "image" if suffix == "png" else "document" if suffix == "pdf" else "file", "format": suffix, "size": path.stat().st_size, "sha256": compute_file_hash(path), } manifest_data = { "manifest_version": "1.0", "stage": "tree_plot_revise", "status": "success" if result.get("success") else "failed", "prefix": prefix, "source_stage": source_stage, "display_stage": display_stage, "created_at": datetime.now().isoformat(), "parameters": { "tree_layout": layout, "output_prefix": output_prefix, "source_stage": source_stage, "display_stage": display_stage, "source_image": tree_source_image or kwargs.get("treeSourceImage", ""), **plot_params, }, "artifacts": {"source_tree": str(resolved_tree), **artifacts}, "metrics": {"tree_plotting_success": bool(result.get("success")), "elapsed_sec": result.get("elapsed_sec")}, "plot": result, } tree_manifest.write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2), encoding="utf-8") canonical_manifest = final_dir / f"{prefix}.tree_plot_revise.manifest.json" canonical_manifest.write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2), encoding="utf-8") if result.get("success"): return "✅ 进化树图修订完成。", canonical_manifest return f"❌ 进化树图修订失败:{result.get('error') or result.get('stderr') or 'unknown'}", tree_manifest if stage in PREPARE_SUBSTAGES: script = Path("apps/cli/run_prepare_substep.py") if not script.exists(): return "Prepare substep script not found: apps/cli/run_prepare_substep.py", manifest prep_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix or "data_prep", "--stage", stage, "--operation", PREPARE_SUBSTAGES[stage], "--cpu", str(cpu), ] print(f"[CMD] {' '.join(prep_cmd)}") code = run_cmd_logged(prep_cmd, log_file, cwd=Path(".")) prep_manifest = ws / "final" / f"{prefix or 'data_prep'}.{stage}.manifest.json" if code == 0 and prep_manifest.exists(): return f"✅ Prepare substep complete: {stage}. Log: {log_file}", prep_manifest return f"❌ Prepare substep failed: {stage} (exit code: {code}). Log: {log_file}", prep_manifest if prep_manifest.exists() else manifest if stage == "identify_refine": script = Path("apps/cli/run_identify_refine.py") if not script.exists(): return "Identify refine script not found: apps/cli/run_identify_refine.py", manifest cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, ] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) identify_manifest = ws / "final" / f"{prefix}.identify.manifest.json" refine_manifest = ws / "final" / f"{prefix}.identify_refine.manifest.json" if code == 0 and identify_manifest.exists(): return f"✅ Identify成员修订完成。后续亚家族证据阶段将使用 curated_members;final_members 将在亚家族确认后生成。日志:{log_file}", refine_manifest if refine_manifest.exists() else identify_manifest return f"❌ Identify成员修订失败 (exit code: {code}). 请上传 {prefix}.curated_members.id 或 {prefix}.final_members.edited.id。日志:{log_file}", refine_manifest if refine_manifest.exists() else identify_manifest if identify_manifest.exists() else manifest if stage == "identify": # ... 现有identify代码保持不变 ... script = Path("apps/cli/run_identify.py") if not script.exists(): return ( "❌ Identify 入口缺失:apps/cli/run_identify.py 不存在", manifest, ) cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--retention-days", str(retention_days), "--log-file", str(log_file), "--dom-i-evalue-max", str(id_evalue), "--hmm-cov-min", str(id_coverage), "--blast-evalue", str(blast_evalue), "--blast-identity-min", str(blast_identity), ] # 直接传参(用户填了就传;没填就不传,让脚本内部推断/默认) if required_pfams: cmd += ["--required-pfams", required_pfams] if ref_species: cmd += ["--ref-species", ",".join(ref_species)] if scope_file: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): scope_path = candidate break if not scope_path.exists(): return f"❌ Identify scope 文件不存在: {scope_file}", manifest try: scope_data = read_scope(scope_path) print(f"[INFO] Identify scope: {scope_data.get('scope_id', scope_path.name)}") except Exception as exc: return f"❌ Identify scope 文件无法读取: {scope_file}; {exc}", manifest cmd += ["--scope-file", str(scope_path)] selected_targets = _species_arg_list(target_species) if selected_targets: cmd += ["--target-species", ",".join(selected_targets)] # 记录命令到可复现性管理器 if repro_manager: try: repro_manager.start_stage(stage, cmd, f"Run {stage} analysis") except Exception as e: print(f"[WARN] Failed to record command: {e}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 标记阶段完成 if repro_manager: try: status = "success" if code == 0 else "failed" repro_manager.end_stage(stage, status=status, log_file=str(log_file)) except Exception as e: print(f"[WARN] Failed to end stage recording: {e}") if code != 0: # 读取日志文件最后50行,提取详细错误信息 error_detail = "" try: if log_file.exists(): with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() # 获取最后50行 last_lines = lines[-50:] if len(lines) > 50 else lines # 查找包含错误关键字的行 error_lines = [] for line in last_lines: line = line.strip() if any(keyword in line for keyword in ['Error', 'ERROR', 'Exception', 'Traceback', 'RuntimeError', '❌', 'failed', '失败']): error_lines.append(line) # 如果有错误行,使用最后5行;否则使用最后5行日志 if error_lines: error_detail = "\\n".join(error_lines[-5:]) else: error_detail = "\\n".join([l.strip() for l in last_lines[-5:]]) except Exception as e: error_detail = f"(无法读取日志: {e})" error_msg = f"❌ Identify 运行失败" if error_detail: error_msg += f"\\n详细错误:\\n{error_detail}" error_msg += f"\\n完整日志: {log_file}" return error_msg, manifest identify_manifest = first_existing_stage_manifest(ws / "final", "identify", prefix) or find_latest_manifest(ws) audit_failed, audit_reason = identify_hmm_audit_failure(identify_manifest) if audit_failed: return f"❌ Identify HMM模型审计失败,已阻断下游:{audit_reason}。日志:{log_file}", identify_manifest or manifest manifest = identify_manifest or manifest # 生成可复现性文件(仅在最后一个阶段或 all 阶段完成时) if repro_manager and stage == "all": try: repro_manager.finalize() except Exception as e: print(f"[WARN] Failed to finalize reproducibility: {e}") return f"✅ Identify 完成。日志:{log_file}", manifest # ==================== 修改从这里开始 ==================== if stage == "phylogeny": script = Path("apps/cli/run_phylogeny.py") if not script.exists(): return ( "❌ Phylogeny 入口缺失:apps/cli/run_phylogeny.py 不存在", manifest, ) protein_fasta = None if not scope_file: protein_fasta, member_error = resolve_member_protein_for_stage_route( ws=ws, final_dir=final_dir, in_dir=in_dir, prefix=prefix, analysis_route=analysis_route, member_set_id=member_set_id, ) if member_error: return member_error, manifest # 构建phylogeny命令 cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), # workspace/ 目录 "--cpu", str(cpu), "--log-file", str(log_file), ] if scope_file: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): scope_path = candidate break if not scope_path.exists(): return f"❌ Phylogeny scope 文件不存在: {scope_file}", manifest cmd += ["--scope-file", str(scope_path)] elif protein_fasta: cmd += ["--proteins", str(protein_fasta)] if analysis_route: cmd += ["--analysis-route", analysis_route] if member_set_id: cmd += ["--member-set-id", member_set_id] phylogeny_scope_data = {} phylogeny_scope_id_for_labels = "" if scope_file: try: phylogeny_scope_data = read_scope(scope_path) phylogeny_scope_id_for_labels = str(phylogeny_scope_data.get("scope_id") or "") except Exception: phylogeny_scope_data = {} phylogeny_scope_id_for_labels = "" subfamily_file = None if should_disable_cli_subfamily_auto(analysis_route, subfamily_label_source): cmd += ["--subfamily", "NONE"] else: subfamily_file = resolve_subfamily_for_plotting(ws / "final", in_dir, prefix, phylogeny_scope_id_for_labels, subfamily_label_source) if not subfamily_file and phylogeny_scope_data: subfamily_file = merge_subfamily_for_scope(ws / "final", in_dir, prefix, phylogeny_scope_data, subfamily_label_source) if subfamily_file: cmd += ["--subfamily", str(subfamily_file)] # 添加高级参数 if phy_mafft: cmd += [f"--mafft-params={phy_mafft}"] if phy_iqtree: cmd += [f"--iqtree-params={phy_iqtree}"] if phy_tree_layout: cmd += ["--tree-layout", phy_tree_layout] cmd += [ "--tree-subfamily-arc-enabled", "true" if tree_subfamily_arc_enabled else "false", "--tree-subfamily-arc-gap", str(tree_subfamily_arc_gap), "--tree-subfamily-arc-label-gap", str(tree_subfamily_arc_label_gap), "--tree-subfamily-arc-label-size", str(tree_subfamily_arc_label_size or 0.0), ] code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 严格检查:必须生成 manifest 文件才算成功。scope 运行只认 scoped manifest, # 避免旧的 {prefix}.phylogeny.manifest.json 让新物种组合误判完成。 phylogeny_scope_id = "" if scope_file: try: phylogeny_scope_id = str(read_scope(scope_path).get("scope_id") or "") except Exception: phylogeny_scope_id = "" output_prefix_token = stage_output_prefix("phylogeny", prefix, phylogeny_scope_data, analysis_route, member_set_id) new_manifest = first_existing_stage_manifest(ws / "final", "phylogeny", prefix, phylogeny_scope_data, analysis_route, member_set_id) expected_manifest = ws / "final" / f"{output_prefix_token}.phylogeny.manifest.json" if code == 0 and new_manifest and new_manifest.exists(): # 成功:检查输出文件完整性 expected_files = [ ws / "final" / f"{output_prefix_token}.pep.msa.fa", ws / "final" / f"{output_prefix_token}.tree.nwk", ws / "final" / f"{output_prefix_token}.phylogeny_report.md", new_manifest ] existing = [f for f in expected_files if f.exists()] if len(existing) >= 3: # 至少3个主要文件 return f"✅ Phylogeny 完成。生成{len(existing)}个文件。日志:{log_file}", new_manifest else: # manifest 存在但其他文件不全,视为部分成功但标记为失败 return f"❌ Phylogeny 失败:输出文件不全({len(existing)}/{len(expected_files)})。日志:{log_file}", manifest elif code == 0 and not new_manifest: # 返回码0但没有manifest,视为失败 return f"❌ Phylogeny 失败:未生成 manifest 文件(期望 {expected_manifest.name})。日志:{log_file}", manifest elif new_manifest and new_manifest.exists(): # If the CLI wrote a phylogeny manifest, return it so the frontend shows the real failure. return f"Phylogeny failed (exit code: {code}). Log: {log_file}", new_manifest elif code == 1: return f"Phylogeny failed: MAFFT succeeded but complete outputs were not generated. Log: {log_file}", manifest else: return f"Phylogeny failed (exit code: {code}). Log: {log_file}", manifest ######## END PHYLOGENY ######### if stage == "reference_classification": script = Path("apps/cli/run_reference_classification.py") if not script.exists(): return "❌ Reference classification script not found: apps/cli/run_reference_classification.py", manifest scope_path = None reference_scope_id = "" if scope_file: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): scope_path = candidate break if not scope_path.exists(): return f"❌ Reference classification scope 文件不存在: {scope_file}", manifest try: reference_scope_id = str(read_scope(scope_path).get("scope_id") or "") except Exception: reference_scope_id = "" if scope_path: try: parent_scope_data = read_scope(scope_path) except Exception as exc: return f"❌ Reference classification scope 文件无法读取: {scope_file}; {exc}", manifest family = str(parent_scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip() config_family = _gene_family_from_config_xlsx(in_dir / "config.xlsx") analysis_family = str(((parent_scope_data.get("analysis") or {}).get("Family") if isinstance(parent_scope_data.get("analysis"), dict) else "") or "").strip() target_list = [str(x) for x in parent_scope_data.get("target_species") or [] if str(x).strip()] comparative_list = [str(x) for x in parent_scope_data.get("comparative_species") or [] if str(x).strip()] reference_list = [str(x) for x in parent_scope_data.get("reference_species") or [] if str(x).strip()] selected_codes = {item.lower() for item in [*target_list, *comparative_list, *reference_list]} authoritative_family = config_family or analysis_family if authoritative_family and family != authoritative_family: return f"❌ Reference classification scope identity conflict: scope family={family}, config family={authoritative_family}", manifest if family and family.lower() in selected_codes: return f"❌ Reference classification scope identity conflict: {family} 是物种代码,不是 gene family", manifest if not reference_list: return f"❌ Reference classification 需要 referenceSpecies;当前 scope={reference_scope_id or ''} 未包含参考物种", manifest classifiable_species = [] for item in [*target_list, *comparative_list]: if item and item not in classifiable_species: classifiable_species.append(item) if classifiable_species and family: parent_evidence_member_set = f"{reference_scope_id}:subfamily_evidence:domain_refined" if reference_scope_id else "" parent_evidence_tree = None parent_evidence_ready = False if analysis_mode != "per_species" and len(classifiable_species) > 1 and parent_evidence_member_set: parent_evidence_prefix = stage_output_prefix( "phylogeny", prefix, parent_scope_data, "subfamily_evidence", parent_evidence_member_set, ) parent_evidence_tree = final_dir / f"{parent_evidence_prefix}.tree.nwk" parent_evidence_manifests = { stage_name: first_existing_stage_manifest( final_dir, stage_name, prefix, parent_scope_data, "subfamily_evidence", parent_evidence_member_set, ) for stage_name in ("phylogeny", "motif", "domain", "structure", "combo") } parent_evidence_ready = ( parent_evidence_tree.exists() and parent_evidence_tree.stat().st_size > 0 and all(path and path.exists() for path in parent_evidence_manifests.values()) ) joint_mode = analysis_mode != "per_species" and len(classifiable_species) > 1 if parent_evidence_ready and parent_evidence_tree is not None and scope_path is not None: ref_member_set = f"{reference_scope_id}:subfamily_prediction:all_members" ref_cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--input-dir", str(in_dir), "--tree", str(parent_evidence_tree), "--target-prefix", prefix.split(".")[0] if "." in prefix else prefix, "--reference-prefix", reference_prefix or (reference_list[0] if reference_list else "At"), "--log-file", str(log_file), "--scope-file", str(scope_path), "--analysis-route", "subfamily_prediction", "--member-set-id", ref_member_set, ] if reference_subfamily_file: ref_cmd.extend(["--reference-subfamily", reference_subfamily_file]) if reference_overwrite_gene_subfamily: ref_cmd.append("--overwrite-gene-subfamily") print(f"[CMD] {' '.join(ref_cmd)}") ref_code = run_cmd_logged(ref_cmd, log_file, cwd=Path(".")) ref_manifest = first_existing_stage_manifest( final_dir, "reference_classification", prefix, parent_scope_data, "subfamily_prediction", ref_member_set, ) if ref_code == 0 and ref_manifest: try: manifest_data = json.loads(Path(ref_manifest).read_text(encoding="utf-8")) manifest_data["analysis_mode"] = "joint_multi_species" manifest_data["classifiable_species"] = classifiable_species manifest_data["target_species"] = target_list manifest_data["comparative_species"] = comparative_list manifest_data["reference_species"] = reference_list manifest_data["policy"] = "joint_multi_species; one aggregate subfamily tree/combo/review table" manifest_data.setdefault("metrics", {})["classifiable_species_count"] = len(classifiable_species) stage_context = manifest_data.setdefault("stage_context", {}) if isinstance(stage_context, dict): stage_context["analysis_mode"] = "joint_multi_species" stage_context["classifiable_species"] = classifiable_species Path(ref_manifest).write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") except Exception as exc: print(f"[Reference classification] failed to annotate joint manifest: {exc}") return ( f"✅ Reference classification 使用联合 evidence 生成一张联合亚家族分类结果:{', '.join(classifiable_species)}。日志:{log_file}", ref_manifest, ) return f"❌ Reference classification 使用联合 evidence 失败; log={log_file}", ref_manifest or manifest if joint_mode: missing_joint = [] if not parent_evidence_tree or not parent_evidence_tree.exists() or parent_evidence_tree.stat().st_size <= 0: missing_joint.append("phylogeny_tree") try: missing_joint.extend([ stage_name for stage_name, manifest_path in parent_evidence_manifests.items() if not manifest_path or not manifest_path.exists() ]) except Exception: missing_joint.extend(["phylogeny", "motif", "domain", "structure", "combo"]) return ( "❌ Reference classification 联合模式需要同一 scope 的联合 evidence,不能自动拆成 per_species。" f"缺失:{', '.join(dict.fromkeys(missing_joint)) or 'unknown'}。日志:{log_file}", manifest, ) aggregate_results = [] aggregate_errors = [] for species in classifiable_species: species_prefix = f"{species}.{family}" evidence_scope = build_gene_family_scope( input_dir=in_dir, workspace=ws, prefix=species_prefix, family=family, target_species=[species], reference_species=reference_list, comparative_species=[], scope_mode="target_plus_reference", ) evidence_scope_path = final_dir / f"{species_prefix}.target_reference_evidence.scope.json" if not evidence_scope_path.exists(): write_scope(evidence_scope, evidence_scope_path) evidence_scope_data = read_scope(evidence_scope_path) evidence_sid = sanitize_token(str(evidence_scope.scope_id)) evidence_member_set = f"{evidence_sid}:subfamily_evidence:domain_refined" evidence_output_prefix = stage_output_prefix( "phylogeny", species_prefix, evidence_scope_data, "subfamily_evidence", evidence_member_set, ) evidence_tree = final_dir / f"{evidence_output_prefix}.tree.nwk" evidence_manifests = { stage_name: first_existing_stage_manifest( final_dir, stage_name, species_prefix, evidence_scope_data, "subfamily_evidence", evidence_member_set, ) for stage_name in ("phylogeny", "motif", "domain", "structure", "combo") } missing_evidence = [ stage_name for stage_name, manifest_path in evidence_manifests.items() if not manifest_path or not manifest_path.exists() ] if not evidence_tree.exists() or evidence_tree.stat().st_size <= 0: missing_evidence.append("phylogeny_tree") if missing_evidence: aggregate_errors.append( f"{species_prefix} reference_classification missing explicit upstream subfamily_evidence stages: " f"{', '.join(dict.fromkeys(missing_evidence))}. " f"Expected evidence tree: {evidence_tree}. " "PlanningAgentV2 must schedule target+reference phylogeny/motif/domain/structure/combo with analysisRoute=subfamily_evidence first; " "the regular target-only phylogeny tree is not a valid substitute." ) continue combo_manifest = evidence_manifests.get("combo") compact_combo_outputs = materialize_compact_combo_outputs(final_dir, species_prefix, combo_manifest) ref_scope = build_gene_family_scope( input_dir=in_dir, workspace=ws, prefix=species_prefix, family=family, target_species=[species], reference_species=reference_list, comparative_species=[], scope_mode="target_plus_reference", ) ref_scope_path = final_dir / f"{species_prefix}.target_plus_reference.scope.json" if not ref_scope_path.exists(): write_scope(ref_scope, ref_scope_path) ref_scope_id = str(ref_scope.scope_id) ref_sid = sanitize_token(ref_scope_id) ref_member_set = f"{ref_sid}:subfamily_prediction:all_members" ref_cmd = [ sys.executable, str(script), "--prefix", species_prefix, "--workspace", str(ws), "--input-dir", str(in_dir), "--tree", str(evidence_tree), "--target-prefix", species, "--reference-prefix", reference_prefix or (reference_list[0] if reference_list else "At"), "--log-file", str(log_file), "--scope-file", str(ref_scope_path), "--analysis-route", "subfamily_prediction", "--member-set-id", ref_member_set, ] if reference_subfamily_file: ref_cmd.extend(["--reference-subfamily", reference_subfamily_file]) if reference_overwrite_gene_subfamily: ref_cmd.append("--overwrite-gene-subfamily") print(f"[CMD] {' '.join(ref_cmd)}") ref_code = run_cmd_logged(ref_cmd, log_file, cwd=Path(".")) ref_scope_data = read_scope(ref_scope_path) ref_manifest = first_existing_stage_manifest( final_dir, "reference_classification", species_prefix, ref_scope_data, "subfamily_prediction", ref_member_set, ) if ref_code == 0 and ref_manifest: result_item = { "species": species, "prefix": species_prefix, "evidence_scope_file": str(evidence_scope_path), "reference_scope_file": str(ref_scope_path), "manifest": str(ref_manifest), "tree": str(final_dir / f"{species_prefix}.target_plus_reference.reference_classification.tree.png"), "circular_tree": str(final_dir / f"{species_prefix}.target_plus_reference.reference_classification.tree.circular_tree.png"), } if compact_combo_outputs.get(".png"): result_item["evidence_combo"] = str(compact_combo_outputs[".png"]) if combo_manifest: result_item["evidence_combo_manifest"] = str(combo_manifest) aggregate_results.append(result_item) else: aggregate_errors.append(f"{species_prefix} reference_classification failed; log={log_file}") if ( len(aggregate_results) == 1 and not aggregate_errors and aggregate_results[0].get("prefix") == prefix and aggregate_results[0].get("manifest") ): return ( f"✅ Reference classification 完成 {prefix}。日志:{log_file}", Path(aggregate_results[0]["manifest"]), ) aggregate_output_prefix = stage_output_prefix( "reference_classification", prefix, parent_scope_data, "subfamily_prediction", f"{reference_scope_id}:subfamily_prediction:all_members", ) aggregate_manifest = final_dir / f"{aggregate_output_prefix}.reference_classification.manifest.json" aggregate_payload = { "manifest_version": "1.0", "stage": "reference_classification", "status": "success" if aggregate_results and not aggregate_errors else "failed", "prefix": prefix, "output_prefix": aggregate_output_prefix, "legacy_output_prefix": f"{prefix}.{sanitize_token(reference_scope_id)}" if reference_scope_id else prefix, "scope_id": reference_scope_id, "scope_mode": parent_scope_data.get("scope_mode", ""), "target_species": target_list, "comparative_species": comparative_list, "classifiable_species": classifiable_species, "reference_species": reference_list, "analysis_route": "subfamily_prediction", "analysis_mode": "per_species", "member_set_id": f"{reference_scope_id}:subfamily_prediction:all_members", "result_group": "subfamily_confirmation", "stage_context": { "stage": "reference_classification", "prefix": prefix, "scope_id": reference_scope_id, "scope_mode": parent_scope_data.get("scope_mode", ""), "analysis_route": "subfamily_prediction", "member_set_id": f"{reference_scope_id}:subfamily_prediction:all_members", "result_group": "subfamily_confirmation", "analysis_mode": "per_species", "family": family, "target_species": target_list, "reference_species": reference_list, "comparative_species": comparative_list, "selected_species": [*target_list, *reference_list, *comparative_list], }, "policy": "per_species; each classifiable species has its own tree/combo/review table", "artifacts": { f"{item['species']}_reference_classification_manifest": { "path": f"final/{Path(item['manifest']).name}", "type": "json", } for item in aggregate_results }, "parameters": { "analysis_route": "subfamily_prediction", "member_set_id": f"{reference_scope_id}:subfamily_prediction:all_members", "result_group": "subfamily_confirmation", }, "metrics": { "per_species_count": len(aggregate_results), "classifiable_species_count": len(classifiable_species), "failed_species_count": len(aggregate_errors), "errors": aggregate_errors, }, "per_species_results": aggregate_results, "created_at": datetime.now().isoformat(), } aggregate_manifest.write_text(json.dumps(aggregate_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if reference_scope_id: legacy_aggregate_manifest = final_dir / f"{prefix}.{sanitize_token(reference_scope_id)}.reference_classification.manifest.json" if legacy_aggregate_manifest != aggregate_manifest: legacy_aggregate_manifest.write_text(aggregate_manifest.read_text(encoding="utf-8"), encoding="utf-8") if aggregate_errors: return f"❌ Reference classification 部分/全部物种失败: {'; '.join(aggregate_errors)}。日志:{log_file}", aggregate_manifest return f"✅ Reference classification 按单物种完成 {len(aggregate_results)} 个待分类物种。日志:{log_file}", aggregate_manifest if scope_path: try: tree_file, _scope_data_for_tree = resolve_scoped_tree_from_phylogeny(ws, final_dir, prefix, scope_path) except Exception as exc: return f"❌ Reference classification 找不到同 scope 的 phylogeny 树文件: {exc}", manifest else: tree_file = final_dir / f"{prefix}.tree.nwk" if not tree_file.exists(): return "❌ Reference classification requires phylogeny results. Please run phylogeny first.", manifest target_prefix_arg = reference_target_prefix or prefix.split(".")[0] cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--input-dir", str(in_dir), "--tree", str(tree_file), "--target-prefix", target_prefix_arg, "--reference-prefix", reference_prefix or "At", "--log-file", str(log_file), ] if reference_subfamily_file: cmd.extend(["--reference-subfamily", reference_subfamily_file]) if scope_path: cmd.extend(["--scope-file", str(scope_path)]) if analysis_route: cmd.extend(["--analysis-route", analysis_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) if reference_overwrite_gene_subfamily: cmd.append("--overwrite-gene-subfamily") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) reference_scope_data = read_scope(scope_path) if scope_path else {} reference_manifest = first_existing_stage_manifest( final_dir, "reference_classification", prefix, reference_scope_data, analysis_route or "subfamily_prediction", member_set_id, ) or (final_dir / f"{stage_output_prefix('reference_classification', prefix, reference_scope_data, analysis_route or 'subfamily_prediction', member_set_id)}.reference_classification.manifest.json") if code == 0 and reference_manifest.exists(): return f"✅ Reference classification 完成。日志:{log_file}", reference_manifest if reference_manifest.exists(): return f"❌ Reference classification 失败。日志:{log_file}", reference_manifest return f"❌ Reference classification failed (exit code: {code}). Log: {log_file}", manifest if stage == "subfamily_confirm": script = Path("apps/cli/run_subfamily_confirm.py") if not script.exists(): return "❌ Subfamily confirm script not found: apps/cli/run_subfamily_confirm.py", manifest cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--input-dir", str(in_dir), ] edited_file = (subfamily_confirm_file or str(kwargs.get("subfamilyConfirmFile") or "")).strip() if edited_file: cmd.extend(["--edited-subfamily", edited_file]) confirm_scope_data = {} if scope_file: cmd.extend(["--scope-file", scope_file]) try: confirm_scope_data = read_scope(resolve_scope_path(scope_file, in_dir, ws)) except Exception: confirm_scope_data = {} source_prediction_route = str(kwargs.get("sourcePredictionRoute") or kwargs.get("source_prediction_route") or "").strip() if not source_prediction_route and analysis_route and analysis_route != "confirmed_subfamily": source_prediction_route = analysis_route confirm_route = "confirmed_subfamily" expected_member_set = member_set_id_for(prefix, confirm_scope_data, confirm_route) if confirm_scope_data.get("scope_id") else "" if expected_member_set and (not member_set_id or f":{confirm_route}:" not in member_set_id): member_set_id = expected_member_set cmd.extend(["--analysis-route", confirm_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) code = run_cmd_logged(cmd, log_file, cwd=Path(".")) confirm_manifest = first_existing_stage_manifest( final_dir, "subfamily_confirm", prefix, confirm_scope_data, confirm_route, member_set_id ) or (final_dir / f"{stage_output_prefix('subfamily_confirm', prefix, confirm_scope_data, confirm_route, member_set_id)}.subfamily_confirm.manifest.json") if code == 0 and confirm_manifest.exists(): return f"✅ Subfamily confirm 完成。日志:{log_file}", confirm_manifest if confirm_manifest.exists(): return f"❌ Subfamily confirm 失败。日志:{log_file}", confirm_manifest return f"❌ Subfamily confirm failed (exit code: {code}). Log: {log_file}", manifest if stage == "structure": # 检查GFF3来源:scope 多物种解析优先;无 scope 时沿用单文件上传逻辑 gff3_file = None structure_scope_path = None structure_tree_file = None structure_scope_id = "" if not scope_file: # 1. 检查用户是否上传了GFF3 uploaded_gff3 = list(Path(job.input_dir).glob("*.gff3")) + list(Path(job.input_dir).glob("*.gff")) if uploaded_gff3: gff3_file = uploaded_gff3[0] print(f"[INFO] Using uploaded GFF3: {gff3_file}") else: # 2. TODO: 从数据库/参考基因组提取 # 暂时报错 return "❌ Structure stage requires GFF3 file. Please upload genome annotation (GFF3).", manifest else: print(f"[INFO] Structure will resolve target GFF3 files from scope: {scope_file}") structure_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not structure_scope_path.exists(): return f"❌ Structure scope 文件不存在: {scope_file}", manifest try: structure_tree_file, _structure_scope_data = resolve_scoped_tree_from_phylogeny( ws, final_dir, prefix, structure_scope_path ) structure_scope_id = str(_structure_scope_data.get("scope_id") or "") except Exception as exc: return f"❌ Structure 找不到同 scope 的 phylogeny 树文件: {exc}", manifest if not structure_tree_file: structure_tree_file = final_dir / f"{prefix}.tree.nwk" if not structure_tree_file.exists(): return "❌ Structure stage requires phylogeny results. Please run /run phylogeny first.", manifest script = Path("apps/cli/run_structure.py") if not script.exists(): return "❌ Structure script not found", manifest cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--cpu", str(cpu), "--log-file", str(log_file) ] if gff3_file: cmd.extend(["--gff3", str(gff3_file)]) if structure_tree_file: cmd.extend(["--tree", str(structure_tree_file)]) if structure_scope_path: cmd.extend(["--scope-file", str(structure_scope_path)]) if analysis_route: cmd.extend(["--analysis-route", analysis_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) subfamily_file = None if should_disable_cli_subfamily_auto(analysis_route, subfamily_label_source): cmd.extend(["--subfamily", "NONE"]) else: subfamily_file = resolve_subfamily_for_plotting(final_dir, in_dir, prefix, structure_scope_id, subfamily_label_source) if subfamily_file: cmd.extend(["--subfamily", str(subfamily_file)]) code = run_cmd_logged(cmd, log_file) structure_scope_data_for_manifest = _structure_scope_data if structure_scope_id else {} structure_manifest = first_existing_stage_manifest( final_dir, "structure", prefix, structure_scope_data_for_manifest, analysis_route, member_set_id ) or (final_dir / f"{stage_output_prefix('structure', prefix, structure_scope_data_for_manifest, analysis_route, member_set_id)}.structure.manifest.json") if code == 0 and structure_manifest.exists(): return f"✅ Structure visualization complete. Log: {log_file}", structure_manifest if code == 0: return f"✅ Structure visualization complete. Log: {log_file}", find_latest_manifest(ws) else: return f"❌ Structure failed. Log: {log_file}", structure_manifest if structure_manifest.exists() else manifest ########结束######### if stage == "domain": # Scoped domain runs use the same member set as scoped phylogeny/motif. # Do not require a legacy bare {prefix}.identify.manifest.json: single-target # members may come from Target.{species}.{family}.final_members.* or from the # scoped phylogeny input FASTA recorded in {prefix}.{scope_id}.phylogeny.manifest.json. domain_member_protein = None if not scope_file: domain_member_protein, member_error = resolve_member_protein_for_stage_route( ws=ws, final_dir=final_dir, in_dir=in_dir, prefix=prefix, analysis_route=analysis_route, member_set_id=member_set_id, ) if member_error: return member_error, manifest phylogeny_manifest = final_dir / f"{prefix}.phylogeny.manifest.json" if not phylogeny_manifest.exists(): return "❌ Domain stage requires phylogeny results. Please run phylogeny first.", None pfam_base = Path(__file__).resolve().parents[2] / "data" / "hmm" pfam_hmm = pfam_base / "Pfam-A.hmm" if not pfam_hmm.exists(): return f"❌ Pfam database not found: {pfam_hmm}", None required_indices = [".h3f", ".h3i", ".h3m", ".h3p"] missing_indices = [f"Pfam-A.hmm{ext}" for ext in required_indices if not (pfam_base / f"Pfam-A.hmm{ext}").exists()] if missing_indices: return f"❌ Missing Pfam index files: {', '.join(missing_indices)}. Run 'hmmpress Pfam-A.hmm' first.", None script = Path("apps/cli/run_domain.py") if not script.exists(): return "❌ Domain script not found: apps/cli/run_domain.py", None domain_scope_id = "" domain_manifest = final_dir / f"{prefix}.domain.manifest.json" cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--cpu", str(cpu), "--evalue", str(dom_evalue), "--log-file", str(log_file), ] if scope_file: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): scope_path = candidate break if not scope_path.exists(): return f"❌ Domain scope 文件不存在: {scope_file}", manifest cmd.extend(["--scope-file", str(scope_path)]) if analysis_route: cmd.extend(["--analysis-route", analysis_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) try: domain_tree_file, _domain_scope_data = resolve_scoped_tree_from_phylogeny(ws, final_dir, prefix, scope_path) cmd.extend(["--tree", str(domain_tree_file)]) domain_scope_id = str(_domain_scope_data.get("scope_id") or "") except Exception as exc: return f"❌ Domain 找不到同 scope 的 phylogeny 树文件: {exc}", manifest else: protein_file = domain_member_protein if protein_file: cmd.extend(["--proteins", str(protein_file)]) subfamily_file = None if should_disable_cli_subfamily_auto(analysis_route, subfamily_label_source): cmd.extend(["--subfamily", "NONE"]) else: subfamily_file = resolve_subfamily_for_plotting(final_dir, in_dir, prefix, domain_scope_id, subfamily_label_source) if subfamily_file: cmd.extend(["--subfamily", str(subfamily_file)]) if dom_coverage is not None: cmd.extend(["--coverage", str(dom_coverage)]) domain_scope_data_for_manifest = _domain_scope_data if domain_scope_id else {} if domain_scope_id: domain_manifest = final_dir / f"{stage_output_prefix('domain', prefix, domain_scope_data_for_manifest, analysis_route, member_set_id)}.domain.manifest.json" print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) domain_manifest = first_existing_stage_manifest( final_dir, "domain", prefix, domain_scope_data_for_manifest, analysis_route, member_set_id ) or domain_manifest if domain_manifest.exists(): if code == 0: return f"Domain complete. Log: {log_file}", domain_manifest return f"Domain failed. Log: {log_file}", domain_manifest if code == 0: return f"Domain finished but manifest not found. Log: {log_file}", manifest return f"Domain failed (exit code: {code}). Log: {log_file}", manifest # if stage == "msa_figure": # cmd = ["python", "apps/cli/run_msa_figure.py", "--manifest", str(manifest)] # code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # if code != 0: # return f"❌ MSA Figure 失败,日志:{log_file}", manifest # return f"✅ MSA Figure 完成。日志:{log_file}", manifest if stage == "structure": # 查找subfamily文件 subfamily_file = resolve_subfamily_for_plotting(final_dir, in_dir, prefix, label_source=subfamily_label_source) cmd = [sys.executable, "apps/cli/run_structure.py", "--manifest", str(manifest), "--cpu", str(cpu)] if subfamily_file and subfamily_file.exists(): cmd += ["--subfamily", str(subfamily_file)] code = run_cmd_logged(cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ Structure 失败,日志:{log_file}", manifest return f"✅ Structure 完成。日志:{log_file}", manifest if stage == "motif": motif_scope_id = "" motif_manifest = final_dir / f"{prefix}.motif.manifest.json" candidate_manifests = [ final_dir / f"{prefix}.pep.manifest.json", final_dir / f"{prefix}.phylogeny.manifest.json", final_dir / f"{prefix}.identify.manifest.json", ] input_manifest = next((p for p in candidate_manifests if p.exists()), manifest) cmd = [sys.executable, "apps/cli/run_motif.py", "--manifest", str(input_manifest), "--cpu", str(cpu)] # Advanced parameters cmd += ["--nmotifs", str(motif_nmotifs), "--minw", str(motif_minw), "--maxw", str(motif_maxw)] if scope_file: scope_path = Path(scope_file).expanduser() if not scope_path.is_absolute(): for candidate in (in_dir / scope_path, ws / scope_path, Path(scope_file)): if candidate.exists(): scope_path = candidate break if not scope_path.exists(): return f"❌ Motif scope 文件不存在: {scope_file}", manifest cmd += ["--scope-file", str(scope_path)] if analysis_route: cmd += ["--analysis-route", analysis_route] if member_set_id: cmd += ["--member-set-id", member_set_id] try: motif_tree_file, _motif_scope_data = resolve_scoped_tree_from_phylogeny( ws, final_dir, prefix, scope_path, explicit_tree_file=tree_file, ) cmd += ["--tree", str(motif_tree_file)] motif_scope_id = str(_motif_scope_data.get("scope_id") or "") except Exception as exc: return f"❌ Motif 找不到同 scope 的 phylogeny 树文件: {exc}", manifest else: protein_file, member_error = resolve_member_protein_for_stage_route( ws=ws, final_dir=final_dir, in_dir=in_dir, prefix=prefix, analysis_route=analysis_route, member_set_id=member_set_id, ) if member_error: return member_error, manifest if protein_file: cmd += ["--fasta", str(protein_file)] subfamily_file = None if should_disable_cli_subfamily_auto(analysis_route, subfamily_label_source): cmd += ["--subfamily", "NONE"] else: subfamily_file = resolve_subfamily_for_plotting(final_dir, in_dir, prefix, motif_scope_id, subfamily_label_source) if subfamily_file: cmd += ["--subfamily", str(subfamily_file)] motif_scope_data_for_manifest = _motif_scope_data if motif_scope_id else {} if motif_scope_id: motif_manifest = final_dir / f"{stage_output_prefix('motif', prefix, motif_scope_data_for_manifest, analysis_route, member_set_id)}.motif.manifest.json" code = run_cmd_logged(cmd, log_file, cwd=Path(".")) motif_manifest = first_existing_stage_manifest( final_dir, "motif", prefix, motif_scope_data_for_manifest, analysis_route, member_set_id ) or motif_manifest if code == 0 and motif_manifest.exists(): return f"Motif complete. Log: {log_file}", motif_manifest if motif_manifest.exists(): return f"Motif failed. Log: {log_file}", motif_manifest return f"Motif failed. Log: {log_file}", manifest if stage == "pep": # PEP 阶段 - 蛋白特征分析 (技能化版本) # 输入:scope模式优先复用同一scope的phylogeny蛋白集合;非scope模式沿用identify输出 pep_scope_path = None pep_scope_data = None if scope_file: pep_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not pep_scope_path.exists(): return f"❌ PEP scope 文件不存在: {scope_file}", manifest try: protein_file, pep_scope_data = resolve_scoped_protein_fasta_from_phylogeny(ws, final_dir, prefix, pep_scope_path, in_dir=in_dir) except Exception as exc: return f"❌ PEP stage cannot resolve scoped protein FASTA: {exc}", manifest else: protein_file = final_dir / f"{prefix}.final_members.pep.fa" if not protein_file.exists(): return "❌ PEP stage requires protein FASTA file. Please run Identify stage first.", manifest protein_file = ensure_protein_fasta_without_stop_markers(protein_file, final_dir, prefix) # PEP 的完整发表级流程固定使用 apps/cli/run_pep.py: # 理化性质 + DeepTMHMM + WoLF PSORT。Plant-mPLoc代码保留但默认关闭;旧 skills/pep 已移入备份,避免误走轻量实现。 cli_script = Path("apps/cli/run_pep.py") if not cli_script.exists(): return "❌ PEP script not found: apps/cli/run_pep.py", manifest script = cli_script use_skill = False cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--fasta", str(protein_file), "--cpu", str(cpu), "--log-file", str(log_file), ] if pep_scope_path: cmd += ["--scope-file", str(pep_scope_path)] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 检查输出结果 pep_manifest = final_dir / f"{prefix}.pep.manifest.json" if code == 0 and pep_scope_path and pep_scope_data: annotate_pep_manifest_with_scope(pep_manifest, prefix, protein_file, pep_scope_path, pep_scope_data) if code == 0 and pep_manifest.exists(): if pep_scope_path and pep_scope_data: scoped_pep_manifest = final_dir / f"{prefix}.{sanitize_token(str(pep_scope_data.get('scope_id')))}.pep.manifest.json" if scoped_pep_manifest.exists(): pep_manifest = scoped_pep_manifest annotate_pep_manifest_with_scope( pep_manifest, prefix, protein_file, pep_scope_path, pep_scope_data, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) return f"✅ PEP analysis complete. Log: {log_file}", pep_manifest elif code == 0: return f"⚠️ PEP finished but manifest not found. Log: {log_file}", manifest else: return f"❌ PEP failed (exit code: {code}). Log: {log_file}", manifest if stage == "gene_family_report": expected_stages = kwargs.get("expected_stages") or kwargs.get("expectedStages") or [] if isinstance(expected_stages, str): expected_stages = [value.strip() for value in expected_stages.split(",") if value.strip()] else: expected_stages = [str(value).strip() for value in expected_stages if str(value).strip()] report_action = str(kwargs.get("report_action") or kwargs.get("reportAction") or "generate").strip() if report_action == "generate" and not expected_stages: report_stage_order = ( "identify", "phylogeny", "reference_classification", "motif", "domain", "structure", "combo", "promoter", "tfbs", "synteny", "karyotype", "interspecies_synteny", "pep", ) recovered_stages = set() for candidate in final_dir.glob("*.manifest.json"): try: payload = json.loads(candidate.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue candidate_stage = str(payload.get("stage") or "").strip() candidate_prefix = str( payload.get("prefix") or (payload.get("parameters") or {}).get("prefix") or "" ).strip() if ( candidate_stage in report_stage_order and str(payload.get("status") or "") == "success" and candidate_prefix == prefix ): recovered_stages.add(candidate_stage) expected_stages = [stage_id for stage_id in report_stage_order if stage_id in recovered_stages] if report_action == "generate" and not expected_stages: return "❌ 基因家族结果报告生成需要非空 expected_stages,且当前前缀没有可恢复的成功 manifest", manifest stage_statuses = kwargs.get("stage_statuses") or kwargs.get("stageStatuses") or {} if isinstance(stage_statuses, str): try: stage_statuses = json.loads(stage_statuses) except json.JSONDecodeError: return "❌ 基因家族结果报告的 stage_statuses 不是合法 JSON", manifest if not isinstance(stage_statuses, dict): return "❌ 基因家族结果报告的 stage_statuses 必须是对象", manifest if not stage_statuses: stage_statuses = {stage_id: "success" for stage_id in expected_stages} report_use_model_raw = kwargs.get("report_use_model", kwargs.get("reportUseModel", True)) rnaseq_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip() cmd = [ sys.executable, "apps/cli/run_gene_family_report.py", "--workspace", str(ws), "--prefix", prefix, "--job-id", job.job_id, "--expected-stages", ",".join(expected_stages), "--stage-statuses-json", json.dumps(stage_statuses, ensure_ascii=False, separators=(",", ":")), "--action", report_action, ] for flag, value in ( ("--scope-id", scope_id), ("--analysis-route", analysis_route), ("--member-set-id", member_set_id), ("--rnaseq-scope-id", rnaseq_scope_id), ): if value: cmd += [flag, value] if not _truthy(report_use_model_raw): cmd.append("--no-model") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) report_manifests = sorted( final_dir.glob(f"{prefix}*.gene_family_report.manifest.json"), key=lambda path: path.stat().st_mtime, ) report_manifest = report_manifests[-1] if report_manifests else manifest if code != 0: return f"❌ 基因家族结果报告生成失败,日志:{log_file}", report_manifest return f"✅ 基因家族结果报告已生成。日志:{log_file}", report_manifest if stage == "summary": summary_manifest_seed = ( ws / "final" / f"{prefix}.identify.manifest.json" if (ws / "final" / f"{prefix}.identify.manifest.json").exists() else find_latest_manifest(ws) ) cmd = [sys.executable, "apps/cli/run_summary.py", "--workspace", str(ws), "--prefix", prefix] if summary_manifest_seed and summary_manifest_seed.exists(): cmd += ["--manifest", str(summary_manifest_seed)] summary_manifest = ws / "final" / f"{prefix}.summary.manifest.json" if scope_file: summary_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not summary_scope_path.exists(): return f"❌ Summary scope 文件不存在: {scope_file}", manifest try: summary_scope_data = read_scope(summary_scope_path) summary_scope_id = str(summary_scope_data.get("scope_id") or "") if summary_scope_id: summary_manifest = ws / "final" / f"{prefix}.{sanitize_token(summary_scope_id)}.summary.manifest.json" except Exception as exc: return f"❌ Summary scope 文件无法读取: {scope_file}; {exc}", manifest cmd += ["--scope-file", str(summary_scope_path)] code = run_cmd_logged(cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ Summary 失败,日志:{log_file}", summary_manifest if summary_manifest.exists() else manifest return f"✅ Summary 完成。日志:{log_file}", summary_manifest if summary_manifest.exists() else manifest # ========== TFBS Stage (MEME/FIMO) ========== if stage == "tfbs": tfbs_scope_path = None tfbs_scope_data = {} if scope_file: tfbs_scope_path = Path(scope_file).expanduser() if not tfbs_scope_path.exists(): for candidate in (in_dir / tfbs_scope_path, ws / tfbs_scope_path, Path(scope_file)): if candidate.exists(): tfbs_scope_path = candidate break if not tfbs_scope_path.exists(): return f"❌ TFBS scope 文件不存在: {scope_file}", manifest try: tfbs_scope_data = read_scope(tfbs_scope_path) except Exception as exc: return f"❌ TFBS scope 文件无法读取: {scope_file}; {exc}", manifest script = Path("apps/cli/run_tfbs.py") if not script.exists(): return "❌ TFBS script not found: apps/cli/run_tfbs.py", manifest def _tfbs_int(value, default, min_value=1): try: return max(min_value, int(value)) except (TypeError, ValueError): return default def _tfbs_float(value, default): try: return float(value) except (TypeError, ValueError): return default motif_mode = str(kwargs.get("tfbsMotifDbMode") or kwargs.get("tfbs_motif_db_mode") or tfbs_motif_db_mode or "auto").strip().lower() if motif_mode not in {"auto", "planttfdb", "jaspar", "both"}: motif_mode = "auto" background_mode = str(kwargs.get("tfbsBackgroundMode") or kwargs.get("tfbs_background_mode") or tfbs_background_mode or "none").strip().lower() if background_mode not in {"all_annotated_promoters", "family_promoters", "none"}: background_mode = "none" max_rank = str(kwargs.get("tfbsMaxNearestRank") or kwargs.get("tfbs_max_nearest_rank") or tfbs_max_nearest_rank or "order").strip().lower() upstream_len = _tfbs_int(kwargs.get("tfbsUpstreamLength") or kwargs.get("tfbs_upstream_length") or tfbs_upstream_length, 2000) top_motifs = _tfbs_int(kwargs.get("tfbsTopMotifs") or kwargs.get("tfbs_top_motifs") or tfbs_top_motifs, 30) pvalue = _tfbs_float(kwargs.get("tfbsFimoPvalue") or kwargs.get("tfbs_fimo_pvalue") or tfbs_fimo_pvalue, 1e-4) qvalue = _tfbs_float(kwargs.get("tfbsFimoQvalue") or kwargs.get("tfbs_fimo_qvalue") or tfbs_fimo_qvalue, 0.05) scan_both = kwargs.get("tfbsScanBothStrands") if scan_both is None: scan_both = kwargs.get("tfbs_scan_both_strands", tfbs_scan_both_strands) scan_both = bool(scan_both) cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--upstream-length", str(upstream_len), "--motif-db-mode", motif_mode, "--fimo-pvalue", str(pvalue), "--fimo-qvalue", str(qvalue), "--background-mode", background_mode, "--max-nearest-rank", max_rank, "--top-motifs", str(top_motifs), "--analysis-route", analysis_route, "--member-set-id", member_set_id, ] if not scan_both: cmd.append("--no-scan-both-strands") if tfbs_scope_path: cmd.extend(["--scope-file", str(tfbs_scope_path)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) tfbs_scope_id = str(tfbs_scope_data.get("scope_id") or "") if tfbs_scope_data else "" tfbs_manifest = ws / "final" / f"{prefix}.tfbs.manifest.json" if tfbs_scope_id: scoped_tfbs_manifest = ws / "final" / f"{prefix}.{sanitize_token(tfbs_scope_id)}.tfbs.manifest.json" if scoped_tfbs_manifest.exists(): tfbs_manifest = scoped_tfbs_manifest if code == 0 and tfbs_manifest.exists(): if tfbs_scope_path and tfbs_scope_data: annotate_stage_manifest_with_scope( tfbs_manifest, prefix, "tfbs", tfbs_scope_path, tfbs_scope_data, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) return f"✅ TFBS prediction complete. Log: {log_file}", tfbs_manifest if code == 0: return f"⚠️ TFBS finished but manifest not found. Log: {log_file}", manifest return f"❌ TFBS failed (exit code: {code}). Log: {log_file}", tfbs_manifest if tfbs_manifest.exists() else manifest # ========== Promoter Stage (PlantCARE) ========== if stage == "promoter": promoter_scope_path = None promoter_scope_data = {} if scope_file: promoter_scope_path = Path(scope_file).expanduser() if not promoter_scope_path.exists(): for candidate in (in_dir / promoter_scope_path, ws / promoter_scope_path, Path(scope_file)): if candidate.exists(): promoter_scope_path = candidate break if not promoter_scope_path.exists(): return f"❌ Promoter scope 文件不存在: {scope_file}", manifest try: promoter_scope_data = read_scope(promoter_scope_path) except Exception as exc: return f"❌ Promoter scope 文件无法读取: {scope_file}; {exc}", manifest else: # Promoter 阶段需要 GFF3 和基因组文件;无 scope 时保持 legacy 自动检测。 gff3_files = list(in_dir.glob("*.gff3")) + list(in_dir.glob("*.gff")) genome_files = [] for p in in_dir.iterdir(): if p.is_file(): n = p.name.lower() if n.endswith(('.fasta', '.fa')) and 'genome' in n: genome_files.append(p) if not gff3_files: return "❌ Promoter stage requires GFF3 file. Please upload genome annotation (GFF3).", manifest if not genome_files: # 尝试排除蛋白和pep文件 for p in in_dir.iterdir(): if p.is_file() and p.suffix.lower() in ('.fasta', '.fa'): n = p.name.lower() if 'pep' not in n and 'protein' not in n and 'cds' not in n: if 'ath' not in n and 'arabidopsis' not in n: genome_files.append(p) if not genome_files: return "❌ Promoter stage requires genome FASTA file. Please upload genome sequence.", manifest gff3_file = gff3_files[0] genome_file = genome_files[0] # 查找树文件(用于可视化)。Promoter 图是 tree + cis-element # 组合图,scope 模式下必须等同 scope phylogeny 可用后再执行。 tree_file = ws / "final" / f"{prefix}.tree.nwk" if promoter_scope_path: try: tree_file, _promoter_scope_data = resolve_scoped_tree_from_phylogeny( ws, final_dir, prefix, promoter_scope_path ) except Exception as exc: return ( "❌ Promoter requires the same-scope phylogeny tree for visualization. " f"Run phylogeny first or fix the scoped phylogeny manifest. Detail: {exc}" ), manifest elif not tree_file.exists(): return ( "❌ Promoter requires phylogeny tree for visualization. " f"Missing: {tree_file}. Run phylogeny first." ), manifest # 查找亚家族文件(可选) promoter_scope_id = str(promoter_scope_data.get("scope_id") or "") if promoter_scope_data else "" subfamily_file = resolve_subfamily_for_plotting(ws / "final", in_dir, prefix, promoter_scope_id, subfamily_label_source) script = Path("apps/cli/run_promoter.py") if not script.exists(): return "❌ Promoter script not found: apps/cli/run_promoter.py", manifest cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--log-file", str(log_file), "--force", # 强制重新提交,不使用缓存结果 ] if promoter_scope_path: cmd.extend(["--scope-file", str(promoter_scope_path)]) else: cmd.extend(["--gff3", str(gff3_file), "--genome", str(genome_file)]) if tree_file.exists(): cmd.extend(["--tree", str(tree_file)]) if subfamily_file.exists(): cmd.extend(["--subfamily", str(subfamily_file)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 检查输出结果 promoter_manifest = ws / "final" / f"{prefix}.promoter.manifest.json" if promoter_scope_path and promoter_scope_data.get("scope_id"): scoped_promoter_manifest = ws / "final" / f"{prefix}.{sanitize_token(str(promoter_scope_data.get('scope_id')))}.promoter.manifest.json" if scoped_promoter_manifest.exists(): promoter_manifest = scoped_promoter_manifest if code == 0 and promoter_manifest.exists(): if promoter_scope_path and promoter_scope_data: annotate_stage_manifest_with_scope( promoter_manifest, prefix, "promoter", promoter_scope_path, promoter_scope_data, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) return f"✅ Promoter analysis complete. Log: {log_file}", promoter_manifest elif code == 0: return f"⚠️ Promoter finished but manifest not found. Log: {log_file}", manifest else: return f"❌ Promoter failed (exit code: {code}). Log: {log_file}", manifest # ========== Synteny 阶段 ========== if stage == "synteny": synteny_scope_path = None if scope_file: synteny_scope_path = Path(scope_file).expanduser() if not synteny_scope_path.exists(): for candidate in (in_dir / synteny_scope_path, ws / synteny_scope_path, Path(scope_file)): if candidate.exists(): synteny_scope_path = candidate break if not synteny_scope_path.exists(): return f"❌ Synteny scope 文件不存在: {scope_file}", manifest else: # Synteny 阶段需要 GFF3 和基因组文件;无 scope 时保持 legacy 自动检测。 gff3_files = list(in_dir.glob("*.gff3")) + list(in_dir.glob("*.gff")) genome_files = [] for p in in_dir.iterdir(): if p.is_file(): n = p.name.lower() if n.endswith(('.fasta', '.fa')) and ('genome' in n or 'chromosome' in n): genome_files.append(p) if not gff3_files: return "❌ Synteny stage requires GFF3 file. Please upload genome annotation (GFF3).", manifest if not genome_files: for p in in_dir.iterdir(): if p.is_file() and p.suffix.lower() in ('.fasta', '.fa'): n = p.name.lower() if 'pep' not in n and 'protein' not in n and 'cds' not in n: genome_files.append(p) if not genome_files: return "❌ Synteny stage requires genome FASTA file. Please upload genome sequence.", manifest gff3_file = gff3_files[0] genome_file = genome_files[0] # 查找基因ID文件(可选) gene_id_files = list(in_dir.glob("*geneID*")) gene_id_file = gene_id_files[0] if gene_id_files else None script = Path("apps/cli/run_synteny.py") if not script.exists(): return "❌ Synteny script not found: apps/cli/run_synteny.py", manifest if synteny_scope_path: try: synteny_scope_data_for_split = read_scope(synteny_scope_path) except Exception as exc: return f"❌ Synteny scope 文件无法读取: {scope_file}; {exc}", manifest synteny_targets = [str(x) for x in synteny_scope_data_for_split.get("target_species", []) if str(x)] if len(synteny_targets) > 1: scope_dir = ws / "gene_family_scopes" scope_dir.mkdir(parents=True, exist_ok=True) runs = [] success_count = 0 for target in synteny_targets: child_scope = dict(synteny_scope_data_for_split) child_scope["target_species"] = [target] child_scope["selected_species"] = [target] family = str(child_scope.get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix)) refs = [str(x) for x in child_scope.get("reference_species", []) if str(x)] comps = [str(x) for x in child_scope.get("comparative_species", []) if str(x)] scope_parts = [f"{family}.target-{target}"] if refs: scope_parts.append("ref-" + "-".join(refs)) if comps: scope_parts.append("comp-" + "-".join(comps)) scope_parts.append(str(child_scope.get("scope_mode") or "target_only")) child_scope["scope_id"] = ".".join(scope_parts) child_scope_path = scope_dir / f"{prefix}.{sanitize_token(child_scope['scope_id'])}.scope.json" child_scope_path.write_text(json.dumps(child_scope, ensure_ascii=False, indent=2), encoding="utf-8") child_log = log_file.with_name(f"{log_file.stem}_{sanitize_token(target)}{log_file.suffix}") child_cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--log-file", str(child_log), "--scope-file", str(child_scope_path), ] print(f"[CMD] {' '.join(child_cmd)}") child_code = run_cmd_logged(child_cmd, child_log, cwd=Path(".")) child_manifest = ws / "final" / f"{prefix}.{sanitize_token(child_scope['scope_id'])}.synteny.manifest.json" if child_code == 0: success_count += 1 runs.append({ "target_species": target, "scope_file": str(child_scope_path), "scope_id": child_scope["scope_id"], "returncode": child_code, "status": "success" if child_code == 0 else "failed", "log": str(child_log), "manifest": str(child_manifest) if child_manifest.exists() else "", }) original_scope_id = str(synteny_scope_data_for_split.get("scope_id") or "") aggregate_manifest = ws / "final" / f"{prefix}.{sanitize_token(original_scope_id)}.synteny.manifest.json" if original_scope_id else ws / "final" / f"{prefix}.synteny.manifest.json" aggregate_status = "success" if success_count == len(runs) else ("partial_success" if success_count else "failed") aggregate_data = { "manifest_version": "1.0", "stage": "synteny", "status": aggregate_status, "prefix": prefix, "job_id": job.job_id, "workspace": str(ws), "workspace_root": str(ws), "created_at": datetime.now().isoformat(), "scope_file": str(synteny_scope_path), "scope_id": original_scope_id, "scope_mode": synteny_scope_data_for_split.get("scope_mode", ""), "target_species": synteny_scope_data_for_split.get("target_species", []), "reference_species": synteny_scope_data_for_split.get("reference_species", []), "comparative_species": synteny_scope_data_for_split.get("comparative_species", []), "selected_species": synteny_scope_data_for_split.get("selected_species", []), "family": synteny_scope_data_for_split.get("family", ""), "parameters": { "prefix": prefix, "scope_file": str(synteny_scope_path), "split_multi_target": True, }, "metrics": { "species_runs": len(runs), "success_count": success_count, "failure_count": len(runs) - success_count, }, "runs": runs, "artifacts": { f"{run['target_species']}_manifest": { "artifact": "synteny_manifest", "species": run["target_species"], "path": str(Path(run["manifest"]).relative_to(ws)) if run["manifest"] and Path(run["manifest"]).exists() else "", "full_path": run["manifest"], "type": "json", } for run in runs if run.get("manifest") }, } aggregate_manifest.write_text(json.dumps(aggregate_data, ensure_ascii=False, indent=2), encoding="utf-8") if success_count == len(runs): return f"✅ Synteny analysis complete for {success_count}/{len(runs)} target species. Log: {log_file}", aggregate_manifest if success_count: return f"⚠️ Synteny partial success: {success_count}/{len(runs)} target species. See child logs.", aggregate_manifest return f"❌ Synteny failed for all {len(runs)} target species. See child logs.", aggregate_manifest cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--log-file", str(log_file), ] if synteny_scope_path: cmd.extend(["--scope-file", str(synteny_scope_path)]) else: cmd.extend(["--gff3", str(gff3_file), "--genome", str(genome_file)]) if gene_id_file: cmd.extend(["--gene-id-file", str(gene_id_file)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 检查输出结果 synteny_manifest = ws / "final" / f"{prefix}.synteny.manifest.json" if synteny_scope_path: try: synteny_scope_data = read_scope(synteny_scope_path) synteny_scope_id = str(synteny_scope_data.get("scope_id") or "") if synteny_scope_id: scoped_synteny_manifest = ws / "final" / f"{prefix}.{sanitize_token(synteny_scope_id)}.synteny.manifest.json" if scoped_synteny_manifest.exists(): synteny_manifest = scoped_synteny_manifest except Exception: pass if code == 0 and synteny_manifest.exists(): if synteny_scope_path: try: synteny_scope_data_for_annotate = read_scope(synteny_scope_path) annotate_stage_manifest_with_scope( synteny_manifest, prefix, "synteny", synteny_scope_path, synteny_scope_data_for_annotate, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) except Exception as exc: print(f"[WARN] Failed to annotate synteny manifest with scope identity: {exc}") return f"✅ Synteny analysis complete. Log: {log_file}", synteny_manifest elif code == 0: return f"⚠️ Synteny finished but manifest not found. Log: {log_file}", manifest else: return f"❌ Synteny failed (exit code: {code}). Log: {log_file}", synteny_manifest if synteny_manifest.exists() else manifest # ========== Karyotype 阶段 ========== if stage == "karyotype": karyotype_scope_path = None karyotype_scope_data = {} if scope_file: karyotype_scope_path = Path(scope_file).expanduser() if not karyotype_scope_path.exists(): for candidate in (in_dir / karyotype_scope_path, ws / karyotype_scope_path, Path(scope_file)): if candidate.exists(): karyotype_scope_path = candidate break if not karyotype_scope_path.exists(): return f"❌ Karyotype scope 文件不存在: {scope_file}", manifest try: karyotype_scope_data = read_scope(karyotype_scope_path) except Exception as exc: return f"❌ Karyotype scope 文件无法读取: {scope_file}; {exc}", manifest else: raw_targets = target_species or kwargs.get("targetSpecies") or kwargs.get("target_species") or [] if isinstance(raw_targets, str): targets = [x.strip() for x in raw_targets.replace(",", ",").split(",") if x.strip()] elif isinstance(raw_targets, (list, tuple, set)): targets = [str(x).strip() for x in raw_targets if str(x).strip()] else: targets = [] if targets: karyotype_scope_data = { "family": kwargs.get("geneFamily") or kwargs.get("gene_family") or (prefix.split(".")[-1] if "." in prefix else prefix), "scope_mode": kwargs.get("scopeMode") or kwargs.get("scope_mode") or "target_only", "target_species": targets, } identify_manifest_file = ws / "final" / f"{prefix}.identify.manifest.json" gene_id_file = resolve_single_target_gene_id_file(ws, in_dir, final_dir, prefix.split(".", 1)[0], prefix) if not identify_manifest_file.exists(): target_species_for_scope = [str(x).strip() for x in (karyotype_scope_data.get("target_species") or []) if str(x).strip()] if len(target_species_for_scope) == 1: target = target_species_for_scope[0] gene_id_file = resolve_single_target_gene_id_file(ws, in_dir, final_dir, target, prefix) family = str(karyotype_scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else prefix)) matching_manifest = find_identify_manifest_for_target(final_dir, prefix, target, family) if matching_manifest: identify_manifest_file = matching_manifest if not identify_manifest_file.exists(): scope_hint = "" if karyotype_scope_data.get("scope_id") or karyotype_scope_data.get("target_species"): scope_hint = f" Scope: {karyotype_scope_data.get('scope_id') or karyotype_scope_data.get('target_species')}" return f"Karyotype stage requires identify results for {prefix}.{scope_hint} Please run identify first with the same target scope.", manifest if not gene_id_file: return f"Karyotype stage requires identify gene ID file for {prefix}. Please run identify first with the same target scope.", manifest script = Path("apps/cli/run_karyotype.py") if not script.exists(): return "Karyotype script not found: apps/cli/run_karyotype.py", manifest cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--log-file", str(log_file), ] if karyotype_scope_path: cmd.extend(["--scope-file", str(karyotype_scope_path)]) if analysis_route: cmd.extend(["--analysis-route", analysis_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) if gene_id_file: cmd.extend(["--gene-id-file", str(gene_id_file)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) karyotype_manifest = ws / "final" / f"{prefix}.karyotype.manifest.json" if karyotype_scope_path: try: karyotype_scope_data = read_scope(karyotype_scope_path) karyotype_scope_id = str(karyotype_scope_data.get("scope_id") or "") if karyotype_scope_id: scoped_karyotype_manifest = ws / "final" / f"{prefix}.{sanitize_token(karyotype_scope_id)}.karyotype.manifest.json" if scoped_karyotype_manifest.exists(): karyotype_manifest = scoped_karyotype_manifest except Exception: pass if code == 0 and karyotype_manifest.exists(): if karyotype_scope_path: try: karyotype_scope_data_for_annotate = read_scope(karyotype_scope_path) annotate_stage_manifest_with_scope( karyotype_manifest, prefix, "karyotype", karyotype_scope_path, karyotype_scope_data_for_annotate, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) except Exception as exc: print(f"[WARN] Failed to annotate karyotype manifest with scope identity: {exc}") return f"Karyotype analysis complete. Log: {log_file}", karyotype_manifest elif code == 0: return f"Karyotype finished but manifest not found. Log: {log_file}", manifest elif karyotype_manifest.exists(): return f"Karyotype failed (exit code: {code}). Log: {log_file}", karyotype_manifest else: return f"Karyotype failed (exit code: {code}). Log: {log_file}", manifest # ========== Interspecies Synteny 阶段 ========== if stage == "interspecies_synteny": interspecies_scope_path = None if scope_file: interspecies_scope_path = Path(scope_file).expanduser() if not interspecies_scope_path.exists(): for candidate in (in_dir / interspecies_scope_path, ws / interspecies_scope_path, Path(scope_file)): if candidate.exists(): interspecies_scope_path = candidate break if not interspecies_scope_path.exists(): return f"❌ Interspecies Synteny scope 文件不存在: {scope_file}", manifest else: # Interspecies Synteny legacy mode requires protein FASTA files in input/final. pep_files = list(in_dir.glob("*.pep.fasta")) + list(in_dir.glob("*pep*.fa")) + list(ws.glob("final/*.final_members.pep.fa")) if not pep_files: return "❌ Interspecies Synteny stage requires protein FASTA files. Please run identify first or upload protein sequences.", manifest script = Path("apps/cli/run_interspecies_synteny.py") if not script.exists(): return "❌ Interspecies Synteny script not found: apps/cli/run_interspecies_synteny.py", manifest cmd = [ sys.executable, str(script), "--input-dir", str(in_dir), "--workspace", str(ws), "--prefix", prefix, "--cpu", str(cpu), "--log-file", str(log_file), ] selected_targets = _species_arg_list(target_species) selected_refs = _species_arg_list(interspecies_ref_species) if selected_targets: cmd += ["--target-species", ",".join(selected_targets)] if selected_refs: cmd += ["--ref-species", ",".join(selected_refs)] cmd += ["--blast-engine", interspecies_blast_engine] cmd += ["--plot-mode", interspecies_plot_mode] if (ws / "final" / f"{prefix}.interspecies_tree.nwk").exists(): cmd += ["--reuse-tree"] if interspecies_scope_path: cmd += ["--scope-file", str(interspecies_scope_path)] if analysis_route: cmd += ["--analysis-route", analysis_route] if member_set_id: cmd += ["--member-set-id", member_set_id] cmd += [ "--tree-subfamily-arc-enabled", "true" if tree_subfamily_arc_enabled else "false", "--tree-subfamily-arc-gap", str(tree_subfamily_arc_gap), "--tree-subfamily-arc-label-gap", str(tree_subfamily_arc_label_gap), "--tree-subfamily-arc-label-size", str(tree_subfamily_arc_label_size or 0.0), ] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 检查输出结果 interspecies_manifest = ws / "final" / f"{prefix}.interspecies_synteny.manifest.json" if interspecies_scope_path: try: interspecies_scope_data = read_scope(interspecies_scope_path) interspecies_scope_id = str(interspecies_scope_data.get("scope_id") or "") if interspecies_scope_id: scoped_interspecies_manifest = ws / "final" / f"{prefix}.{sanitize_token(interspecies_scope_id)}.interspecies_synteny.manifest.json" if scoped_interspecies_manifest.exists(): interspecies_manifest = scoped_interspecies_manifest except Exception: pass if code == 0 and interspecies_manifest.exists(): try: interspecies_manifest_data = json.loads(interspecies_manifest.read_text(encoding="utf-8")) except Exception as exc: return f"Interspecies Synteny finished but manifest is invalid: {exc}. Log: {log_file}", interspecies_manifest interspecies_status = str(interspecies_manifest_data.get("status") or "") if interspecies_status not in {"success", "partial_success"}: detail = interspecies_manifest_data.get("error") or (interspecies_manifest_data.get("parameters") or {}).get("error") or interspecies_status return f"Interspecies Synteny failed: manifest status={interspecies_status}. {detail}. Log: {log_file}", interspecies_manifest if not (interspecies_manifest_data.get("artifacts") or {}): return f"Interspecies Synteny failed: manifest has no artifacts. Log: {log_file}", interspecies_manifest if interspecies_scope_path: try: interspecies_scope_data_for_annotate = read_scope(interspecies_scope_path) annotate_stage_manifest_with_scope( interspecies_manifest, prefix, "interspecies_synteny", interspecies_scope_path, interspecies_scope_data_for_annotate, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) except Exception as exc: print(f"[WARN] Failed to annotate interspecies_synteny manifest with scope identity: {exc}") return f"Interspecies Synteny analysis complete. Log: {log_file}", interspecies_manifest elif code == 0: return f"Interspecies Synteny finished but manifest not found. Log: {log_file}", manifest elif interspecies_manifest.exists(): return f"Interspecies Synteny failed (exit code: {code}). Log: {log_file}", interspecies_manifest else: return f"Interspecies Synteny failed (exit code: {code}). Log: {log_file}", manifest # ========== Expression 阶段 (差异表达分析) ========== if stage == "expression": # Expression 阶段需要:表达量矩阵、基因ID列表、样本信息 # 如果提供了geo_id,优先从GEO下载 expression_file = None sample_file = None expression_scope_path = None expression_scope_data = None expression_target_species = "" if scope_file: expression_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not expression_scope_path.exists(): return f"❌ Expression scope 文件不存在: {scope_file}", manifest try: expression_scope_data = read_scope(expression_scope_path) except Exception as exc: return f"❌ Expression scope 文件无法读取: {scope_file}; {exc}", manifest target_species_for_expression = expression_scope_data.get("target_species", []) if len(target_species_for_expression) == 0: print("[Expression] Empty scope target_species; falling back to non-scoped expression inputs") expression_scope_path = None expression_scope_data = None elif len(target_species_for_expression) != 1: return ( f"❌ Expression 阶段一次只能分析一个目标物种;当前 scope target_species={target_species_for_expression}。" "请在弹窗中只选择一个 target,或分别运行每个目标物种。", manifest, ) else: expression_target_species = str(target_species_for_expression[0]) if geo_id: print(f"[GEO] 开始下载GEO数据: {geo_id}") # 导入下载模块 from apps.cli.run_fetch_expression import download_geo_expression # 创建下载目录 geo_download_dir = in_dir / "geo_download" geo_download_dir.mkdir(parents=True, exist_ok=True) # 下载GEO数据 expr_file, samp_file, msg = download_geo_expression(geo_id, geo_download_dir) if not expr_file: return f"❌ GEO数据下载失败: {msg}", manifest # 移动文件到input目录 target_expr = in_dir / expr_file.name shutil.copy(expr_file, target_expr) expression_file = target_expr if samp_file and samp_file.exists(): target_sample = in_dir / "sample.txt" shutil.copy(samp_file, target_sample) sample_file = target_sample print(f"[GEO] 下载完成: {expression_file.name}") # 检查输入文件。前端“Expression(使用FPKM)”可能显式传入 rnaseqExpressionMatrix; # 先尊重显式选择,再自动搜索,自动搜索时优先使用 rnaseq_quant 生成的 FPKM。 if not expression_file and rnaseq_expression_matrix: explicit_candidates = [Path(rnaseq_expression_matrix)] if not Path(rnaseq_expression_matrix).is_absolute(): explicit_candidates.extend([ in_dir / rnaseq_expression_matrix, final_dir / rnaseq_expression_matrix, ws / rnaseq_expression_matrix, ]) explicit_candidates = [p for p in explicit_candidates if p.exists()] if explicit_candidates: expression_file = explicit_candidates[0] if not expression_file: expression_files = list(in_dir.glob("*.fpkm_matrix.txt")) + list(in_dir.glob("*_fpkm_matrix.txt")) \ + list(in_dir.glob("*.fpkm.txt")) + list(in_dir.glob("*_fpkm.txt")) \ + list(in_dir.glob("*.salmon.tpm_matrix.txt")) + list(in_dir.glob("*.kallisto.tpm_matrix.txt")) \ + list(in_dir.glob("*.rsem.tpm_matrix.txt")) + list(in_dir.glob("*.tpm_matrix.txt")) \ + list(in_dir.glob("*_tpm_matrix.txt")) + list(in_dir.glob("*.tpm.txt")) \ + list(in_dir.glob("*_tpm.txt")) if not expression_files: expression_files = list((ws / "final").glob("*.fpkm_matrix.txt")) + list((ws / "final").glob("*_fpkm_matrix.txt")) \ + list((ws / "final").glob("*.fpkm.txt")) + list((ws / "final").glob("*_fpkm.txt")) \ + list((ws / "final").glob("*.salmon.tpm_matrix.txt")) + list((ws / "final").glob("*.kallisto.tpm_matrix.txt")) \ + list((ws / "final").glob("*.rsem.tpm_matrix.txt")) + list((ws / "final").glob("*.tpm_matrix.txt")) \ + list((ws / "final").glob("*_tpm_matrix.txt")) + list((ws / "final").glob("*.tpm.txt")) \ + list((ws / "final").glob("*_tpm.txt")) if not expression_files: return "❌ Expression stage requires an expression matrix. Prefer *.fpkm_matrix.txt for this FPKM expression plot stage; TPM is accepted only as fallback.", manifest expression_file = expression_files[0] # 查找基因ID文件。scope模式必须使用单个target物种的成员ID,避免多物种合并ID误入表达矩阵。 if expression_scope_data: gene_id_file = resolve_single_target_gene_id_file(ws, in_dir, final_dir, expression_target_species, prefix) if not gene_id_file: return ( f"❌ Expression stage requires {expression_target_species} gene family member IDs. " f"Expected identify/{expression_target_species}/final/{expression_target_species}.final_gfmembers.id " "or an equivalent uploaded file.", manifest, ) else: gene_id_files = ( list(in_dir.glob("*geneID*")) + list(in_dir.glob("*.final_members.id")) + list((ws / "final").glob("*geneID*")) + list((ws / "final").glob("*.final_members.id")) ) if not gene_id_files: return "❌ Expression stage requires gene ID file (*geneID* or *.final_members.id). Please upload or run Identify first.", manifest gene_id_file = gene_id_files[0] # 查找样本信息文件 if not sample_file: sample_files = list(in_dir.glob("sample.txt")) + list((ws / "final").glob("sample.txt")) if not sample_files: return "❌ Expression stage requires sample info file (sample.txt). Please upload.", manifest sample_file = sample_files[0] # 查找R脚本 script = Path("apps/cli/run_expression.py") if not script.exists(): return "❌ Expression script not found: apps/cli/run_expression.py", manifest cmd = [ sys.executable, str(script), "--prefix", prefix, "--workspace", str(ws), "--expression-file", str(expression_file), "--gene-id-file", str(gene_id_file), "--sample-file", str(sample_file), "--id-transform", id_transform, "--log-file", str(log_file), ] if expression_scope_path: cmd += ["--scope-file", str(expression_scope_path)] if analysis_route: cmd += ["--analysis-route", analysis_route] if member_set_id: cmd += ["--member-set-id", member_set_id] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 检查输出结果 expression_manifest = ws / "final" / f"{prefix}.expression.manifest.json" if code == 0 and expression_manifest.exists(): if expression_scope_path and expression_scope_data: try: annotate_stage_manifest_with_scope( expression_manifest, prefix, "expression", expression_scope_path, expression_scope_data, {"analysis_route": analysis_route, "member_set_id": member_set_id} if (analysis_route or member_set_id) else None, ) scoped_expression_manifest = final_dir / f"{prefix}.{sanitize_token(str(expression_scope_data.get('scope_id') or ''))}.expression.manifest.json" if scoped_expression_manifest.exists(): expression_manifest = scoped_expression_manifest except Exception as exc: print(f"[WARN] Failed to annotate expression manifest with scope identity: {exc}") return f"✅ Expression analysis complete. Log: {log_file}", expression_manifest elif code == 0: return f"⚠️ Expression finished but manifest not found. Log: {log_file}", manifest else: return f"❌ Expression failed (exit code: {code}). Log: {log_file}", manifest if stage == "wgs_qc": try: sample_config = _resolve_wgs_sample_config(in_dir, ws, wgs_sample_config) except ValueError as exc: return f"❌ {exc}", manifest wgs_manifest = final_dir / f"{prefix}.wgs_qc.manifest.json" before = wgs_manifest.stat().st_mtime_ns if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_qc", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--wgs-samples", str(sample_config), "--fastp", str(wgs_fastp), "--threads", str(wgs_threads), "--max-jobs", str(wgs_max_jobs), ] command = [sys.executable, "-m", "apps.cli.run_wgs_qc", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--wgs-samples", str(sample_config), "--fastp", str(wgs_fastp), "--threads", str(wgs_threads), "--max-jobs", str(wgs_max_jobs)] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.stat().st_mtime_ns != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} from gfa_core.wgs_samples import load_wgs_samples sample_ids = {sample.sample_id for sample in load_wgs_samples(sample_config)} if _valid_wgs_qc_manifest(data, job_id=job.job_id, prefix=prefix, sample_ids=sample_ids): return f"✅ WGS QC completed. Log: {log_file}", wgs_manifest return f"❌ WGS QC failed or did not publish a fresh successful Manifest. Log: {log_file}", wgs_manifest if stage == "wgs_index": try: reference = _resolve_wgs_reference_fasta(in_dir, wgs_reference_fasta) except ValueError as exc: return f"❌ {exc}", manifest wgs_manifest = final_dir / f"{prefix}.wgs_index.manifest.json" before = wgs_manifest.stat().st_mtime_ns if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_index", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--reference-fasta", str(reference), "--samtools", str(wgs_samtools), "--bwa-mem2", str(wgs_bwa_mem2), "--gatk", str(wgs_gatk), "--threads", str(wgs_threads), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.stat().st_mtime_ns != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} if _valid_wgs_index_manifest(data, job_id=job.job_id, prefix=prefix): return f"✅ WGS reference index completed. Log: {log_file}", wgs_manifest return f"❌ WGS reference index failed or did not publish a fresh successful Manifest. Log: {log_file}", wgs_manifest if stage == "wgs_align": try: qc_manifest = _explicit_wgs_authority(wgs_qc_manifest, ws, prefix, "wgs_qc") index_manifest = _explicit_wgs_authority(wgs_index_manifest, ws, prefix, "wgs_index") qc_header = json.loads(qc_manifest.read_text(encoding="utf-8")) index_header = json.loads(index_manifest.read_text(encoding="utf-8")) expected_samples = str(wgs_sample_set_id or qc_header.get("wgs_sample_set_id") or "") expected_reference = str(wgs_reference_id or index_header.get("wgs_reference_id") or "") if not expected_samples or not expected_reference: raise ValueError("wgs_align requires exact upstream sample/reference identities") if qc_header.get("job_id") != job.job_id or qc_header.get("prefix") != prefix or qc_header.get("stage") != "wgs_qc": raise ValueError("wgs_qc authoritative Manifest identity mismatch") if index_header.get("job_id") != job.job_id or index_header.get("prefix") != prefix or index_header.get("stage") != "wgs_index": raise ValueError("wgs_index authoritative Manifest identity mismatch") resolve_wgs_artifacts(qc_manifest, "wgs_qc", ("clean_reads_r1", "clean_reads_r2"), expected_samples) resolve_wgs_artifacts(index_manifest, "wgs_index", ("reference_fasta", "reference_fai", "reference_dict", "bwa_mem2_index_component"), expected_reference_id=expected_reference) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = final_dir / f"{prefix}.wgs_align.manifest.json" before = wgs_manifest.stat().st_mtime_ns if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_align", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--qc-manifest", str(qc_manifest), "--index-manifest", str(index_manifest), "--samtools", str(wgs_samtools), "--bwa-mem2", str(wgs_bwa_mem2), "--threads", str(wgs_threads), "--max-jobs", str(wgs_max_jobs), "--platform", str(wgs_platform), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.stat().st_mtime_ns != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} if (_valid_wgs_align_manifest(data, job_id=job.job_id, prefix=prefix) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference): try: resolve_wgs_artifacts( wgs_manifest, "wgs_align", ("sorted_bam", "sorted_bam_index", "align_log"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, ) except ValueError: pass else: return f"✅ WGS alignment completed. Log: {log_file}", wgs_manifest return f"❌ WGS alignment failed or did not publish a fresh complete Manifest. Log: {log_file}", wgs_manifest if stage == "wgs_markdup": try: align_manifest = _explicit_wgs_authority( wgs_align_manifest, ws, prefix, "wgs_align" ) align_header = json.loads(align_manifest.read_text(encoding="utf-8")) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_align_analysis = str(wgs_analysis_id or "") if not all((expected_samples, expected_reference, expected_align_analysis)): raise ValueError( "wgs_markdup requires exact upstream sample, reference and analysis identities" ) if ( align_header.get("job_id") != job.job_id or align_header.get("prefix") != prefix or align_header.get("stage") != "wgs_align" or align_header.get("wgs_sample_set_id") != expected_samples or align_header.get("wgs_reference_id") != expected_reference or align_header.get("wgs_analysis_id") != expected_align_analysis ): raise ValueError("wgs_align authoritative Manifest identity mismatch") resolve_wgs_artifacts( align_manifest, "wgs_align", ("sorted_bam", "sorted_bam_index"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=expected_align_analysis, ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = final_dir / f"{prefix}.wgs_markdup.manifest.json" before = wgs_manifest.read_bytes() if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_markdup", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--align-manifest", str(align_manifest), "--gatk", str(wgs_gatk), "--samtools", str(wgs_samtools), "--max-jobs", str(wgs_max_jobs), "--remove-duplicates=true", ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.read_bytes() != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} if ( _valid_wgs_markdup_manifest(data, job_id=job.job_id, prefix=prefix) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_id") == expected_align_analysis ): try: resolved = resolve_wgs_artifacts( wgs_manifest, "wgs_markdup", ("markdup_bam", "markdup_bam_index", "markdup_metrics", "markdup_log"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=str(data.get("wgs_analysis_id") or ""), ) resolved_samples = { item.sample_id for values in resolved.values() for item in values } if resolved_samples != set(data["metrics"]["input_sample_ids"]): raise ValueError("wgs_markdup resolved sample set mismatch") except ValueError: pass else: return f"✅ WGS duplicate removal completed. Log: {log_file}", wgs_manifest return f"❌ WGS duplicate removal failed or did not publish a fresh complete Manifest. Log: {log_file}", wgs_manifest if stage == "wgs_bam_qc": try: markdup_manifest = _explicit_wgs_authority( wgs_markdup_manifest, ws, prefix, "wgs_markdup" ) index_manifest = _explicit_wgs_authority( wgs_index_manifest, ws, prefix, "wgs_index" ) markdup_header = json.loads(markdup_manifest.read_text(encoding="utf-8")) index_header = json.loads(index_manifest.read_text(encoding="utf-8")) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_markdup_analysis = str(wgs_analysis_id or "") expected_index_analysis = str(wgs_index_analysis_id or "") if not all(( expected_samples, expected_reference, expected_markdup_analysis, expected_index_analysis, )): raise ValueError( "wgs_bam_qc requires exact upstream sample, reference and both analysis identities" ) if ( markdup_header.get("job_id") != job.job_id or markdup_header.get("prefix") != prefix or markdup_header.get("stage") != "wgs_markdup" or markdup_header.get("wgs_sample_set_id") != expected_samples or markdup_header.get("wgs_reference_id") != expected_reference or markdup_header.get("wgs_analysis_id") != expected_markdup_analysis ): raise ValueError("wgs_markdup authoritative Manifest identity mismatch") if ( index_header.get("job_id") != job.job_id or index_header.get("prefix") != prefix or index_header.get("stage") != "wgs_index" or index_header.get("wgs_reference_id") != expected_reference or index_header.get("wgs_analysis_id") != expected_index_analysis ): raise ValueError("wgs_index authoritative Manifest identity mismatch") resolve_wgs_artifacts( markdup_manifest, "wgs_markdup", ("markdup_bam", "markdup_bam_index"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=expected_markdup_analysis, ) resolve_wgs_artifacts( index_manifest, "wgs_index", ("reference_fai",), expected_reference_id=expected_reference, expected_analysis_id=expected_index_analysis, ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = final_dir / f"{prefix}.wgs_bam_qc.manifest.json" before = wgs_manifest.read_bytes() if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_bam_qc", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--markdup-manifest", str(markdup_manifest), "--index-manifest", str(index_manifest), "--samtools", str(wgs_samtools), "--coverage-threshold", str(wgs_coverage_threshold), "--threads", str(wgs_threads), "--max-jobs", str(wgs_max_jobs), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.read_bytes() != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} if ( _valid_wgs_bam_qc_manifest(data, job_id=job.job_id, prefix=prefix) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == { "wgs_markdup": expected_markdup_analysis, "wgs_index": expected_index_analysis, } ): try: resolved = resolve_wgs_artifacts( wgs_manifest, "wgs_bam_qc", ("bam_flagstat", "bam_depth_summary", "bam_qc_summary", "bam_qc_report"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=str(data.get("wgs_analysis_id") or ""), ) resolved_samples = { item.sample_id for role in ("bam_flagstat", "bam_depth_summary") for item in resolved[role] } if resolved_samples != set(data["metrics"]["input_sample_ids"]): raise ValueError("wgs_bam_qc resolved sample set mismatch") except ValueError: pass else: return f"✅ WGS BAM QC completed. Log: {log_file}", wgs_manifest return f"❌ WGS BAM QC failed or did not publish a fresh complete Manifest. Log: {log_file}", wgs_manifest if stage == "wgs_haplotype_calling": try: markdup_manifest = _explicit_wgs_authority( wgs_markdup_manifest, ws, prefix, "wgs_markdup" ) bam_qc_manifest = _explicit_wgs_authority( wgs_bam_qc_manifest, ws, prefix, "wgs_bam_qc" ) index_manifest = _explicit_wgs_authority( wgs_index_manifest, ws, prefix, "wgs_index" ) markdup_header = json.loads(markdup_manifest.read_text(encoding="utf-8")) bam_qc_header = json.loads(bam_qc_manifest.read_text(encoding="utf-8")) index_header = json.loads(index_manifest.read_text(encoding="utf-8")) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_upstream = { "wgs_markdup": str(wgs_markdup_analysis_id or ""), "wgs_bam_qc": str(wgs_bam_qc_analysis_id or ""), "wgs_index": str(wgs_index_analysis_id or ""), } if ( not expected_samples or not expected_reference or not all(expected_upstream.values()) ): raise ValueError( "wgs_haplotype_calling requires exact sample, reference " "and three upstream analysis identities" ) expected_headers = ( ( markdup_header, "wgs_markdup", expected_upstream["wgs_markdup"], ), ( bam_qc_header, "wgs_bam_qc", expected_upstream["wgs_bam_qc"], ), ( index_header, "wgs_index", expected_upstream["wgs_index"], ), ) for header, producer, analysis_id in expected_headers: if ( header.get("job_id") != job.job_id or header.get("prefix") != prefix or header.get("stage") != producer or header.get("wgs_reference_id") != expected_reference or header.get("wgs_analysis_id") != analysis_id or ( producer != "wgs_index" and header.get("wgs_sample_set_id") != expected_samples ) ): raise ValueError( f"{producer} authoritative Manifest identity mismatch" ) if bam_qc_header.get("upstream_wgs_analysis_ids") != { "wgs_markdup": expected_upstream["wgs_markdup"], "wgs_index": expected_upstream["wgs_index"], }: raise ValueError("wgs_bam_qc upstream lineage mismatch") from gfa_core.wgs_upstream_manifest import ( validate_wgs_bam_qc_completion, ) resolved_markdup = resolve_wgs_artifacts( markdup_manifest, "wgs_markdup", ("markdup_bam", "markdup_bam_index"), expected_samples, expected_reference, expected_upstream["wgs_markdup"], ) validate_wgs_bam_qc_completion( bam_qc_header, [ item.sample_id for item in resolved_markdup["markdup_bam"] ], ) resolve_wgs_artifacts( bam_qc_manifest, "wgs_bam_qc", ( "bam_flagstat", "bam_depth_summary", "bam_qc_summary", "bam_qc_report", ), expected_samples, expected_reference, expected_upstream["wgs_bam_qc"], ) resolve_wgs_artifacts( index_manifest, "wgs_index", ("reference_fasta",), expected_reference_id=expected_reference, expected_analysis_id=expected_upstream["wgs_index"], ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_haplotype_calling.manifest.json" ) before = wgs_manifest.read_bytes() if wgs_manifest.exists() else None command = [ sys.executable, "-m", "apps.cli.run_wgs_haplotype_calling", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--markdup-manifest", str(markdup_manifest), "--bam-qc-manifest", str(bam_qc_manifest), "--index-manifest", str(index_manifest), "--gatk", str(wgs_gatk), "--native-pair-hmm-threads", str(wgs_native_pair_hmm_threads), "--max-jobs", str(wgs_max_jobs), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = wgs_manifest.exists() and wgs_manifest.read_bytes() != before if code == 0 and fresh: try: data = json.loads(wgs_manifest.read_text(encoding="utf-8")) except Exception: data = {} if ( _valid_wgs_haplotype_calling_manifest( data, job_id=job.job_id, prefix=prefix ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == expected_upstream ): try: resolved = resolve_wgs_artifacts( wgs_manifest, "wgs_haplotype_calling", ("gvcf", "gvcf_index", "haplotype_log"), expected_samples, expected_reference, str(data.get("wgs_analysis_id") or ""), ) resolved_samples = { item.sample_id for values in resolved.values() for item in values } if resolved_samples != set( data["metrics"]["input_sample_ids"] ): raise ValueError( "wgs_haplotype_calling resolved sample set mismatch" ) except ValueError: pass else: return ( f"✅ WGS haplotype calling completed. Log: {log_file}", wgs_manifest, ) return ( "❌ WGS haplotype calling failed or did not publish a fresh " f"complete Manifest. Log: {log_file}", wgs_manifest, ) if stage == "wgs_gvcf_combine": try: haplotype_manifest = _explicit_wgs_authority( wgs_haplotype_manifest, ws, prefix, "wgs_haplotype_calling", ) index_manifest = _explicit_wgs_authority( wgs_index_manifest, ws, prefix, "wgs_index" ) haplotype_header = json.loads( haplotype_manifest.read_text(encoding="utf-8") ) index_header = json.loads( index_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_upstream = { "wgs_haplotype_calling": str( wgs_haplotype_analysis_id or "" ), "wgs_index": str(wgs_index_analysis_id or ""), } if ( not expected_samples or not expected_reference or not all(expected_upstream.values()) ): raise ValueError( "wgs_gvcf_combine requires exact sample, reference " "and two upstream analysis identities" ) if ( haplotype_header.get("job_id") != job.job_id or haplotype_header.get("prefix") != prefix or haplotype_header.get("stage") != "wgs_haplotype_calling" or haplotype_header.get("wgs_sample_set_id") != expected_samples or haplotype_header.get("wgs_reference_id") != expected_reference or haplotype_header.get("wgs_analysis_id") != expected_upstream["wgs_haplotype_calling"] ): raise ValueError( "wgs_haplotype_calling authoritative Manifest " "identity mismatch" ) if ( index_header.get("job_id") != job.job_id or index_header.get("prefix") != prefix or index_header.get("stage") != "wgs_index" or index_header.get("wgs_reference_id") != expected_reference or index_header.get("wgs_analysis_id") != expected_upstream["wgs_index"] ): raise ValueError( "wgs_index authoritative Manifest identity mismatch" ) haplotype_upstream = haplotype_header.get( "upstream_wgs_analysis_ids" ) if ( not isinstance(haplotype_upstream, dict) or haplotype_upstream.get("wgs_index") != expected_upstream["wgs_index"] ): raise ValueError( "wgs_haplotype_calling index lineage mismatch" ) resolved_haplotype = resolve_wgs_artifacts( haplotype_manifest, "wgs_haplotype_calling", ("gvcf", "gvcf_index"), expected_samples, expected_reference, expected_upstream["wgs_haplotype_calling"], ) resolved_samples = { item.sample_id for item in resolved_haplotype["gvcf"] } metrics = haplotype_header.get("metrics") or {} if ( sorted(resolved_samples) != metrics.get("input_sample_ids") or metrics.get("input_sample_ids") != metrics.get("output_sample_ids") ): raise ValueError( "wgs_haplotype_calling sample set mismatch" ) resolve_wgs_artifacts( index_manifest, "wgs_index", ("reference_fasta", "reference_fai", "reference_dict"), expected_reference_id=expected_reference, expected_analysis_id=expected_upstream["wgs_index"], ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_gvcf_combine.manifest.json" ) before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) command = [ sys.executable, "-m", "apps.cli.run_wgs_gvcf_combine", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--haplotype-manifest", str(haplotype_manifest), "--index-manifest", str(index_manifest), "--gatk", str(wgs_gatk), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) if code == 0 and fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if ( _valid_wgs_gvcf_combine_manifest( data, job_id=job.job_id, prefix=prefix ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == expected_upstream ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_gvcf_combine", ( "combined_gvcf", "combined_gvcf_index", "combine_log", ), expected_samples, expected_reference, str(data.get("wgs_analysis_id") or ""), ) except ValueError: pass else: return ( f"✅ WGS GVCF combine completed. Log: {log_file}", wgs_manifest, ) return ( "❌ WGS GVCF combine failed or did not publish a fresh complete " f"Manifest. Log: {log_file}", wgs_manifest, ) if stage == "wgs_joint_genotyping": try: combine_manifest = _explicit_wgs_authority( wgs_combine_manifest, ws, prefix, "wgs_gvcf_combine" ) index_manifest = _explicit_wgs_authority( wgs_index_manifest, ws, prefix, "wgs_index" ) combine_header = json.loads( combine_manifest.read_text(encoding="utf-8") ) index_header = json.loads( index_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_upstream = { "wgs_gvcf_combine": str(wgs_combine_analysis_id or ""), "wgs_index": str(wgs_index_analysis_id or ""), } if ( not expected_samples or not expected_reference or not all(expected_upstream.values()) ): raise ValueError( "wgs_joint_genotyping requires exact sample, reference " "and two upstream analysis identities" ) if ( combine_header.get("job_id") != job.job_id or combine_header.get("prefix") != prefix or combine_header.get("stage") != "wgs_gvcf_combine" or combine_header.get("wgs_sample_set_id") != expected_samples or combine_header.get("wgs_reference_id") != expected_reference or combine_header.get("wgs_analysis_id") != expected_upstream["wgs_gvcf_combine"] ): raise ValueError( "wgs_gvcf_combine authoritative Manifest identity mismatch" ) if ( index_header.get("job_id") != job.job_id or index_header.get("prefix") != prefix or index_header.get("stage") != "wgs_index" or index_header.get("wgs_reference_id") != expected_reference or index_header.get("wgs_analysis_id") != expected_upstream["wgs_index"] ): raise ValueError( "wgs_index authoritative Manifest identity mismatch" ) combine_upstream = combine_header.get( "upstream_wgs_analysis_ids" ) if ( not isinstance(combine_upstream, dict) or combine_upstream.get("wgs_index") != expected_upstream["wgs_index"] ): raise ValueError("wgs_gvcf_combine index lineage mismatch") resolve_wgs_artifacts( combine_manifest, "wgs_gvcf_combine", ("combined_gvcf", "combined_gvcf_index"), expected_samples, expected_reference, expected_upstream["wgs_gvcf_combine"], ) metrics = combine_header.get("metrics") or {} if ( metrics.get("input_sample_ids") != metrics.get("output_sample_ids") or not metrics.get("output_sample_ids") ): raise ValueError("wgs_gvcf_combine sample set mismatch") resolve_wgs_artifacts( index_manifest, "wgs_index", ("reference_fasta", "reference_fai", "reference_dict"), expected_reference_id=expected_reference, expected_analysis_id=expected_upstream["wgs_index"], ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_joint_genotyping.manifest.json" ) before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) command = [ sys.executable, "-m", "apps.cli.run_wgs_joint_genotyping", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--combine-manifest", str(combine_manifest), "--index-manifest", str(index_manifest), "--gatk", str(wgs_gatk), "--max-alternate-alleles", str(wgs_max_alternate_alleles), "--calling-threshold", str(wgs_calling_threshold), "--include-non-variant-sites", str(bool(wgs_include_non_variant_sites)).lower(), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) if code == 0 and fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if ( _valid_wgs_joint_genotyping_manifest( data, job_id=job.job_id, prefix=prefix ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == expected_upstream ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_joint_genotyping", ( "joint_vcf", "joint_vcf_index", "joint_summary", "joint_log", ), expected_samples, expected_reference, str(data.get("wgs_analysis_id") or ""), ) except ValueError: pass else: return ( f"✅ WGS joint genotyping completed. Log: {log_file}", wgs_manifest, ) return ( "❌ WGS joint genotyping failed or did not publish a fresh " f"complete Manifest. Log: {log_file}", wgs_manifest, ) if stage == "wgs_variant_select": method = str(wgs_method or "gatk").strip().lower() if method not in {"gatk", "bcftools"}: return "❌ wgs_variant_select method must be gatk or bcftools", None try: joint_manifest = _explicit_wgs_authority( wgs_joint_manifest, ws, prefix, "wgs_joint_genotyping", ) joint_header = json.loads( joint_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_joint_analysis = str(wgs_joint_analysis_id or "") if ( not expected_samples or not expected_reference or not expected_joint_analysis ): raise ValueError( "wgs_variant_select requires exact sample, reference " "and joint analysis identities" ) if ( joint_header.get("job_id") != job.job_id or joint_header.get("prefix") != prefix or joint_header.get("stage") != "wgs_joint_genotyping" or joint_header.get("wgs_sample_set_id") != expected_samples or joint_header.get("wgs_reference_id") != expected_reference or joint_header.get("wgs_analysis_id") != expected_joint_analysis ): raise ValueError( "wgs_joint_genotyping authoritative Manifest " "identity mismatch" ) resolve_wgs_artifacts( joint_manifest, "wgs_joint_genotyping", ("joint_vcf", "joint_vcf_index"), expected_samples, expected_reference, expected_joint_analysis, ) metrics = joint_header.get("metrics") or {} if ( metrics.get("input_sample_ids") != metrics.get("output_sample_ids") or not metrics.get("output_sample_ids") ): raise ValueError( "wgs_joint_genotyping sample set mismatch" ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_variant_select.{method}.manifest.json" ) before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) failure_pattern = ( f"{prefix}.wgs_variant_select.{method}.failed.*.manifest.json" ) before_failures = set(final_dir.glob(failure_pattern)) command = [ sys.executable, "-m", "apps.cli.run_wgs_variant_select", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--joint-manifest", str(joint_manifest), "--method", method, "--gatk", str(wgs_gatk), "--bcftools", str(wgs_bcftools), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) data = {} if fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if code == 0 and fresh: if ( _valid_wgs_variant_select_manifest( data, job_id=job.job_id, prefix=prefix, method=method, ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == {"wgs_joint_genotyping": expected_joint_analysis} ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_variant_select", ( "selected_snp_vcf", "selected_snp_vcf_index", "selected_indel_vcf", "selected_indel_vcf_index", "variant_select_report", ), expected_samples, expected_reference, str(data.get("wgs_analysis_id") or ""), method, ) except ValueError: pass else: return ( f"✅ WGS variant selection completed with {method}. " f"Log: {log_file}", wgs_manifest, ) new_failures = sorted( set(final_dir.glob(failure_pattern)) - before_failures, key=lambda path: path.stat().st_mtime_ns, ) failure_path = new_failures[-1] if new_failures else None if ( failure_path is None and fresh and data.get("stage") == "wgs_variant_select" and data.get("status") == "failed" ): failure_path = wgs_manifest failure_data = {} if failure_path is not None: try: failure_data = json.loads( failure_path.read_text(encoding="utf-8") ) except Exception: failure_data = {} errors = failure_data.get("errors") reason = ( str(errors[0]) if isinstance(errors, list) and errors else "no fresh complete Manifest was published" ) return ( f"❌ WGS variant selection ({method}) failed: {reason}. " f"Log: {log_file}", failure_path or wgs_manifest, ) if stage == "wgs_variant_filter": method = str(wgs_method or "gatk").strip().lower() if method not in {"gatk", "bcftools"}: return "❌ wgs_variant_filter method must be gatk or bcftools", None try: select_manifest = _explicit_wgs_authority( wgs_select_manifest, ws, prefix, "wgs_variant_select", ) select_header = json.loads( select_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_select_analysis = str(wgs_select_analysis_id or "") select_method = str(select_header.get("method") or "") if ( not expected_samples or not expected_reference or not expected_select_analysis or select_method not in {"gatk", "bcftools"} ): raise ValueError( "wgs_variant_filter requires exact sample, reference, " "select analysis and select method identities" ) if ( select_header.get("job_id") != job.job_id or select_header.get("prefix") != prefix or select_header.get("stage") != "wgs_variant_select" or select_header.get("wgs_sample_set_id") != expected_samples or select_header.get("wgs_reference_id") != expected_reference or select_header.get("wgs_analysis_id") != expected_select_analysis or (select_header.get("parameters") or {}).get("method") != select_method ): raise ValueError( "wgs_variant_select authoritative Manifest identity mismatch" ) resolve_wgs_artifacts( select_manifest, "wgs_variant_select", ( "selected_snp_vcf", "selected_snp_vcf_index", "selected_indel_vcf", "selected_indel_vcf_index", ), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=expected_select_analysis, expected_method=select_method, ) metrics = select_header.get("metrics") or {} if ( metrics.get("input_sample_ids") != metrics.get("output_sample_ids") or not metrics.get("output_sample_ids") ): raise ValueError("wgs_variant_select sample set mismatch") except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_variant_filter.{method}.manifest.json" ) before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) failure_pattern = ( f"{prefix}.wgs_variant_filter.{method}.failed.*.manifest.json" ) before_failures = set(final_dir.glob(failure_pattern)) command = [ sys.executable, "-m", "apps.cli.run_wgs_variant_filter", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--select-manifest", str(select_manifest), "--method", method, "--gatk", str(wgs_gatk), "--bcftools", str(wgs_bcftools), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) data = {} if fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if code == 0 and fresh: if ( _valid_wgs_variant_filter_manifest( data, job_id=job.job_id, prefix=prefix, method=method, ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == {"wgs_variant_select": expected_select_analysis} ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_variant_filter", ( "filtered_snp_vcf", "filtered_snp_vcf_index", "pass_snp_vcf", "pass_snp_vcf_index", "filtered_indel_vcf", "filtered_indel_vcf_index", "pass_indel_vcf", "pass_indel_vcf_index", "variant_filter_report", ), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=str( data.get("wgs_analysis_id") or "" ), expected_method=method, ) except ValueError: pass else: return ( f"✅ WGS variant filtering completed with {method}. " f"Log: {log_file}", wgs_manifest, ) new_failures = sorted( set(final_dir.glob(failure_pattern)) - before_failures, key=lambda path: path.stat().st_mtime_ns, ) failure_path = new_failures[-1] if new_failures else None if ( failure_path is None and fresh and data.get("stage") == "wgs_variant_filter" and data.get("status") == "failed" ): failure_path = wgs_manifest failure_data = {} if failure_path is not None: try: failure_data = json.loads( failure_path.read_text(encoding="utf-8") ) except Exception: failure_data = {} errors = failure_data.get("errors") reason = ( str(errors[0]) if isinstance(errors, list) and errors else "no fresh complete Manifest was published" ) return ( f"❌ WGS variant filtering ({method}) failed: {reason}. " f"Log: {log_file}", failure_path or wgs_manifest, ) if stage == "wgs_impute": try: filter_manifest = _explicit_wgs_authority( wgs_filter_manifest, ws, prefix, "wgs_variant_filter", ) filter_header = json.loads( filter_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_filter_analysis = str(wgs_filter_analysis_id or "") filter_method = str(filter_header.get("method") or "") if ( not expected_samples or not expected_reference or not expected_filter_analysis or filter_method not in {"gatk", "bcftools"} ): raise ValueError( "wgs_impute requires exact sample, reference, filter " "analysis and Stage10 method identities" ) if ( filter_header.get("job_id") != job.job_id or filter_header.get("prefix") != prefix or filter_header.get("stage") != "wgs_variant_filter" or filter_header.get("wgs_sample_set_id") != expected_samples or filter_header.get("wgs_reference_id") != expected_reference or filter_header.get("wgs_analysis_id") != expected_filter_analysis or (filter_header.get("parameters") or {}).get("method") != filter_method ): raise ValueError( "wgs_variant_filter authoritative Manifest identity mismatch" ) resolve_wgs_artifacts( filter_manifest, "wgs_variant_filter", ("pass_snp_vcf", "pass_snp_vcf_index"), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=expected_filter_analysis, expected_method=filter_method, ) metrics = filter_header.get("metrics") or {} if ( metrics.get("input_sample_ids") != metrics.get("output_sample_ids") or not metrics.get("output_sample_ids") ): raise ValueError("wgs_variant_filter sample set mismatch") except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = final_dir / f"{prefix}.wgs_impute.manifest.json" before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) failure_pattern = f"{prefix}.wgs_impute.failed.*.manifest.json" before_failures = set(final_dir.glob(failure_pattern)) command = [ sys.executable, "-m", "apps.cli.run_wgs_impute", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--filter-manifest", str(filter_manifest), "--java", str(wgs_java), "--beagle-jar", str(wgs_beagle_jar), "--memory", str(wgs_memory), "--window", str(wgs_window), "--overlap", str(wgs_overlap), "--ne", str(wgs_ne), "--iterations", str(wgs_iterations), "--phase-states", str(wgs_phase_states), "--imp-states", str(wgs_imp_states), "--cluster", str(wgs_cluster), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) data = {} if fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if ( code == 0 and fresh and _valid_wgs_impute_manifest( data, job_id=job.job_id, prefix=prefix ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == {"wgs_variant_filter": expected_filter_analysis} and data.get("upstream_method") == filter_method ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_impute", ( "imputed_snp_vcf", "imputed_snp_vcf_index", "impute_report", "impute_log", ), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=str( data.get("wgs_analysis_id") or "" ), ) except ValueError: pass else: return ( f"✅ WGS imputation completed. Log: {log_file}", wgs_manifest, ) new_failures = sorted( set(final_dir.glob(failure_pattern)) - before_failures, key=lambda path: path.stat().st_mtime_ns, ) failure_path = new_failures[-1] if new_failures else None if ( failure_path is None and fresh and data.get("stage") == "wgs_impute" and data.get("status") == "failed" ): failure_path = wgs_manifest failure_data = {} if failure_path is not None: try: failure_data = json.loads( failure_path.read_text(encoding="utf-8") ) except Exception: failure_data = {} errors = failure_data.get("errors") reason = ( str(errors[0]) if isinstance(errors, list) and errors else "no fresh complete Manifest was published" ) return ( f"❌ WGS imputation failed: {reason}. Log: {log_file}", failure_path or wgs_manifest, ) if stage == "wgs_maf_missing_filter": try: impute_manifest = _explicit_wgs_authority( wgs_impute_manifest, ws, prefix, "wgs_impute", ) impute_header = json.loads( impute_manifest.read_text(encoding="utf-8") ) expected_samples = str(wgs_sample_set_id or "") expected_reference = str(wgs_reference_id or "") expected_impute_analysis = str(wgs_impute_analysis_id or "") if ( not expected_samples or not expected_reference or not expected_impute_analysis ): raise ValueError( "wgs_maf_missing_filter requires exact sample, reference " "and impute analysis identities" ) if ( impute_header.get("job_id") != job.job_id or impute_header.get("prefix") != prefix or impute_header.get("stage") != "wgs_impute" or impute_header.get("status") != "success" or impute_header.get("method") != "beagle" or (impute_header.get("parameters") or {}).get("method") != "beagle" or impute_header.get("wgs_sample_set_id") != expected_samples or impute_header.get("wgs_reference_id") != expected_reference or impute_header.get("wgs_analysis_id") != expected_impute_analysis ): raise ValueError( "wgs_impute authoritative Manifest identity mismatch" ) resolve_wgs_artifacts( impute_manifest, "wgs_impute", ( "imputed_snp_vcf", "imputed_snp_vcf_index", "impute_report", "impute_log", ), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=expected_impute_analysis, ) metrics = impute_header.get("metrics") or {} if ( metrics.get("input_sample_ids") != metrics.get("output_sample_ids") or not metrics.get("output_sample_ids") or metrics.get("input_site_keys") != metrics.get("output_site_keys") or not metrics.get("output_site_keys") ): raise ValueError( "wgs_impute sample or site identity mismatch" ) except (ValueError, OSError, json.JSONDecodeError) as exc: return f"❌ {exc}", None wgs_manifest = ( final_dir / f"{prefix}.wgs_maf_missing_filter.manifest.json" ) before = ( wgs_manifest.read_bytes() if wgs_manifest.exists() else None ) failure_pattern = ( f"{prefix}.wgs_maf_missing_filter.failed.*.manifest.json" ) before_failures = set(final_dir.glob(failure_pattern)) command = [ sys.executable, "-m", "apps.cli.run_wgs_maf_missing_filter", "--workspace", str(ws), "--job-id", job.job_id, "--prefix", prefix, "--impute-manifest", str(impute_manifest), "--bcftools", str(wgs_bcftools), "--maf", str(wgs_maf), "--max-missing", str(wgs_max_missing), ] code = run_cmd_logged(command, log_file, cwd=Path(".")) fresh = ( wgs_manifest.exists() and wgs_manifest.read_bytes() != before ) data = {} if fresh: try: data = json.loads( wgs_manifest.read_text(encoding="utf-8") ) except Exception: data = {} if ( code == 0 and fresh and _valid_wgs_maf_missing_filter_manifest( data, job_id=job.job_id, prefix=prefix ) and data.get("wgs_sample_set_id") == expected_samples and data.get("wgs_reference_id") == expected_reference and data.get("upstream_wgs_analysis_ids") == {"wgs_impute": expected_impute_analysis} ): try: resolve_wgs_artifacts( wgs_manifest, "wgs_maf_missing_filter", ( "final_snp_vcf", "final_snp_vcf_index", "maf_missing_report", "maf_missing_stats", ), expected_sample_set_id=expected_samples, expected_reference_id=expected_reference, expected_analysis_id=str( data.get("wgs_analysis_id") or "" ), ) except ValueError: pass else: return ( f"✅ WGS MAF/missing filtering completed. Log: {log_file}", wgs_manifest, ) new_failures = sorted( set(final_dir.glob(failure_pattern)) - before_failures, key=lambda path: path.stat().st_mtime_ns, ) failure_path = new_failures[-1] if new_failures else None if ( failure_path is None and fresh and data.get("stage") == "wgs_maf_missing_filter" and data.get("status") == "failed" ): failure_path = wgs_manifest failure_data = {} if failure_path is not None: try: failure_data = json.loads( failure_path.read_text(encoding="utf-8") ) except Exception: failure_data = {} errors = failure_data.get("errors") reason = ( str(errors[0]) if isinstance(errors, list) and errors else "no fresh complete Manifest was published" ) return ( f"❌ WGS MAF/missing filtering failed: {reason}. Log: {log_file}", failure_path or wgs_manifest, ) # ========== RNA-seq 各步骤单独运行 ========== if stage == "rnaseq_qc": # Step 1: QC 质控 sample_config = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) if not sample_config: return "❌ RNA-seq QC requires sample configuration file (samples.yaml). Please upload.", manifest qc_dir = ws / "rnaseq_qc" qc_dir.mkdir(parents=True, exist_ok=True) try: import yaml with open(sample_config, "r", encoding="utf-8") as handle: sample_count = len((yaml.safe_load(handle) or {}).get("samples", {}) or {}) except Exception: sample_count = 1 try: from gfa_core.mas.resource_governor import recommend_rnaseq_parallelism thread_plan = recommend_rnaseq_parallelism( total_cpu=rnaseq_project_max_cpu or cpu, sample_parallel_jobs=rnaseq_sample_parallel_jobs or None, threads_per_sample=rnaseq_threads_per_sample or None, sample_count=sample_count, stage="rnaseq_qc", ) except Exception as e: print(f"[WARN] Failed to resolve RNA-seq QC thread plan, using serial fallback: {e}") thread_plan = { "total_cpu": cpu, "project_cpu_limit": cpu, "sample_parallel_jobs": 1, "threads_per_sample": max(1, int(cpu or 1)), } print( "[RNA-seq QC] thread plan: " f"project_cpu_limit={thread_plan.get('project_cpu_limit')}, " f"sample_parallel_jobs={thread_plan['sample_parallel_jobs']}, " f"threads_per_sample={thread_plan['threads_per_sample']}, " f"effective_cpu={thread_plan['total_cpu']}" ) cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_qc.py", "-i", str(in_dir), "-c", str(sample_config), "-o", str(qc_dir), "-j", job.job_id, "-t", str(thread_plan["total_cpu"]), "-p", prefix, "--sample-parallel-jobs", str(thread_plan["sample_parallel_jobs"]), "--threads-per-sample", str(thread_plan["threads_per_sample"]), "--resume-existing", ] qc_tool = str(rnaseq_qc_tool or "fastp").strip().lower() if qc_tool not in {"cutadapt", "fastp"}: return f"❌ RNA-seq QC tool not supported: {rnaseq_qc_tool}. Choose cutadapt or fastp.", manifest cmd.extend(["--qc-tool", qc_tool]) if qc_tool == "fastp": fastp_bin = str(rnaseq_fastp_bin or "").strip() if fastp_bin: cmd.extend(["--fastp-bin", fastp_bin]) cmd.extend([ "--cut-window-size", str(int(rnaseq_fastp_cut_window_size or 4)), "--cut-mean-quality", str(int(rnaseq_fastp_cut_mean_quality or 20)), "--qualified-quality-phred", str(int(rnaseq_fastp_qualified_quality_phred or 20)), "--unqualified-percent-limit", str(int(rnaseq_fastp_unqualified_percent_limit or 30)), "--n-base-limit", str(int(rnaseq_fastp_n_base_limit or 5)), "--length-required", str(int(rnaseq_fastp_length_required or 30)), ]) cmd.append("--detect-adapter-for-pe" if _bool_with_default(rnaseq_fastp_detect_adapter_for_pe, True) else "--no-detect-adapter-for-pe") cmd.append("--cut-front" if _bool_with_default(rnaseq_fastp_cut_front, True) else "--no-cut-front") cmd.append("--cut-tail" if _bool_with_default(rnaseq_fastp_cut_tail, True) else "--no-cut-tail") print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) if code == 0: # 脚本已直接输出到 final/ 目录,验证结果 if not final_dir.exists(): return f"❌ RNA-seq QC failed: final directory not found", manifest # 统计输出文件(只统计 rnaseq_qc 相关文件) fastqc_html = list(final_dir.glob("*_fastqc.html")) clean_fastq = list(final_dir.glob("*.clean.fastq.gz")) cutadapt_logs = list(final_dir.glob("*_cutadapt.log")) fastp_outputs = ( list(final_dir.glob("*_fastp.html")) + list(final_dir.glob("*_fastp.json")) + list(final_dir.glob("*_fastp.log")) ) rnaseq_files = fastqc_html + clean_fastq + cutadapt_logs + fastp_outputs file_count = len(rnaseq_files) # 读取manifest验证样本数 rnaseq_manifest = final_dir / f"{prefix}.rnaseq_qc.manifest.json" expected_samples = 0 actual_samples = 0 if rnaseq_manifest.exists(): try: with open(rnaseq_manifest, 'r') as f: mdata = json.load(f) expected_samples = mdata.get("metrics", {}).get("total_samples", 0) actual_samples = len(mdata.get("metrics", {}).get("samples", {})) except Exception as e: print(f"[WARN] Failed to parse manifest: {e}") # 严格验证:必须有输出文件且非空 if file_count == 0: return f"❌ RNA-seq QC failed: no output files generated", manifest # 验证每个文件非空 empty_files = [f for f in rnaseq_files if f.stat().st_size == 0] if empty_files: empty_names = ", ".join([f.name for f in empty_files[:3]]) return f"❌ RNA-seq QC failed: {len(empty_files)} empty output file(s): {empty_names}...", manifest # 验证样本数匹配(如果manifest可用) if expected_samples > 0 and actual_samples != expected_samples: return f"❌ RNA-seq QC failed: expected {expected_samples} samples but processed {actual_samples}", manifest return f"✅ RNA-seq QC completed. {file_count} files in final/ ({actual_samples} samples)", rnaseq_manifest else: return f"❌ RNA-seq QC failed (exit code: {code}). Log: {log_file}", manifest if stage == "rnaseq_align": # Step 2: 比对 # 输入:clean reads (来自 rnaseq_qc), HISAT2索引 # 输出:排序后的BAM文件 # 查找 clean reads clean_reads = list(final_dir.glob("*.clean.fastq.gz")) if not clean_reads: return "❌ RNA-seq Align requires clean reads from QC stage. Please run RNA-seq QC first.", manifest # 查找样本配置,并通过统一 RNA-seq context 锁定物种和参考资源。 sample_config = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) rnaseq_context = resolve_rnaseq_project_context( input_dir=in_dir, final_dir=final_dir, requested_prefix=prefix, sample_config=sample_config, ) resolved_rnaseq_prefix = rnaseq_context.get("output_prefix", "") if resolved_rnaseq_prefix and resolved_rnaseq_prefix != prefix: print( f"[INFO] Using RNA-seq output prefix {resolved_rnaseq_prefix} for rnaseq_align " f"instead of {prefix or ''}" ) prefix = resolved_rnaseq_prefix rnaseq_species = rnaseq_context.get("species_code") or rnaseq_context.get("output_prefix", "").split(".", 1)[0] rnaseq_reference_id = rnaseq_context.get("reference_id", "") if not rnaseq_species or rnaseq_species.lower() in {"unknown", "genome", "genome_prepare", "rnaseq"}: return ( "❌ RNA-seq Align requires a real species code. Add species.code/speciesCode " "and rnaseq.outputPrefix/rnaseqPrefix to samples.yaml, e.g. Os.", manifest, ) hisat2_idx = find_rnaseq_hisat2_index(in_dir, rnaseq_species, rnaseq_reference_id) if hisat2_idx: print(f"[INFO] Using locked genome HISAT2 index from context: {hisat2_idx}") else: genome_file = find_rnaseq_reference_file(in_dir, "genome", rnaseq_species, rnaseq_reference_id) if not genome_file: return ( f"❌ RNA-seq Align requires {rnaseq_species}.genome.fasta/fa/fna or " f"{rnaseq_species}.genome.*.ht2. CDS/PEP indexes are not valid for RNA-seq alignment.", manifest, ) hisat2_idx = genome_file.parent / f"{rnaseq_species}.genome" print(f"[INFO] Locked genome FASTA for HISAT2 build in CLI: {genome_file}") annotation_file = find_rnaseq_reference_file(in_dir, "annotation", rnaseq_species, rnaseq_reference_id) align_dir = ws / "rnaseq_align" align_dir.mkdir(parents=True, exist_ok=True) sample_count = max(1, len({p.name.rsplit("_", 1)[0] for p in clean_reads})) try: from gfa_core.mas.resource_governor import recommend_rnaseq_parallelism thread_plan = recommend_rnaseq_parallelism( total_cpu=rnaseq_project_max_cpu or cpu, sample_parallel_jobs=rnaseq_sample_parallel_jobs or None, threads_per_sample=rnaseq_threads_per_sample or None, sample_count=sample_count, stage="rnaseq_align", ) except Exception as e: print(f"[WARN] Failed to resolve RNA-seq thread plan, using serial fallback: {e}") thread_plan = { "total_cpu": cpu, "project_cpu_limit": cpu, "sample_parallel_jobs": 1, "threads_per_sample": max(1, int(cpu or 1)), } if rnaseq_resource_decision: print(f"[RNA-seq Align] resource decision: {rnaseq_resource_decision}") print( "[RNA-seq Align] thread plan: " f"project_cpu_limit={thread_plan.get('project_cpu_limit')}, " f"sample_parallel_jobs={thread_plan['sample_parallel_jobs']}, " f"threads_per_sample={thread_plan['threads_per_sample']}, " f"effective_cpu={thread_plan['total_cpu']}" ) cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_align.py", "-i", str(final_dir), # 从 final/ 读取 clean reads "-x", str(hisat2_idx), "-o", str(align_dir), "-j", job.job_id, "-t", str(thread_plan["total_cpu"]), "-p", prefix, "--sample-parallel-jobs", str(thread_plan["sample_parallel_jobs"]), "--threads-per-sample", str(thread_plan["threads_per_sample"]), ] if sample_config: cmd.extend(["--sample-config", str(sample_config)]) splice_source_val = str(rnaseq_splice_site_source or "auto").strip().lower() if splice_source_val not in {"auto", "off", "manual", "annotation"}: splice_source_val = "auto" known_splice_text = str(rnaseq_known_splice_site or "").strip() known_splice_path = Path(known_splice_text) if known_splice_text else None if known_splice_path and not known_splice_path.is_absolute(): for base in (in_dir, final_dir, ws, Path(".")): candidate = base / known_splice_path if candidate.exists(): known_splice_path = candidate break if splice_source_val in {"auto", "annotation"} and not known_splice_path and annotation_file: known_splice_path = annotation_file cmd.extend(["--splice-site-source", splice_source_val]) if known_splice_path: cmd.extend(["--known-splice-site", str(known_splice_path)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) if code == 0: # 验证结果 bam_files = list(final_dir.glob("*.sorted.bam")) file_count = len(bam_files) # 返回新生成的 manifest align_manifest = final_dir / f"{prefix}.rnaseq_align.manifest.json" return f"✅ RNA-seq Align completed. {file_count} BAM files in final/", align_manifest else: return f"❌ RNA-seq Align failed (exit code: {code}). Log: {log_file}", manifest if stage == "rnaseq_quant": # Step 3: 定量 # 输入:BAM文件 (来自rnaseq_align), GTF注释 bam_files = list(final_dir.glob("*.sorted.bam")) sample_config_for_context = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) rnaseq_context = resolve_rnaseq_project_context( input_dir=in_dir, final_dir=final_dir, requested_prefix=prefix, sample_config=sample_config_for_context, ) resolved_rnaseq_prefix = rnaseq_context.get("output_prefix", "") if resolved_rnaseq_prefix and resolved_rnaseq_prefix != prefix: print( f"[INFO] Using RNA-seq output prefix {resolved_rnaseq_prefix} for rnaseq_quant " f"instead of {prefix or ''}" ) prefix = resolved_rnaseq_prefix rnaseq_species = rnaseq_context.get("species_code") or rnaseq_context.get("output_prefix", "").split(".", 1)[0] rnaseq_reference_id = rnaseq_context.get("reference_id", "") gtf_file = find_rnaseq_reference_file(in_dir, "annotation", rnaseq_species, rnaseq_reference_id) transcriptome_file = find_rnaseq_reference_file(in_dir, "transcriptome", rnaseq_species, rnaseq_reference_id) transcriptome_candidates = [transcriptome_file] if transcriptome_file else [] if not bam_files: return "❌ RNA-seq Quant requires sorted BAM files (*.sorted.bam). Please run RNA-seq Align first.", manifest if not gtf_file: return ( f"❌ RNA-seq Quant requires an annotation file for {rnaseq_species or 'the RNA-seq species'} " "(*.gff3/*.gff/*.gtf). Please upload or run genome_prepare first.", manifest, ) quant_dir = ws / "rnaseq_quant" quant_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) print(f"[RNA-seq Quant] BAM files: {len(bam_files)}") print(f"[RNA-seq Quant] GTF: {gtf_file.name}") quant_mode_val = str(rnaseq_quant_mode or "dual").strip().lower() if quant_mode_val not in {"dual", "count_only"}: quant_mode_val = "dual" abundance_engine_val = str(rnaseq_abundance_engine or "auto").strip().lower() if abundance_engine_val not in {"auto", "salmon", "kallisto", "none"}: abundance_engine_val = "auto" tximport_counts_val = str(rnaseq_tximport_counts_from_abundance or "no").strip() if tximport_counts_val not in {"no", "scaledTPM", "lengthScaledTPM", "dtuScaledTPM"}: tximport_counts_val = "no" salmon_libtype_val = str(rnaseq_salmon_libtype or "A").strip() or "A" def resolve_optional_rnaseq_path(value: str) -> Path | None: text = str(value or "").strip() if not text: return None raw = Path(text) if raw.is_absolute(): return raw for base in (in_dir, final_dir, ws, Path(".")): candidate = base / raw if candidate.exists(): return candidate return raw transcriptome_override = resolve_optional_rnaseq_path(rnaseq_transcriptome_fasta) tx2gene_override = resolve_optional_rnaseq_path(rnaseq_tx2gene) sample_count = max(1, len(bam_files)) try: from gfa_core.mas.resource_governor import recommend_rnaseq_parallelism thread_plan = recommend_rnaseq_parallelism( total_cpu=rnaseq_project_max_cpu or cpu, sample_parallel_jobs=rnaseq_sample_parallel_jobs or None, threads_per_sample=rnaseq_threads_per_sample or None, sample_count=sample_count, stage="rnaseq_quant", ) except Exception as e: print(f"[WARN] Failed to resolve RNA-seq Quant thread plan, using serial fallback: {e}") thread_plan = { "total_cpu": cpu, "project_cpu_limit": cpu, "sample_parallel_jobs": 1, "threads_per_sample": max(1, int(cpu or 1)), } print( "[RNA-seq Quant] thread plan: " f"project_cpu_limit={thread_plan.get('project_cpu_limit')}, " f"sample_parallel_jobs={thread_plan['sample_parallel_jobs']}, " f"threads_per_sample={thread_plan['threads_per_sample']}, " f"effective_cpu={thread_plan['total_cpu']}" ) quant_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_quant.py", "-i", str(final_dir), "-g", str(gtf_file), "-o", str(quant_dir), "-p", prefix, "-j", job.job_id, "-t", str(thread_plan["total_cpu"]), "--sample-parallel-jobs", str(thread_plan["sample_parallel_jobs"]), "--threads-per-sample", str(thread_plan["threads_per_sample"]), "--quant-mode", quant_mode_val, "--abundance-engine", abundance_engine_val, "--salmon-libtype", salmon_libtype_val, "--tximport-counts-from-abundance", tximport_counts_val, ] if sample_config_for_context: quant_cmd.extend(["--sample-config", str(sample_config_for_context)]) if transcriptome_override: quant_cmd.extend(["--transcriptome-fasta", str(transcriptome_override)]) elif transcriptome_candidates: quant_cmd.extend(["--transcriptome-fasta", str(transcriptome_candidates[0])]) if tx2gene_override: quant_cmd.extend(["--tx2gene", str(tx2gene_override)]) if rnaseq_kallisto_bias: quant_cmd.append("--kallisto-bias") print(f"[CMD] {' '.join(quant_cmd)}") code = run_cmd_logged(quant_cmd, log_file, cwd=Path(".")) if code == 0: # 统计输出文件 count_files = list(final_dir.glob("*.count_matrix.txt")) file_count = len(count_files) quant_manifest = final_dir / f"{prefix}.rnaseq_quant.manifest.json" if not quant_manifest.exists(): candidates = sorted(final_dir.glob("*.rnaseq_quant.manifest.json")) if candidates: quant_manifest = candidates[0] return f"✅ RNA-seq Quant completed. {file_count} count matrices in final/", quant_manifest else: return f"❌ RNA-seq Quant failed (exit code: {code}). Log: {log_file}", manifest if stage == "rnaseq_de": # Step 4: 差异表达 # 输入:count_matrix (来自rnaseq_quant), 分组配置文件 count_matrix_files = list(in_dir.glob("*.count_matrix.txt")) + list((ws / "final").glob("*.count_matrix.txt")) config_file = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) if not count_matrix_files: return "❌ RNA-seq DE requires count matrix file (*.count_matrix.txt). Please run RNA-seq Quant first or upload.", manifest if not config_file: return "❌ RNA-seq DE requires sample configuration file (samples.yaml). Please upload.", manifest count_matrix = count_matrix_files[0] de_dir = ws / "rnaseq_de" de_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) print(f"[RNA-seq DE] Count matrix: {count_matrix.name}") print(f"[RNA-seq DE] Config: {config_file.name}") try: de_padj_val = float(rnaseq_de_padj) except (TypeError, ValueError): de_padj_val = 0.05 if not (0 <= de_padj_val <= 1): de_padj_val = 0.05 try: de_log2fc_val = float(rnaseq_de_log2fc) except (TypeError, ValueError): de_log2fc_val = 1.0 if de_log2fc_val < 0: de_log2fc_val = 1.0 de_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_de.py", "-i", str(count_matrix.parent), "-c", str(config_file), "-o", str(de_dir), "--final-dir", str(final_dir), # 指定主final目录 "-p", prefix, "-j", job.job_id, "-t", str(cpu), "--padj", str(de_padj_val), "--log2fc", str(de_log2fc_val), ] if rnaseq_de_module: de_cmd.extend(["--module", rnaseq_de_module]) print(f"[DEBUG] final_dir = {final_dir}") print(f"[DEBUG] de_cmd = {' '.join(de_cmd)}") foreground_mode_val = (rnaseq_foreground_mode or "auto").strip().lower() if foreground_mode_val not in {"auto", "global", "family"}: foreground_mode_val = "auto" family_id_files = list(in_dir.glob("*.final_members.id")) + list(final_dir.glob("*.final_members.id")) if foreground_mode_val == "auto": foreground_mode_val = "family" if family_id_files else "global" print(f"[RNA-seq DE] Foreground mode: {foreground_mode_val}") # 仅在用户选择基因家族 foreground 时才生成 family-specific DE 文件。 # 全基因组模式仍会使用相同 count matrix 做完整 DE,但不按 final_members.id 过滤。 if foreground_mode_val == "family" and family_id_files: family_id_file = family_id_files[0] family_name = family_id_file.stem.replace(".final_members", "").replace(".final_members.id", "") de_cmd.extend(["-f", str(family_id_file), "-n", family_name]) print(f"[RNA-seq DE] Family IDs: {family_id_file.name}") elif foreground_mode_val == "family": return ( "❌ RNA-seq DE family foreground requires gene family member file (*.final_members.id). " "Please run Identify first, upload the member ID file, or choose global foreground mode.", manifest, ) print(f"[CMD] {' '.join(de_cmd)}") code = run_cmd_logged(de_cmd, log_file, cwd=Path(".")) if code == 0: # 统计输出文件 de_files = list(final_dir.glob("*.DE_results.txt")) + list(final_dir.glob("*.DE_genes.txt")) file_count = len(de_files) de_manifest = final_dir / f"{prefix}.rnaseq_de.manifest.json" legacy_manifest = final_dir / "rnaseq_de.manifest.json" if not de_manifest.exists() and legacy_manifest.exists(): try: with open(legacy_manifest, "r", encoding="utf-8") as handle: legacy_data = json.load(handle) legacy_data["prefix"] = prefix or legacy_data.get("prefix") legacy_data["stage"] = "rnaseq_de" legacy_data["status"] = legacy_data.get("status") or "success" with open(de_manifest, "w", encoding="utf-8") as handle: json.dump(legacy_data, handle, ensure_ascii=False, indent=2) print(f"[RNA-seq DE] Migrated legacy manifest to standard name: {de_manifest.name}") except Exception as e: print(f"[WARN] Failed to migrate legacy rnaseq_de manifest: {e}") # RNA-seq-derived expression plots are an automatic post-processing # product of rnaseq_de. The standalone expression stage remains # available for user-supplied FPKM/TPM matrices. expression_note = "" fpkm_candidates = list(final_dir.glob(f"{prefix}.fpkm_matrix.txt")) + list(final_dir.glob("*.fpkm_matrix.txt")) tpm_candidates = list(final_dir.glob(f"{prefix}.tpm_matrix.txt")) + list(final_dir.glob("*.tpm_matrix.txt")) vst_candidates = list(final_dir.glob(f"{prefix}.rnaseq_sample_qc.vst_matrix.tsv")) + list(final_dir.glob("*.rnaseq_sample_qc.vst_matrix.tsv")) sample_table_candidates = [ws / "rnaseq_sample_qc" / "sample_table.tsv"] + list(final_dir.glob("sample_table.tsv")) count_candidates = list(final_dir.glob(f"{prefix}.count_matrix.txt")) + list(final_dir.glob("*.count_matrix.txt")) fpkm_candidates = [p for p in fpkm_candidates if p.exists() and p.stat().st_size > 0] tpm_candidates = [p for p in tpm_candidates if p.exists() and p.stat().st_size > 0] vst_candidates = [p for p in vst_candidates if p.exists() and p.stat().st_size > 0] sample_table_candidates = [p for p in sample_table_candidates if p.exists() and p.stat().st_size > 0] count_candidates = [p for p in count_candidates if p.exists() and p.stat().st_size > 0] if fpkm_candidates and tpm_candidates and vst_candidates and sample_table_candidates: expression_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_expression.py", "--mode", "rnaseq_auto", "--prefix", prefix, "--workspace", str(ws), "--expression-file", str(fpkm_candidates[0]), "--tpm-file", str(tpm_candidates[0]), "--vst-file", str(vst_candidates[0]), "--sample-file", str(sample_table_candidates[0]), "--de-dir", str(final_dir), "--triggered-by", "rnaseq_de", ] if count_candidates: expression_cmd.extend(["--count-matrix", str(count_candidates[0])]) family_id_files = list(in_dir.glob("*.final_members.id")) + list(final_dir.glob("*.final_members.id")) family_id_files = [p for p in family_id_files if p.exists() and p.stat().st_size > 0] if family_id_files: family_id_file = family_id_files[0] family_label = family_id_file.stem.replace(".final_members", "") expression_cmd.extend(["--gene-id-file", str(family_id_file), "--family-label", family_label]) print(f"[RNA-seq DE] Auto expression plotting: {' '.join(expression_cmd)}") expression_code = run_cmd_logged(expression_cmd, log_file, cwd=Path(".")) expression_manifest = final_dir / f"{prefix}.expression.manifest.json" if not expression_manifest.exists(): alt_expression_manifest = final_dir / f"{prefix}.rnaseq_expression.manifest.json" if alt_expression_manifest.exists(): expression_manifest = alt_expression_manifest if expression_code == 0 and expression_manifest.exists(): try: if de_manifest.exists(): de_data = json.loads(de_manifest.read_text(encoding="utf-8")) de_data["triggered_expression"] = True de_data["triggered_expression_manifest"] = f"final/{expression_manifest.name}" params = de_data.setdefault("parameters", {}) if isinstance(params, dict): params["triggered_expression"] = True params["triggered_expression_manifest"] = f"final/{expression_manifest.name}" de_manifest.write_text(json.dumps(de_data, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as exc: print(f"[WARN] Failed to annotate rnaseq_de manifest with expression trigger: {exc}") expression_note = f" Auto expression plots generated: {expression_manifest.name}." else: expression_note = f" Auto expression plotting failed or manifest missing; see log: {log_file}." else: missing_expr_inputs = [] if not fpkm_candidates: missing_expr_inputs.append("FPKM") if not tpm_candidates: missing_expr_inputs.append("TPM") if not vst_candidates: missing_expr_inputs.append("VST") if not sample_table_candidates: missing_expr_inputs.append("sample_table") expression_note = f" Auto expression plotting skipped: missing {', '.join(missing_expr_inputs)}." return f"✅ RNA-seq DE completed. {file_count} files in final/.{expression_note}", de_manifest else: return f"❌ RNA-seq DE failed (exit code: {code}). Log: {log_file}", manifest if stage == "rnaseq_sample_qc": count_matrix_candidates = [] if rnaseq_count_matrix: count_matrix_candidates.append(Path(rnaseq_count_matrix)) count_matrix_candidates.extend(list(in_dir.glob("*.count_matrix.txt"))) count_matrix_candidates.extend(list(final_dir.glob("*.count_matrix.txt"))) count_matrix_candidates = [p for p in count_matrix_candidates if p.exists()] if not count_matrix_candidates: return "❌ RNA-seq Sample QC requires count matrix (*.count_matrix.txt). Please run RNA-seq Quant first or upload.", manifest sample_config = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) sample_qc_dir = ws / "rnaseq_sample_qc" sample_qc_dir.mkdir(parents=True, exist_ok=True) sample_qc_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_sample_qc.py", "--workspace", str(ws), "-p", prefix, "-j", job.job_id, "--count-matrix", str(count_matrix_candidates[0]), ] if sample_config: sample_qc_cmd.extend(["--config", str(sample_config)]) print(f"[CMD] {' '.join(sample_qc_cmd)}") code = run_cmd_logged(sample_qc_cmd, log_file, cwd=Path(".")) sample_qc_manifest = final_dir / f"{prefix}.rnaseq_sample_qc.manifest.json" if code == 0: return "✅ RNA-seq Sample QC completed.", sample_qc_manifest return f"❌ RNA-seq Sample QC failed (exit code: {code}). Log: {log_file}", sample_qc_manifest if sample_qc_manifest.exists() else manifest if stage == "rnaseq_fgsea": if not list(final_dir.glob(f"{prefix}.rnaseq_de.*.DE_results.txt")): return f"❌ RNA-seq fgsea requires full DESeq2 results ({prefix}.rnaseq_de.*.DE_results.txt). Please run RNA-seq DE first.", manifest gene2ko_candidates = ( list(in_dir.glob("*gene2ko*.txt")) + list(in_dir.glob("*gene2ko*.tsv")) + list(final_dir.glob("*gene2ko*.txt")) + list(final_dir.glob("*gene2ko*.tsv")) ) gene2ko_candidates = [p for p in gene2ko_candidates if _has_non_comment_data(p) and ".test." not in p.name] if not gene2ko_candidates: return "❌ RNA-seq fgsea requires gene2ko background file. Please run annotation or upload gene2ko.txt/gene2ko.tsv.", manifest fgsea_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_fgsea.py", "--workspace", str(ws), "-p", prefix, "-j", job.job_id, "--gene2ko", str(gene2ko_candidates[0]), "--species-name", species_name or "", "--kegg-organism", kegg_organism or "", "--kegg-mode", "auto", "--kegg-filter-scope", "plant", "--min-size", str(fgsea_min_size), "--max-size", str(fgsea_max_size), "--nperm", str(fgsea_nperm), "--seed", str(fgsea_seed), ] if fgsea_comparison: fgsea_cmd.extend(["--comparison", fgsea_comparison]) print(f"[CMD] {' '.join(fgsea_cmd)}") code = run_cmd_logged(fgsea_cmd, log_file, cwd=Path(".")) fgsea_manifest = final_dir / f"{prefix}.rnaseq_fgsea.manifest.json" if code == 0: return "✅ RNA-seq fgsea completed.", fgsea_manifest return f"❌ RNA-seq fgsea failed (exit code: {code}). Log: {log_file}", fgsea_manifest if fgsea_manifest.exists() else manifest if stage == "rnaseq_wgcna": matrix_candidates = [] matrix_type = "expression" if rnaseq_expression_matrix: matrix_candidates.append(Path(rnaseq_expression_matrix)) matrix_candidates.append(final_dir / f"{prefix}.rnaseq_sample_qc.vst_matrix.tsv") matrix_candidates = [p for p in matrix_candidates if p.exists()] if not matrix_candidates: matrix_type = "counts" if rnaseq_count_matrix: matrix_candidates.append(Path(rnaseq_count_matrix)) matrix_candidates.extend(list(final_dir.glob("*.count_matrix.txt"))) matrix_candidates.extend(list(in_dir.glob("*.count_matrix.txt"))) matrix_candidates = [p for p in matrix_candidates if p.exists()] if not matrix_candidates: return "❌ RNA-seq WGCNA requires a VST/expression matrix or count matrix. Please run Sample QC or RNA-seq Quant first.", manifest sample_table_candidates = [] if rnaseq_sample_table: sample_table_candidates.append(Path(rnaseq_sample_table)) sample_table_candidates.append(ws / "rnaseq_sample_qc" / "sample_table.tsv") sample_table_candidates = [p for p in sample_table_candidates if p.exists()] sample_config = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) wgcna_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_wgcna.py", "--workspace", str(ws), "-p", prefix, "-j", job.job_id, "--min-mean", str(wgcna_min_mean), "--max-genes", str(wgcna_max_genes), "--min-module-size", str(wgcna_min_module_size), "--merge-cut-height", str(wgcna_merge_cut_height), "--powers", str(wgcna_powers), ] if matrix_type == "expression": wgcna_cmd.extend(["--expression-matrix", str(matrix_candidates[0])]) else: wgcna_cmd.extend(["--count-matrix", str(matrix_candidates[0])]) if sample_table_candidates: wgcna_cmd.extend(["--sample-table", str(sample_table_candidates[0])]) elif sample_config: wgcna_cmd.extend(["--config", str(sample_config)]) print(f"[CMD] {' '.join(wgcna_cmd)}") code = run_cmd_logged(wgcna_cmd, log_file, cwd=Path(".")) wgcna_manifest = final_dir / f"{prefix}.rnaseq_wgcna.manifest.json" if code == 0: return "✅ RNA-seq WGCNA completed.", wgcna_manifest return f"❌ RNA-seq WGCNA failed (exit code: {code}). Log: {log_file}", wgcna_manifest if wgcna_manifest.exists() else manifest if stage == "rnaseq_report": report_action = str(kwargs.get("rnaseq_report_action") or kwargs.get("report_action") or "generate").strip().lower() if report_action not in {"generate", "open"}: report_action = "generate" expected_raw = kwargs.get("rnaseq_expected_stages") or kwargs.get("expected_stages") or "" if isinstance(expected_raw, (list, tuple, set)): expected_arg = ",".join(str(item).strip() for item in expected_raw if str(item).strip()) else: expected_arg = str(expected_raw or "").strip() report_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip() report_use_model = kwargs.get("rnaseq_report_use_model", kwargs.get("report_use_model", True)) report_cmd = [ sys.executable, "apps/cli/run_rnaseq_report.py", "--workspace", str(ws), "--prefix", prefix, "--job-id", job.job_id, "--action", report_action, "--log-file", str(log_file), ] if report_scope_id: report_cmd.extend(["--rnaseq-scope-id", report_scope_id]) if expected_arg: report_cmd.extend(["--expected-stages", expected_arg]) if not _truthy(report_use_model): report_cmd.append("--no-model") print(f"[CMD] {' '.join(report_cmd)}") code = run_cmd_logged(report_cmd, log_file, cwd=Path(".")) report_manifest = final_dir / f"{prefix}.rnaseq_report.manifest.json" if code == 0 and report_manifest.exists(): action_label = "打开" if report_action == "open" else "生成" return f"✅ RNA-seq 结果报告已{action_label}。日志:{log_file}", report_manifest if code == 2: return "⚠️ 暂无可生成的 RNA-seq 结果:当前 prefix/scope 下没有成功阶段。", None return f"❌ RNA-seq 结果报告生成失败(exit code: {code})。日志:{log_file}", report_manifest if report_manifest.exists() else None if stage == "rnaseq_candidate_family": _SPECIES_RESOURCE_ALIASES = { "os": ["Os", "Oryza", "Oryza_sativa", "rice"], "ft": ["Ft", "Fagopyrum", "Fagopyrum_tataricum"], "at": ["At", "Arabidopsis", "Arabidopsis_thaliana"], } def _species_aliases(species: str) -> list[str]: species = str(species or "").strip() aliases = [species] if species else [] aliases.extend(_SPECIES_RESOURCE_ALIASES.get(species.lower(), [])) out: list[str] = [] seen: set[str] = set() for alias in aliases: key = alias.lower() if alias and key not in seen: seen.add(key) out.append(alias) return out def _is_species_resource(path: Path, aliases: list[str]) -> bool: if not aliases: return True name = path.name.lower() return any(alias.lower() in name for alias in aliases) def _historical_annotation_roots() -> list[Path]: bases = [] for root in (PROJECT_ROOT, in_dir): try: bases.append(Path(root).absolute().parent) bases.append(Path(root).resolve().parent) except OSError: bases.append(Path(root).absolute().parent) try: bases.append(INPUT_ROOT.resolve().parent.parent) except OSError: pass roots = [] seen_bases = set() for base in bases: if not base.exists() or base in seen_bases: continue seen_bases.add(base) for project_dir in sorted(base.glob("*.bak")) + sorted(base.glob("*backup*")): workspace_root = project_dir / "workspace" if not workspace_root.exists(): continue roots.extend(sorted(workspace_root.glob("job_*/annotation/analysis"))) job_id_for_priority = in_dir.name return sorted( [root.absolute() for root in roots if root.exists()], key=lambda root: (0 if job_id_for_priority and job_id_for_priority in root.parts else 1, str(root)), ) def _shared_resource_roots() -> list[Path]: roots = [ in_dir, ws / "annotation" / "analysis", INPUT_ROOT, PROJECT_ROOT / "data", PROJECT_ROOT / "resources", PROJECT_ROOT / "reference", PROJECT_ROOT / "references", ] roots.extend(_historical_annotation_roots()) out: list[Path] = [] seen: set[Path] = set() for root in roots: root = Path(root).absolute() if root.exists() and root not in seen: seen.add(root) out.append(root) return out def _is_family_member_interproscan(path: Path) -> bool: name = path.name.lower() return any( marker in name for marker in ( "final_members.interproscan", "curated_members.interproscan", "candidate_members.interproscan", ) ) def _prefix_species_family(value: str) -> tuple[str, str]: parts = [part for part in str(value or "").split(".") if part] species = parts[0] if parts else "" family = parts[1] if len(parts) > 1 else "" return species, family def _is_family_subset_protein(path: Path, family: str = "") -> bool: name = path.name.lower() family = str(family or "").lower() if "final_members" in name or "curated_members" in name or "candidate_members" in name: return True if name.startswith("ref_combined"): return True if family and f".{family}." in name: return True return False de_files = list(final_dir.glob(f"{prefix}.rnaseq_de.*.DE_genes.txt")) de_files = [p for p in de_files if "family_DE_genes" not in p.name] if not de_files: de_files = [p for p in final_dir.glob("*.DE_genes.txt") if "family_DE_genes" not in p.name] if not de_files: return ( "❌ RNA-seq candidate family requires global DE genes (*.DE_genes.txt). " "Please run RNA-seq DE first.", manifest, ) species_code, family_name = _prefix_species_family(prefix) species_aliases = _species_aliases(species_code) preferred_proteins = [] if species_code: preferred_proteins.extend( [ in_dir / f"{species_code}.pep.fasta", in_dir / f"{species_code}.pep.fa", in_dir / species_code / f"{species_code}.pep.fasta", in_dir / species_code / f"{species_code}.pep.fa", in_dir / f"{species_code}.protein.fasta", in_dir / f"{species_code}.protein.fa", in_dir / species_code / f"{species_code}.protein.fasta", in_dir / species_code / f"{species_code}.protein.fa", in_dir / f"{species_code}.faa", in_dir / species_code / f"{species_code}.faa", ] ) protein_roots = [in_dir, ws / "annotation" / "analysis", *_shared_resource_roots()] if in_dir.exists(): protein_roots.extend([p for p in sorted(in_dir.iterdir()) if p.is_dir()]) deduped_protein_roots = [] seen_protein_roots = set() for root in protein_roots: root = Path(root).absolute() if root.exists() and root not in seen_protein_roots: seen_protein_roots.add(root) deduped_protein_roots.append(root) protein_roots = deduped_protein_roots protein_candidates = list(preferred_proteins) local_protein_roots = {in_dir.absolute(), (ws / "annotation" / "analysis").absolute()} shared_protein_roots = set(_shared_resource_roots()) for root in protein_roots: globber = root.rglob if root in shared_protein_roots and root not in local_protein_roots else root.glob protein_candidates.extend( list(globber("*.pep.fasta")) + list(globber("*.pep.fa")) + list(globber("*.protein.fasta")) + list(globber("*.protein.fa")) + list(globber("*.faa")) + list(globber("*annotation_safe.pep.fasta")) ) protein_candidates = [ p for p in protein_candidates if p.exists() and p.stat().st_size > 0 and not _is_family_subset_protein(p, family_name) and _is_species_resource(p, species_aliases) ] interpro_candidates = ( list((ws / "annotation" / "analysis").glob(f"{prefix}.interproscan.tsv")) + list((ws / "annotation" / "analysis").glob("*interproscan.tsv")) + list((ws / "rnaseq_candidate_family" / "analysis").glob(f"{prefix}.rnaseq_candidate_family.interproscan.tsv")) + list(final_dir.glob("*interproscan*.tsv")) ) for root in _shared_resource_roots(): interpro_candidates.extend(list(root.rglob("*interproscan*.tsv"))) interpro_candidates = [ p for p in interpro_candidates if p.exists() and p.stat().st_size > 0 and not _is_family_member_interproscan(p) and _is_species_resource(p, species_aliases) ] if not protein_candidates and not interpro_candidates: checked_roots = ", ".join(str(root) for root in _shared_resource_roots()) expected_roots = [ in_dir.absolute(), (ws / "annotation" / "analysis").absolute(), INPUT_ROOT, PROJECT_ROOT / "data", PROJECT_ROOT / "resources", PROJECT_ROOT / "reference", PROJECT_ROOT / "references", ] missing_roots = ", ".join(str(root) for root in expected_roots if not Path(root).exists()) return ( "❌ RNA-seq candidate family requires a whole target proteome FASTA or a genome-wide InterProScan TSV. " "Family-member InterProScan files such as *.final_members.interproscan.tsv are not valid for genome-wide RNA-seq candidate family ranking. " f"Requested species={species_code or prefix}; accepted aliases={', '.join(species_aliases) or 'any'}. " f"Checked roots: {checked_roots}. " f"Missing expected roots: {missing_roots or 'none'}. " f"Next action: place {species_code or 'target'}.pep.fasta/{species_code or 'target'}.faa or {species_code or 'target'}.interproscan.tsv under the job input directory or a project shared resource directory.", manifest, ) candidate_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_rnaseq_candidate_family.py", "--workspace", str(ws), "--input-dir", str(in_dir), "-p", prefix, "-j", job.job_id, "-t", str(cpu), "--applications", annotation_applications_arg, "--run-interproscan", str(candidate_run_interproscan or "auto"), "--max-deg-proteins", str(candidate_max_deg_proteins or 500), ] if protein_candidates: candidate_cmd.extend(["--protein-fasta", str(protein_candidates[0])]) if interpro_candidates: candidate_cmd.extend(["--interproscan-tsv", str(interpro_candidates[0])]) print(f"[CMD] {' '.join(candidate_cmd)}") code = run_cmd_logged(candidate_cmd, log_file, cwd=Path(".")) candidate_manifest = final_dir / f"{prefix}.rnaseq_candidate_family.manifest.json" if code == 0: score_note, _score_manifest = _run_rnaseq_family_score_if_ready( ws, in_dir, final_dir, prefix, job.job_id, cpu, log_file, trigger="rnaseq_candidate_family", ) return f"\u2705 RNA-seq candidate family completed.{score_note}", candidate_manifest return f"❌ RNA-seq candidate family failed (exit code: {code}). Log: {log_file}", candidate_manifest if candidate_manifest.exists() else manifest if stage == "rnaseq_family_score": score_note, score_manifest = _run_rnaseq_family_score_if_ready( ws, in_dir, final_dir, prefix, job.job_id, cpu, log_file, trigger="manual_stage", ) if score_manifest.exists(): try: status = (json.loads(score_manifest.read_text(encoding="utf-8")) or {}).get("status", "") except Exception: status = "" if status == "success": return f"✅ RNA-seq family score completed.{score_note}", score_manifest if status == "skipped": return f"⚠️ RNA-seq family score skipped.{score_note}", score_manifest return f"⚠️ RNA-seq family score post-processing finished with notice.{score_note}", score_manifest if stage in {"rnaseq_enrich", "rnaseq_go_enrich", "rnaseq_kegg_enrich"}: # Step 5: 富集分析 # 输入:DE genes (来自rnaseq_de), 物种代码 # 从主final目录查找DE结果(所有阶段结果都应放在这里) foreground_mode_val = (rnaseq_foreground_mode or "auto").strip().lower() if foreground_mode_val not in {"auto", "global", "family"}: foreground_mode_val = "auto" all_de_files = list((ws / "final").glob("*DE_genes.txt")) family_de_files = [p for p in all_de_files if "family_DE_genes" in p.name] global_de_files = [p for p in all_de_files if "family_DE_genes" not in p.name] if foreground_mode_val == "auto": # 数据驱动:优先家族前景(若可用),否则回退到全基因组前景 if family_de_files: foreground_mode_val = "family" elif global_de_files: foreground_mode_val = "global" else: foreground_mode_val = "family" if foreground_mode_val == "family": de_files = family_de_files else: de_files = global_de_files if not de_files: if foreground_mode_val == "family": return ( "❌ 无法进行 RNA-seq 富集分析:当前选择的是基因家族 foreground," "但没有找到家族差异基因文件 *family_DE_genes.txt。\n\n" "前景依赖缺失:需要 family_DE_genes。\n" "背景依赖(gene2go/gene2ko 或内置注释)与此错误无关。\n\n" "可选处理方式:\n" "1. 重新运行 RNA-seq DE 并选择基因家族 foreground;\n" "2. 在富集参数中改选全基因组 foreground(若已有全局 DE_genes)。", None, ) return ( "❌ RNA-seq Enrich global foreground requires global DE genes (*.DE_genes.txt). " "Please run RNA-seq DE first or upload.", None, ) requested_enrich_stage = stage if requested_enrich_stage == "rnaseq_go_enrich": enrich_type = "go" elif requested_enrich_stage == "rnaseq_kegg_enrich": enrich_type = "kegg" enrich_dir_name = { "go": "rnaseq_go_enrich", "kegg": "rnaseq_kegg_enrich", "both": "rnaseq_enrich", }.get((enrich_type or "go").strip().lower(), "rnaseq_go_enrich") enrich_dir = ws / enrich_dir_name enrich_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) print(f"[RNA-seq Enrich] Foreground mode: {foreground_mode_val}") print(f"[RNA-seq Enrich] DE files: {len(de_files)}") # 查找自定义GO注释文件。全基因组eggNOG注释是长任务,应通过 # 单独的后台任务显式启动,不能在交互式富集阶段隐式运行。 gene2go_files = ( list(in_dir.glob("*gene2go*.txt")) + list(in_dir.glob("*gene2go*.tsv")) + list(final_dir.glob("*gene2go*.txt")) + list(final_dir.glob("*gene2go*.tsv")) ) gene2ko_files = ( list(in_dir.glob("*gene2ko*.txt")) + list(in_dir.glob("*gene2ko*.tsv")) + list(final_dir.glob("*gene2ko*.txt")) + list(final_dir.glob("*gene2ko*.tsv")) ) gene2go_files = [p for p in gene2go_files if _has_non_comment_data(p) and ".test." not in p.name] gene2ko_files = [p for p in gene2ko_files if _has_non_comment_data(p) and ".test." not in p.name] compatible_gene2go_files, gene2go_overlap_reports = _filter_gene2go_files_by_de_overlap(gene2go_files, final_dir) gene2go_mismatch = bool(gene2go_files and gene2go_overlap_reports and not compatible_gene2go_files) if compatible_gene2go_files: gene2go_files = compatible_gene2go_files elif gene2go_mismatch: gene2go_files = [] enrich_type_val = (enrich_type or "go").strip().lower() if enrich_type_val not in {"go", "kegg", "both"}: enrich_type_val = "go" supported_orgdb_species = {"ath", "hsa", "mmu", "rno", "dme", "sce"} enrich_species_val = (enrich_species or species_name or "").strip().lower() kegg_organism_val = (kegg_organism or "").strip().lower() requested_background_source_val = (enrich_background_source or "auto").strip().lower() background_source_val = requested_background_source_val confirmed_auto_annotation = bool(kwargs.get("confirmedAutoAnnotation") or kwargs.get("confirmed_auto_annotation")) if background_source_val in {"annotation", "eggnog", "auto_annotation"}: background_source_val = "annotation" if gene2go_mismatch and background_source_val in {"gene2go", "gene2ko"} and enrich_type_val in {"go", "both"}: report = gene2go_overlap_reports[0] if gene2go_overlap_reports else {} return ( "❌ 无法进行 RNA-seq GO 富集:当前 gene2go 背景与 DE 基因没有交集。\n\n" f"gene2go:{report.get('path', '')}\n" f"DE genes:{report.get('de_genes', 0)};背景基因:{report.get('background_genes', 0)};overlap=0\n" f"DE示例:{report.get('de_examples', [])}\n" f"背景示例:{report.get('background_examples', [])}\n\n" "处理方式:上传/生成与当前 DE 基因 ID 体系一致的 gene2go,或选择本地自动注释重新生成背景。", None, ) if background_source_val == "auto": need_go = enrich_type_val in {"go", "both"} need_kegg = enrich_type_val in {"kegg", "both"} has_go_background = bool(gene2go_files) or (need_go and enrich_species_val in supported_orgdb_species) has_kegg_background = bool(gene2ko_files) if confirmed_auto_annotation and ((need_go and not has_go_background) or (need_kegg and not has_kegg_background)): background_source_val = "annotation" if background_source_val == "annotation": pep_candidates = ( list(in_dir.glob("*.pep.fasta")) + list(in_dir.glob("*.pep.fa")) + list(in_dir.glob("*.protein.fa")) + list(in_dir.glob("*protein*.fasta")) + list(final_dir.glob("*final_members.pep.fa")) ) if not pep_candidates: return ( "❌ 无法进行富集分析:选择了内置自动注释,但没有找到蛋白序列文件。\n\n" "需要上传目标物种的全基因组蛋白序列文件,例如 *.pep.fasta、*.pep.fa 或 *.protein.fa。" "上传后可重新选择“本地按需自动注释 + 富集”。", None, ) pep_file = pep_candidates[0] annotation_start = time.time() need_go = enrich_type_val in {"go", "both"} need_kegg = enrich_type_val in {"kegg", "both"} go_annotation_manifest = _annotation_state_paths(ws, final_dir, prefix, "go")["manifest"] kegg_annotation_manifest = _annotation_state_paths(ws, final_dir, prefix, "kegg")["manifest"] annotation_manifest = go_annotation_manifest if need_go and not need_kegg else kegg_annotation_manifest if need_kegg and not need_go else final_dir / f"{prefix}.annotation.manifest.json" def refresh_background_files() -> tuple[list[Path], list[Path]]: go_files = ( list(final_dir.glob("*gene2go*.txt")) + list(final_dir.glob("*gene2go*.tsv")) + list(in_dir.glob("*gene2go*.txt")) + list(in_dir.glob("*gene2go*.tsv")) ) ko_files = ( list(final_dir.glob("*gene2ko*.txt")) + list(final_dir.glob("*gene2ko*.tsv")) + list(in_dir.glob("*gene2ko*.txt")) + list(in_dir.glob("*gene2ko*.tsv")) ) go_files = [p for p in go_files if _has_non_comment_data(p) and ".test." not in p.name] ko_files = [p for p in ko_files if _has_non_comment_data(p) and ".test." not in p.name] compatible_go_files, _ = _filter_gene2go_files_by_de_overlap(go_files, final_dir) return compatible_go_files if compatible_go_files else [], ko_files def run_annotation_scope(scope: str) -> tuple[str, Path | None]: state = _annotation_state_paths(ws, final_dir, prefix, scope) scope_manifest = state["manifest"] progress_file = state["progress"] annotation_lock = state["lock"] annotation_lock.parent.mkdir(parents=True, exist_ok=True) acquired_lock = False try: if _annotation_progress_active(progress_file, ws, scope, prefix): print(f"[RNA-seq Enrich] Existing {scope} background annotation is running; waiting before deciding what is still missing") if not _wait_for_annotation_reuse(scope_manifest, progress_file, log_file, scope=scope, ws=ws, prefix=prefix): return ( "❌ 无法进行富集分析:已有本地自动注释任务未能成功完成。\n\n" f"scope:{scope}\n" f"蛋白文件:{pep_file}\n" f"日志:{log_file}\n" "处理方式:等待当前自动注释结束,或上传已有背景文件后重试。", scope_manifest, ) return "", scope_manifest try: annotation_lock.mkdir() acquired_lock = True except FileExistsError: print(f"[RNA-seq Enrich] {scope} background annotation lock exists; waiting before deciding what is still missing") if _annotation_progress_active(progress_file, ws, scope, prefix): should_wait = _wait_for_annotation_reuse(scope_manifest, progress_file, log_file, scope=scope, ws=ws, prefix=prefix) else: should_wait = False if not should_wait: shutil.rmtree(annotation_lock, ignore_errors=True) try: annotation_lock.mkdir() acquired_lock = True except FileExistsError: return ( "❌ 无法进行富集分析:自动注释锁仍存在,且无法重新获取。\n\n" f"scope:{scope}\n" f"蛋白文件:{pep_file}\n" f"日志:{log_file}\n" "处理方式:确认没有同一 job 的自动注释任务正在运行后重试。", scope_manifest, ) else: return "", scope_manifest if not acquired_lock: return ( "❌ 无法进行富集分析:已有本地自动注释任务未能成功完成。\n\n" f"scope:{scope}\n" f"蛋白文件:{pep_file}\n" f"日志:{log_file}\n" "处理方式:等待当前自动注释结束,或上传已有背景文件后重试。", scope_manifest, ) annotation_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_annotation.py", "-i", str(pep_file), "-o", str(ws), "-p", prefix, "-j", job.job_id, "-t", str(cpu), "--applications", annotation_applications_arg, "--species-name", species_name or enrich_species or prefix.split(".", 1)[0], "--skip-existing", "--skip-existing-kegg", "--annotation-scope", scope, ] if scope == "go": annotation_cmd.append("--skip-kegg") else: annotation_cmd.extend(["--skip-go", "--require-kegg"]) label = "GO/gene2go" if scope == "go" else "KEGG/gene2ko" print(f"[RNA-seq Enrich] Running local {label} background annotation first: {' '.join(annotation_cmd)}") annotation_code = run_cmd_logged(annotation_cmd, log_file, cwd=Path(".")) if annotation_code != 0: return ( f"❌ 无法进行富集分析:本地 {label} 自动背景注释失败。\n\n" f"蛋白文件:{pep_file}\n" f"日志:{log_file}\n" "处理方式:检查 InterProScan/KofamScan 数据库,或上传已有背景文件后重试。", scope_manifest, ) return "", scope_manifest finally: if acquired_lock: shutil.rmtree(annotation_lock, ignore_errors=True) missing_go_background = need_go and not gene2go_files missing_kegg_background = need_kegg and not gene2ko_files if missing_go_background: message, scope_manifest = run_annotation_scope("go") annotation_manifest = scope_manifest or go_annotation_manifest if message: return message, annotation_manifest gene2go_files, gene2ko_files = refresh_background_files() missing_go_background = need_go and not gene2go_files if missing_kegg_background: message, scope_manifest = run_annotation_scope("kegg") annotation_manifest = scope_manifest or kegg_annotation_manifest if message: return message, annotation_manifest gene2go_files, gene2ko_files = refresh_background_files() missing_kegg_background = need_kegg and not gene2ko_files gene2go_files, gene2ko_files = refresh_background_files() if need_go and not any(_has_non_comment_data(p) for p in gene2go_files): return ( "❌ 自动背景注释已运行,但没有生成可用的 GO 背景映射。\n\n" f"蛋白文件:{pep_file}\n" f"注释 manifest:{go_annotation_manifest}\n" "处理方式:上传目标物种已有的 gene2go.txt/gene2go.tsv 背景文件后重试。", go_annotation_manifest, ) if need_kegg and not any(_has_non_comment_data(p) for p in gene2ko_files): return ( "❌ 自动背景注释已运行,但没有生成可用的 KEGG/KO 背景映射。\n\n" f"蛋白文件:{pep_file}\n" f"注释 manifest:{kegg_annotation_manifest}\n" "处理方式:确认 KofamScan profiles 已完整解压,或上传 gene2ko.txt/gene2ko.tsv 后重试。", kegg_annotation_manifest, ) background_source_val = "gene2ko" if enrich_type_val == "kegg" else "gene2go" print(f"[RNA-seq Enrich] Background annotation completed in {time.time() - annotation_start:.1f}s") if background_source_val == "auto": if enrich_type_val == "go" and gene2go_files: background_source_val = "gene2go" elif enrich_type_val == "go" and enrich_species_val in supported_orgdb_species: background_source_val = "orgdb" elif enrich_type_val == "kegg" and gene2ko_files: background_source_val = "gene2ko" elif enrich_type_val == "both" and gene2ko_files and gene2go_files: background_source_val = "gene2go" else: return ( "❌ 无法进行 RNA-seq 富集分析:缺少背景注释文件。\n\n" f"当前 job 没有找到 *gene2go*.txt 或 *gene2go*.tsv;物种 {enrich_species_val or species_name or '未指定'} 也不是内置 OrgDb 支持物种 " "(ath, hsa, mmu, rno, dme, sce),因此系统无法自动确定富集背景。\n\n" "可选处理方式:\n" "1. 上传目标物种已有的 gene2go.txt/gene2go.tsv 背景文件后重新执行。\n" "2. 如果要做 KEGG,请同时上传 gene2ko.txt/gene2ko.tsv(gene 到 KO 映射)。\n" "3. 在富集分析参数中选择“本地按需自动注释 + 富集”。GO 会补 gene2go,KEGG 会补 gene2ko;" "小型/中型蛋白组通常需要数小时,几百 MB 级别数据可能需要半天左右。\n" "4. 如果分析的是支持的模式物种,选择对应 OrgDb 物种代码。", None, ) if background_source_val == "orgdb" and not enrich_species_val: return ( "❌ RNA-seq Enrich built-in OrgDb requires an explicit species code. " "Supported species: ath, hsa, mmu, rno, dme, sce.", None, ) if background_source_val == "orgdb" and enrich_species_val not in supported_orgdb_species: return ( f"❌ RNA-seq Enrich built-in OrgDb does not support species '{enrich_species_val}'. " "Supported species: ath, hsa, mmu, rno, dme, sce. " "Please upload a gene2go background file or run local InterProScan annotation as a separate background task.", None, ) if background_source_val in {"gene2go", "gene2ko"} and enrich_type_val in {"go", "both"} and not gene2go_files: return ( "❌ 无法进行 RNA-seq 富集分析:你选择了 gene2go 背景文件,但当前 job 中没有找到 " "*gene2go*.txt 或 *gene2go*.tsv。\n\n" "请上传背景文件,或改选“内置自动注释 + 富集”。自动注释会作为后台长任务运行," "完成并生成 gene2go 后继续富集分析。", None, ) if enrich_type_val in {"kegg", "both"} and not gene2ko_files: return ( "❌ 无法进行 KEGG 富集分析:缺少 gene2ko 背景文件(gene 到 KO 映射)。\n\n" "请上传 *gene2ko*.txt/*.tsv,或选择“本地按需自动注释 + 富集”自动生成后再执行。", None, ) if enrich_type_val in {"kegg", "both"} and background_source_val == "orgdb": return ( "❌ 当前 KEGG 富集不走 OrgDb keyType 背景。请使用 gene2ko 背景(上传或自动注释生成)。\n" "说明:GO 依赖 OrgDb keyType;KEGG 依赖 KEGG organism code 与 KO/ID 映射,这是两套参数。", None, ) # 获取top_pathways参数(从前端传入,默认20) top_pathways_val = top_pathways if top_pathways else 20 try: pvalue_cutoff_val = float(enrich_pvalue_cutoff) except (TypeError, ValueError): pvalue_cutoff_val = 0.05 if not (0 <= pvalue_cutoff_val <= 1): pvalue_cutoff_val = 0.05 try: qvalue_cutoff_val = float(enrich_qvalue_cutoff) except (TypeError, ValueError): qvalue_cutoff_val = 0.05 if not (0 <= qvalue_cutoff_val <= 1): qvalue_cutoff_val = 0.05 go_obo_val = str(go_obo or "data/go/go-basic.obo").strip() or "data/go/go-basic.obo" kegg_level_val = str(kegg_level or "pathway").strip().lower() if kegg_level_val not in {"pathway", "ko"}: kegg_level_val = "pathway" kegg_filter_scope_val = str(kegg_filter_scope or "plant").strip().lower() if kegg_filter_scope_val not in {"plant", "all"}: kegg_filter_scope_val = "plant" kegg_highlight_mode_val = str(kegg_highlight_mode or "auto").strip().lower() if kegg_highlight_mode_val not in {"auto", "ko", "organism"}: kegg_highlight_mode_val = "auto" enrich_plot_type_val = str(enrich_plot_type or "r_plot").strip().lower() if enrich_plot_type_val not in {"svg", "r_plot"}: enrich_plot_type_val = "r_plot" enrich_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_enrich.py", "-i", str(ws / "final"), "-s", enrich_species_val, "-o", str(enrich_dir), "-p", prefix, "-j", job.job_id, "-t", str(cpu), "--top-pathways", str(top_pathways_val), "--background-source", background_source_val, "--key-type", enrich_key_type or "auto", "--pvalue-cutoff", str(pvalue_cutoff_val), "--qvalue-cutoff", str(qvalue_cutoff_val), "--go-obo", go_obo_val, "--foreground-mode", foreground_mode_val, "--enrich-type", enrich_type_val, "--kegg-organism", kegg_organism_val, "--kegg-level", kegg_level_val, "--kegg-filter-scope", kegg_filter_scope_val, "--kegg-highlight-mode", kegg_highlight_mode_val, "--stage", enrich_dir_name, "--plot-type", enrich_plot_type_val, ] if kegg_no_network: enrich_cmd.append("--no-kegg-network") if kegg_skip_pathview: enrich_cmd.append("--skip-pathview") if foreground_mode_val == "family": family_name = (prefix) enrich_cmd.extend(["--family-name", family_name]) if background_source_val in {"gene2go", "gene2ko"}: if gene2go_files: gene2go_file = gene2go_files[0] enrich_cmd.extend(["-g", str(gene2go_file)]) print(f"[RNA-seq Enrich] Custom GO annotation: {gene2go_file.name}") if enrich_type_val in {"kegg", "both"} and gene2ko_files: gene2ko_file = gene2ko_files[0] enrich_cmd.extend(["--gene2ko", str(gene2ko_file)]) print(f"[RNA-seq Enrich] Custom KEGG annotation: {gene2ko_file.name}") else: print(f"[RNA-seq Enrich] Built-in OrgDb species: {enrich_species_val}, keyType={enrich_key_type or 'auto'}") print(f"[CMD] {' '.join(enrich_cmd)}") code = run_cmd_logged(enrich_cmd, log_file, cwd=Path(".")) if code == 0: # 统计输出文件 if enrich_dir_name == "rnaseq_go_enrich": enrich_result_files = list(final_dir.glob(f"{prefix}.rnaseq_go_enrich.*.GO_enrich.txt")) elif enrich_dir_name == "rnaseq_kegg_enrich": enrich_result_files = list(final_dir.glob(f"{prefix}.rnaseq_kegg_enrich.*.KEGG_enrich.txt")) else: enrich_result_files = ( list(final_dir.glob(f"{prefix}.rnaseq_enrich.*.GO_enrich.txt")) + list(final_dir.glob(f"{prefix}.rnaseq_enrich.*.KEGG_enrich.txt")) ) file_count = len(enrich_result_files) enrich_manifest = final_dir / f"{prefix}.{enrich_dir_name}.manifest.json" return f"✅ RNA-seq Enrich completed. {file_count} files in final/", enrich_manifest else: return f"❌ RNA-seq Enrich failed (exit code: {code}). Log: {log_file}", manifest # ========== RNA-seq 完整流程 ========== if stage == "rnaseq": # RNA-seq 阶段需要:原始FASTQ文件 + 样本配置 + 参考基因组/GTF # 两种输入模式:用户上传 或 检索下载 # 直接使用函数参数,不再从kwargs获取(避免被默认值覆盖) print(f"[DEBUG rnaseq] stage={stage}, rnaseq_mode={rnaseq_mode}, species_name={species_name}") # FASTQ 的 R1/R2 角色由 samples.yaml 定义,不从文件名推断。 sample_config = _resolve_rnaseq_sample_config(in_dir, ws, rnaseq_sample_config) # 查找参考基因组和GTF ref_genome = None gtf_file = None ref_genome_files = list(in_dir.glob("*.fa")) + list(in_dir.glob("*.fasta")) \ + list(in_dir.glob("*.fa.gz")) + list(in_dir.glob("*.fasta.gz")) if ref_genome_files: ref_genome = ref_genome_files[0] gtf_files = list(in_dir.glob("*.gtf")) + list(in_dir.glob("*.gff")) \ + list(in_dir.glob("*.gtf.gz")) + list(in_dir.glob("*.gff.gz")) if gtf_files: gtf_file = gtf_files[0] # 检索下载模式 - 直接返回搜索结果,不需要本地文件 if rnaseq_mode == "download" and species_name: print(f"[RNA-seq] 检索下载模式: {species_name}") species_underscore = species_name.replace(" ", "_").lower() # 构建Ensembl FTP链接 ftp_fasta = f"ftp://ftp.ensemblgenomes.ebi.ac.uk/pub/current/plants/fasta/{species_underscore}/dna/" ftp_gtf = f"ftp://ftp.ensemblgenomes.ebi.ac.uk/pub/plants/current/gff3/{species_underscore}/" return f"🔍 物种: **{species_name}**\n\n" \ f"**参考基因组FTP:**\n```\n{ftp_fasta}\n```\n\n" \ f"**GTF注释FTP:**\n```\n{ftp_gtf}\n```\n\n" \ f"请手动下载这些文件上传到input目录,或使用上传模式直接提供文件。\n\n" \ f"**下载命令示例:**\n```bash\n# 下载基因组\nwget -r -np -nH --cut-dirs=4 {ftp_fasta}\n# 下载GTF\nwget -r -np -nH --cut-dirs=4 {ftp_gtf}\n```\n\n" \ f"或者你可以使用上传模式,直接提供本地文件。", manifest # 上传模式才需要检查本地文件 if not sample_config: return "❌ RNA-seq stage requires sample configuration file (samples.yaml). Please upload.", manifest if not ref_genome: return "❌ RNA-seq stage requires reference genome file (*.fa or *.fasta). Please upload.", manifest if not gtf_file: return "❌ RNA-seq stage requires GTF/GFF annotation file. Please upload.", manifest print(f"[RNA-seq] 样本配置: {sample_config.name}") print(f"[RNA-seq] 参考基因组: {ref_genome.name}") print(f"[RNA-seq] GTF文件: {gtf_file.name}") # 创建输出目录 rnaseq_output_dir = ws / "rnaseq" rnaseq_output_dir.mkdir(parents=True, exist_ok=True) # Step 1: 质控 (QC) print("[RNA-seq] Step 1: Quality Control...") qc_dir = rnaseq_output_dir / "qc" qc_dir.mkdir(exist_ok=True) qc_cmd = [ "/home/xzq/miniconda3/envs/rnaseq/bin/python", "apps/cli/run_rnaseq_qc.py", "-i", str(in_dir), "-c", str(sample_config), "-o", str(qc_dir), "-j", job.job_id, "-t", str(cpu), "--resume-existing", ] qc_tool = str(rnaseq_qc_tool or "fastp").strip().lower() if qc_tool not in {"cutadapt", "fastp"}: return f"❌ RNA-seq QC tool not supported: {rnaseq_qc_tool}. Choose cutadapt or fastp.", manifest qc_cmd.extend(["--qc-tool", qc_tool]) if qc_tool == "fastp" and str(rnaseq_fastp_bin or "").strip(): qc_cmd.extend(["--fastp-bin", str(rnaseq_fastp_bin).strip()]) if qc_tool == "fastp": qc_cmd.extend([ "--cut-window-size", str(int(rnaseq_fastp_cut_window_size or 4)), "--cut-mean-quality", str(int(rnaseq_fastp_cut_mean_quality or 20)), "--qualified-quality-phred", str(int(rnaseq_fastp_qualified_quality_phred or 20)), "--unqualified-percent-limit", str(int(rnaseq_fastp_unqualified_percent_limit or 30)), "--n-base-limit", str(int(rnaseq_fastp_n_base_limit or 5)), "--length-required", str(int(rnaseq_fastp_length_required or 30)), ]) qc_cmd.append("--detect-adapter-for-pe" if _bool_with_default(rnaseq_fastp_detect_adapter_for_pe, True) else "--no-detect-adapter-for-pe") qc_cmd.append("--cut-front" if _bool_with_default(rnaseq_fastp_cut_front, True) else "--no-cut-front") qc_cmd.append("--cut-tail" if _bool_with_default(rnaseq_fastp_cut_tail, True) else "--no-cut-tail") print(f"[CMD] {' '.join(qc_cmd)}") code = run_cmd_logged(qc_cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ RNA-seq QC failed (exit code: {code}). Log: {log_file}", manifest # 返回进度信息 return f"✅ RNA-seq 流程正在开发中...\n\n" \ f"已完成: Step 1 - QC (质控)\n" \ f"待集成: Step 2 - Alignment (HISAT2)\n" \ f"待集成: Step 3 - Quantification (featureCounts)\n" \ f"待集成: Step 4 - Differential Expression\n" \ f"待集成: Step 5 - Enrichment Analysis\n\n" \ f"QC输出目录: {qc_dir}\n" \ f"日志: {log_file}", manifest # ========== Annotation 阶段 (GO功能注释) ========== if stage == "annotation": pep_files = ( list(in_dir.glob("*.pep.fa")) + list(in_dir.glob("*.pep.fasta")) + list(in_dir.glob("*.protein.fa")) + list(in_dir.glob("*.protein.fasta")) + list(in_dir.glob("*protein*.fa")) + list(in_dir.glob("*protein*.fasta")) + list((ws / "final").glob("*final_members.pep.fa")) + list((ws / "final").glob("*final_members.pep.fasta")) ) if not pep_files: # 也搜索input目录 pep_files = ( list(in_dir.glob("*/*.pep.fa")) + list(in_dir.glob("*/*.pep.fasta")) + list(in_dir.glob("*/*.protein.fa")) + list(in_dir.glob("*/*.protein.fasta")) ) if not pep_files: return "❌ Annotation stage requires protein sequence file (*.pep.fa or *.protein.fa). Please upload.", manifest pep_file = pep_files[0] annotation_manifest = ws / "final" / f"{prefix}.annotation.manifest.json" annotation_cmd = [ "/home/xzq/miniconda3/envs/test4py311/bin/python", "apps/cli/run_annotation.py", "-i", str(pep_file), "-o", str(ws), "-p", prefix, "-j", job.job_id, "-t", str(cpu), "--applications", annotation_applications_arg, "--species-name", species_name or enrich_species or prefix.split(".", 1)[0], "--annotation-scope", "both", ] print(f"[Annotation] 使用蛋白文件: {pep_file.name}") print(f"[CMD] {' '.join(annotation_cmd)}") code = run_cmd_logged(annotation_cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ Annotation failed (exit code: {code}). Log: {log_file}", annotation_manifest try: manifest_data = json.loads(annotation_manifest.read_text(encoding="utf-8")) except Exception: manifest_data = {} metrics = manifest_data.get("metrics", {}) gene2go = ws / "final" / f"{prefix}.gene2go.txt" if not _has_non_comment_data(gene2go): return ( "❌ Annotation 已运行,但没有生成可用于富集分析的 gene2go 背景映射。\n\n" f"蛋白文件:{pep_file}\n" f"输出文件:{gene2go}\n\n" "可能原因:InterProScan 没有为当前蛋白集匹配到 GO term,或原始 TSV 解析失败。", annotation_manifest, ) return ( "✅ Annotation complete! " f"{metrics.get('canonical_genes_with_go', 0)} genes, " f"{metrics.get('go_term_count', 0)} GO terms. Method: interproscan", annotation_manifest, ) if stage == "combo": combo_manifest = ws / "final" / f"{prefix}.combo.manifest.json" combo_scope_path = None combo_scope_data = None if scope_file: combo_scope_path = Path(scope_file).expanduser() if not combo_scope_path.is_absolute(): for candidate in (in_dir / combo_scope_path, ws / combo_scope_path, Path(scope_file)): if candidate.exists(): combo_scope_path = candidate break if not combo_scope_path.exists(): return f"❌ Combo scope 文件不存在: {scope_file}", combo_manifest try: combo_scope_data = read_scope(combo_scope_path) combo_scope_id = str((combo_scope_data or {}).get("scope_id") or "").strip() if combo_scope_id and analysis_route: expected_member_set = member_set_id_for(prefix, combo_scope_data, analysis_route) if expected_member_set and member_set_id != expected_member_set: print( f"[Combo] member_set_id synchronized with scope_file: " f"{member_set_id or ''} -> {expected_member_set}" ) member_set_id = expected_member_set combo_manifest = ws / "final" / f"{stage_output_prefix('combo', prefix, combo_scope_data, analysis_route, member_set_id)}.combo.manifest.json" except Exception as exc: return f"❌ Combo scope 文件无法读取: {scope_file}; {exc}", combo_manifest raw_combo_panels = combo_panels or kwargs.get("comboPanels") or kwargs.get("combo_panels") if raw_combo_panels: inferred_combo_panels = raw_combo_panels else: inferred_combo_panels = infer_completed_combo_panels( ws / "final", prefix, combo_scope_data or {}, analysis_route, member_set_id, ) try: selected_combo_panels = normalize_combo_panels(inferred_combo_panels) except ValueError as exc: return f"❌ Combo panel 参数错误:{exc}", combo_manifest # Combo 阶段需要先检查依赖:phylogeny + 已选择的 motif/structure/domain 面板 deps = { dep_stage: first_existing_stage_manifest( ws / "final", dep_stage, prefix, combo_scope_data or {}, analysis_route, member_set_id, ) for dep_stage in ["phylogeny", *selected_combo_panels] } missing = [k for k, v in deps.items() if not v] if missing: return f"❌ Combo 阶段缺少依赖:{', '.join(missing)}。请先运行这些阶段。", combo_manifest # 查找 subfamily 文件,优先使用当前 combo scope 对应的确认结果。 combo_scope_id = str(combo_scope_data.get("scope_id") or "") if combo_scope_data else "" subfamily_file = None disable_combo_subfamily_auto = should_disable_cli_subfamily_auto(analysis_route, subfamily_label_source) if not disable_combo_subfamily_auto: subfamily_file = resolve_subfamily_for_plotting(ws / "final", in_dir, prefix, combo_scope_id, subfamily_label_source) cmd = [ sys.executable, "apps/cli/run_combo.py", "--prefix", prefix, "--workspace", str(ws), "--panels", ",".join(selected_combo_panels), ] if combo_output_label: cmd.extend(["--output-label", combo_output_label]) if disable_combo_subfamily_auto: cmd.extend(["--subfamily", "NONE"]) elif subfamily_file and subfamily_file.exists(): cmd.extend(["--subfamily", str(subfamily_file)]) if combo_scope_path: cmd.extend(["--scope-file", str(combo_scope_path)]) if analysis_route: cmd.extend(["--analysis-route", analysis_route]) if member_set_id: cmd.extend(["--member-set-id", member_set_id]) code = run_cmd_logged(cmd, log_file, cwd=Path(".")) combo_manifest = first_existing_stage_manifest( ws / "final", "combo", prefix, combo_scope_data or {}, analysis_route, member_set_id ) or combo_manifest if combo_scope_data and analysis_route == "subfamily_evidence": materialize_compact_combo_outputs(ws / "final", prefix, combo_manifest) if code != 0: return f"❌ Combo 失败,日志:{log_file}", combo_manifest if not combo_manifest.exists(): return f"⚠️ Combo 完成但未找到manifest。日志:{log_file}", combo_manifest return f"✅ Combo 完成。日志:{log_file}", combo_manifest if stage == "all": msgs = [] if not (ws / "final" / f"{prefix}.identify.manifest.json").exists(): m1, manifest = run_stage("identify", job, prefix, cpu=cpu, required_pfams=required_pfams, ref_species=ref_species, retention_days=retention_days) msgs.append(m1) else: msgs.append("✅ Identify 已完成,跳过") m2, manifest = run_stage("phylogeny", job, prefix, cpu=cpu) msgs.append(m2) m3, manifest = run_stage("structure", job, prefix, cpu=cpu) msgs.append(m3) m4, manifest = run_stage("domain", job, prefix, cpu=cpu) msgs.append(m4) m5, manifest = run_stage("motif", job, prefix, cpu=cpu) msgs.append(m5) m6, manifest = run_stage("promoter", job, prefix, cpu=cpu) msgs.append(m6) m7, manifest = run_stage("combo", job, prefix, cpu=cpu) msgs.append(m7) # ppi m8, manifest = run_stage("ppi", job, prefix, cpu=cpu, ppi_species=ppi_species, ppi_score=ppi_score) msgs.append(m8) # summary m9, manifest = run_stage("summary", job, prefix, cpu=cpu) msgs.append(m9) # ========== Ortholog 阶段 ========== if stage == "ortholog": script = Path("apps/cli/run_ortholog.py") if not script.exists(): return "❌ Ortholog script not found: apps/cli/run_ortholog.py", manifest ortholog_dir = ws / "ortholog" ortholog_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) ortholog_scope_path = None ortholog_scope_data = None family = prefix.split(".")[-1] if "." in prefix else prefix run_specs = [] reference_species = "" ppi_owner_storage_key = str(ppi_reference_storage_key or "").strip() ppi_owner_taxon_id = str(ppi_reference_taxon_id or "").strip() ppi_owned_producer = bool( str(dependency_owner or "").strip().lower() == "ppi" and ppi_reference_user_confirmed and ppi_owner_storage_key ) if scope_file: ortholog_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not ortholog_scope_path.exists(): return f"❌ Ortholog scope 文件不存在: {scope_file}", manifest try: ortholog_scope_data = read_scope(ortholog_scope_path) except Exception as exc: return f"❌ Ortholog scope 文件无法读取: {scope_file}; {exc}", manifest family = str(ortholog_scope_data.get("family") or family) targets = canonicalize_species_codes_for_job(in_dir, [str(x) for x in ortholog_scope_data.get("target_species", []) if str(x)]) references = ( [ppi_owner_storage_key] if ppi_owned_producer else canonicalize_species_codes_for_job( in_dir, [str(x) for x in ortholog_scope_data.get("reference_species", []) if str(x)], ) ) if not targets: return "❌ Ortholog scope 中没有 target_species,无法确定 query 物种。", manifest if not references: return "❌ Ortholog scope 中没有 reference_species,无法确定 subject/reference 物种。", manifest reference_species = references[0] if ppi_owned_producer: subject_fasta = resolve_species_sequence_file( ws, final_dir, in_dir, ortholog_scope_data, reference_species, family, "pep", family_only=False, ) else: subject_fasta = first_existing_scope_file( ortholog_scope_data, reference_species, ["family_pep", "pep"], ) if not subject_fasta: subject_fasta = resolve_species_family_pep( ws, final_dir, in_dir, reference_species, family, ) if not subject_fasta: subject_fasta = resolve_species_sequence_file( ws, final_dir, in_dir, ortholog_scope_data, reference_species, family, "pep", family_only=False, ) if not subject_fasta: return ( f"❌ Ortholog stage requires reference protein FASTA for {reference_species}. " f"Expected {reference_species}.{family}.pep.fa or {reference_species}.pep.fasta.", manifest, ) for species in targets: query_fasta = resolve_species_family_pep(ws, final_dir, in_dir, species, family) if not query_fasta: return ( f"❌ Ortholog stage requires target family protein FASTA for {species}. " f"Expected identify/{species}/final/{species}.final_gfmembers.pep.fa or {species}.{family}.pep.fa.", manifest, ) run_specs.append((species, reference_species, query_fasta, subject_fasta, f"{prefix}.{species}_to_{reference_species}")) else: query_fasta = final_dir / f"{prefix}.final_members.pep.fa" if not query_fasta.exists(): query_fasta = final_dir / f"{prefix}.final_members.pep.fasta" if not query_fasta.exists(): return "❌ Ortholog stage requires identify protein output. Please run identify first.", manifest ref_candidates = [ in_dir / "Ath.pep.fasta", in_dir / "Ath.pep.fa", in_dir / "At.pep.fasta", in_dir / "At.pep.fa", in_dir / f"At.{family}.pep.fa", in_dir / f"At.{family}.pep.fasta", in_dir / "SPL_Ath.fasta", ] subject_fasta = next((p for p in ref_candidates if p.exists()), None) if subject_fasta is None: return "❌ Ortholog stage requires reference protein FASTA, e.g. At.SPL.pep.fa, Ath.pep.fasta or SPL_Ath.fasta.", manifest reference_species = subject_fasta.stem.split(".")[0] run_specs.append(("", reference_species, query_fasta, subject_fasta, prefix)) from gfa_core.ortholog_materialize import ( OrthologRunRecord, build_ortholog_run_identity, build_run_record_from_spec, materialize_ortholog_final_outputs, ortholog_run_identity_matches, ortholog_pairs_has_data, write_ortholog_run_sidecar, ) ortholog_owner = str(dependency_owner or "").strip().lower() if ortholog_owner not in {"", "ppi", "kaks"}: return f"❌ Unsupported Ortholog dependency owner: {dependency_owner}", manifest scope_fields: dict[str, Any] = {} if ortholog_scope_data: source_scope_mode = str(ortholog_scope_data.get("scope_mode") or "") preserve_target_only_scope = ppi_owned_producer effective_scope_id = str(ortholog_scope_data.get("scope_id") or "") if ppi_owned_producer: effective_scope_id = _effective_ppi_owner_scope_id( family=family, selected_targets=[record[0] for record in run_specs], scope_data=ortholog_scope_data, reference_storage_key=reference_species, ) scope_fields = { "scope_file": str(ortholog_scope_path) if source_scope_mode == "target_only" else "", "scope_id": effective_scope_id, "scope_mode": "target_only" if ppi_owned_producer else ortholog_scope_data.get("scope_mode", ""), "target_species": [record[0] for record in run_specs], "reference_species": ( [] if ppi_owned_producer else [reference_species] if reference_species else [] ), "comparative_species": [] if ppi_owned_producer else ortholog_scope_data.get("comparative_species", []), "selected_species": ( [record[0] for record in run_specs] if ppi_owned_producer else [record[0] for record in run_specs] + ([reference_species] if reference_species else []) ), "family": family, } run_records: list[OrthologRunRecord] = [] run_identities: list[dict[str, Any]] = [] for query_species, subject_species, query_fasta, subject_fasta, run_prefix in run_specs: pairs_path = ortholog_dir / f"{run_prefix}_pairs.txt" expected_identity = build_ortholog_run_identity( query_species=query_species, reference_taxon_id=ppi_owner_taxon_id if ppi_owned_producer else "", reference_storage_key=reference_species, query_fasta=query_fasta, reference_pep=subject_fasta, scope_id=str(scope_fields.get("scope_id") or ""), member_set_id="" if ppi_owned_producer else member_set_id, mapping_path=pairs_path, ) existing = None if ortholog_run_identity_matches(pairs_path, expected_identity): existing = build_run_record_from_spec( ortholog_dir, query_species=query_species, subject_species=subject_species, query_fasta=query_fasta, subject_fasta=subject_fasta, run_prefix=run_prefix, ) if existing is not None: print(f"[INFO] Reusing identity-matched ortholog pairs for {query_species or 'query'} -> {subject_species}: {existing.pairs_src}") run_records.append(existing) run_identities.append(expected_identity) continue cmd = [ sys.executable, str(script), "--query", str(query_fasta), "--subject", str(subject_fasta), "--output", str(ortholog_dir), "--prefix", run_prefix, "--evalue", str(blast_evalue), ] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ Ortholog analysis failed for {query_species or 'query'} -> {subject_species} (exit code: {code}). Log: {log_file}", manifest record = build_run_record_from_spec( ortholog_dir, query_species=query_species, subject_species=subject_species, query_fasta=query_fasta, subject_fasta=subject_fasta, run_prefix=run_prefix, ) if record is None: pairs_src = ortholog_dir / f"{run_prefix}_pairs.txt" detail = "empty pairs file" if pairs_src.exists() else "pairs file not found" return ( f"⚠️ Ortholog finished but valid pairs not found for {query_species or 'query'} -> {subject_species} ({detail}). Log: {log_file}", manifest, ) completed_identity = build_ortholog_run_identity( query_species=query_species, reference_taxon_id=ppi_owner_taxon_id if ppi_owned_producer else "", reference_storage_key=reference_species, query_fasta=query_fasta, reference_pep=subject_fasta, scope_id=str(scope_fields.get("scope_id") or ""), member_set_id="" if ppi_owned_producer else member_set_id, mapping_path=record.pairs_src, ) write_ortholog_run_sidecar(record.pairs_src, completed_identity) run_records.append(record) run_identities.append(completed_identity) try: pair_count, ortholog_manifest = materialize_ortholog_final_outputs( final_dir=final_dir, prefix=prefix, dependency_owner=ortholog_owner, run_records=run_records, job_id=job.job_id, workspace=ws, reference_species=reference_species, reference_taxon_id=ppi_owner_taxon_id if ppi_owned_producer else "", reference_storage_key=reference_species, ortholog_identity=run_identities[0] if run_identities else None, analysis_route=analysis_route, member_set_id=member_set_id, scope_fields=scope_fields, blast_evalue=blast_evalue, cpu=cpu, ) except Exception as exc: traceback.print_exc() intermediate = ", ".join(str(record.pairs_src.name) for record in run_records) return ( f"❌ Ortholog post-processing failed after BLAST/intermediate output ({intermediate}): {exc}. Log: {log_file}", manifest, ) return f"✅ Ortholog analysis complete. Found {pair_count} pairs. Log: {log_file}", ortholog_manifest # 生成可复现性文件(如果管理器已初始化) if stage == "ppi": # PPI蛋白互作分析 (技能化版本) # 输入:基因家族ID列表(来自identify阶段) ppi_scope_path = None ppi_scope_data = None ppi_reference_species = "" ppi_selected_targets = [] requested_ppi_species = str(ppi_species or "").strip().lower() explicit_ppi_species = bool(requested_ppi_species and requested_ppi_species != "auto") ppi_owner_intent = str(dependency_owner or "").strip().lower() == "ppi" ppi_owner_taxon_id = str(ppi_reference_taxon_id or "").strip() ppi_owner_auto_fallback = False mapping_file = None def fail_ppi_execution(message: str, diagnostic: Optional[Dict[str, Any]] = None) -> Tuple[str, Path]: current_targets = list(ppi_selected_targets) if not current_targets: requested_targets = target_species if isinstance(target_species, list) else [target_species] current_targets = canonicalize_species_codes_for_job( in_dir, [str(value) for value in requested_targets if str(value or "").strip()] ) if not current_targets: current_targets = [prefix.split(".", 1)[0]] if prefix else [] current_target = current_targets[0] if len(current_targets) == 1 else "" current_identity = resolve_ppi_target_identity(in_dir, current_target) try: current_resolution = resolve_ppi_string_species( load_string_snapshot(STRING_SPECIES_SNAPSHOT_ROOT), current_target, target_scientific_name=str(current_identity.get("scientific_name") or ""), ppi_target_tree_taxon_id=str(current_identity.get("tree_taxon_id") or ""), ) except StringSnapshotError as exc: current_resolution = { "selection_mode": "resource_error", "string_version": "", "snapshot_id": "", "issue_code": exc.code, "requires_user_confirmation": False, } current_ppi_species = str(ppi_species or requested_ppi_species or "") current_taxon_id = ( current_ppi_species if current_ppi_species.isdigit() else string_taxon_id(current_ppi_species, "") ) current_mode = str( ppi_reference_selection_mode or current_resolution.get("selection_mode") or "unresolved" ) current_normalization = str(normalization_species_label or "").strip() if not current_normalization: current_normalization = current_target or current_ppi_species provenance = build_ppi_provenance( raw_target_species=current_targets, target_identity=current_identity, species_resolution=current_resolution, selected_string_taxon_id=current_taxon_id, selection_mode=current_mode, user_confirmed=ppi_reference_user_confirmed, mapping_reference_species=ppi_reference_species or ppi_reference_storage_key, mapping_path=str(mapping_file) if mapping_file else "", normalization_species_label=current_normalization, snapshot_root=STRING_SPECIES_SNAPSHOT_ROOT, status="failed", ) failure = dict(diagnostic or {}) failure.update({ "manifest_version": str(failure.get("manifest_version") or "1.0"), "stage": "ppi", "status": "failed", "job_id": job.job_id, "workspace": str(ws), "workspace_root": str(ws), "error": message, "artifacts": dict(failure.get("artifacts") or {}), }) failed_manifest = write_ppi_failed_manifest(final_dir, prefix, failure) apply_ppi_provenance(failed_manifest, provenance, status="failed", error=message) return message, failed_manifest try: if explicit_ppi_species: ppi_species = resolve_ppi_execution_species(requested_ppi_species) elif ppi_owner_intent: if ppi_owner_taxon_id.isdigit(): ppi_species = ppi_owner_taxon_id if not ppi_reference_user_confirmed: return fail_ppi_execution( "❌ PPI owner STRING taxon must be confirmed before execution." ) if not ppi_owner_taxon_id.isdigit(): return fail_ppi_execution( "❌ PPI owner STRING taxon must be a numeric taxonomy ID before execution." ) ppi_owner_auto_fallback = True else: ppi_species = "" except (StringSnapshotError, ValueError) as exc: return fail_ppi_execution(f"❌ PPI explicit unsupported STRING species: {requested_ppi_species}; {exc}") if scope_file: ppi_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not ppi_scope_path.exists(): return fail_ppi_execution(f"❌ PPI scope 文件不存在: {scope_file}") try: ppi_scope_data = read_scope(ppi_scope_path) except Exception as exc: return fail_ppi_execution(f"❌ PPI scope 文件无法读取: {scope_file}; {exc}") ppi_selected_targets = canonicalize_species_codes_for_job( in_dir, [str(x) for x in ppi_scope_data.get("target_species", []) if str(x)], ) references = canonicalize_species_codes_for_job( in_dir, [str(x) for x in ppi_scope_data.get("reference_species", []) if str(x)], ) ppi_reference_species = references[0] if references else "" target_string_code = species_to_string_code(ppi_selected_targets[0], "") if len(ppi_selected_targets) == 1 else "" target_string_taxon_id = string_taxon_id(target_string_code, "") if target_string_code else "" if len(ppi_selected_targets) == 1: target_identity = resolve_ppi_target_identity(in_dir, ppi_selected_targets[0]) if target_identity.get("status") == "resolved": try: target_resolution = resolve_ppi_string_species( load_string_snapshot(STRING_SPECIES_SNAPSHOT_ROOT), ppi_selected_targets[0], target_scientific_name=str(target_identity.get("scientific_name") or ""), ppi_target_tree_taxon_id=str(target_identity.get("tree_taxon_id") or ""), ) if target_resolution.get("selection_mode") == "exact_string": target_string_taxon_id = str(target_resolution.get("selected_string_taxon_id") or "") except StringSnapshotError: target_string_taxon_id = "" if not explicit_ppi_species and not ppi_owner_auto_fallback: if target_string_code: ppi_species = target_string_code elif ppi_reference_species: ppi_species = species_to_string_code(ppi_reference_species, ppi_species) scope_id = str(ppi_scope_data.get("scope_id") or "") selected_execution_taxon_id = string_taxon_id(ppi_species, "") if ppi_species else "" if str(ppi_species or "").isdigit(): selected_execution_taxon_id = str(ppi_species) use_target_string_directly = bool( len(ppi_selected_targets) == 1 and target_string_taxon_id and selected_execution_taxon_id == target_string_taxon_id ) mapping_candidates = [] if not use_target_string_directly: mapping_candidates = ortholog_mapping_candidates( final_dir, prefix, dependency_owner="ppi" if ppi_owner_intent else "", scope_id=scope_id, ) mapping_file = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None) if mapping_file: gene_id_file = ws / "ppi" / "analysis" / f"{prefix}.{sanitize_token(scope_id) if scope_id else 'scope'}.ppi_source_ids.txt" mapped_source_count = build_ppi_gene_ids_from_ortholog_mapping(mapping_file, gene_id_file, ppi_selected_targets) if mapped_source_count == 0: return fail_ppi_execution( f"❌ PPI scope mapping file has no source IDs for target species {ppi_selected_targets}: {mapping_file}" ) if not ppi_species: ppi_species = infer_string_species_from_ortholog_mapping(mapping_file, "") if not ppi_species: diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested_ppi_species or "auto", target_species=",".join(ppi_selected_targets), target_string_supported=False, reference_species=ppi_reference_species, reference_data_source="", ortholog_mapping_used=True, fallback_level="invalid_mapping", fallback_reason="ortholog_mapping_target_species_not_supported_by_string", prefix=prefix, ) return fail_ppi_execution( "❌ PPI 无法从 Ortholog 映射推断 STRING 参考物种;" "请检查映射文件目标物种列,或显式选择受支持的参考物种后重跑 Ortholog。", diagnostic, ) elif len(ppi_selected_targets) == 1: gene_id_file = resolve_single_target_gene_id_file(ws, in_dir, final_dir, ppi_selected_targets[0], prefix) if not gene_id_file: return fail_ppi_execution( f"❌ PPI stage requires {ppi_selected_targets[0]} member IDs or ortholog mapping. Please run Identify/Ortholog first." ) if not use_target_string_directly and not target_string_code and not ppi_reference_species: family = str(ppi_scope_data.get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix)) reference_data_source = "" reference_species = references[0] if references else "" if reference_species: ref_pep = resolve_species_sequence_file( ws, final_dir, in_dir, ppi_scope_data, reference_species, family, "pep", family_only=False, ) reference_data_source = str(ref_pep) if ref_pep else "" diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested_ppi_species or "auto", target_species=ppi_selected_targets[0], target_string_supported=False, reference_species=reference_species, reference_data_source=reference_data_source, ortholog_mapping_used=False, fallback_level="near_reference" if reference_data_source else "reference_data_missing", fallback_reason="target_species_not_supported_by_string", prefix=prefix, ) return fail_ppi_execution( f"❌ PPI requires Ortholog before STRING fallback for target species {ppi_selected_targets[0]}. " "See the failed PPI manifest for details.", diagnostic, ) if ppi_species and not use_target_string_directly and target_string_code != ppi_species: family = str(ppi_scope_data.get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix)) ref_pep = resolve_species_sequence_file( ws, final_dir, in_dir, ppi_scope_data, ppi_reference_species or ppi_species, family, "pep", family_only=False, ) diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested_ppi_species or ppi_species or "auto", target_species=ppi_selected_targets[0], target_string_supported=bool(target_string_code), reference_species=ppi_reference_species or ppi_species, reference_data_source=str(ref_pep) if ref_pep else "", ortholog_mapping_used=False, fallback_level="explicit_reference", fallback_reason="ortholog_mapping_required_for_reference_string_species", prefix=prefix, ) return fail_ppi_execution( f"❌ PPI 使用 {ppi_species} STRING 库前需要先运行 Ortholog,将 {ppi_selected_targets[0]} 成员 ID 映射为参考物种 ID;" f"当前只有本地成员文件 {gene_id_file},不能直接提交到 {ppi_species} STRING。", diagnostic, ) ppi_species = ppi_species or target_string_code else: return fail_ppi_execution("❌ PPI 多 target scope 需要先运行 Ortholog,生成 target→reference 映射后再查询 STRING。") else: try: ppi_member_resolution = resolve_effective_members( workspace=ws, final_dir=final_dir, input_dir=in_dir, prefix=prefix, phase="auto", require="id", ) gene_id_file = ppi_member_resolution.path_id if ppi_member_resolution.warnings: print(f"[WARN] {'; '.join(ppi_member_resolution.warnings)}") print( f"[INFO] PPI member source: {ppi_member_resolution.source_kind} " f"({ppi_member_resolution.member_count} IDs)" ) except MemberResolutionError as exc: return fail_ppi_execution(f"❌ PPI stage requires an effective member ID file: {exc}") if not gene_id_file or not gene_id_file.exists(): return fail_ppi_execution( "❌ PPI stage requires an effective member ID file. Please run Identify or confirm subfamily first." ) if not ppi_species: prefix_species = prefix.split(".", 1)[0] if "." in prefix else "" ppi_species = species_to_string_code(prefix_species, "") if not ppi_species: diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested_ppi_species or "auto", target_species=prefix_species, target_string_supported=False, reference_species="", reference_data_source="", ortholog_mapping_used=False, fallback_level="reference_data_missing", fallback_reason="target_species_not_supported_by_string", prefix=prefix, ) return fail_ppi_execution( f"❌ PPI target species {prefix_species or ''} is not directly supported by STRING; " "provide reference protein data and run Ortholog before fallback.", diagnostic, ) elif explicit_ppi_species: prefix_species = prefix.split(".", 1)[0] if "." in prefix else "" target_string_code = species_to_string_code(prefix_species, "") if target_string_code and target_string_code != ppi_species and not mapping_file: diagnostic = build_ppi_species_diagnostic( requested_ppi_species=requested_ppi_species or ppi_species, target_species=prefix_species, target_string_supported=True, reference_species=ppi_species, reference_data_source="", ortholog_mapping_used=False, fallback_level="explicit_reference", fallback_reason="ortholog_mapping_required_for_reference_string_species", prefix=prefix, ) return fail_ppi_execution( f"❌ PPI 使用 {ppi_species} STRING 库前需要 Ortholog 映射;" f"不能把 {prefix_species} 成员 ID 直接提交到 {ppi_species} STRING。", diagnostic, ) if not ppi_species: return fail_ppi_execution( "❌ PPI 无法从 scope/prefix 推断 STRING 物种代码;" "请显式设置 ppiSpecies,或先提供 Ortholog 映射到支持的参考物种。" ) # 检查技能脚本 skill_script = Path("skills/ppi/run.py") if not skill_script.exists(): return fail_ppi_execution( "❌ Authoritative PPI skill not found: skills/ppi/run.py; " "legacy fallback disabled" ) script = skill_script ppi_dir = ws / "ppi" ppi_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) print(f"[PPI] Gene ID file: {gene_id_file.name}") print(f"[PPI] Species: {ppi_species}") effective_normalization_label = str(normalization_species_label or "").strip() if not effective_normalization_label: if mapping_file: effective_normalization_label = ( ppi_reference_storage_key if ppi_reference_storage_key else ppi_reference_species or ppi_species ) elif len(ppi_selected_targets) == 1: effective_normalization_label = target_string_code or ppi_species else: effective_normalization_label = ppi_species print(f"[PPI] Normalization species label: {effective_normalization_label}") print(f"[PPI] Score threshold: {ppi_score}") print("[PPI] Using authoritative skill version") raw_ppi_targets = ppi_selected_targets or ([prefix.split(".", 1)[0]] if prefix else []) raw_ppi_target = raw_ppi_targets[0] if len(raw_ppi_targets) == 1 else "" submitted_target_identity = resolve_ppi_target_identity(in_dir, raw_ppi_target) try: submitted_species_resolution = resolve_ppi_string_species( load_string_snapshot(STRING_SPECIES_SNAPSHOT_ROOT), raw_ppi_target, target_scientific_name=str(submitted_target_identity.get("scientific_name") or ""), ppi_target_tree_taxon_id=str(submitted_target_identity.get("tree_taxon_id") or ""), ) except StringSnapshotError as exc: submitted_species_resolution = { "selection_mode": "resource_error", "string_version": "", "snapshot_id": "", "issue_code": exc.code, "requires_user_confirmation": False, } submitted_taxon_id = string_taxon_id(ppi_species, "") if not str(ppi_species).isdigit() else str(ppi_species) submitted_selection_mode = str( ppi_reference_selection_mode or submitted_species_resolution.get("selection_mode") or "unresolved" ) ppi_provenance = build_ppi_provenance( raw_target_species=raw_ppi_targets, target_identity=submitted_target_identity, species_resolution=submitted_species_resolution, selected_string_taxon_id=submitted_taxon_id, selection_mode=submitted_selection_mode, user_confirmed=ppi_reference_user_confirmed, mapping_reference_species=ppi_reference_species or ppi_reference_storage_key, mapping_path=str(mapping_file) if mapping_file else "", normalization_species_label=effective_normalization_label, snapshot_root=STRING_SPECIES_SNAPSHOT_ROOT, status="submitted", ) ppi_provenance_path = ppi_dir / f"{prefix}.ppi_provenance.json" ppi_provenance_path.write_text( json.dumps(ppi_provenance, ensure_ascii=False, indent=2), encoding="utf-8", ) # Do not opportunistically pick stale ortholog/KaKs/synteny files here. # PPI fallback must use scope-derived or explicit mapping evidence. if mapping_file: print(f"[PPI] Found ortholog mapping file: {mapping_file}") # STRING TaxID is the remote query identity; local reference files use # the stable storage key selected by the PPI resolver. ppi_storage_key = str(ppi_reference_storage_key or "").strip() if not ppi_storage_key and str(ppi_reference_species).startswith("string_taxon_"): ppi_storage_key = str(ppi_reference_species) if not ppi_storage_key: ppi_storage_key = str(ppi_species) id_map_file = Path(job.input_dir) / ppi_storage_key / f"{ppi_storage_key}.pep_cds_idmap.tsv" if mapping_file and not id_map_file.is_file(): reference_family = str( (ppi_scope_data or {}).get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix) ) reference_pep = resolve_species_sequence_file( ws, final_dir, in_dir, ppi_scope_data or {}, ppi_storage_key, reference_family, "pep", family_only=False, ) reference_cds = resolve_species_sequence_file( ws, final_dir, in_dir, ppi_scope_data or {}, ppi_storage_key, reference_family, "cds", family_only=False, ) if reference_pep and reference_cds: try: _build_reference_bundle_id_map( species_code=ppi_storage_key, pep_path=reference_pep, cds_path=reference_cds, output_path=id_map_file, ) except (OSError, UnicodeError, csv.Error) as exc: print(f"[PPI] Could not build species-matched ID map: {exc}") if mapping_file and not id_map_file.is_file(): return fail_ppi_execution( f"❌ PPI projected analysis requires species-matched ID map: {id_map_file}" ) cmd = [ sys.executable, str(script), "--gene-ids", str(gene_id_file), "--species", ppi_species, "--normalization-species-label", effective_normalization_label, "--prefix", prefix, "--output", str(ppi_dir), "--score", str(ppi_score), "--provenance-json", str(ppi_provenance_path), ] if mapping_file: cmd.extend(["--mapping", str(mapping_file)]) if id_map_file.is_file(): cmd.extend(["--id-map", str(id_map_file)]) print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) # 技能化版本:复制产物到 final/ if code == 0: print(f"[PPI] Copying artifacts to final/...") # 技能实际生成的文件 -> final/目标文件名 ppi_file_mappings = { f"{prefix}_network.tsv": f"{prefix}.ppi_network.tsv", f"{prefix}_report.md": f"{prefix}.ppi_report.md", f"{prefix}_url.txt": f"{prefix}.ppi_url.txt", f"{prefix}_id_resolution.tsv": f"{prefix}.ppi_id_resolution.tsv", f"{prefix}.manifest.json": f"{prefix}.ppi.manifest.json", } copied = 0 for src_name, dst_name in ppi_file_mappings.items(): src = ppi_dir / src_name if src.exists(): dst = final_dir / dst_name shutil.copy2(src, dst) print(f"[PPI] Copied: {src.name} -> {dst.name}") copied += 1 print(f"[PPI] Copied {copied} files to final/") sync_ppi_manifest_final_artifacts(final_dir / f"{prefix}.ppi.manifest.json", final_dir, prefix) ppi_manifest_after_copy = final_dir / f"{prefix}.ppi.manifest.json" if ppi_manifest_after_copy.exists(): apply_ppi_provenance(ppi_manifest_after_copy, ppi_provenance, status="success") if code == 0: # 读取 STRING URL url_file = final_dir / f"{prefix}.ppi_url.txt" string_url = None if url_file.exists(): with open(url_file) as f: for line in f: if line.startswith("View online:"): string_url = line.replace("View online:", "").strip() break string_image_file = None if string_url: string_image_path = final_dir / f"{prefix}.ppi_network.string.png" string_svg_path = string_image_path.with_suffix(".svg") for stale_path in (string_image_path, string_svg_path): if not stale_path.exists(): continue try: stale_path.unlink() except Exception as exc: print(f"[PPI] Could not remove stale STRING image: {exc}") string_image_file = download_string_network_image( string_url, string_image_path, ) if string_image_file: print(f"[PPI] STRING network image saved: {string_image_file.name}") alphafold2_links_file = create_alphafold2_links( id_resolution_file=final_dir / f"{prefix}.ppi_id_resolution.tsv", network_file=final_dir / f"{prefix}.ppi_network.tsv", output_file=final_dir / f"{prefix}.ppi_alphafold2_links.tsv", ) if alphafold2_links_file: print(f"[PPI] AlphaFold DB/Fold2 link table saved: {alphafold2_links_file.name}") # 统计输出文件 ppi_files = list(final_dir.glob("*.ppi_network.tsv")) file_count = len(ppi_files) ppi_manifest = final_dir / f"{prefix}.ppi.manifest.json" if not ppi_manifest.exists(): ppi_artifacts = {} network_file = final_dir / f"{prefix}.ppi_network.tsv" if network_file.exists(): ppi_artifacts["ppi_network"] = { "artifact": "ppi_network", "path": f"final/{network_file.name}", "full_path": str(network_file.absolute()), "type": "table", "format": "tsv", "size": network_file.stat().st_size, "sha256": _sha256_file(network_file), } if url_file.exists(): ppi_artifacts["string_url"] = { "artifact": "string_url", "path": f"final/{url_file.name}", "full_path": str(url_file.absolute()), "type": "link", "format": "txt", "size": url_file.stat().st_size, "sha256": _sha256_file(url_file), } if alphafold2_links_file and alphafold2_links_file.exists(): ppi_artifacts["alphafold2_links"] = { "artifact": "alphafold2_links", "path": f"final/{alphafold2_links_file.name}", "full_path": str(alphafold2_links_file.absolute()), "type": "table", "format": "tsv", "size": alphafold2_links_file.stat().st_size, "sha256": _sha256_file(alphafold2_links_file), "source": "AlphaFold DB links derived from STRING PPI outputs", "structure_type": "AlphaFold DB/Fold2 single protein", } if ppi_artifacts: ppi_manifest_data = { "version": "1.0", "stage": "ppi", "status": "success", "prefix": prefix, "job_id": job.job_id, "workspace": str(ws), "workspace_root": str(ws), "created_at": datetime.now().isoformat(), "inputs": [ {"type": "gene_id_file", "path": str(gene_id_file)}, {"type": "ortholog_mapping", "path": str(mapping_file) if mapping_file else ""}, ], "parameters": { "prefix": prefix, "species": ppi_species, "string_taxon_id": string_taxon_id(ppi_species, "") if not str(ppi_species).isdigit() else ppi_species, "normalization_species_label": effective_normalization_label, "score": ppi_score, }, "metrics": { "ppi_network_files": file_count, }, "artifacts": ppi_artifacts, } with ppi_manifest.open("w", encoding="utf-8") as handle: json.dump(ppi_manifest_data, handle, ensure_ascii=False, indent=2) if string_image_file and ppi_manifest.exists(): try: with ppi_manifest.open("r", encoding="utf-8") as handle: ppi_manifest_data = json.load(handle) string_image_format = string_image_file.suffix.lower().lstrip(".") or "png" ppi_manifest_data.setdefault("artifacts", {})["string_network_image"] = { "artifact": "string_network_image", "path": f"final/{string_image_file.name}", "full_path": str(string_image_file.absolute()), "type": string_image_format, "format": string_image_format, "size": string_image_file.stat().st_size, "sha256": _sha256_file(string_image_file), "source": "STRING network image API", } with ppi_manifest.open("w", encoding="utf-8") as handle: json.dump(ppi_manifest_data, handle, ensure_ascii=False, indent=2) except Exception as exc: print(f"[PPI] Could not record STRING image in manifest: {exc}") if alphafold2_links_file and ppi_manifest.exists(): try: with ppi_manifest.open("r", encoding="utf-8") as handle: ppi_manifest_data = json.load(handle) ppi_manifest_data.setdefault("artifacts", {})["alphafold2_links"] = { "artifact": "alphafold2_links", "path": f"final/{alphafold2_links_file.name}", "full_path": str(alphafold2_links_file.absolute()), "type": "table", "format": "tsv", "size": alphafold2_links_file.stat().st_size, "sha256": _sha256_file(alphafold2_links_file), "source": "AlphaFold DB links derived from STRING PPI outputs", "structure_type": "AlphaFold DB/Fold2 single protein", } with ppi_manifest.open("w", encoding="utf-8") as handle: json.dump(ppi_manifest_data, handle, ensure_ascii=False, indent=2) except Exception as exc: print(f"[PPI] Could not record AlphaFold DB/Fold2 links in manifest: {exc}") if ppi_scope_path and ppi_scope_data: annotate_stage_manifest_with_scope( ppi_manifest, prefix, "ppi", ppi_scope_path, ppi_scope_data, extra_parameters={ "analysis_route": analysis_route, "member_set_id": member_set_id, "gene_id_file": str(gene_id_file), "ortholog_mapping": str(mapping_file) if mapping_file else "", "string_species": ppi_species, "string_taxon_id": string_taxon_id(ppi_species, "") if not str(ppi_species).isdigit() else ppi_species, "normalization_species_label": effective_normalization_label, "reference_species": ppi_reference_species, }, ) ppi_scope_id = str(ppi_scope_data.get("scope_id") or "") if ppi_scope_id: scoped_ppi_manifest = final_dir / f"{prefix}.{sanitize_token(ppi_scope_id)}.ppi.manifest.json" if scoped_ppi_manifest.exists(): ppi_manifest = scoped_ppi_manifest # 返回消息包含 STRING 链接 if string_url: return f"✅ PPI analysis completed. {file_count} files in final/. View STRING Network", ppi_manifest else: return f"✅ PPI analysis completed. {file_count} files in final/", ppi_manifest else: ppi_manifest = final_dir / f"{prefix}.ppi.manifest.json" if not ppi_manifest.exists(): write_ppi_failed_manifest(final_dir, prefix, { "manifest_version": "1.0", "stage": "ppi", "status": "failed", "job_id": job.job_id, "workspace": str(ws), "workspace_root": str(ws), "parameters": { "string_taxon_id": submitted_taxon_id, "normalization_species_label": effective_normalization_label, }, "artifacts": {}, }) apply_ppi_provenance( ppi_manifest, ppi_provenance, status="failed", error=f"PPI analysis failed (exit code: {code}). Log: {log_file}", ) return f"❌ PPI analysis failed (exit code: {code}). Log: {log_file}", ppi_manifest if stage == "kaks": # Ka/Ks分析 # 输入:需要两个物种的CDS和蛋白序列,以及同源基因对列表 kaks_dir = ws / "kaks" kaks_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(exist_ok=True) if scope_file: kaks_scope_path = resolve_scope_path(scope_file, in_dir, ws) if not kaks_scope_path.exists(): return f"❌ KaKs scope 文件不存在: {scope_file}", manifest try: kaks_scope_data = read_scope(kaks_scope_path) except Exception as exc: return f"❌ KaKs scope 文件无法读取: {scope_file}; {exc}", manifest family = str(kaks_scope_data.get("family") or (prefix.split(".", 1)[1] if "." in prefix else prefix)) target_species_list = [str(x) for x in kaks_scope_data.get("target_species", []) if str(x)] reference_species_list = [str(x) for x in kaks_scope_data.get("reference_species", []) if str(x)] if not target_species_list: return "❌ KaKs scope 中没有 target_species,无法确定物种1。", manifest if not reference_species_list: return "❌ KaKs scope 中没有 reference_species,无法确定物种2/reference。", manifest ref_species = reference_species_list[0] scope_id = str(kaks_scope_data.get("scope_id") or "") candidate_prefixes = [prefix] for target_species_name in target_species_list: if family: candidate_prefixes.extend([ f"{target_species_name}.{family}", f"Target.{target_species_name}.{family}", ]) candidate_prefixes = list(dict.fromkeys(str(x) for x in candidate_prefixes if str(x))) selected_mapping_owner = str(ortholog_mapping_owner or "").strip().lower() if selected_mapping_owner not in {"", "ppi", "kaks"}: return f"❌ KaKs Ortholog 映射所有者无效: {ortholog_mapping_owner}", manifest mapping_candidates = [] for candidate_prefix in candidate_prefixes: mapping_candidates.extend(ortholog_mapping_candidates( final_dir, candidate_prefix, dependency_owner=selected_mapping_owner, scope_id=scope_id, )) if not selected_mapping_owner: mapping_candidates.append(in_dir / "ortholog_pairs.txt") scoped_pairs_source = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None) if not scoped_pairs_source: owner_hint = f"({selected_mapping_owner} 专属)" if selected_mapping_owner else "" return f"❌ KaKs scope 模式需要{owner_hint} Ortholog 映射文件。", manifest ref_cds = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, ref_species, family, "cds") ref_pep = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, ref_species, family, "pep", family_only=False) if not ref_cds: return f"❌ KaKs 缺少参考物种 {ref_species} CDS 文件,例如 {ref_species}.cds.fasta 或全基因组 CDS。", manifest if not ref_pep: return f"❌ KaKs 缺少参考物种 {ref_species} 全蛋白文件;Ka/Ks 要求 reference CDS 和 PEP 来自同一套注释。", manifest run_records = [] for target_species_name in target_species_list: target_cds = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, target_species_name, family, "cds") target_pep = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, target_species_name, family, "pep", family_only=True) target_gene_list = resolve_single_target_gene_id_file(ws, in_dir, final_dir, target_species_name, prefix) if not target_cds: return f"❌ KaKs 缺少目标物种 {target_species_name} CDS 文件,例如 {target_species_name}.cds.fasta。", manifest if not target_pep: return f"❌ KaKs 缺少目标物种 {target_species_name} 家族蛋白文件。请先运行 Identify。", manifest pair_file = kaks_dir / "analysis" / f"{target_species_name}_to_{ref_species}.ortholog_pairs.tsv" pair_count = write_species_filtered_pairs(scoped_pairs_source, pair_file, target_species_name, ref_species) if pair_count == 0: print(f"[KaKs] No pairs for {target_species_name}->{ref_species}, skipping.") continue run_prefix = f"{prefix}.{target_species_name}_to_{ref_species}" kaks_cmd = [ sys.executable, "apps/cli/run_kaks_v2.py", "-o", str(kaks_dir), "--prefix-name", run_prefix, "--cds1", str(target_cds), "--cds2", str(ref_cds), "--pep1", str(target_pep), "--pep2", str(ref_pep), "--pairs", str(pair_file), "--neutral-rate", str(kaks_neutral_rate), ] if target_gene_list: kaks_cmd.extend(["--gene-list1", str(target_gene_list)]) print(f"[CMD] {' '.join(kaks_cmd)}") code = run_cmd_logged(kaks_cmd, log_file, cwd=Path(".")) if code != 0: return f"❌ Ka/Ks analysis failed for {target_species_name}->{ref_species} (exit code: {code}). Log: {log_file}", manifest run_records.append({ "target_species": target_species_name, "reference_species": ref_species, "prefix": run_prefix, "pairs": str(pair_file.resolve()), "pair_count": pair_count, "cds1": str(Path(target_cds).resolve()), "cds2": str(Path(ref_cds).resolve()), "pep1": str(Path(target_pep).resolve()), "pep2": str(Path(ref_pep).resolve()), "manifest": str((final_dir / f"{run_prefix}.kaks.manifest.json").resolve()), "results": str((final_dir / f"{run_prefix}.kaks_results.csv").resolve()), }) if not run_records: return f"❌ KaKs 没有可分析的 {','.join(target_species_list)}->{ref_species} ortholog pairs。", manifest artifacts = {} for record in run_records: result_path = Path(record["results"]) manifest_path = Path(record["manifest"]) key_base = f"{record['target_species']}_to_{record['reference_species']}" if result_path.exists(): result_artifact = { "artifact": f"{key_base}_kaks_results", "path": f"final/{result_path.name}", "full_path": str(result_path.resolve()), "type": "table", "format": "csv", "size": result_path.stat().st_size, "sha256": _sha256_file(result_path), } artifacts[f"{key_base}_kaks_results"] = result_artifact artifacts.setdefault("kaks_results", {**result_artifact, "artifact": "kaks_results"}) if manifest_path.exists(): artifacts[f"{key_base}_manifest"] = { "artifact": f"{key_base}_manifest", "path": f"final/{manifest_path.name}", "full_path": str(manifest_path.resolve()), "type": "json", "format": "json", "size": manifest_path.stat().st_size, "sha256": _sha256_file(manifest_path), } scope_fields = { "scope_file": str(kaks_scope_path), "scope_id": kaks_scope_data.get("scope_id", ""), "scope_mode": kaks_scope_data.get("scope_mode", ""), "target_species": kaks_scope_data.get("target_species", []), "reference_species": kaks_scope_data.get("reference_species", []), "comparative_species": kaks_scope_data.get("comparative_species", []), "selected_species": kaks_scope_data.get("selected_species", []), "family": family, } aggregate_metrics = _aggregate_kaks_run_metrics(run_records) aggregate_mean_ka_ks = _float_or_none(aggregate_metrics.get("mean_ka_ks")) if aggregate_mean_ka_ks is None: aggregate_selection_pressure = "Unknown" elif aggregate_mean_ka_ks > 1: aggregate_selection_pressure = "Positive" elif aggregate_mean_ka_ks < 1: aggregate_selection_pressure = "Purifying" else: aggregate_selection_pressure = "Neutral" aggregate_manifest = final_dir / f"{prefix}.kaks.manifest.json" manifest_data = { "manifest_version": "1.0", "stage": "kaks", "status": "success", "prefix": prefix, "job_id": job.job_id, "workspace": str(ws), "workspace_root": str(ws), "created_at": datetime.now().isoformat(), "parameters": { "prefix": prefix, "analysis_route": analysis_route, "member_set_id": member_set_id, "neutral_rate": kaks_neutral_rate, "ortholog_mapping": str(scoped_pairs_source), "reference_cds": str(ref_cds), "reference_pep": str(ref_pep), **scope_fields, }, "metrics": aggregate_metrics, "method_summary": { "scope_mode": "target_reference_aggregate", "run_count": len(run_records), "aggregation": "weighted_by_successful_pairs", "note": "Per-comparison Ka/Ks metrics are preserved in metrics.run_summaries and runs.", }, "comparative_summary": { "total_pairs": aggregate_metrics.get("total_pairs", 0), "successful_pairs": aggregate_metrics.get("successful_pairs", 0), "positive_selection_count": aggregate_metrics.get("positive_selection_count", 0), "purifying_selection_count": aggregate_metrics.get("purifying_selection_count", 0), "neutral_selection_count": aggregate_metrics.get("neutral_selection_count", 0), "mean_ka_ks": aggregate_metrics.get("mean_ka_ks"), "mean_ks": aggregate_metrics.get("mean_ks"), "selection_pressure": aggregate_selection_pressure, }, "analysis_route": analysis_route, "member_set_id": member_set_id, "runs": run_records, "artifacts": artifacts, **scope_fields, } aggregate_manifest.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") if scope_fields.get("scope_id"): scoped_manifest = final_dir / f"{prefix}.{sanitize_token(str(scope_fields['scope_id']))}.kaks.manifest.json" manifest_data["scope_manifest"] = str(scoped_manifest.absolute()) scoped_manifest.write_text(json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8") aggregate_manifest = scoped_manifest return f"✅ Ka/Ks analysis completed. {len(run_records)} species comparison(s) in final/", aggregate_manifest # 检查输入文件 cds1_file = in_dir / "cds1.fasta" cds2_file = in_dir / "cds2.fasta" pep1_file = in_dir / "pep1.fasta" pep2_file = in_dir / "pep2.fasta" pairs_file = in_dir / "ortholog_pairs.txt" target_species = prefix.split('.')[0] if prefix else "Ft" gene_family = prefix.split('.', 1)[1] if prefix and '.' in prefix else "" requested_ref_species = ref_species if isinstance(requested_ref_species, (list, tuple)): requested_ref_species = requested_ref_species[0] if requested_ref_species else "" ref_species = str(requested_ref_species or "").strip() or "Ath" inter_manifest = final_dir / f"{prefix}.interspecies_synteny.manifest.json" if inter_manifest.exists(): try: inter_data = json.loads(inter_manifest.read_text(encoding="utf-8")) params = inter_data.get("parameters") or {} refs = params.get("selected_ref_species") or params.get("ref_species") or [] targets = params.get("selected_target_species") or params.get("target_species") or [] if targets: target_species = targets[0] if refs: ref_species = refs[0] except Exception as exc: print(f"[KaKs] Warning: failed to read interspecies manifest: {exc}") # 自动查找同源基因对文件(支持多种位置) # 优先查找 .anchors 文件(种间共线性分析结果) if not pairs_file.exists(): pairs_candidates = [ final_dir / f"{prefix}.orthologs.txt", final_dir / f"{prefix}.ortholog_pairs.txt", ws / "interspecies_synteny" / "analysis" / f"{target_species}.{ref_species}.anchors", ws / "interspecies_synteny" / "analysis" / "Ft.Ath.anchors", ws / "interspecies_synteny" / "analysis" / f"{prefix.split('.')[0] if prefix else 'Ft'}.Ath.anchors", ws / "interspecies_synteny" / "analysis" / f"{target_species}.{ref_species}.anchors.simple", in_dir / "Ft.Ath.anchors", in_dir / "ortholog_pairs.txt", ws / "kaks_analysis" / "ortholog_pairs.txt", ws / "kaks_analysis" / "ortholog_pairs_fixed.txt", in_dir / "Ft_Ath_orthologs.txt", in_dir / "orthologs.txt", ] for candidate in pairs_candidates: if candidate.exists(): pairs_file = candidate print(f"[KaKs] Found ortholog pairs file: {pairs_file}") break # 自动查找CDS和蛋白序列文件 if not cds1_file.exists(): cds1_candidates = [ in_dir / "Ft.cds.fasta", in_dir / f"{target_species}.cds.fasta", in_dir / target_species / f"{target_species}.cds.fasta", ] for candidate in cds1_candidates: if candidate.exists(): cds1_file = candidate print(f"[KaKs] Found CDS1 file: {cds1_file}") break if not cds2_file.exists(): cds2_candidates = [ in_dir / f"{ref_species}.cds.fasta", in_dir / ref_species / f"{ref_species}.cds.fasta", in_dir / "Ath.cds.fasta", in_dir / "ath.cds.fasta", ] for candidate in cds2_candidates: if candidate.exists(): cds2_file = candidate print(f"[KaKs] Found CDS2 file: {cds2_file}") break if not pep1_file.exists(): pep1_candidates = [ in_dir / "Ft.pep.fasta", in_dir / f"{target_species}.pep.fasta", in_dir / target_species / f"{target_species}.pep.fasta", final_dir / f"{prefix}.final_members.pep.fa", ] for candidate in pep1_candidates: if candidate.exists(): pep1_file = candidate print(f"[KaKs] Found PEP1 file: {pep1_file}") break if not pep2_file.exists(): pep2_candidates = [ in_dir / f"{ref_species}.pep.fasta", in_dir / ref_species / f"{ref_species}.pep.fasta", in_dir / "Ath.pep.fasta", in_dir / "ath.pep.fasta", ] for candidate in pep2_candidates: if candidate.exists(): pep2_file = candidate print(f"[KaKs] Found PEP2 file: {pep2_file}") break # 验证必要文件是否存在 missing_files = [] if not pep1_file.exists(): missing_files.append("pep1.fasta (物种1蛋白序列)") if not pep2_file.exists(): missing_files.append("pep2.fasta (物种2蛋白序列)") if not pairs_file.exists(): missing_files.append("ortholog_pairs.txt (同源基因对)") # 如果缺少CDS文件,尝试从GFF3+Genome提取 if not cds1_file.exists(): print("[KaKs] CDS1 file not found, attempting to extract from GFF3+Genome...") gff1_candidates = [ in_dir / "Ft_final.gff3", in_dir / f"{prefix.split('.')[0] if prefix else 'Ft'}_final.gff3", in_dir / "Ft.gff3", ] genome1_candidates = [ in_dir / "Ft.genome.fasta", in_dir / f"{prefix.split('.')[0] if prefix else 'Ft'}.genome.fasta", in_dir / "Ft.fa", ] gff1_file = None genome1_file = None for candidate in gff1_candidates: if candidate.exists(): gff1_file = candidate break for candidate in genome1_candidates: if candidate.exists(): genome1_file = candidate break if gff1_file and genome1_file: cds1_extracted = in_dir / "Ft.cds.fasta" cmd = [ '/home/xzq/miniconda3/bin/conda', 'run', '-n', 'test4py311', 'gffread', '-x', str(cds1_extracted), '-g', str(genome1_file), str(gff1_file) ] print(f"[KaKs] Extracting CDS1 with conda gffread...") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0 and cds1_extracted.exists() and cds1_extracted.stat().st_size > 0: cds1_file = cds1_extracted print(f"[KaKs] ✅ Successfully extracted CDS1: {cds1_file} ({cds1_extracted.stat().st_size} bytes)") else: print(f"[KaKs] ❌ Failed to extract CDS1: {result.stderr}") missing_files.append("cds1.fasta (物种1 CDS序列,尝试从GFF3提取失败)") else: missing_files.append("cds1.fasta (物种1 CDS序列,缺少GFF3或Genome文件)") if not cds2_file.exists(): print("[KaKs] CDS2 file not found, attempting to extract from GFF3+Genome...") gff2_candidates = [ in_dir / "Ath_final.gff3", in_dir / "ath_final.gff3", in_dir / "Ath.gff3", ] genome2_candidates = [ in_dir / "Ath.genome.fasta", in_dir / "ath.genome.fasta", ] gff2_file = None genome2_file = None for candidate in gff2_candidates: if candidate.exists(): gff2_file = candidate break for candidate in genome2_candidates: if candidate.exists(): genome2_file = candidate break if gff2_file and genome2_file: cds2_extracted = in_dir / "Ath.cds.fasta" cmd = [ '/home/xzq/miniconda3/bin/conda', 'run', '-n', 'test4py311', 'gffread', '-x', str(cds2_extracted), '-g', str(genome2_file), str(gff2_file) ] print(f"[KaKs] Extracting CDS2 with conda gffread...") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0 and cds2_extracted.exists() and cds2_extracted.stat().st_size > 0: cds2_file = cds2_extracted print(f"[KaKs] ✅ Successfully extracted CDS2: {cds2_file} ({cds2_extracted.stat().st_size} bytes)") else: print(f"[KaKs] ❌ Failed to extract CDS2: {result.stderr}") missing_files.append("cds2.fasta (物种2 CDS序列,尝试从GFF3提取失败)") else: # Ath的CDS可能不是必须的,如果用户提供了蛋白序列,我们可以尝试继续 print(f"[KaKs] ⚠️ Cannot extract CDS2: GFF2={gff2_file}, Genome2={genome2_file}") missing_files.append("cds2.fasta (物种2 CDS序列)") if missing_files: return f"❌ KaKs stage missing required files: {', '.join(missing_files)}", manifest print(f"[KaKs] Neutral rate: {kaks_neutral_rate}") print(f"[KaKs] Output dir: {kaks_dir}") print(f"[KaKs] CDS1: {cds1_file}") print(f"[KaKs] CDS2: {cds2_file}") print(f"[KaKs] PEP1: {pep1_file}") print(f"[KaKs] PEP2: {pep2_file}") print(f"[KaKs] Pairs: {pairs_file}") # 查找基因列表文件(用于过滤pairs,只分析特定基因家族) gene_list1_file = None gene_list2_file = None # 物种1的基因列表 if prefix: gene_list1_candidates = [ in_dir / f"{prefix}.geneID.subfamily", # Multi.SPL.geneID.subfamily in_dir / f"{target_species}.geneID.subfamily", # Ft.geneID.subfamily final_dir / f"{prefix}.geneID.subfamily", ] for candidate in gene_list1_candidates: if candidate.exists(): gene_list1_file = candidate print(f"[KaKs] Found gene list 1: {gene_list1_file}") break # 物种2的基因列表(Ath) gene_list2_candidates = [ in_dir / f"{ref_species}.{gene_family}.geneID.subfamily", in_dir / ref_species / f"{ref_species}.{gene_family}.geneID.subfamily", final_dir / f"{ref_species}.{gene_family}.geneID.subfamily", in_dir / "SPL_Ath.subfamily", in_dir / "Ath.geneID.subfamily", ] for candidate in gene_list2_candidates: if candidate.exists(): gene_list2_file = candidate print(f"[KaKs] Found gene list 2: {gene_list2_file}") break # 使用conda环境运行KaKs分析 kaks_cmd = [ "/home/xzq/miniconda3/bin/conda", "run", "-n", "test4py311", "python", "apps/cli/run_kaks_v2.py", "-o", str(kaks_dir), "--prefix-name", prefix, "--cds1", str(cds1_file), "--cds2", str(cds2_file), "--pep1", str(pep1_file), "--pep2", str(pep2_file), "--pairs", str(pairs_file), "--neutral-rate", str(kaks_neutral_rate), ] # 添加基因列表参数(如果找到) if gene_list1_file: kaks_cmd.extend(["--gene-list1", str(gene_list1_file)]) if gene_list2_file: kaks_cmd.extend(["--gene-list2", str(gene_list2_file)]) print(f"[CMD] {' '.join(kaks_cmd)}") code = run_cmd_logged(kaks_cmd, log_file, cwd=Path(".")) if code == 0: # 复制结果到final目录 kaks_results = list(kaks_dir.glob("*.kaks_results.csv")) for f in kaks_results: shutil.copy2(f, final_dir) file_count = len(kaks_results) kaks_manifest = final_dir / f"{prefix}.kaks.manifest.json" return f"✅ Ka/Ks analysis completed. {file_count} files in final/", kaks_manifest else: return f"❌ Ka/Ks analysis failed (exit code: {code}). Log: {log_file}", manifest # ========== Genome Prepare stage ========== if stage == "genome_prepare": script = Path("apps/cli/run_genome_prepare.py") if not script.exists(): return "Genome Prepare script not found: apps/cli/run_genome_prepare.py", manifest # genome_prepare is a data-preparation stage, not a gene-family stage. # Ignore stale UI/plan gene-family prefixes so # prepared reference resources are not labeled with an invented family. genome_prepare_prefix = "genome_prepare" cmd = [ sys.executable, str(script), "--input", str(in_dir), "--output", str(in_dir), "--workspace", str(ws), "--prefix", genome_prepare_prefix, "--scripts-dir", str(Path("scripts").resolve()), ] print(f"[CMD] {' '.join(cmd)}") code = run_cmd_logged(cmd, log_file, cwd=Path(".")) input_final_dir = in_dir / "final" workspace_final_dir = ws / "final" genome_prepare_manifest = None if workspace_final_dir.exists(): manifest_files = sorted(workspace_final_dir.glob("*.genome_prepare.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True) if manifest_files: genome_prepare_manifest = manifest_files[0] if not genome_prepare_manifest and input_final_dir.exists(): manifest_files = sorted(input_final_dir.glob("*.genome_prepare.manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True) if manifest_files: input_manifest = manifest_files[0] workspace_final_dir.mkdir(parents=True, exist_ok=True) workspace_manifest = workspace_final_dir / input_manifest.name try: shutil.copy2(input_manifest, workspace_manifest) genome_prepare_manifest = workspace_manifest except Exception as e: print(f"[WARN] Failed to copy genome_prepare manifest to workspace: {e}") genome_prepare_manifest = input_manifest if not genome_prepare_manifest: genome_prepare_manifest = workspace_final_dir / f"{genome_prepare_prefix or prefix or 'multi_species'}.genome_prepare.manifest.json" def _genome_prepare_summary(path: Path) -> str: if not path or not path.exists(): return "" try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: return f" Manifest read failed: {exc}" metrics = data.get("metrics") or {} preflight = metrics.get("config_preflight") or {} parts = [] invalid = preflight.get("invalid_species") or [] if invalid: parts.append("Skipped by config preflight: " + ", ".join(str(x) for x in invalid)) for item in (preflight.get("row_errors") or [])[:8]: row = item.get("row", "?") species = item.get("species", "?") original = item.get("original") or "-" reason = item.get("reason") or "unknown" parts.append(f"{species} row {row} {original}: {reason}") runtime_failures = [] for result in metrics.get("species_results") or []: if result.get("success"): continue if result.get("error_type") == "config_preflight_failed": continue runtime_failures.append(f"{result.get('species', '?')}: {result.get('error', 'unknown')}") if runtime_failures: parts.append("Runtime failures: " + "; ".join(runtime_failures[:5])) successes = [str(r.get("species")) for r in metrics.get("species_results") or [] if r.get("success")] if successes: parts.append("Executed successfully: " + ", ".join(successes)) return " | " + " | ".join(parts) if parts else "" existing_files = [genome_prepare_manifest] if genome_prepare_manifest.exists() else [] summary = _genome_prepare_summary(genome_prepare_manifest) if code == 0 and genome_prepare_manifest.exists(): return f"Genome Prepare complete. Generated {len(existing_files)} files. Log: {log_file}{summary}", genome_prepare_manifest elif code == 0: return f"Genome Prepare finished but manifest not found in workspace/final. Log: {log_file}", manifest else: return f"Genome Prepare failed (exit code: {code}). Log: {log_file}{summary}", genome_prepare_manifest if genome_prepare_manifest.exists() else manifest if 'repro_manager' in locals() and repro_manager: try: repro_manager.finalize() except Exception as e: print(f"[WARN] Failed to finalize reproducibility: {e}") return f"❌ 未知 stage: {stage}", manifest