Files

4720 lines
230 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
DiagnosisAgent V3 - 错误诊断与自动修复
集成Knowledge查询、自动修复、前端通知和全局迭代计数
"""
import re
import json
import shutil
import shlex
import gzip
import os
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime
from .base import AgentMessage, AgentState, BaseAgent
from gfa_core.mas.llm import LLMManager
from gfa_core.mas.stage_catalog import STAGE_DEPENDENCIES as CATALOG_STAGE_DEPENDENCIES
from gfa_core.project_paths import PROJECT_ROOT
from gfa_core.ppi_provenance import ppi_diagnosis_code
STAGE_SCRIPT_MAP = {
"pop_pca": "run_pop_pca.py",
"pop_phylogeny": "run_pop_phylogeny.py",
"pop_structure": "run_pop_structure.py",
"pop_ld_decay": "run_pop_ld_decay.py",
"pop_snp_density": "run_pop_snp_density.py",
"gwas_fmt": "run_gwas_tassel.py",
"identify": "run_identify.py",
"phylogeny": "run_phylogeny.py",
"motif": "run_motif.py",
"domain": "run_domain.py",
"structure": "run_structure.py",
"promoter": "run_promoter.py",
"synteny": "run_synteny.py",
"interspecies_synteny": "run_interspecies_synteny.py",
"karyotype": "run_karyotype.py",
"combo": "run_combo.py",
"ppi": "run_ppi.py",
"expression": "run_expression.py",
"pep": "run_pep.py",
"rnaseq_qc": "run_rnaseq_qc.py",
"rnaseq_align": "run_rnaseq_align.py",
"rnaseq_quant": "run_rnaseq_quant.py",
"rnaseq_de": "run_rnaseq_de.py",
"rnaseq_enrich": "run_rnaseq_enrich_v2.py",
"rnaseq_go_enrich": "run_rnaseq_enrich.py",
"rnaseq_kegg_enrich": "run_rnaseq_enrich.py",
"rnaseq_fgsea": "run_rnaseq_fgsea.py",
"annotation": "run_annotation.py",
"annotation_go": "run_annotation.py",
"annotation_kegg": "run_annotation.py",
"rnaseq_wgcna": "run_rnaseq_wgcna.py",
"rnaseq_candidate_family": "run_rnaseq_candidate_family.py",
"rnaseq_report": "run_rnaseq_report.py",
"rnaseq_sample_qc": "run_rnaseq_sample_qc.py",
"genome_prepare": "run_genome_prepare.py",
"genome_prepare_v2": "run_genome_prepare.py",
"ortholog": "run_ortholog.py",
"ncbi_fetch_sequences": "run_ncbi_fetch_sequences.py",
"sequence_extract_by_id": "run_sequence_extract_by_id.py",
}
RAP_TRANSCRIPT_RE = re.compile(r"^(Os\d{2})t(\d+)(?:-\d+)?$", re.IGNORECASE)
KNOWN_COPY_PATCH_CODES = {
"python_import_path_error",
"protein_fasta_stop_markers",
"r_table_comment_char",
"success_with_zero_alignment_rate",
"success_with_zero_qc_reads",
"fgsea_rank_gene2ko_id_mismatch",
"wgcna_matrix_comment_char",
"quant_fpkm_tpm_comment_char",
}
RUNTIME_REPAIR_CODES = {
"protein_fasta_stop_markers",
"identify_pfam_remote_404",
"identify_pfam_partial_fetch",
"identify_hmm_model_mismatch",
"pfam_remote_404",
"identify_missing_hmm_or_pfam",
"ncbi_family_reference_link_missing",
"manifest_missing",
"manifest_missing_stage_incomplete",
"ortholog_intermediate_not_materialized",
"genome_prepare_final_gff_empty",
"genome_prepare_pep_looks_like_genome",
"genome_prepare_cds_looks_like_genome",
"gene_gff_id_overlap_low",
}
REPAIR_POLICY_BY_CODE = {
"gwas_fmt_external_vcf_missing": ("report_only", "provide_external_vcf"),
"gwas_fmt_tassel_missing": ("report_only", "configure_tassel_pipeline"),
"gwas_fmt_manifest_failed": ("report_only", "inspect_gwas_fmt_manifest_error"),
"protein_fasta_stop_markers": ("auto", "protein_stop_marker_clean"),
"genome_prepare_final_gff_empty": ("auto", "restore_final_gff_from_source"),
"genome_prepare_pep_looks_like_genome": ("auto", "extract_pep_from_genome_gff"),
"genome_prepare_cds_looks_like_genome": ("auto", "extract_cds_from_genome_gff"),
"gene_gff_id_overlap_low": ("report_only", "write_id_overlap_report"),
"identify_pfam_remote_404": ("auto", "prepare_local_pfam_hmm"),
"identify_pfam_partial_fetch": ("auto", "prepare_local_pfam_hmm"),
"identify_hmm_model_mismatch": ("auto", "prepare_local_pfam_hmm"),
"pfam_remote_404": ("auto", "prepare_local_pfam_hmm"),
"ncbi_family_reference_link_missing": ("auto", "create_family_reference_pep_link"),
"hmm_or_pfam_missing": ("confirm", "provide_hmm_or_required_pfams"),
"identify_missing_hmm_or_pfam": ("auto", "prepare_local_pfam_hmm"),
"manifest_missing_stage_incomplete": ("auto", "recover_manifest_from_final_artifacts"),
"manifest_missing": ("auto", "recover_manifest_from_final_artifacts"),
"ortholog_intermediate_not_materialized": ("auto", "materialize_ortholog_from_intermediate"),
"manifest_failed": ("report_only", "inspect_stage_log"),
"stage_runtime_same_file_copy": ("dev_fix", "guard_same_file_copy"),
"expression_missing_gene_ids": ("report_only", "provide_gene_family_member_ids"),
"expression_missing_sample_txt": ("report_only", "provide_or_convert_sample_table"),
"rnaseq_auto_expression_missing": ("report_only", "rerun_rnaseq_de_or_expression_rnaseq_auto"),
"rnaseq_auto_expression_failed": ("report_only", "inspect_expression_log"),
"rnaseq_context_missing": ("dev_fix", "align_rnaseq_manifest_context"),
"stage_identity_contract_mismatch": ("dev_fix", "align_manifest_identity_with_scope_contract"),
"combo_artifact_scope_mismatch": ("dev_fix", "align_combo_output_naming_with_scope_contract"),
"domain_scoped_identify_guard": ("report_only", "align_domain_backend_guard_with_scope_contract"),
"upstream_genome_prepare_partial_success": ("report_only", "repair_or_exclude_failed_species"),
"plantcare_id_mismatch": ("report_only", "rerun_plantcare_for_current_promoters"),
"multi_target_single_species_stage": ("report_only", "split_target_scope_per_species"),
"kaks_scope_missing_reference": ("dev_fix", "plan_kaks_with_target_plus_reference_scope"),
"interspecies_pair_no_hits": ("report_only", "inspect_proteome_or_relax_similarity_threshold"),
"go_foreground_background_id_mismatch": ("confirm", "use_auto_annotation_background"),
"id_fasta_mismatch": ("report_only", "convert_gene_or_transcript_ids_before_sequence_extraction"),
"blast_binding_or_output_failure": ("report_only", "check_extension_stage_query_reference_binding"),
"misplaced_outputs": ("dev_fix", "fix_extension_wrapper_output_directory"),
"hmmer_no_hits_or_threshold": ("report_only", "inspect_hmmer_hits_and_thresholds"),
"hmmer_postprocess_empty": ("dev_fix", "fix_hmmer_postprocess_parser"),
}
ISSUE_LAYER_BY_CODE = {
"gwas_fmt_external_vcf_missing": "input",
"gwas_fmt_tassel_missing": "tool_runtime",
"gwas_fmt_manifest_failed": "artifact_status",
"multi_target_single_species_stage": "planning",
"kaks_scope_missing_reference": "planning",
"hmm_or_pfam_missing": "input",
"identify_missing_hmm_or_pfam": "input",
"identify_pfam_partial_fetch": "tool_runtime",
"identify_hmm_model_mismatch": "tool_runtime",
"protein_fasta_stop_markers": "input_repair",
"missing_final_member_protein": "artifact_status",
"manifest_missing": "artifact_status",
"manifest_missing_stage_incomplete": "artifact_status",
"ortholog_intermediate_not_materialized": "artifact_status",
"manifest_failed": "artifact_status",
"stage_runtime_same_file_copy": "execution",
"expression_missing_gene_ids": "input",
"expression_missing_sample_txt": "input",
"rnaseq_auto_expression_missing": "execution",
"rnaseq_auto_expression_failed": "execution",
"rnaseq_context_missing": "artifact_status",
"stage_identity_contract_mismatch": "artifact_status",
"combo_artifact_scope_mismatch": "artifact_status",
"domain_scoped_identify_guard": "execution",
"success_with_empty_metrics": "artifact_status",
"success_with_zero_alignment_rate": "artifact_status",
"success_with_zero_qc_reads": "artifact_status",
"tool_version_format": "tool_runtime",
"pfam_remote_404": "tool_runtime",
"identify_pfam_remote_404": "tool_runtime",
"interspecies_pair_no_hits": "tool_runtime",
"id_mismatch": "input_contract",
"plantcare_id_mismatch": "input_contract",
"gene_gff_id_overlap_low": "input_contract",
"go_foreground_background_id_mismatch": "input_contract",
"id_fasta_mismatch": "input_contract",
"blast_binding_or_output_failure": "execution",
"misplaced_outputs": "execution",
"hmmer_no_hits_or_threshold": "tool_runtime",
"hmmer_postprocess_empty": "execution",
}
ISSUE_LAYER_PRIORITY = {
"planning": 0,
"input_contract": 1,
"input": 2,
"tool_runtime": 3,
"artifact_status": 4,
"input_repair": 5,
"report": 6,
}
DIAGNOSIS_STAGE_COVERAGE = {
"pop_pca": {"preflight": ["population_vcf", "plink"], "postflight": ["scientific_identity", "required_artifacts"], "failure": ["manifest_error"]},
"pop_phylogeny": {"preflight": ["population_vcf", "tassel", "fasttree"], "postflight": ["alignment_qc", "tree_qc"], "failure": ["manifest_error"]},
"pop_structure": {"preflight": ["population_vcf", "plink", "admixture", "k_range"], "postflight": ["all_k", "q_fam", "best_k"], "failure": ["manifest_error"]},
"pop_ld_decay": {"preflight": ["population_vcf", "group_qc", "poplddecay"], "postflight": ["statistics", "plots"], "failure": ["manifest_error"]},
"pop_snp_density": {"preflight": ["population_vcf", "chromosome_policy", "cmplot"], "postflight": ["count_conservation", "canonical_plots"], "failure": ["manifest_error"]},
"gwas_fmt": {
"preflight": ["external_vcf", "vcf_header", "tassel_available"],
"postflight": ["sorted_vcf", "tassel_hapmap", "sample_variant_conservation", "artifact_sha256"],
"failure": ["tassel_exit_code", "conversion_cardinality_mismatch", "genotype_scope_mismatch"],
},
"genome_prepare": {
"preflight": ["config_exists", "declared_files_exist", "fasta_content_type", "gff_nonempty"],
"postflight": ["manifest_status", "species_result_status", "final_gff_nonempty", "pep_cds_content_type"],
"failure": ["log_root_cause", "runtime_repair_candidates"],
},
"identify": {
"preflight": ["genome_prepare_quality", "target_protein_available", "hmm_or_required_pfams"],
"postflight": ["final_members_nonempty", "protein_stop_markers", "domain_check"],
"failure": ["hmm_pfam_missing", "target_content_type", "header_length"],
},
"identify_refine": {
"preflight": ["curated_id_list", "protein_source", "id_overlap"],
"postflight": ["refined_members_nonempty", "refined_protein_nonempty"],
"failure": ["missing_ids", "id_overlap_zero"],
},
"phylogeny": {
"preflight": ["final_member_protein", "protein_stop_markers", "sequence_count"],
"postflight": ["tree_nonempty", "plot_nonempty"],
"failure": ["mafft_iqtree_error", "protein_format"],
},
"reference_classification": {
"preflight": ["phylogeny_tree", "reference_subfamily"],
"postflight": ["assignment_table", "subfamily_table"],
"failure": ["missing_phylogeny_tree", "missing_reference_subfamily"],
},
"motif": {
"preflight": ["final_member_protein", "protein_stop_markers", "sequence_count"],
"postflight": ["meme_outputs", "motif_plot"],
"failure": ["meme_empty", "protein_format"],
},
"domain": {
"preflight": ["final_member_protein", "pfam_database", "protein_stop_markers"],
"postflight": ["domain_table", "domain_plot"],
"failure": ["pfam_unavailable", "hmmscan_parse"],
},
"structure": {
"preflight": ["final_members", "gff_overlap"],
"postflight": ["structure_outputs"],
"failure": ["id_overlap_zero", "tool_unavailable"],
},
"promoter": {
"preflight": ["final_members", "genome", "gff_overlap"],
"postflight": ["promoter_fasta", "cis_element_outputs"],
"failure": ["gff_id_mismatch", "genome_missing"],
},
"synteny": {
"preflight": ["whole_genome_pep", "gff_nonempty", "family_ids", "id_overlap"],
"postflight": ["anchors", "plots"],
"failure": ["mcscanx_empty", "gff_id_mismatch"],
},
"interspecies_synteny": {
"preflight": ["target_ref_pep", "target_ref_gff", "species_selection"],
"postflight": ["pairwise_anchors", "jcvi_plot", "tree_plot"],
"failure": ["missing_whole_proteome", "species_config_misread"],
},
"karyotype": {
"preflight": ["gff_nonempty", "family_ids", "chromosome_assignment"],
"postflight": ["karyotype_plot", "unplaced_report"],
"failure": ["empty_gff", "all_unplaced"],
},
"combo": {
"preflight": ["phylogeny", "motif", "domain", "structure"],
"postflight": ["combo_plot"],
"failure": ["missing_upstream_artifacts"],
},
"ortholog": {
"preflight": ["target_family_pep", "reference_family_pep", "scope_reference_species"],
"postflight": ["ortholog_pairs", "ortholog_manifest", "intermediate_materialized"],
"failure": ["blast_failed", "intermediate_not_materialized", "post_processing_failed"],
},
"ppi": {
"preflight": ["family_ids", "string_species_or_orthologs", "ppi_provenance_identity"],
"postflight": ["ppi_edges", "ppi_plot"],
"failure": [
"string_unavailable",
"ortholog_missing",
"ppi_requires_ortholog_mapping",
"ppi_reference_data_missing",
"ppi_invalid_ortholog_mapping",
"ppi_string_id_mapping_low",
],
},
"expression": {
"preflight": ["expression_matrix", "sample_info", "family_ids_optional"],
"postflight": ["heatmap", "expression_table"],
"failure": ["matrix_id_mismatch", "sample_group_error"],
},
"pep": {
"preflight": ["final_member_protein", "protein_stop_markers"],
"postflight": ["physicochemical_table", "feature_plots", "tool_availability"],
"failure": ["protein_format", "external_tool_unavailable"],
},
"rnaseq_qc": {
"preflight": ["samples_yaml", "fastq_pairs", "duplicate_fastq"],
"postflight": ["clean_fastq", "qc_metrics"],
"failure": ["missing_fastq", "sample_config_error"],
},
"rnaseq_align": {
"preflight": ["clean_fastq", "genome_index"],
"postflight": ["bam_nonempty", "alignment_rate"],
"failure": ["index_missing", "zero_alignment"],
},
"rnaseq_quant": {
"preflight": ["bam_files", "gtf_gff", "featurecounts_ready"],
"postflight": ["count_matrix", "fpkm_tpm", "matrix_parse"],
"failure": ["featurecounts_error", "comment_char_parse"],
},
"extension_stage": {
"preflight": ["publish_gate", "semantic_input_binding", "explicit_file_roles"],
"postflight": ["output_contract", "empty_outputs", "manifest_quality", "prefix_consistency"],
"failure": ["diagnosis_report", "stdout_stderr", "repair_candidate_gate"],
},
"rnaseq_de": {
"preflight": ["count_matrix", "samples_yaml", "comparisons"],
"postflight": ["de_results", "significant_gene_counts"],
"failure": ["comparison_missing", "design_error"],
},
"rnaseq_sample_qc": {
"preflight": ["count_or_vst_matrix", "sample_table"],
"postflight": ["pca", "correlation", "duplicate_samples"],
"failure": ["matrix_parse", "sample_mismatch"],
},
"rnaseq_go_enrich": {
"preflight": ["de_results", "gene2go_or_annotation", "id_overlap"],
"postflight": ["go_results", "foreground_background_overlap"],
"failure": ["missing_gene2go", "id_overlap_zero"],
},
"rnaseq_kegg_enrich": {
"preflight": ["de_results", "gene2ko_or_annotation", "id_overlap"],
"postflight": ["kegg_results", "foreground_background_overlap"],
"failure": ["missing_gene2ko", "id_overlap_zero"],
},
"rnaseq_enrich": {
"preflight": ["de_results", "background_source", "go_kegg_background"],
"postflight": ["go_kegg_results", "id_overlap"],
"failure": ["missing_background", "id_overlap_zero"],
},
"rnaseq_fgsea": {
"preflight": ["de_ranks", "gene2ko", "rank_background_overlap"],
"postflight": ["fgsea_results", "pathway_size"],
"failure": ["rank_gene2ko_id_mismatch", "kegg_species"],
},
"rnaseq_wgcna": {
"preflight": ["expression_or_count_matrix", "sample_table"],
"postflight": ["module_count", "grey_ratio", "soft_threshold"],
"failure": ["matrix_parse", "sample_size"],
},
"rnaseq_candidate_family": {
"preflight": ["de_results", "annotation_sources", "optional_enrichment_wgcna"],
"postflight": ["candidate_family_ranking", "evidence_matrix", "recommendation", "rag_queries", "rag_references"],
"failure": ["annotation_missing", "id_mapping"],
},
"rnaseq_report": {
"preflight": ["successful_rnaseq_manifest", "prefix_scope_identity"],
"postflight": ["report_markdown", "evidence_snapshot", "coverage_status"],
"failure": ["no_successful_rnaseq_evidence", "scope_mismatch", "artifact_missing"],
},
"annotation": {
"preflight": ["protein_background", "tool_availability"],
"postflight": ["gene2go", "gene2ko", "annotation_coverage"],
"failure": ["interproscan_unavailable", "kofamscan_unavailable"],
},
"annotation_go": {
"preflight": ["protein_background", "interproscan_availability"],
"postflight": ["gene2go", "annotation_go_manifest", "go_progress"],
"failure": ["interproscan_unavailable", "go_terms_empty"],
},
"annotation_kegg": {
"preflight": ["protein_background", "kofamscan_availability"],
"postflight": ["gene2ko", "annotation_kegg_manifest", "kegg_progress"],
"failure": ["kofamscan_unavailable", "ko_hits_empty"],
},
}
class DiagnosisAgent(BaseAgent):
"""
诊断Agent V3
功能:
1. 分析错误日志,定位问题
2. 调用Knowledge查询修复方案
3. 自动修复常见问题
4. 生成前端通知消息
5. 集成全局迭代计数
6. 记录错误模式
"""
name = "diagnosis"
@staticmethod
def ppi_identity_diagnosis(payload: Dict[str, Any]) -> Dict[str, Any]:
provenance = payload.get("ppi_provenance") if isinstance(payload.get("ppi_provenance"), dict) else {}
selection_mode = str(provenance.get("selection_mode") or payload.get("selection_mode") or "unresolved")
code = str(provenance.get("diagnosis_code") or ppi_diagnosis_code(selection_mode, str(payload.get("status") or "")))
return {
"code": code,
"selection_mode": selection_mode,
"selected_string_taxon_id": str(provenance.get("selected_string_taxon_id") or ""),
"snapshot": dict(provenance.get("string_snapshot") or {}),
}
role = "Error Diagnosis Specialist"
# 常见错误模式及修复方案
ERROR_PATTERNS = {
"missing_input_file": {
"patterns": [r"文件不存在", r"FileNotFoundError", r"No such file", r"找不到文件"],
"suggestion_zh": "检查输入文件路径是否正确,确保文件已上传到input目录",
"suggestion_en": "Check if input file path is correct; ensure files are uploaded to the input directory",
"auto_fix": False
},
"hmm_not_found": {
"patterns": [r"required_pfams is empty", r"No HMM file", r"找不到HMM"],
"suggestion_zh": "请上传HMM模型文件或指定--required-pfams参数",
"suggestion_en": "Please upload HMM model file or specify --required-pfams parameter",
"auto_fix": False
},
"blast_db_error": {
"patterns": [r"BLAST Database error", r"makeblastdb failed"],
"suggestion_zh": "Identify BLAST 固定用目标物种全蛋白 FASTA 建库;请优先检查目标全蛋白 FASTA 是否存在、非空且为有效蛋白FASTA格式,再检查参考家族蛋白 query 文件。",
"suggestion_en": "Identify BLAST uses target species full protein FASTA for database; check that target FASTA exists, is non-empty and valid, then check reference family protein query file.",
"auto_fix": False
},
"memory_error": {
"patterns": [r"MemoryError", r"out of memory", r"内存不足"],
"suggestion_zh": "减少--cpu参数值或分批处理,建议使用--cpu 4",
"suggestion_en": "Reduce --cpu parameter or use batch processing; recommend --cpu 4",
"auto_fix": True,
"fix_action": "reduce_cpu"
},
"rnaseq_library_strandness_error": {
"patterns": [r"library_strandness", r"链特异性", r"rna-strandness", r"featureCounts.*-s"],
"suggestion_zh": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples.<sample>.library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。",
"suggestion_en": "Please specify non_stranded / forward / reverse in samples.yaml rnaseq.library_strandness or each samples.<sample>.library_strandness; mixed strand-specific samples cannot be quantified in a single featureCounts run.",
"auto_fix": False
},
"timeout_error": {
"patterns": [r"Timeout", r"timed out", r"超时"],
"suggestion_zh": "增加超时时间或检查输入数据大小",
"suggestion_en": "Increase timeout or check input data size",
"auto_fix": False
},
"conda_env_error": {
"patterns": [r"conda", r"Environment", r"找不到命令"],
"suggestion_zh": "检查conda环境是否正确激活,确保工具已安装",
"suggestion_en": "Check that conda environment is activated and tools are installed",
"auto_fix": False
},
"permission_error": {
"patterns": [r"Permission denied", r"权限不足"],
"suggestion_zh": "检查文件权限,确保有读写权限",
"suggestion_en": "Check file permissions; ensure read/write access",
"auto_fix": False
},
"invalid_parameter": {
"patterns": [r"unrecognized arguments", r"invalid parameter", r"参数错误"],
"suggestion_zh": "检查参数名称是否正确,注意使用短横线而非下划线",
"suggestion_en": "Check parameter name; use hyphens instead of underscores",
"auto_fix": True,
"fix_action": "fix_parameter_format"
},
"tool_not_found": {
"patterns": [
r"No such file or directory: 'mafft'",
r"No such file or directory: 'makeblastdb'",
r"No such file or directory: 'iqtree'",
r"No such file or directory: 'hmmsearch'",
r"command not found: mafft",
r"command not found: blastp",
r"command not found: iqtree",
r"command not found: hmmsearch",
r"RuntimeError: MAFFT失败.*No such file",
r"RuntimeError:.*失败.*No such file or directory"
],
"suggestion_zh": "生物信息学工具未安装或未添加到PATH。建议:1) 激活conda环境: conda activate test4py311 2) 安装缺失工具: conda install -c bioconda mafft blast iqtree hmmer",
"suggestion_en": "Bioinformatics tools not installed or not in PATH. Suggestions: 1) activate conda environment: conda activate test4py311 2) install missing tools: conda install -c bioconda mafft blast iqtree hmmer",
"auto_fix": False,
"severity": "high"
},
"dependency_failed": {
"patterns": [
r"依赖阶段.*未完成",
r"derived_from.*manifest.*not found",
r"前置阶段.*未执行",
r"请先执行.*阶段",
r"requires.*stage.*not completed",
r"dependency.*not satisfied"
],
"suggestion_zh": "前置分析阶段未完成,系统将自动先执行依赖阶段",
"suggestion_en": "Prerequisite analysis stage not completed; system will execute dependency stage first automatically",
"auto_fix": True,
"fix_action": "execute_dependencies",
"severity": "high"
},
"go_foreground_background_id_mismatch": {
"patterns": [
r"GO foreground/background ID mismatch",
r"no DE genes overlap uploaded gene2go background",
r"foreground.*background.*ID mismatch",
r"gene2go.*DE genes.*overlap=0"
],
"suggestion": "当前 gene2go 背景与 DE 基因 ID 体系不一致。应提示用户确认本地自动注释,生成与当前蛋白/DE ID 匹配的 gene2go 后再做 GO 富集。",
"auto_fix": False,
"severity": "high"
}
}
# 参数修复映射
PARAMETER_FIXES = {
"dom_i_evalue_max": "dom-i-evalue-max",
"hmm_cov_min": "hmm-cov-min",
"blast_evalue": "blast-evalue",
"idEvalue": "id-evalue",
"idCoverage": "id-coverage"
}
def __init__(self, llm_manager: LLMManager):
super().__init__(llm_manager)
self.error_history: List[Dict] = []
self.auto_fix_count = 0
async def execute(self, state: AgentState, message: AgentMessage) -> AgentState:
"""执行诊断"""
print(f"[DiagnosisAgent] 开始诊断...")
error_info = message.payload.get("error", {})
execution_result = message.payload.get("execution_result", {})
# 提取错误信息
stage = error_info.get("stage") or execution_result.get("stage", "unknown")
error_message = error_info.get("message") or error_info.get("error") or execution_result.get("error") or execution_result.get("message") or ""
stderr = error_info.get("stderr") or execution_result.get("stderr", "")
stdout = error_info.get("stdout") or execution_result.get("stdout", "")
full_error = f"{error_message}\n{stderr}\n{stdout}"
print(f"[DiagnosisAgent] 诊断阶段: {stage}")
# 诊断错误
diagnosis = await self._diagnose_error(stage, full_error, error_info)
# 如果未匹配到已知模式,调用 Knowledge Agent 查询
if diagnosis.get("error_type") == "unknown":
print(f"[DiagnosisAgent] 未知错误,尝试调用 Knowledge Agent...")
knowledge_result = await self._query_knowledge_for_error(full_error)
if knowledge_result:
diagnosis = knowledge_result
# 获取当前迭代计数
iteration_count = state.results.get("iteration_count", 0)
max_iterations = state.results.get("max_iterations", 3)
# 检查是否达到最大迭代次数
if iteration_count >= max_iterations:
diagnosis["can_auto_fix"] = False
diagnosis["suggestion"] = f"已达到最大迭代次数({max_iterations}),停止自动修复。"
diagnosis["stop_iteration"] = True
# 尝试自动修复
if diagnosis.get("can_auto_fix") and diagnosis.get("fix_action"):
fix_result = await self._attempt_auto_fix(
diagnosis["fix_action"], error_info, full_error, diagnosis
)
diagnosis["fix_result"] = fix_result
if fix_result and fix_result.get("success"):
self.auto_fix_count += 1
iteration_count += 1
state.results["iteration_count"] = iteration_count
state.results["auto_fix_count"] = self.auto_fix_count
diagnosis["iteration_count"] = iteration_count
# 生成前端通知
notification = self._generate_notification(stage, diagnosis, fix_result)
diagnosis["notification"] = notification
state.results["auto_fix_notification"] = notification
# 更新状态
state.results["diagnosis"] = diagnosis
state.results["can_auto_fix"] = diagnosis.get("can_auto_fix", False)
state.messages.append({
"role": "diagnosis",
"content": f"🔍 诊断完成: {diagnosis.get('error_type', 'unknown')}",
})
return state
async def _diagnose_error(self, stage: str, error_text: str, error_info: Dict) -> Dict:
"""诊断错误"""
# 尝试匹配已知错误模式
matched_pattern = self._match_error_pattern(error_text)
if matched_pattern:
error_type = matched_pattern["error_type"]
suggestion = matched_pattern["suggestion"]
can_auto_fix = matched_pattern.get("auto_fix", False)
fix_action = matched_pattern.get("fix_action")
return {
"error_type": error_type,
"stage": stage,
"suggestion": suggestion,
"can_auto_fix": can_auto_fix,
"fix_action": fix_action,
"confidence": 0.9,
"error_text": error_text[:500]
}
# 未知错误
return {
"error_type": "unknown",
"stage": stage,
"suggestion": "无法自动诊断,请检查错误日志",
"can_auto_fix": False,
"requires_user_confirmation": True,
"confirmation_reason": "未知错误不能自动修复,需要用户查看日志后确认下一步。",
"confidence": 0.3,
"error_text": error_text[:500]
}
def diagnose_job_stage(
self,
job_id: str,
stage: str,
prefix: str = "",
project_root: Path = PROJECT_ROOT,
prepare_patch_workspace: bool = False,
scope_id: str = "",
params: Optional[Dict[str, Any]] = None,
language: str = "zh",
) -> Dict[str, Any]:
"""只读审计某个 job/stage 的产物、manifest、日志和输入配置。
该方法不重跑阶段、不修改输入、不修改全局脚本。它只返回结构化问题、
用户确认需求以及可选的当前 job 修复副本工作区。
"""
workspace = project_root / "workspace" / job_id
final_dir = workspace / "final"
input_dir = project_root / "input" / job_id
logs_dir = workspace / "logs"
issues: List[Dict[str, Any]] = []
evidence: List[str] = []
params = params or {}
stage_context = self._stage_context_from_params(stage, prefix, params)
scope_id = scope_id or self._scope_id_from_params(params) or str(stage_context.get("scope_id") or "")
def add_issue(
code: str,
severity: str,
message: str,
recommendation: str,
*,
requires_confirmation: bool = True,
runtime_repair: bool = False,
safe_patch_candidate: bool = False,
blocks_downstream: bool = False,
evidence_items: Optional[List[str]] = None,
) -> None:
layer = ISSUE_LAYER_BY_CODE.get(code, "report")
issues.append({
"code": code,
"severity": severity,
"layer": layer,
"issue_layer": layer,
"message": message,
"recommendation": recommendation,
"requires_user_confirmation": requires_confirmation,
"runtime_repair": runtime_repair,
"safe_patch_candidate": safe_patch_candidate,
"blocks_downstream": blocks_downstream,
"evidence": evidence_items or [],
})
manifest = self._load_stage_manifest(final_dir, stage, prefix, scope_id=scope_id)
if manifest and not stage_context:
stage_context = manifest.get("stage_context") or {
"scope_id": manifest.get("scope_id", ""),
"scope_mode": manifest.get("scope_mode", ""),
"analysis_route": manifest.get("analysis_route", ""),
"member_set_id": manifest.get("member_set_id", ""),
"target_species": manifest.get("target_species", []),
"reference_species": manifest.get("reference_species", []),
"comparative_species": manifest.get("comparative_species", []),
}
if manifest:
evidence.append(f"manifest={manifest.get('_path')}")
status = manifest.get("status", "unknown")
if status not in {"success", "partial_success"}:
manifest_detail = str(manifest.get("error") or manifest.get("message") or "").strip()
log_text = self._latest_stage_log_text(logs_dir, stage, limit=6000)
log_lines = []
if log_text:
for line in log_text.splitlines():
stripped = line.strip()
if not stripped:
continue
if any(token in stripped for token in ["Traceback", "Error", "ERROR", "失败", "FileNotFoundError", "cannot", "Cannot", "missing", "未找到"]):
log_lines.append(stripped)
if not log_lines:
log_lines = [line.strip() for line in log_text.splitlines()[-8:] if line.strip()]
detail_items = []
if manifest_detail:
detail_items.append(manifest_detail[:500])
if log_lines:
detail_items.append("日志摘要: " + " | ".join(log_lines[-6:])[:900])
add_issue(
"manifest_failed",
"blocking",
f"{stage} manifest 状态为 {status}",
"查看日志摘要定位根因;若是缺上游产物,应先补跑对应上游阶段后重试。",
blocks_downstream=True,
evidence_items=detail_items,
)
else:
stage_dir = workspace / stage
has_intermediate = stage_dir.exists() and any(stage_dir.rglob("*"))
latest_log = self._latest_stage_log_text(logs_dir, stage, limit=8000)
root_cause_found = False
if "SameFileError" in latest_log or "are the same file" in latest_log:
add_issue(
"stage_runtime_same_file_copy",
"blocking",
f"{stage} 阶段脚本在收尾复制文件时把同一个文件复制到自身,导致运行崩溃。",
"这是阶段脚本的运行时保护缺失,不是用户输入文件或 manifest 收尾问题。应修复同文件 copy guard 后重新执行该阶段。",
requires_confirmation=False,
blocks_downstream=True,
evidence_items=[latest_log[-1600:] if latest_log else str(logs_dir)],
)
root_cause_found = True
if "ModuleNotFoundError: No module named 'apps'" in latest_log:
add_issue(
"python_import_path_error",
"blocking",
f"{stage} 启动失败:阶段脚本无法导入项目模块 apps。",
"这是工程启动路径问题,不是用户输入文件问题。应在阶段脚本中加入项目根目录到 sys.path,或用模块方式从项目根目录执行。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=[latest_log[-1200:]],
)
root_cause_found = True
if "requires Gene_Family" in latest_log and "known_family" in latest_log or "family_pep requires Gene_Family" in latest_log:
add_issue(
"known_family_missing_gene_family",
"blocking",
f"{stage} 读取 config.xlsx 失败:known-family/family_pep 行缺少 Gene_Family。",
"应检查 config.xlsx 中已知家族 seed 文件行,例如 At.NAC.pep.fasta 应填写 Gene_Family=NAC、Species_Role=seed_reference、File_Scope=known_family。",
requires_confirmation=True,
blocks_downstream=True,
evidence_items=[latest_log[-1200:]],
)
root_cause_found = True
if stage == "domain" and "Domain stage requires identify results" in latest_log:
add_issue(
"domain_scoped_identify_guard",
"blocking",
"domain 后端执行守卫要求 legacy identify manifest,但当前 scope 流程已有同 scope 的成员蛋白和系统发育输入。",
"这是后端执行层与 scope 契约不一致,不应按 manifest 缺失自动恢复;应修订 domain 后端守卫,使其接受 scope_file/同 scope phylogeny manifest 解析到的成员蛋白。",
requires_confirmation=False,
blocks_downstream=True,
evidence_items=[latest_log[-1200:]],
)
root_cause_found = True
if stage == "kaks":
scope_data = self._scope_from_params(params)
ref_species = params.get("referenceSpecies") or params.get("reference_species") or params.get("refSpeciesList") or []
if isinstance(ref_species, str):
ref_species = [item.strip() for item in re.split(r"[,;,;\s]+", ref_species) if item.strip()]
if not ref_species and scope_data:
ref_species = scope_data.get("reference_species") or []
scope_mode = str(params.get("scopeMode") or params.get("scope_mode") or (scope_data.get("scope_mode") if scope_data else "") or "")
missing_ref_failure = "KaKs scope 中没有 reference_species" in latest_log or (bool(params) and not ref_species and scope_mode in {"", "target_only"})
if missing_ref_failure:
add_issue(
"kaks_scope_missing_reference",
"blocking",
"Ka/Ks 是 target-vs-reference 分析,但本次 scope 没有 reference_species。",
"应在规划阶段使用 target_plus_reference scope,并从 config/用户参数中带入参考物种;若用户未指定,按项目默认参考物种处理。",
requires_confirmation=False,
blocks_downstream=True,
evidence_items=[latest_log[-1200:] if latest_log else json.dumps(params, ensure_ascii=False)[:1200]],
)
root_cause_found = True
if stage == "ortholog":
from gfa_core.ortholog_materialize import ortholog_pairs_has_data
ortholog_dir = workspace / "ortholog"
final_pairs = final_dir / f"{prefix}.ortholog_pairs.txt" if prefix else None
intermediate_pairs = sorted(ortholog_dir.glob("*_pairs.txt")) if ortholog_dir.exists() else []
intermediate_with_data = [path for path in intermediate_pairs if ortholog_pairs_has_data(path)]
final_missing = (
final_pairs is None
or not final_pairs.exists()
or final_pairs.stat().st_size == 0
or not ortholog_pairs_has_data(final_pairs)
)
if intermediate_with_data and final_missing:
add_issue(
"ortholog_intermediate_not_materialized",
"blocking",
"Ortholog 已在 ortholog/ 生成 BLAST 中间产物,但 final/ 映射文件与 manifest 缺失。",
"复用现有 intermediate pairs 执行 ortholog 收尾落盘(无需重跑 BLAST),完成后再运行 kaks/ppi。",
requires_confirmation=False,
runtime_repair=True,
blocks_downstream=True,
evidence_items=[str(path) for path in intermediate_with_data[:5]],
)
root_cause_found = True
if "Ortholog post-processing failed" in latest_log:
add_issue(
"ortholog_post_processing_failed",
"blocking",
"Ortholog BLAST 已完成,但 backend 收尾落盘失败。",
"检查日志中的 post-processing 异常;可重跑 ortholog 阶段,系统会复用 ortholog/ 现有 pairs。",
blocks_downstream=True,
evidence_items=[latest_log[-1200:] if latest_log else str(logs_dir)],
)
root_cause_found = True
if stage == "expression":
gene_id_candidates = (
list(input_dir.glob("*geneID*"))
+ list(input_dir.glob("*.final_members.id"))
+ list(final_dir.glob("*geneID*"))
+ list(final_dir.glob("*.final_members.id"))
)
if not gene_id_candidates:
add_issue(
"expression_missing_gene_ids",
"blocking",
"Expression(使用FPKM) 已找到/可使用表达矩阵,但缺少目标基因列表。",
"请先运行 Identify 生成 *.final_members.id,或上传要绘图的基因列表文件(*geneID* 或 *.final_members.id)。不要把全基因组 FPKM 矩阵直接当作基因家族成员列表。",
requires_confirmation=False,
blocks_downstream=True,
evidence_items=[str(input_dir), str(final_dir)],
)
root_cause_found = True
sample_candidates = list(input_dir.glob("sample.txt")) + list(final_dir.glob("sample.txt"))
if not sample_candidates:
add_issue(
"expression_missing_sample_txt",
"warning" if root_cause_found else "blocking",
"Expression 阶段缺少 sample.txt 分组文件。",
"当前 job 有 samples.yaml 时可转换为 sample.txt;但基因家族绘图仍必须先提供目标基因列表。",
requires_confirmation=False,
blocks_downstream=not root_cause_found,
evidence_items=[str(input_dir / "samples.yaml")],
)
root_cause_found = True
if has_intermediate and not (stage == "ortholog" and root_cause_found):
add_issue(
"manifest_missing_stage_incomplete",
"warning",
f"未找到 {stage} 阶段 manifest,但阶段目录已有中间产物。",
"该阶段可能正在运行或未完成收尾。不要中断进程;等待阶段结束后再做最终诊断。",
requires_confirmation=False,
blocks_downstream=False,
evidence_items=[str(stage_dir)],
)
elif not root_cause_found:
add_issue(
"manifest_missing",
"blocking",
f"未找到 {stage} 阶段 manifest",
"先确认该阶段是否已执行;若已执行但未产出 manifest,应检查阶段日志。",
requires_confirmation=False,
blocks_downstream=True,
)
identity_status = self._add_stage_identity_status_issue(job_id, stage, prefix, params, manifest, add_issue, gate="diagnose")
if identity_status:
evidence.append(f"stage_identity_status={json.dumps(identity_status, ensure_ascii=False)[:1200]}")
if stage == "gwas_fmt":
self._audit_gwas_fmt(manifest, add_issue)
elif stage.startswith("pop_"):
self._audit_population_stage(stage, manifest, add_issue)
elif stage == "rnaseq_qc":
self._audit_rnaseq_qc(input_dir, final_dir, manifest, add_issue)
elif stage == "rnaseq_align":
self._audit_rnaseq_align(final_dir, manifest, add_issue)
elif stage == "rnaseq_sample_qc":
self._audit_rnaseq_sample_qc(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_quant":
self._audit_rnaseq_quant(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_go_enrich":
self._audit_rnaseq_go_enrich(final_dir, manifest, add_issue)
elif stage == "rnaseq_fgsea":
self._audit_rnaseq_fgsea(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_wgcna":
self._audit_rnaseq_wgcna(final_dir, logs_dir, manifest, add_issue)
else:
self._audit_generic_gene_family_stage(stage, final_dir, logs_dir, manifest, add_issue, prefix)
issues = self._prioritize_issues(self._dedupe_issues(issues))
highest = self._highest_severity(issues)
repair_plan = self._build_repair_plan(stage, issues)
primary_issue = issues[0] if issues else None
primary_code = str((primary_issue or {}).get("code") or "")
primary_layer = str(
(primary_issue or {}).get("layer")
or (primary_issue or {}).get("issue_layer")
or ISSUE_LAYER_BY_CODE.get(primary_code, "report")
)
block_sidecar_repairs = (
primary_layer == "planning"
or (
primary_layer == "artifact_status"
and primary_code in {"manifest_missing", "manifest_missing_stage_incomplete", "manifest_failed"}
)
)
safe_patch_candidates = [
issue for issue in issues
if issue.get("safe_patch_candidate") and issue.get("code") in KNOWN_COPY_PATCH_CODES
]
if block_sidecar_repairs:
safe_patch_candidates = []
needs_confirmation = repair_plan.get("requires_user_confirmation", False)
patch_workspace = None
if prepare_patch_workspace and safe_patch_candidates:
patch_workspace = self.prepare_repair_workspace(
job_id=job_id,
stage=stage,
issue=safe_patch_candidates[0],
project_root=project_root,
)
ppi_identity_diagnosis = self.ppi_identity_diagnosis(manifest or params) if stage == "ppi" else {}
return {
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"stage_context": stage_context,
"scope_id": scope_id,
"status": "ok" if not issues else "needs_attention",
"severity": highest,
"primary_issue": primary_issue,
"requires_user_confirmation": needs_confirmation,
"issues": issues,
"evidence": evidence,
"repair_plan": repair_plan,
"safe_patch_candidates": [issue["code"] for issue in safe_patch_candidates],
"patch_workspace": patch_workspace,
"ppi_identity_diagnosis": ppi_identity_diagnosis,
"policy": {
"auto_modify_input": False,
"auto_modify_global_code": False,
"auto_rerun_stage": False,
"patch_scope": "job_reproducibility_copy_only",
},
}
def diagnose_stage_gate(
self,
job_id: str,
stage: str,
prefix: str = "",
gate: str = "preflight",
project_root: Path = PROJECT_ROOT,
params: Optional[Dict[str, Any]] = None,
language: str = "zh",
) -> Dict[str, Any]:
"""Global QA gate for stage preflight/postflight/failure.
This is intentionally job-local and deterministic: it reads manifests,
logs and files, assigns issue codes, and exposes runtime repair actions.
It never edits global source code.
"""
gate = gate if gate in {"preflight", "postflight", "failure"} else "preflight"
requested_stage = stage
stage = {"genome_prepare_v2": "genome_prepare"}.get(stage, stage)
workspace = project_root / "workspace" / job_id
final_dir = workspace / "final"
input_dir = project_root / "input" / job_id
logs_dir = workspace / "logs"
issues: List[Dict[str, Any]] = []
evidence: List[str] = []
def add_issue(
code: str,
severity: str,
message: str,
recommendation: str,
*,
requires_confirmation: bool = False,
runtime_repair: bool = False,
safe_patch_candidate: bool = False,
blocks_downstream: bool = False,
evidence_items: Optional[List[str]] = None,
) -> None:
layer = ISSUE_LAYER_BY_CODE.get(code, "report")
issues.append({
"code": code,
"severity": severity,
"layer": layer,
"issue_layer": layer,
"message": message,
"recommendation": recommendation,
"requires_user_confirmation": requires_confirmation,
"runtime_repair": runtime_repair,
"safe_patch_candidate": safe_patch_candidate,
"blocks_downstream": blocks_downstream,
"evidence": evidence_items or [],
})
params = params or {}
stage_context = self._stage_context_from_params(stage, prefix, params)
scope_id = self._scope_id_from_params(params) or str(stage_context.get("scope_id") or "")
manifest = self._load_stage_manifest(final_dir, stage, prefix, scope_id=scope_id)
if stage == "genome_prepare" and gate == "preflight" and self._genome_prepare_manifest_stale(input_dir, manifest):
evidence.append("genome_prepare_previous_manifest_ignored=input_updated_after_failed_manifest")
manifest = None
if manifest and not stage_context:
stage_context = manifest.get("stage_context") or {
"scope_id": manifest.get("scope_id", ""),
"scope_mode": manifest.get("scope_mode", ""),
"analysis_route": manifest.get("analysis_route", ""),
"member_set_id": manifest.get("member_set_id", ""),
"target_species": manifest.get("target_species", []),
"reference_species": manifest.get("reference_species", []),
"comparative_species": manifest.get("comparative_species", []),
}
if gate in {"postflight", "failure"}:
base = self.diagnose_job_stage(job_id, stage, prefix, project_root, scope_id=scope_id, params=params)
for issue in base.get("issues", []):
issues.append(issue)
evidence.extend(base.get("evidence", []))
if manifest:
evidence.append(f"manifest={manifest.get('_path')}")
identity_status = self._add_stage_identity_status_issue(job_id, stage, prefix, params, manifest, add_issue, gate=gate)
if identity_status:
evidence.append(f"stage_identity_status={json.dumps(identity_status, ensure_ascii=False)[:1200]}")
if params.get("source") == "methodology" or params.get("methodology"):
self._gate_saved_methodology(stage, final_dir, input_dir, params, add_issue, evidence)
if stage == "gwas_fmt":
if gate == "preflight":
self._audit_gwas_fmt_preflight(params, add_issue)
else:
self._audit_gwas_fmt(manifest, add_issue)
elif stage.startswith("pop_"):
self._audit_population_stage(stage, manifest, add_issue)
elif stage == "genome_prepare":
self._gate_genome_prepare(input_dir, final_dir, logs_dir, manifest, gate, add_issue)
elif stage == "identify":
self._gate_identify(input_dir, final_dir, logs_dir, prefix, params, gate, add_issue)
elif stage in {"phylogeny", "motif", "domain", "pep"}:
self._gate_protein_stage(stage, final_dir, prefix, manifest, add_issue, params)
elif stage in {"structure", "promoter", "synteny", "karyotype"}:
self._gate_gff_member_stage(stage, final_dir, prefix, manifest, add_issue, params)
elif stage == "reference_classification":
self._gate_reference_classification(input_dir, final_dir, logs_dir, prefix, params, add_issue)
elif stage == "interspecies_synteny":
self._gate_interspecies_synteny(input_dir, final_dir, add_issue)
elif stage in {"rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_de", "rnaseq_sample_qc", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_enrich", "rnaseq_fgsea", "rnaseq_wgcna"}:
# Reuse the existing RNA-seq audits for both postflight and failure.
if gate != "preflight":
if stage == "rnaseq_qc":
self._audit_rnaseq_qc(input_dir, final_dir, manifest, add_issue)
elif stage == "rnaseq_align":
self._audit_rnaseq_align(final_dir, manifest, add_issue)
elif stage == "rnaseq_sample_qc":
self._audit_rnaseq_sample_qc(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_quant":
self._audit_rnaseq_quant(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_de":
self._audit_rnaseq_de_auto_expression(final_dir, logs_dir, manifest, add_issue)
elif stage in {"rnaseq_go_enrich", "rnaseq_enrich"}:
self._audit_rnaseq_go_enrich(final_dir, manifest, add_issue)
elif stage == "rnaseq_fgsea":
self._audit_rnaseq_fgsea(final_dir, logs_dir, manifest, add_issue)
elif stage == "rnaseq_wgcna":
self._audit_rnaseq_wgcna(final_dir, logs_dir, manifest, add_issue)
if gate == "failure":
self._augment_failure_with_llm(
job_id=job_id,
stage=stage,
prefix=prefix,
input_dir=input_dir,
final_dir=final_dir,
logs_dir=logs_dir,
manifest=manifest,
params=params,
issues=issues,
evidence=evidence,
add_issue=add_issue,
)
issues = self._prioritize_issues(self._dedupe_issues(issues))
highest = self._highest_severity(issues)
repair_plan = self._build_repair_plan(stage, issues)
primary_issue = issues[0] if issues else None
runtime_repairs = list(dict.fromkeys(
issue["code"] for issue in issues
if issue.get("runtime_repair") or issue.get("code") in RUNTIME_REPAIR_CODES
))
blocking = any(issue.get("severity") == "blocking" or issue.get("blocks_downstream") for issue in issues)
ppi_identity_diagnosis = self.ppi_identity_diagnosis(manifest or params) if stage == "ppi" else {}
return {
"stage": stage,
"requested_stage": requested_stage,
"job_id": job_id,
"prefix": prefix,
"stage_context": stage_context,
"scope_id": scope_id,
"gate": gate,
"status": "pass" if not blocking else "blocked",
"severity": highest,
"primary_issue": primary_issue,
"coverage": DIAGNOSIS_STAGE_COVERAGE.get(stage, {}),
"issues": issues,
"evidence": evidence,
"repair_plan": repair_plan,
"requires_user_confirmation": repair_plan.get("requires_user_confirmation", False),
"runtime_repair_candidates": runtime_repairs,
"ppi_identity_diagnosis": ppi_identity_diagnosis,
"policy": {
"auto_modify_input": True,
"auto_modify_global_code": False,
"auto_rerun_stage": gate in {"preflight", "failure"},
"patch_scope": "job_local_runtime_repair",
},
}
def _gate_saved_methodology(self, stage: str, final_dir: Path, input_dir: Path, params: Dict[str, Any], add_issue, evidence: List[str]) -> None:
family = str(params.get("geneFamily") or params.get("gene_family") or "").strip()
targets = [str(x).strip() for x in (params.get("targetSpecies") or params.get("target_species") or []) if str(x).strip()]
refs = [str(x).strip() for x in (params.get("referenceSpecies") or params.get("reference_species") or params.get("refSpeciesList") or []) if str(x).strip()]
if params.get("requiresConfirmedSubfamily") and stage not in {"reference_classification", "subfamily_confirm"}:
confirmed = []
if final_dir.exists():
for path in final_dir.glob("*confirmed_subfamily.tsv"):
name = path.name
if family and family not in name:
continue
if targets and not any(target in name for target in targets):
continue
if path.stat().st_size > 0:
confirmed.append(str(path))
if not confirmed:
add_issue(
"methodology_confirmed_subfamily_missing",
"blocking",
"保存方法学复现链路要求先完成 confirmed subfamily,再执行确认后常规分析。",
"先执行 reference_classification 并在亚家族确认弹窗保存 confirmed_subfamily.tsv。",
blocks_downstream=True,
evidence_items=["missing=confirmed_subfamily.tsv", "missing=subfamily_confirm.manifest.json"],
)
else:
evidence.append("methodology_confirmed_subfamily=" + confirmed[0])
if stage == "expression" and (params.get("source") == "methodology" or params.get("methodology")):
matrix_files = []
if input_dir.exists():
for pattern in ("*TPM*", "*tpm*", "*FPKM*", "*fpkm*", "*expression*matrix*", "*expr*matrix*"):
matrix_files.extend(str(p) for p in input_dir.rglob(pattern) if p.is_file() and p.name != "config.xlsx")
geo_id = str(params.get("geoId") or params.get("geo_id") or "").strip()
if not matrix_files and not geo_id:
add_issue(
"methodology_expression_input_missing",
"blocking",
"保存方法学记录了组织表达来源,但当前 job 未提供表达矩阵,也未在 expression 参数中确认 GEO ID。",
"上传 TPM/FPKM/count 表达矩阵,或在 expression 弹窗中填写 GEO ID 后再执行;raw RNA-seq accession 仍需单独授权。",
blocks_downstream=True,
)
if str(params.get("analysisRoute") or params.get("analysis_route") or "").startswith("raw_rnaseq"):
add_issue(
"methodology_raw_rnaseq_requires_user_consent",
"blocking",
"Raw RNA-seq accession 重分析需要用户明确授权,当前只作为 blocked 子流程记录。",
"获得授权并准备 FASTQ/samples.yaml 后再展开 RNA-seq 子流程。",
requires_confirmation=True,
blocks_downstream=True,
)
if stage in {"reference_classification", "interspecies_synteny"} and not refs:
add_issue(
"methodology_reference_species_missing",
"blocking",
"保存方法学链路需要 referenceSpecies(当前 HDZIP 复现为 Si vs At),但参数中缺失参考物种。",
"在 config.xlsx 或执行参数中补齐 referenceSpecies/refSpeciesList,例如 At。",
blocks_downstream=True,
)
def apply_runtime_repairs(
self,
job_id: str,
stage: str,
prefix: str = "",
diagnosis: Optional[Dict[str, Any]] = None,
project_root: Path = PROJECT_ROOT,
*,
execute_heavy: bool = False,
) -> Dict[str, Any]:
"""Apply safe job-local runtime repairs, never global source patches."""
diagnosis = diagnosis or self.diagnose_stage_gate(job_id, stage, prefix, "preflight", project_root)
issues = diagnosis.get("issues", [])
repair_plan = diagnosis.get("repair_plan") or self._build_repair_plan(stage, issues)
auto_codes = {
action.get("issue_code")
for action in repair_plan.get("actions", [])
if action.get("policy") == "auto"
}
if any(issue.get("code", "").startswith("genome_prepare_") for issue in issues) and self.is_stage_running(job_id, "genome_prepare"):
return {
"success": False,
"status": "running_stage_blocked",
"stage": stage,
"job_id": job_id,
"message": "genome_prepare 当前仍在运行,拒绝执行 job-local 修复以避免覆盖正在生成的产物。",
"results": [],
}
results = []
extracted_species: set[str] = set()
for issue in issues:
code = issue.get("code")
if code not in auto_codes:
continue
if code == "protein_fasta_stop_markers":
results.append(self._apply_protein_stop_marker_fix(job_id, stage, prefix, issue, project_root))
elif code == "gene_gff_id_overlap_low":
results.append(self._write_gene_gff_overlap_report(job_id, stage, prefix, issue, project_root))
elif code == "genome_prepare_final_gff_empty":
results.append(self._repair_empty_final_gff(job_id, issue, project_root))
elif code in {"genome_prepare_pep_looks_like_genome", "genome_prepare_cds_looks_like_genome"}:
species = str(self._issue_payload(issue).get("species") or "")
if species and species in extracted_species:
continue
results.append(self._repair_genome_prepare_extract_sequences(job_id, issue, project_root, execute_heavy=execute_heavy))
if species:
extracted_species.add(species)
elif code in {"identify_pfam_remote_404", "pfam_remote_404", "identify_missing_hmm_or_pfam", "identify_pfam_partial_fetch", "identify_hmm_model_mismatch"}:
results.append(self._repair_identify_local_pfam_hmm(job_id, prefix, issue, project_root))
elif code == "ncbi_family_reference_link_missing":
results.append(self._repair_ncbi_family_reference_link(job_id, issue, project_root))
elif code in {"manifest_missing", "manifest_missing_stage_incomplete"}:
results.append(self._repair_missing_manifest_from_artifacts(job_id, stage, prefix, issue, project_root))
elif code == "ortholog_intermediate_not_materialized":
results.append(self._repair_ortholog_materialize_from_intermediate(job_id, prefix, issue, project_root))
changed = any(item.get("success") and item.get("changed", True) for item in results)
should_rerun = any(bool(item.get("should_rerun_stage")) for item in results)
return {
"success": all(item.get("success", False) for item in results) if results else True,
"status": "repaired" if changed else "no_repair_needed",
"stage": stage,
"job_id": job_id,
"repair_plan": repair_plan,
"results": results,
"should_rerun_stage": should_rerun,
"global_code_changed": False,
}
def _build_repair_plan(self, stage: str, issues: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Normalize issue-specific fixes into a common repair contract.
Policies:
- auto: safe job-local action; may be applied without user input.
- confirm: needs user confirmation or missing user parameter.
- report_only: diagnostic/report action only; no data mutation.
- dev_fix: global source change or tool installation required.
"""
actions: List[Dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
primary_issue = issues[0] if issues else {}
primary_code = str(primary_issue.get("code") or "")
primary_layer = str(
primary_issue.get("layer")
or primary_issue.get("issue_layer")
or ISSUE_LAYER_BY_CODE.get(primary_code, "report")
)
sidecar_repairs_blocked = (
primary_layer == "planning"
or (
primary_layer == "artifact_status"
and primary_code in {"manifest_missing", "manifest_missing_stage_incomplete", "manifest_failed"}
)
)
for issue in issues:
code = str(issue.get("code") or "unknown")
policy, action = REPAIR_POLICY_BY_CODE.get(code, ("report_only", "review_issue"))
layer = str(issue.get("layer") or issue.get("issue_layer") or ISSUE_LAYER_BY_CODE.get(code, "report"))
if issue.get("runtime_repair") and code in RUNTIME_REPAIR_CODES and code != "gene_gff_id_overlap_low":
policy, action = REPAIR_POLICY_BY_CODE.get(code, ("auto", code))
if issue.get("safe_patch_candidate") and code in KNOWN_COPY_PATCH_CODES and code not in RUNTIME_REPAIR_CODES:
policy, action = "dev_fix", "prepare_job_patch_then_upgrade_source"
if issue.get("requires_user_confirmation") and policy == "auto":
policy = "confirm"
if layer == "planning" or (sidecar_repairs_blocked and code != primary_code):
policy = "report_only"
key = (code, action)
if key in seen:
continue
seen.add(key)
actions.append({
"issue_code": code,
"policy": policy,
"action": action,
"layer": layer,
"issue_layer": layer,
"stage": stage,
"severity": issue.get("severity", "warning"),
"message": issue.get("message", ""),
"recommendation": issue.get("recommendation", ""),
"safe_job_local": policy in {"auto", "report_only"},
"requires_user_confirmation": policy == "confirm",
"global_code_change": policy == "dev_fix",
})
policies = {action["policy"] for action in actions}
return {
"stage": stage,
"actions": actions,
"has_auto": "auto" in policies,
"has_confirm": "confirm" in policies,
"has_report_only": "report_only" in policies,
"has_dev_fix": "dev_fix" in policies,
"requires_user_confirmation": "confirm" in policies,
"auto_action_count": sum(1 for action in actions if action["policy"] == "auto"),
"confirm_action_count": sum(1 for action in actions if action["policy"] == "confirm"),
"dev_fix_count": sum(1 for action in actions if action["policy"] == "dev_fix"),
}
def _add_stage_identity_status_issue(
self,
job_id: str,
stage: str,
prefix: str,
params: Dict[str, Any],
manifest: Optional[Dict[str, Any]],
add_issue,
*,
gate: str = "diagnose",
) -> Optional[Dict[str, Any]]:
"""Explain manifest-success/status-failure cases with the shared scope identity contract."""
if not manifest or str(manifest.get("status") or "") not in {"success", "partial_success"}:
return None
try:
from gfa_core.mas.tools.stage_identity import resolve_stage_identity, get_stage_status_for_identity, explain_status_mismatch
identity = resolve_stage_identity(
stage=stage,
prefix=prefix,
params={**(params or {}), "prefix": prefix},
job_id=job_id,
)
report = get_stage_status_for_identity(job_id=job_id, identity=identity, strict_identity=True)
if report.completed:
return report.to_dict()
evidence_items = [
f"manifest_status={report.manifest_status}",
f"manifest_path={report.manifest_path}",
explain_status_mismatch(identity, report),
]
if gate == "preflight":
# A successful manifest from a different scope route means the current
# request still needs to run. It must not block the run before the
# formal stage has a chance to create matching artifacts.
return report.to_dict()
add_issue(
"stage_identity_contract_mismatch",
"blocking",
f"{stage} manifest 已成功生成,但与当前 scope identity 契约不匹配。",
"检查并补齐 manifest 的 scope_id、analysis_route、member_set_id/stage_context;不要把该问题误判为未生成输出。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=evidence_items,
)
return report.to_dict()
except Exception as exc:
return {"status": "identity_status_unavailable", "error": str(exc)}
def _stage_context_from_params(self, stage: str, prefix: str, params: Dict[str, Any]) -> Dict[str, Any]:
raw = params.get("stageContext") or params.get("stage_context")
if isinstance(raw, dict):
return raw
try:
from gfa_core.gene_family_stage_context import build_stage_context, scope_data_from_params
scope_data = self._scope_from_params(params) or scope_data_from_params(prefix, params)
context = build_stage_context(
stage=stage,
prefix=prefix,
scope_data=scope_data,
scope_file=params.get("scopeFile") or params.get("scope_file") or "",
analysis_route=params.get("analysisRoute") or params.get("analysis_route") or "",
member_set_id=params.get("memberSetId") or params.get("member_set_id") or "",
)
return context.to_dict()
except Exception:
return {}
def prepare_repair_workspace(
self,
job_id: str,
stage: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
"""为当前 job 准备修复副本目录,不修改副本内容、不执行副本。
后续若用户确认,执行层可以在该副本上应用最小 patch 并用同样输入/输出契约测试。
"""
script_name = STAGE_SCRIPT_MAP.get(stage)
if not script_name:
return {"success": False, "error": f"No script mapping for stage: {stage}"}
original_script = project_root / "apps" / "cli" / script_name
if not original_script.exists():
return {"success": False, "error": f"Original script not found: {original_script}"}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
patch_dir = project_root / "workspace" / job_id / "reproducibility" / "patches" / stage / ts
patch_dir.mkdir(parents=True, exist_ok=True)
patched_script = patch_dir / f"{original_script.stem}.patched{original_script.suffix}"
shutil.copy2(original_script, patched_script)
report = {
"stage": stage,
"job_id": job_id,
"created_at": datetime.now().isoformat(),
"repair_type": "contract_preserving_job_patch_workspace",
"issue": issue,
"original_script": str(original_script),
"patched_script": str(patched_script),
"input_changed": False,
"output_contract_changed": False,
"analysis_parameters_changed": False,
"global_code_changed": False,
"requires_user_approval_before_execution": True,
}
report_path = patch_dir / "patch_report.json"
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
return {
"success": True,
"patch_dir": str(patch_dir),
"patched_script": str(patched_script),
"patch_report": str(report_path),
}
def apply_confirmed_fix(
self,
job_id: str,
stage: str,
prefix: str = "",
diagnosis: Optional[Dict[str, Any]] = None,
fix_data: Optional[Dict[str, Any]] = None,
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
"""用户确认后应用 job 级修复副本流程。
默认只准备副本和 patch 报告;只有 fix_data.execute_patch 为 True 时才执行。
执行时必须使用 patched script,且输入/输出路径由原命令或用户确认命令保持不变。
"""
fix_data = fix_data or {}
diagnosis = diagnosis or self.diagnose_job_stage(job_id, stage, prefix, project_root)
action = fix_data.get("action") or "prepare_job_patch"
issues = diagnosis.get("issues", [])
issue_code = fix_data.get("issue_code")
issue = None
for item in issues:
if issue_code and item.get("code") == issue_code:
issue = item
break
if not issue_code and item.get("safe_patch_candidate") and item.get("code") in KNOWN_COPY_PATCH_CODES:
issue = item
break
if issue is None:
return {
"success": False,
"status": "blocked",
"message": "没有可安全副本修复的问题;需要用户手动处理或补充确认信息。",
"diagnosis": diagnosis,
}
if issue.get("code") == "protein_fasta_stop_markers":
return self._apply_protein_stop_marker_fix(
job_id=job_id,
stage=stage,
prefix=prefix,
issue=issue,
project_root=project_root,
)
patch_ws = self.prepare_repair_workspace(job_id, stage, issue, project_root)
if not patch_ws.get("success"):
return patch_ws
patch_result = self._apply_known_patch_to_copy(
issue_code=issue.get("code", ""),
patched_script=Path(patch_ws["patched_script"]),
)
patch_ws["patch_application"] = patch_result
self._update_patch_report(Path(patch_ws["patch_report"]), {"patch_application": patch_result})
if action == "prepare_job_patch" or not fix_data.get("execute_patch", False):
return {
"success": True,
"status": "prepared",
"message": "已准备当前 job 的修复副本;尚未执行。",
"patch_workspace": patch_ws,
"requires_user_confirmation_before_execution": True,
}
if self.is_stage_running(job_id, stage):
return {
"success": False,
"status": "running_stage_blocked",
"message": f"{job_id}/{stage} 当前仍在运行,拒绝执行修复副本以避免覆盖正在生成的产物。",
"patch_workspace": patch_ws,
}
cmd_result = self._build_patched_command(
job_id=job_id,
stage=stage,
prefix=prefix,
patched_script=Path(patch_ws["patched_script"]),
fix_data=fix_data,
project_root=project_root,
)
if not cmd_result.get("success"):
self._update_patch_report(Path(patch_ws["patch_report"]), {"execution": cmd_result})
return {
"success": False,
"status": "needs_command",
"message": cmd_result.get("message", "无法构建修复副本执行命令"),
"patch_workspace": patch_ws,
}
backup = self._backup_failed_run(job_id, stage, prefix, project_root)
execution = self._execute_patched_command(
cmd=cmd_result["cmd"],
cwd=project_root,
timeout=int(fix_data.get("timeout", 3600)),
)
recovery = {
"used_patched_script": True,
"patched_script": patch_ws["patched_script"],
"original_script": str(project_root / "apps" / "cli" / STAGE_SCRIPT_MAP.get(stage, "")),
"patch_report": patch_ws["patch_report"],
"reason": issue.get("message"),
"issue_code": issue.get("code"),
"contract_preserved": True,
"input_changed": False,
"output_contract_changed": False,
"analysis_parameters_changed": False,
"backup": backup,
"executed_at": datetime.now().isoformat(),
}
manifest_update = self._annotate_recovery_manifest(job_id, stage, prefix, recovery, project_root)
execution_record = {
"command": cmd_result["cmd"],
"execution": execution,
"recovery": recovery,
"manifest_update": manifest_update,
}
self._update_patch_report(Path(patch_ws["patch_report"]), {"execution": execution_record})
return {
"success": execution.get("returncode") == 0,
"status": "executed" if execution.get("returncode") == 0 else "execution_failed",
"message": "修复副本执行完成" if execution.get("returncode") == 0 else "修复副本执行失败",
"patch_workspace": patch_ws,
**execution_record,
}
def is_stage_running(self, job_id: str, stage: str) -> bool:
"""检查同一 job/stage 是否有正在运行的命令。"""
try:
proc = subprocess.run(
["ps", "-eo", "pid,ppid,cmd"],
text=True,
capture_output=True,
check=False,
timeout=10,
)
except Exception:
return False
script = STAGE_SCRIPT_MAP.get(stage, stage)
for line in proc.stdout.splitlines():
if job_id in line and script in line:
if "ps -eo" not in line and "rg " not in line:
return True
return False
def _scope_id_from_params(self, params: Dict[str, Any]) -> str:
raw = str(params.get("scope_id") or params.get("scopeId") or "").strip()
if raw:
return raw
scope_file = params.get("scopeFile") or params.get("scope_file")
if not scope_file:
return ""
try:
path = Path(str(scope_file))
if not path.is_absolute():
path = PROJECT_ROOT / path
data = json.loads(path.read_text(encoding="utf-8"))
return str(data.get("scope_id") or "").strip()
except Exception:
return ""
def _scope_from_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
scope_file = params.get("scopeFile") or params.get("scope_file")
if not scope_file:
return {}
try:
path = Path(str(scope_file))
if not path.is_absolute():
path = PROJECT_ROOT / path
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _manifest_path_value(self, raw: Any, final_dir: Path, manifest: Optional[Dict[str, Any]]) -> Optional[Path]:
if not raw:
return None
p = Path(str(raw))
if p.is_absolute():
return p
workspace = (manifest or {}).get("workspace_root") or (manifest or {}).get("workspace")
if workspace:
candidate = Path(str(workspace)) / p
if candidate.exists():
return candidate
if str(p).startswith("final/"):
return final_dir / p.name
return PROJECT_ROOT / p
def _artifact_path_from_manifest(self, manifest: Optional[Dict[str, Any]], final_dir: Path, keys: List[str]) -> Optional[Path]:
artifacts = (manifest or {}).get("artifacts", {})
if not isinstance(artifacts, dict):
return None
for key in keys:
artifact = artifacts.get(key)
raw = None
if isinstance(artifact, dict):
raw = artifact.get("full_path") or artifact.get("path") or artifact.get("relpath_or_hint") or artifact.get("file")
elif isinstance(artifact, str):
raw = artifact
path = self._manifest_path_value(raw, final_dir, manifest)
if path and path.exists() and path.stat().st_size > 0:
return path
return None
def _clean_fasta_candidate(self, protein_fasta: Path) -> Path:
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"
return protein_fasta.with_name(clean_name)
def _protein_fasta_for_prefix(self, final_dir: Path, prefix: str, manifest: Optional[Dict[str, Any]]) -> Optional[Path]:
candidates: List[Path] = []
params = (manifest or {}).get("parameters", {})
if isinstance(params, dict):
for key in ("protein_fasta", "protein_source", "scope_input_fasta", "proteins", "fasta"):
path = self._manifest_path_value(params.get(key), final_dir, manifest)
if path:
clean_path = self._clean_fasta_candidate(path)
if clean_path.exists() and clean_path.stat().st_mtime >= path.stat().st_mtime:
candidates.append(clean_path)
candidates.append(path)
artifacts = (manifest or {}).get("artifacts", {})
if isinstance(artifacts, dict):
for key in ("final_members", "final_proteins", "protein_fasta", "proteins", "scope_input_fasta", "input_fasta"):
artifact = artifacts.get(key)
if isinstance(artifact, dict):
raw = artifact.get("full_path") or artifact.get("path") or artifact.get("relpath_or_hint")
if raw:
p = self._manifest_path_value(raw, final_dir, manifest)
if p:
candidates.append(p)
elif isinstance(artifact, str):
p = self._manifest_path_value(artifact, final_dir, manifest)
if p:
candidates.append(p)
if prefix:
candidates.append(final_dir / f"{prefix}.final_members.pep.clean.fa")
candidates.append(final_dir / f"{prefix}.final_members.pep.fa")
candidates.append(final_dir / f"{prefix}.final_members.pep.fasta")
candidates.extend(sorted(final_dir.glob("*final_members.pep.clean.fa")))
candidates.extend(sorted(final_dir.glob("*final_members.pep.fa")))
for path in candidates:
if path.exists() and path.is_file() and path.stat().st_size > 0:
return path
return None
def _protein_stop_marker_stats(self, protein_fasta: Path) -> Dict[str, Any]:
stats = {
"protein_fasta": str(protein_fasta),
"sequence_count": 0,
"sequences_with_stop_markers": 0,
"stop_marker_count": 0,
}
current_changed = False
try:
with open(protein_fasta, "r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
if line.startswith(">"):
if stats["sequence_count"] and current_changed:
stats["sequences_with_stop_markers"] += 1
stats["sequence_count"] += 1
current_changed = False
continue
count = line.count("*")
if count:
stats["stop_marker_count"] += count
current_changed = True
if stats["sequence_count"] and current_changed:
stats["sequences_with_stop_markers"] += 1
except Exception as exc:
stats["error"] = str(exc)
return stats
def _write_clean_protein_fasta(self, source: Path, clean_fasta: Path) -> Dict[str, Any]:
stats = self._protein_stop_marker_stats(source)
clean_fasta.parent.mkdir(parents=True, exist_ok=True)
with open(source, "r", encoding="utf-8", errors="ignore") as src, open(clean_fasta, "w", encoding="utf-8") as dst:
for line in src:
dst.write(line if line.startswith(">") else line.replace("*", ""))
stats.update({
"clean_fasta": str(clean_fasta),
"created_at": datetime.now().isoformat(),
"policy": "job-local derived FASTA; original final_members.pep.fa is unchanged",
})
clean_fasta.with_suffix(clean_fasta.suffix + ".repair.json").write_text(
json.dumps(stats, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return stats
def _normalize_gene_id_for_match(self, value: str) -> str:
text = str(value or "").strip()
if "|" in text:
text = text.split("|")[-1]
for prefix in ("gene:", "transcript:", "CDS:", "mRNA:"):
if text.startswith(prefix):
text = text[len(prefix):]
text = re.sub(r"_[PT]\d+$", "", text)
text = re.sub(r"\.\d+$", "", text)
return text.upper()
def _read_final_member_ids(self, final_dir: Path, prefix: str, manifest: Optional[Dict[str, Any]] = None) -> List[str]:
candidates = []
params = (manifest or {}).get("parameters", {})
if isinstance(params, dict):
for key in ("gene_id_file", "member_id_file"):
path = self._manifest_path_value(params.get(key), final_dir, manifest)
if path:
candidates.append(path)
artifact_path = self._artifact_path_from_manifest(
manifest,
final_dir,
["final_members_id", "member_ids", "gene_ids"],
)
if artifact_path:
candidates.append(artifact_path)
if prefix:
candidates.append(final_dir / f"{prefix}.final_members.id")
candidates.extend(sorted(final_dir.glob("*final_members.id")))
for path in candidates:
if path.exists() and path.stat().st_size > 0:
return [line.strip() for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()]
return []
def _scope_gff_paths(self, params: Dict[str, Any]) -> List[Path]:
scope = self._scope_from_params(params)
if not scope:
return []
wanted = set(scope.get("selected_species") or scope.get("target_species") or [])
paths: List[Path] = []
files = scope.get("files") if isinstance(scope.get("files"), dict) else {}
for species, by_type in files.items():
if wanted and species not in wanted:
continue
if not isinstance(by_type, dict):
continue
for item in by_type.get("gff", []) or []:
if not isinstance(item, dict):
continue
raw = item.get("path")
if not raw:
continue
p = Path(str(raw))
if not p.is_absolute():
p = PROJECT_ROOT / p
if p.exists() and p.stat().st_size > 0:
paths.append(p)
return paths
def _find_job_gff3(self, final_dir: Path, prefix: str, params: Optional[Dict[str, Any]] = None) -> Optional[Path]:
if isinstance(params, dict):
for key in ("gff3_source", "gff3", "gff_file"):
raw = params.get(key)
if raw:
path = Path(str(raw))
if not path.is_absolute():
path = PROJECT_ROOT / path
if path.exists() and path.stat().st_size > 0:
return path
gff3_sources = params.get("gff3_sources")
if isinstance(gff3_sources, list):
for item in gff3_sources:
if not isinstance(item, dict):
continue
raw = item.get("path")
if not raw:
continue
path = Path(str(raw))
if not path.is_absolute():
path = PROJECT_ROOT / path
if path.exists() and path.stat().st_size > 0:
return path
scoped_paths = self._scope_gff_paths(params or {})
if scoped_paths:
return scoped_paths[0]
job_id = final_dir.parent.name
species = prefix.split(".", 1)[0] if prefix and "." in prefix else prefix
candidates: List[Path] = []
if species:
candidates.extend([
final_dir / f"{species}_final.gff3",
final_dir.parent / "input" / f"{species}_final.gff3",
PROJECT_ROOT / "input" / job_id / f"{species}_final.gff3",
])
candidates.extend(sorted(final_dir.glob("*.gff3")))
candidates.extend(sorted((PROJECT_ROOT / "input" / job_id).glob("*.gff3")) if (PROJECT_ROOT / "input" / job_id).exists() else [])
for path in candidates:
if path.exists() and path.stat().st_size > 0:
return path
return None
def _read_gff_gene_ids(self, gff3: Path, limit: int = 500000) -> set[str]:
ids: set[str] = set()
opener = gzip.open if str(gff3).lower().endswith(".gz") else open
with opener(gff3, "rt", encoding="utf-8", errors="ignore") as handle:
for idx, line in enumerate(handle):
if idx > limit:
break
if not line.strip() or line.startswith("#"):
continue
parts = line.rstrip("\n").split("\t")
if len(parts) < 9:
continue
if parts[2] not in {"gene", "mRNA", "transcript", "CDS"}:
continue
attrs = parts[8]
for item in attrs.split(";"):
if item.startswith("ID=") or item.startswith("Parent="):
raw = item.split("=", 1)[1].split(",", 1)[0]
norm = self._normalize_gene_id_for_match(raw)
if norm:
ids.add(norm)
return ids
def _apply_protein_stop_marker_fix(
self,
job_id: str,
stage: str,
prefix: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
workspace = project_root / "workspace" / job_id
final_dir = workspace / "final"
manifest = self._load_stage_manifest(final_dir, stage, prefix)
payload = self._issue_payload(issue)
protein_fasta = Path(str(payload.get("protein_fasta"))).expanduser() if payload.get("protein_fasta") else None
if protein_fasta and not protein_fasta.is_absolute():
protein_fasta = project_root / protein_fasta
if not protein_fasta or not protein_fasta.exists():
protein_fasta = self._protein_fasta_for_prefix(final_dir, prefix, manifest)
if not protein_fasta:
return {
"success": False,
"status": "blocked",
"message": f"未找到可清理的蛋白 FASTA:final/{prefix}.final_members.pep.fa",
"issue": issue,
}
if protein_fasta.parent != final_dir:
clean_fasta = self._clean_fasta_candidate(protein_fasta)
else:
clean_fasta = final_dir / f"{prefix or protein_fasta.stem}.final_members.pep.clean.fa"
repair = self._write_clean_protein_fasta(protein_fasta, clean_fasta)
return {
"success": True,
"status": "prepared",
"message": "已生成去除 '*' stop marker 的 job-local 蛋白 FASTA。后续 phylogeny/motif/domain/pep 将优先使用该文件。",
"issue_code": issue.get("code"),
"repair": repair,
"input_changed": False,
"global_code_changed": False,
"requires_user_confirmation_before_execution": False,
}
def _repair_backup_path(self, path: Path) -> Optional[Path]:
if not path.exists():
return None
backup_dir = path.parent / "diagnosis_repair_backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup = backup_dir / f"{datetime.now():%Y%m%d_%H%M%S}_{path.name}"
shutil.copy2(path, backup)
return backup
def _move_to_repair_backup(self, path: Path) -> Optional[Path]:
"""Move an invalid input out of the way before writing a replacement.
Moving is intentional here: sequence-repair outputs may be symlinks to
large source files. Writing through the symlink would modify the source.
"""
if not path.exists() and not path.is_symlink():
return None
backup_dir = path.parent / "diagnosis_repair_backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup = backup_dir / f"{datetime.now():%Y%m%d_%H%M%S}_{path.name}"
shutil.move(str(path), str(backup))
return backup
def _issue_payload(self, issue: Dict[str, Any]) -> Dict[str, Any]:
for item in issue.get("evidence", []):
if not isinstance(item, str):
continue
text = item.strip()
if text.startswith("{") and text.endswith("}"):
try:
return json.loads(text)
except Exception:
continue
return {}
def _write_gene_gff_overlap_report(
self,
job_id: str,
stage: str,
prefix: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
payload = self._issue_payload(issue)
workspace = project_root / "workspace" / job_id
report_dir = workspace / "final" / "diagnosis_reports"
report_dir.mkdir(parents=True, exist_ok=True)
report_path = report_dir / f"{prefix or stage}.{stage}.gene_gff_id_overlap.report.json"
report = {
"issue_code": issue.get("code"),
"stage": stage,
"prefix": prefix,
"created_at": datetime.now().isoformat(),
"diagnosis": payload or issue,
"policy": "job-local report only; no gene IDs or GFF attributes were modified",
"next_action": "Review unmatched ID examples and choose a mapping rule before changing IDs.",
}
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
return {
"success": True,
"changed": False,
"status": "reported",
"issue_code": issue.get("code"),
"message": "已生成 GFF/成员 ID 重叠诊断报告;该问题不能静默改 ID。",
"report": str(report_path),
"global_code_changed": False,
}
def _repair_identify_local_pfam_hmm(
self,
job_id: str,
prefix: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
"""Prepare a job-local query HMM from config/family mapping/local Pfam resources."""
input_dir = project_root / "input" / job_id
workspace = project_root / "workspace" / job_id
if not input_dir.exists():
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": f"input directory not found: {input_dir}",
}
try:
from apps.cli.run_identify import (
_pfam_alias_to_accession,
build_required_pfam_hmm,
read_analysis_family,
read_required_pfams_from_config,
)
except Exception as exc:
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": f"无法导入 identify Pfam fallback 工具: {exc}",
}
payload = self._issue_payload(issue)
required_pfams = str(
payload.get("required_pfams")
or payload.get("requiredPfams")
or payload.get("pfam")
or payload.get("pfams")
or ""
).strip()
source = "issue_evidence"
if not required_pfams:
required_pfams = read_required_pfams_from_config(input_dir, workspace)
source = "config"
family = ""
if not required_pfams:
parts = [part for part in str(prefix or "").split(".") if part]
if len(parts) >= 2:
family = parts[-1]
elif parts and parts[0].lower() not in {"multi", "target", "reference", "comparative"}:
family = parts[0]
if not family:
family = read_analysis_family(input_dir, workspace)
mapped = _pfam_alias_to_accession(family) if family else None
if mapped:
required_pfams = mapped
source = f"family_alias:{family}"
if not required_pfams:
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": "未能从 issue/config/prefix 推断 Required_Pfams;请在 config.xlsx 的 Analysis.Required_Pfams 填写 PFxxxxx,或上传 HMM。",
}
cache_dir = input_dir / "genome_prepare" / "hmm_cache"
try:
hmm_path, queries, fetch_map = build_required_pfam_hmm(required_pfams, None, cache_dir)
except Exception as exc:
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"required_pfams": required_pfams,
"source": source,
"message": f"本地 Pfam/HMM fallback 失败: {exc}",
}
record = {
"issue_code": issue.get("code"),
"created_at": datetime.now().isoformat(),
"job_id": job_id,
"prefix": prefix,
"required_pfams": required_pfams,
"queries": queries,
"fetch_map": fetch_map,
"source": source,
"query_hmm": str(hmm_path),
"global_code_changed": False,
"next_action": "rerun identify with the same job; run_identify.py will discover this HMM cache before remote lookup.",
}
record_path = cache_dir / f"{Path(hmm_path).stem}.diagnosis_repair.json"
record_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
return {
"success": True,
"changed": True,
"status": "prepared",
"issue_code": issue.get("code"),
"message": "已基于 config/family/Pfam 本地资源生成 identify 可用的 job-local 查询 HMM。",
"required_pfams": required_pfams,
"query_hmm": str(hmm_path),
"repair_record": str(record_path),
"input_changed": True,
"global_code_changed": False,
"should_rerun_stage": True,
"requires_user_confirmation_before_execution": False,
}
def _repair_ncbi_family_reference_link(
self,
job_id: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
payload = self._issue_payload(issue)
protein_fasta = Path(str(payload.get("protein_fasta") or "")).expanduser()
expected = Path(str(payload.get("expected_reference_pep") or "")).expanduser()
if protein_fasta and not protein_fasta.is_absolute():
protein_fasta = project_root / protein_fasta
if expected and not expected.is_absolute():
expected = project_root / expected
if not protein_fasta.exists() or protein_fasta.stat().st_size == 0:
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": f"NCBI protein FASTA not found or empty: {protein_fasta}",
}
if not expected:
parts = protein_fasta.name.split(".")
if len(parts) < 4:
return {
"success": False,
"changed": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": f"无法从文件名推断 Species/Family: {protein_fasta.name}",
}
expected = protein_fasta.parent / f"{parts[0]}.{parts[1]}.pep.fa"
expected.parent.mkdir(parents=True, exist_ok=True)
if expected.exists() and expected.stat().st_size > 0:
return {
"success": True,
"changed": False,
"status": "already_exists",
"issue_code": issue.get("code"),
"message": f"标准家族参考蛋白已存在: {expected}",
"reference_pep": str(expected),
}
try:
if expected.exists() or expected.is_symlink():
expected.unlink()
os.symlink(protein_fasta.name, expected)
link_type = "symlink"
except Exception:
shutil.copy2(protein_fasta, expected)
link_type = "copy"
record = {
"issue_code": issue.get("code"),
"created_at": datetime.now().isoformat(),
"job_id": job_id,
"protein_fasta": str(protein_fasta),
"reference_pep": str(expected),
"link_type": link_type,
"global_code_changed": False,
}
record_path = expected.with_suffix(expected.suffix + ".diagnosis_repair.json")
record_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
return {
"success": True,
"changed": True,
"status": "prepared",
"issue_code": issue.get("code"),
"message": f"已创建标准家族参考蛋白 {expected.name} ({link_type})。",
"protein_fasta": str(protein_fasta),
"reference_pep": str(expected),
"repair_record": str(record_path),
"input_changed": False,
"global_code_changed": False,
"requires_user_confirmation_before_execution": False,
}
def _repair_missing_manifest_from_artifacts(
self,
job_id: str,
stage: str,
prefix: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
"""Recover a minimal success manifest only when required final artifacts exist."""
workspace = project_root / "workspace" / job_id
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
manifest_stage = {"ncbi_fetch": "ncbi_fetch_sequences"}.get(stage, stage)
manifest_alias = "ncbi_fetch" if manifest_stage == "ncbi_fetch_sequences" else manifest_stage
manifest_path = final_dir / f"{prefix}.{manifest_alias}.manifest.json" if prefix else final_dir / f"{manifest_alias}.manifest.json"
if manifest_path.exists() and manifest_path.stat().st_size > 0:
return {
"success": True,
"changed": False,
"status": "already_exists",
"issue_code": issue.get("code"),
"message": f"Manifest 已存在: {manifest_path}",
"manifest_path": str(manifest_path),
}
if self.is_stage_running(job_id, manifest_stage):
return {
"success": False,
"changed": False,
"status": "running_stage_blocked",
"issue_code": issue.get("code"),
"message": f"{job_id}/{manifest_stage} 当前仍在运行,拒绝恢复 manifest。",
}
if manifest_stage == "identify":
manifest = self._build_recovered_identify_manifest(job_id, prefix, workspace, final_dir)
elif manifest_stage == "ncbi_fetch_sequences":
manifest = self._build_recovered_ncbi_fetch_manifest(job_id, prefix, workspace, final_dir)
else:
return {
"success": False,
"changed": False,
"status": "unsupported_stage",
"issue_code": issue.get("code"),
"message": f"暂不支持 {manifest_stage} 的 manifest 自动恢复;需要先定义该阶段核心产物契约。",
}
if not manifest:
return {
"success": False,
"changed": False,
"status": "insufficient_artifacts",
"issue_code": issue.get("code"),
"message": f"{manifest_stage} final 核心产物不足,不能安全恢复 manifest。",
}
manifest["recovered_manifest"] = {
"recovered": True,
"issue_code": issue.get("code"),
"created_at": datetime.now().isoformat(),
"policy": "job-local manifest recovery from verified non-empty final artifacts",
"global_code_changed": False,
}
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
return {
"success": True,
"changed": True,
"status": "recovered",
"issue_code": issue.get("code"),
"message": f"已根据 final 核心产物恢复 {manifest_stage} manifest。",
"manifest_path": str(manifest_path),
"input_changed": False,
"global_code_changed": False,
"requires_user_confirmation_before_execution": False,
}
def _repair_ortholog_materialize_from_intermediate(
self,
job_id: str,
prefix: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
) -> Dict[str, Any]:
"""Reuse ortholog/*_pairs.txt and run backend materialization without rerunning BLAST."""
if self.is_stage_running(job_id, "ortholog"):
return {
"success": False,
"changed": False,
"status": "running_stage_blocked",
"issue_code": issue.get("code"),
"message": f"{job_id}/ortholog 当前仍在运行,拒绝执行 intermediate 落盘修复。",
}
workspace = project_root / "workspace" / job_id
final_dir = workspace / "final"
ortholog_dir = workspace / "ortholog"
from gfa_core.ortholog_materialize import ortholog_pairs_has_data
intermediate_pairs = [path for path in sorted(ortholog_dir.glob("*_pairs.txt")) if ortholog_pairs_has_data(path)] if ortholog_dir.exists() else []
if not intermediate_pairs:
return {
"success": False,
"changed": False,
"status": "insufficient_artifacts",
"issue_code": issue.get("code"),
"message": "ortholog/ 下没有可复用的 *_pairs.txt,无法执行 intermediate 落盘修复。",
}
scope_file = ""
if prefix:
scope_candidates = sorted(final_dir.glob(f"{prefix}.*.scope.json"), key=lambda p: p.stat().st_mtime, reverse=True)
for candidate in scope_candidates:
try:
scope_data = json.loads(candidate.read_text(encoding="utf-8"))
except Exception:
continue
if scope_data.get("reference_species") or scope_data.get("target_species"):
scope_file = str(candidate)
break
try:
from apps.web.pipeline_backend import JobInfo, run_stage
job = JobInfo(
job_id=job_id,
input_dir=str(project_root / "input" / job_id),
workspace_dir=str(workspace),
logs_dir=str(workspace / "logs"),
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
msg, manifest_path = run_stage(
stage="ortholog",
job=job,
prefix=prefix,
scope_file=scope_file,
)
except Exception as exc:
return {
"success": False,
"changed": False,
"status": "repair_failed",
"issue_code": issue.get("code"),
"message": f"ortholog intermediate 落盘修复失败: {exc}",
}
if not manifest_path or not Path(manifest_path).exists() or str(msg).startswith("❌") or str(msg).startswith("⚠️"):
return {
"success": False,
"changed": False,
"status": "repair_failed",
"issue_code": issue.get("code"),
"message": msg or "ortholog intermediate 落盘修复未生成 manifest",
}
return {
"success": True,
"changed": True,
"status": "recovered",
"issue_code": issue.get("code"),
"message": msg,
"manifest_path": str(manifest_path),
"input_changed": False,
"global_code_changed": False,
"requires_user_confirmation_before_execution": False,
"should_rerun_stage": False,
}
def _build_recovered_identify_manifest(
self,
job_id: str,
prefix: str,
workspace: Path,
final_dir: Path,
) -> Optional[Dict[str, Any]]:
if not prefix:
return None
pep = final_dir / f"{prefix}.final_members.pep.fa"
ids = final_dir / f"{prefix}.final_members.id"
if not pep.exists() or pep.stat().st_size == 0 or not ids.exists() or ids.stat().st_size == 0:
return None
sequence_count = self._count_fasta_records(pep)
id_count = self._count_nonempty_lines(ids)
if sequence_count <= 0 or id_count <= 0:
return None
artifacts = {
"final_members": self._manifest_artifact(workspace, pep, "fasta", artifact="final_members"),
"final_members_id": self._manifest_artifact(workspace, ids, "id_list", artifact="final_members_id"),
}
interpro = final_dir / f"{prefix}.final_members.interproscan.tsv"
interpro_status = final_dir / f"{prefix}.interproscan_domain_check.txt"
interpro_input = final_dir / f"{prefix}.final_members.interproscan.input.pep.fa"
if interpro.exists() and interpro.stat().st_size > 0:
artifacts["interproscan_domain_check"] = self._manifest_artifact(workspace, interpro, "tsv", artifact="interproscan_domain_check")
if interpro_status.exists() and interpro_status.stat().st_size > 0:
artifacts["interproscan_domain_check_status"] = self._manifest_artifact(workspace, interpro_status, "text", artifact="interproscan_domain_check_status")
if interpro_input.exists() and interpro_input.stat().st_size > 0:
artifacts["interproscan_clean_input"] = self._manifest_artifact(workspace, interpro_input, "fasta", artifact="interproscan_clean_input")
species_results = []
for species_pep in sorted((workspace / "identify").glob("*/final/*.final_gfmembers.pep.fa")):
species = species_pep.name.split(".", 1)[0]
members = self._count_fasta_records(species_pep)
species_results.append({
"species": species,
"file_prefix": species,
"input_file": "",
"final_members": members,
"final_pep_file": self._relpath(workspace, species_pep),
})
artifacts[f"{species}_final_members"] = self._manifest_artifact(
workspace,
species_pep,
"fasta",
artifact=f"{species}_final_members",
extra={"species": species, "members": members},
)
return {
"manifest_version": "1.0",
"stage": "identify",
"status": "success",
"prefix": prefix,
"job_id": job_id,
"timestamp": datetime.now().isoformat(),
"created_at": datetime.now().isoformat(),
"completed_at": datetime.now().isoformat(),
"workspace_root": str(workspace.resolve()),
"workspace": str(final_dir.resolve()),
"species_count": len(species_results) or 1,
"species_results": species_results,
"metrics": {
"final_members": sequence_count,
"final_member_ids": id_count,
"sequence_count": sequence_count,
},
"artifacts": artifacts,
"warnings": [
"Manifest was recovered from final artifacts; original run parameters and elapsed time are unavailable."
],
}
def _build_recovered_ncbi_fetch_manifest(
self,
job_id: str,
prefix: str,
workspace: Path,
final_dir: Path,
) -> Optional[Dict[str, Any]]:
if not prefix:
return None
protein = final_dir / f"{prefix}.ncbi_fetch.protein.fa"
cds = final_dir / f"{prefix}.ncbi_fetch.cds.fa"
mapping = final_dir / f"{prefix}.ncbi_fetch.mapping.tsv"
if not any(path.exists() and path.stat().st_size > 0 for path in [protein, cds, mapping]):
return None
artifacts: Dict[str, Any] = {}
if mapping.exists() and mapping.stat().st_size > 0:
artifacts["mapping"] = self._manifest_artifact(workspace, mapping, "tsv", artifact="mapping")
for key, suffix, typ in [
("unresolved", "unresolved.tsv", "tsv"),
("ambiguous", "ambiguous.tsv", "tsv"),
("report", "report.md", "markdown"),
]:
path = final_dir / f"{prefix}.ncbi_fetch.{suffix}"
if path.exists() and path.stat().st_size > 0:
artifacts[key] = self._manifest_artifact(workspace, path, typ, artifact=key)
protein_records = self._count_fasta_records(protein) if protein.exists() else 0
cds_records = self._count_fasta_records(cds) if cds.exists() else 0
if protein.exists() and protein.stat().st_size > 0:
artifacts["protein_fasta"] = self._manifest_artifact(workspace, protein, "fasta", artifact="protein_fasta")
if cds.exists() and cds.stat().st_size > 0:
artifacts["cds_fasta"] = self._manifest_artifact(workspace, cds, "fasta", artifact="cds_fasta")
family_ref = final_dir / f"{prefix}.pep.fa"
if family_ref.exists() and family_ref.stat().st_size > 0:
artifacts["family_reference_pep"] = self._manifest_artifact(workspace, family_ref, "fasta", artifact="family_reference_pep")
status = "success" if protein_records or cds_records else "partial_success"
return {
"manifest_version": "1.0",
"stage": "ncbi_fetch_sequences",
"status": status,
"prefix": prefix,
"job_id": job_id,
"created_at": datetime.now().isoformat(),
"completed_at": datetime.now().isoformat(),
"parameters": {},
"metrics": {
"protein_records": protein_records,
"cds_records": cds_records,
"mapping_rows": max(self._count_nonempty_lines(mapping) - 1, 0) if mapping.exists() else 0,
},
"artifacts": artifacts,
"warnings": [
"Manifest was recovered from final artifacts; original NCBI query parameters and elapsed time are unavailable."
],
}
def _manifest_artifact(self, workspace: Path, path: Path, typ: str, *, artifact: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
record = {
"artifact": artifact or path.stem,
"file": path.name,
"path": self._relpath(workspace, path),
"full_path": str(path.resolve()),
"relpath_or_hint": self._relpath(workspace, path),
"type": typ,
"size": path.stat().st_size if path.exists() else 0,
"sha256": self._sha256(path) if path.exists() else "",
}
if extra:
record.update(extra)
return record
def _relpath(self, root: Path, path: Path) -> str:
try:
return str(path.relative_to(root))
except Exception:
return str(path)
def _sha256(self, path: Path) -> str:
import hashlib
h = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _count_fasta_records(self, path: Path) -> int:
if not path.exists():
return 0
count = 0
with path.open("r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
if line.startswith(">"):
count += 1
return count
def _count_nonempty_lines(self, path: Path) -> int:
if not path.exists():
return 0
with path.open("r", encoding="utf-8", errors="ignore") as handle:
return sum(1 for line in handle if line.strip())
def _repair_empty_final_gff(self, job_id: str, issue: Dict[str, Any], project_root: Path = PROJECT_ROOT) -> Dict[str, Any]:
input_dir = project_root / "input" / job_id
payload_text = "\n".join(str(x) for x in issue.get("evidence", []))
match = re.search(r"final_gff=([^\n]+)", payload_text)
final_gff = Path(match.group(1).strip()) if match else None
if not final_gff or not final_gff.exists():
return {"success": False, "status": "blocked", "issue_code": issue.get("code"), "message": "未能定位空 final GFF。"}
species = self._species_from_path_name(final_gff)
candidates = self._candidate_source_gffs(input_dir, species)
if not candidates:
return {"success": False, "status": "blocked", "issue_code": issue.get("code"), "message": f"{species} 没有可用的非空原始 GFF。"}
source = candidates[0]
backup = self._repair_backup_path(final_gff)
shutil.copy2(source, final_gff)
record = {
"issue_code": issue.get("code"),
"species": species,
"source_gff": str(source),
"repaired_gff": str(final_gff),
"backup": str(backup) if backup else "",
"created_at": datetime.now().isoformat(),
"policy": "job-local runtime repair; global source code unchanged",
}
repair_dir = input_dir / "genome_prepare"
repair_dir.mkdir(parents=True, exist_ok=True)
(repair_dir / f"{species}.gff.repair.json").write_text(
json.dumps(record, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return {"success": True, "changed": True, "status": "repaired", "issue_code": issue.get("code"), "repair": record}
def _repair_genome_prepare_extract_sequences(
self,
job_id: str,
issue: Dict[str, Any],
project_root: Path = PROJECT_ROOT,
*,
execute_heavy: bool = False,
) -> Dict[str, Any]:
payload = self._issue_payload(issue)
species = str(payload.get("species") or "").strip()
if not species:
return {"success": False, "status": "blocked", "issue_code": issue.get("code"), "message": "无法从诊断证据中识别物种。"}
input_dir = project_root / "input" / job_id
genome_candidates = self._candidate_source_genomes(input_dir, species)
payload_genome = payload.get("genome")
if payload_genome:
payload_path = Path(str(payload_genome))
genome_candidates.insert(0, payload_path if payload_path.is_absolute() else input_dir / payload_path)
genome = next((p for p in genome_candidates if p.exists() and p.stat().st_size > 0 and self._fasta_profile(p).get("looks_nucleotide")), None)
gff_candidates = self._candidate_source_gffs(input_dir, species)
payload_gff = payload.get("gff")
if payload_gff:
payload_path = Path(str(payload_gff))
gff_candidates.insert(0, payload_path if payload_path.is_absolute() else input_dir / payload_path)
gff = next((p for p in gff_candidates if p.exists() and p.stat().st_size > 0), None)
if not genome or not gff:
return {
"success": False,
"status": "blocked",
"issue_code": issue.get("code"),
"message": f"{species} 缺少可用于 gffread 的 genome 或非空 GFF。",
"genome_candidates": [str(p) for p in genome_candidates],
"gff_candidates": [str(p) for p in gff_candidates],
}
if not execute_heavy:
return {
"success": True,
"changed": False,
"status": "ready",
"issue_code": issue.get("code"),
"message": "已确认可用 genome+GFF,可在确认后执行 gffread 提取 pep/cds。",
"species": species,
"genome": str(genome),
"gff": str(gff),
}
pep_out = input_dir / f"{species}.pep.fasta"
cds_out = input_dir / f"{species}.cds.fasta"
backups = []
for path in (pep_out, cds_out):
backup = self._move_to_repair_backup(path)
if backup:
backups.append(str(backup))
gffread = project_root / "software" / "gffread"
if not gffread.exists():
gffread = Path("/home/xzq/miniconda3/envs/test4py311/bin/gffread")
cmd = [str(gffread) if gffread.exists() else "gffread", str(gff), "-g", str(genome), "-y", str(pep_out), "-x", str(cds_out)]
proc = subprocess.run(cmd, cwd=str(project_root), text=True, capture_output=True, check=False, timeout=3600)
record = {
"issue_code": issue.get("code"),
"species": species,
"cmd": cmd,
"returncode": proc.returncode,
"stdout_tail": proc.stdout[-2000:],
"stderr_tail": proc.stderr[-2000:],
"genome": str(genome),
"gff": str(gff),
"pep_out": str(pep_out),
"cds_out": str(cds_out),
"backups": backups,
"created_at": datetime.now().isoformat(),
"policy": "job-local runtime repair using gffread; global source code unchanged",
}
repair_dir = input_dir / "genome_prepare"
repair_dir.mkdir(parents=True, exist_ok=True)
(repair_dir / f"{species}.gffread_extract.repair.json").write_text(
json.dumps(record, indent=2, ensure_ascii=False),
encoding="utf-8",
)
success = proc.returncode == 0 and pep_out.exists() and pep_out.stat().st_size > 0 and cds_out.exists() and cds_out.stat().st_size > 0
return {
"success": success,
"changed": success,
"status": "repaired" if success else "repair_failed",
"issue_code": issue.get("code"),
"repair": record,
"should_rerun_stage": success,
}
def _apply_known_patch_to_copy(self, issue_code: str, patched_script: Path) -> Dict[str, Any]:
"""对副本应用已知低风险补丁。若全局脚本已修复,则记录 no-op。"""
text = patched_script.read_text(encoding="utf-8")
original = text
notes = []
if issue_code == "python_import_path_error":
marker = "# DiagnosisAgent patch: ensure project root is importable"
if marker in text:
notes.append("Patch already present: project root is added to sys.path.")
else:
lines = text.splitlines()
insert_at = 0
if lines and lines[0].startswith("#!"):
insert_at = 1
while insert_at < len(lines) and (
not lines[insert_at].strip()
or lines[insert_at].startswith("#")
or lines[insert_at].startswith('"""')
or lines[insert_at].startswith("'''")
):
insert_at += 1
if insert_at > 20:
break
patch_lines = [
marker,
"import sys as _diagnosis_sys",
"from pathlib import Path as _DiagnosisPath",
f"_DIAGNOSIS_PROJECT_ROOT = _DiagnosisPath({str(PROJECT_ROOT)!r})",
"if str(_DIAGNOSIS_PROJECT_ROOT) not in _diagnosis_sys.path:",
" _diagnosis_sys.path.insert(0, str(_DIAGNOSIS_PROJECT_ROOT))",
"",
]
lines[insert_at:insert_at] = patch_lines
text = "\n".join(lines) + ("\n" if original.endswith("\n") else "")
notes.append("Inserted project root sys.path guard into patched script copy.")
elif issue_code == "r_table_comment_char":
old = 'read.table(kv$count_matrix, header = TRUE, row.names = 1, sep = "\\t", check.names = FALSE)'
new = 'read.table(kv$count_matrix, header = TRUE, row.names = 1, sep = "\\t", check.names = FALSE, quote = "", comment.char = "")'
if old in text:
text = text.replace(old, new)
notes.append("Disabled R comment parsing for count matrix reads.")
elif "comment.char = \"\"" in text:
notes.append("Patch already present: comment.char is disabled.")
elif issue_code == "success_with_zero_alignment_rate":
if "Overall alignment rate:" in text and "Total pairs:" in text:
notes.append("Patch already present: HISAT2 new-summary parser is supported.")
else:
notes.append("No automatic text patch template matched; manual patch required.")
elif issue_code == "success_with_zero_qc_reads":
if "Total read pairs processed:" in text and "Pairs written (passing filters):" in text:
notes.append("Patch already present: cutadapt paired-end parser is supported.")
else:
notes.append("No automatic text patch template matched; manual patch required.")
elif issue_code == "fgsea_rank_gene2ko_id_mismatch":
if "regexec(\"^(Os[0-9]{2})t([0-9]+)(-[0-9]+)?$\"" in text:
notes.append("Patch already present: RAP transcript IDs are normalized to RAP gene IDs for fgsea.")
elif "normalize_gene_id <- function(x)" in text and "x" in text:
notes.append(
"No direct source patch applied: fgsea R code is generated inside Python; "
"apply the global RAP normalization patch to run_rnaseq_fgsea.py and regenerate the stage script."
)
else:
notes.append("No automatic text patch template matched; manual patch required.")
elif issue_code == "wgcna_matrix_comment_char":
old = 'read.table(path, header = TRUE, row.names = 1, sep = "\\t", check.names = FALSE)'
new = 'read.table(path, header = TRUE, row.names = 1, sep = "\\t",\n check.names = FALSE, quote = "", comment.char = "")'
if old in text:
text = text.replace(old, new)
notes.append("Disabled R comment parsing for WGCNA matrix reads.")
elif 'comment.char = ""' in text and "read_matrix <- function" in text:
notes.append("Patch already present: WGCNA matrix reads disable R comment parsing.")
else:
notes.append("No automatic text patch template matched; manual patch required.")
elif issue_code == "quant_fpkm_tpm_comment_char":
notes.append(
"The known patch is in scripts/calculate_fpkm_tpm_simple.R, not the copied Python runner. "
"Ensure count/GTF reads set quote = \"\" and comment.char = \"\" before rerunning quant."
)
else:
notes.append(f"No known automatic patch template for issue: {issue_code}")
changed = text != original
if changed:
patched_script.write_text(text, encoding="utf-8")
return {
"success": True,
"changed": changed,
"issue_code": issue_code,
"notes": notes,
}
def _build_patched_command(
self,
job_id: str,
stage: str,
prefix: str,
patched_script: Path,
fix_data: Dict[str, Any],
project_root: Path,
) -> Dict[str, Any]:
cmd = fix_data.get("cmd") or fix_data.get("command")
if isinstance(cmd, str):
cmd = shlex.split(cmd)
elif not isinstance(cmd, list):
manifest = self._load_stage_manifest(project_root / "workspace" / job_id / "final", stage, prefix)
cmd = manifest.get("cmd") if manifest else None
if not isinstance(cmd, list) or not cmd:
return {
"success": False,
"message": "缺少可执行命令。请在 fix_data.cmd 中提供原阶段命令,DiagnosisAgent 会替换为 patched script。",
}
original_name = STAGE_SCRIPT_MAP.get(stage, "")
patched_cmd = []
replaced = False
for part in cmd:
if original_name and (str(part).endswith(original_name) or str(part).endswith(f"apps/cli/{original_name}")):
patched_cmd.append(str(patched_script))
replaced = True
else:
patched_cmd.append(str(part))
if not replaced and patched_script.suffix == ".py":
# Conservative fallback for commands like ["python", "-m", ...] is not safe.
return {
"success": False,
"message": "原命令中未找到阶段脚本路径,无法保证使用 patched script 执行。",
"cmd": cmd,
}
return {"success": True, "cmd": patched_cmd}
def _execute_patched_command(self, cmd: List[str], cwd: Path, timeout: int) -> Dict[str, Any]:
try:
proc = subprocess.run(
cmd,
cwd=str(cwd),
text=True,
capture_output=True,
check=False,
timeout=timeout,
)
return {
"returncode": proc.returncode,
"stdout_tail": proc.stdout[-4000:],
"stderr_tail": proc.stderr[-4000:],
}
except subprocess.TimeoutExpired as exc:
return {
"returncode": 124,
"stdout_tail": (exc.stdout or "")[-4000:] if isinstance(exc.stdout, str) else "",
"stderr_tail": (exc.stderr or "")[-4000:] if isinstance(exc.stderr, str) else "",
"error": f"patched command timed out after {timeout}s",
}
except Exception as exc:
return {"returncode": 1, "error": str(exc), "stdout_tail": "", "stderr_tail": ""}
def _backup_failed_run(self, job_id: str, stage: str, prefix: str, project_root: Path) -> Dict[str, Any]:
workspace = project_root / "workspace" / job_id
backup_dir = workspace / "reproducibility" / "failed_runs" / stage / datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir.mkdir(parents=True, exist_ok=True)
copied = []
manifest = self._load_stage_manifest(workspace / "final", stage, prefix)
if manifest and manifest.get("_path"):
src = Path(manifest["_path"])
if src.exists():
dst = backup_dir / src.name
shutil.copy2(src, dst)
copied.append(str(dst))
logs_dir = workspace / "logs"
logs = sorted(logs_dir.glob(f"*_{stage}.log"), key=lambda p: p.stat().st_mtime, reverse=True) if logs_dir.exists() else []
if logs:
dst = backup_dir / logs[0].name
shutil.copy2(logs[0], dst)
copied.append(str(dst))
return {"backup_dir": str(backup_dir), "copied": copied}
def _annotate_recovery_manifest(
self,
job_id: str,
stage: str,
prefix: str,
recovery: Dict[str, Any],
project_root: Path,
) -> Dict[str, Any]:
manifest = self._load_stage_manifest(project_root / "workspace" / job_id / "final", stage, prefix)
if not manifest or not manifest.get("_path"):
return {"success": False, "message": "No manifest found to annotate."}
path = Path(manifest.pop("_path"))
manifest["recovery"] = recovery
path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
return {"success": True, "manifest_path": str(path)}
def _update_patch_report(self, report_path: Path, updates: Dict[str, Any]) -> None:
try:
data = json.loads(report_path.read_text(encoding="utf-8")) if report_path.exists() else {}
data.update(updates)
report_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
return
def _open_text_maybe_gzip(self, path: Path):
if str(path).lower().endswith(".gz"):
return gzip.open(path, "rt", encoding="utf-8", errors="ignore")
return open(path, "r", encoding="utf-8", errors="ignore")
def _fasta_profile(self, path: Path, max_bases: int = 50000) -> Dict[str, Any]:
profile = {
"path": str(path),
"exists": path.exists(),
"size": path.stat().st_size if path.exists() else 0,
"headers": 0,
"letters": 0,
"nucleotide_letters": 0,
"non_nucleotide_letters": 0,
"max_record_len": 0,
"first_header": "",
}
if not path.exists() or path.stat().st_size == 0:
return profile
current_len = 0
try:
with self._open_text_maybe_gzip(path) as handle:
for raw in handle:
if raw.startswith(">"):
if current_len > profile["max_record_len"]:
profile["max_record_len"] = current_len
current_len = 0
profile["headers"] += 1
if not profile["first_header"]:
profile["first_header"] = raw.strip()[:120]
continue
seq = re.sub(r"[^A-Za-z*]", "", raw)
if not seq:
continue
current_len += len(seq)
upper = seq.upper().replace("*", "")
profile["letters"] += len(upper)
profile["nucleotide_letters"] += sum(1 for ch in upper if ch in {"A", "C", "G", "T", "U", "N"})
profile["non_nucleotide_letters"] += sum(1 for ch in upper if ch not in {"A", "C", "G", "T", "U", "N"})
if profile["letters"] >= max_bases:
break
if current_len > profile["max_record_len"]:
profile["max_record_len"] = current_len
except Exception as exc:
profile["error"] = str(exc)
letters = max(int(profile["letters"] or 0), 1)
profile["nucleotide_ratio"] = round(float(profile["nucleotide_letters"]) / letters, 4)
profile["non_nucleotide_ratio"] = round(float(profile["non_nucleotide_letters"]) / letters, 4)
profile["looks_nucleotide"] = profile["letters"] > 0 and profile["nucleotide_ratio"] >= 0.92
profile["looks_protein"] = profile["letters"] > 0 and profile["non_nucleotide_ratio"] >= 0.08
profile["looks_genome_like"] = bool(profile["looks_nucleotide"] and profile["max_record_len"] >= 5000)
return profile
def _species_from_path_name(self, path: Path) -> str:
name = path.name
if "_final.gff" in name:
return name.split("_final.gff", 1)[0]
if "." in name:
return name.split(".", 1)[0]
return path.stem
def _species_path_rank(self, path: Path, species: str) -> Tuple[int, int, str]:
species_l = species.lower()
name_l = path.name.lower()
parent_l = path.parent.name.lower()
score = 100
if parent_l == species_l:
score = min(score, 0)
if name_l.startswith(f"{species_l}_final."):
score = min(score, 1)
if name_l.startswith(f"{species_l}."):
score = min(score, 2)
if name_l.startswith(f"{species_l}_") or name_l.startswith(f"{species_l}-"):
score = min(score, 3)
if species_l in name_l:
score = min(score, 5)
return (score, len(path.name), str(path))
def _dedupe_existing_paths(self, candidates: List[Path]) -> List[Path]:
unique: List[Path] = []
seen = set()
for path in candidates:
try:
resolved = path.resolve()
except Exception:
resolved = path
if resolved in seen or not path.exists() or path.stat().st_size == 0:
continue
seen.add(resolved)
unique.append(path)
return unique
def _candidate_source_gffs(self, input_dir: Path, species: str) -> List[Path]:
species_candidates: List[Path] = []
all_candidates: List[Path] = []
for base in [input_dir / species, input_dir]:
if base.exists():
species_candidates.extend(sorted(base.glob(f"{species}_final.gff3")))
species_candidates.extend(sorted(base.glob(f"{species}.gff")))
species_candidates.extend(sorted(base.glob(f"{species}.gff3")))
species_candidates.extend(sorted(base.glob(f"{species}*.gff")))
species_candidates.extend(sorted(base.glob(f"{species}*.gff3")))
all_candidates.extend(sorted(base.glob("*.gff")))
all_candidates.extend(sorted(base.glob("*.gff3")))
species_unique = self._dedupe_existing_paths(species_candidates)
if species_unique:
return sorted(species_unique, key=lambda p: self._species_path_rank(p, species))
all_unique = self._dedupe_existing_paths(all_candidates)
if len(all_unique) == 1:
return all_unique
return []
def _candidate_source_genomes(self, input_dir: Path, species: str) -> List[Path]:
candidates: List[Path] = []
suffixes = ["fasta", "fa", "fna", "fasta.gz", "fa.gz", "fna.gz"]
exact_names = []
for suffix in suffixes:
exact_names.extend([
f"{species}.genome.{suffix}",
f"{species}.genomic.{suffix}",
f"{species}.dna.{suffix}",
f"{species}.{suffix}",
])
patterns = []
for suffix in suffixes:
patterns.extend([
f"{species}*genome*.{suffix}",
f"{species}*genomic*.{suffix}",
f"{species}*dna*.{suffix}",
f"{species}*Chr*.{suffix}",
f"{species}*.{suffix}",
f"*{species}*genome*.{suffix}",
f"*{species}*genomic*.{suffix}",
f"*{species}*dna*.{suffix}",
f"*{species}*Chr*.{suffix}",
])
for base in [input_dir / species, input_dir]:
if not base.exists():
continue
for name in exact_names:
candidates.append(base / name)
for pattern in patterns:
candidates.extend(sorted(base.glob(pattern)))
blocked_tokens = ("pep", "protein", "cds", "transcript", "mrna")
unique = [
path for path in self._dedupe_existing_paths(candidates)
if not any(token in path.name.lower() for token in blocked_tokens)
]
return sorted(unique, key=lambda p: self._species_path_rank(p, species))
def _gate_genome_prepare(self, input_dir: Path, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], gate: str, add_issue) -> None:
species_files: Dict[str, Dict[str, Path]] = {}
for path in sorted(input_dir.glob("*.pep.fasta")) + sorted(input_dir.glob("*.pep.fa")):
species_files.setdefault(self._species_from_path_name(path), {})["pep"] = path
for path in sorted(input_dir.glob("*.cds.fasta")) + sorted(input_dir.glob("*.cds.fa")):
species_files.setdefault(self._species_from_path_name(path), {})["cds"] = path
for path in sorted(input_dir.glob("*_final.gff3")):
species_files.setdefault(self._species_from_path_name(path), {})["gff"] = path
for path in (
sorted(input_dir.glob("*.genome.fasta"))
+ sorted(input_dir.glob("*.genome.fa"))
+ sorted(input_dir.glob("*.genome.fna"))
+ sorted(input_dir.glob("*.genome.fasta.gz"))
+ sorted(input_dir.glob("*.genome.fa.gz"))
+ sorted(input_dir.glob("*.genome.fna.gz"))
):
species_files.setdefault(self._species_from_path_name(path), {})["genome"] = path
metrics = (manifest or {}).get("metrics", {})
for result in metrics.get("species_results", []) if isinstance(metrics, dict) else []:
if not isinstance(result, dict):
continue
species = str(result.get("species") or "").strip()
files = result.get("files") if isinstance(result.get("files"), dict) else {}
artifacts = result.get("artifacts") if isinstance(result.get("artifacts"), dict) else {}
if species:
species_files.setdefault(species, {})
for key in ("pep", "cds", "gff", "genome"):
value = files.get(key) or (artifacts.get(key, {}) if isinstance(artifacts.get(key), dict) else {}).get("full_path")
if value and species:
species_files[species][key] = Path(value)
if species and result.get("success") is False:
add_issue(
"genome_prepare_species_failed",
"blocking",
f"genome_prepare 中物种 {species} 失败:{result.get('error') or 'unknown error'}",
"阻断依赖该物种的 identify/共线性等下游阶段;先修复该物种输入或从本次分析排除。",
blocks_downstream=True,
evidence_items=[json.dumps(result, ensure_ascii=False)[:1000]],
)
for species, files in sorted(species_files.items()):
gff = files.get("gff") or input_dir / f"{species}_final.gff3"
if gff.exists() and gff.stat().st_size == 0:
sources = self._candidate_source_gffs(input_dir, species)
add_issue(
"genome_prepare_final_gff_empty",
"blocking",
f"{species}_final.gff3 为空,下游结构、启动子、共线性和核型图会失败。",
"优先选择同物种非空原始 GFF 作为 job-local 运行级修复;修复后重跑 genome_prepare 或阻断下游。",
runtime_repair=bool(sources),
blocks_downstream=True,
evidence_items=[
f"final_gff={gff}",
"candidate_gffs=" + json.dumps([str(p) for p in sources[:5]], ensure_ascii=False),
],
)
pep = files.get("pep")
if pep and pep.exists():
profile = self._fasta_profile(pep)
if profile.get("looks_genome_like"):
payload = {"species": species, "pep": str(pep), "profile": profile}
if files.get("genome"):
payload["genome"] = str(files["genome"])
if files.get("gff"):
payload["gff"] = str(files["gff"])
add_issue(
"genome_prepare_pep_looks_like_genome",
"blocking",
f"{species} pep 文件看起来像基因组/核酸序列,不像蛋白序列。",
"不能把该文件作为蛋白输入;若有 genome+GFF,可用 gffread 在 job-local 重新提取 pep。",
runtime_repair=True,
blocks_downstream=True,
evidence_items=[json.dumps(payload, ensure_ascii=False)],
)
cds = files.get("cds")
if cds and cds.exists():
profile = self._fasta_profile(cds)
if profile.get("looks_genome_like"):
payload = {"species": species, "cds": str(cds), "profile": profile}
if files.get("genome"):
payload["genome"] = str(files["genome"])
if files.get("gff"):
payload["gff"] = str(files["gff"])
add_issue(
"genome_prepare_cds_looks_like_genome",
"warning",
f"{species} cds 文件像整段基因组而不是 CDS 集合。",
"若有 genome+GFF,可用 gffread 在 job-local 重新提取 CDS;否则标记该物种 CDS 不可用于 Ka/Ks。",
runtime_repair=True,
blocks_downstream=False,
evidence_items=[json.dumps(payload, ensure_ascii=False)],
)
def _gate_identify(self, input_dir: Path, final_dir: Path, logs_dir: Path, prefix: str, params: Dict[str, Any], gate: str, add_issue) -> None:
genome_prepare_manifests = sorted(final_dir.glob("*.genome_prepare.manifest.json"))
for manifest_path in genome_prepare_manifests:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
continue
if manifest.get("status") == "partial_success":
add_issue(
"upstream_genome_prepare_partial_success",
"blocking",
"上游 genome_prepare 为 partial_success,identify 不应静默继续使用有问题物种。",
"先运行 genome_prepare postflight 诊断,修复或排除失败物种后再鉴定。",
blocks_downstream=True,
evidence_items=[str(manifest_path)],
)
self._gate_genome_prepare(input_dir, final_dir, logs_dir, manifest, "postflight", add_issue)
has_hmm = any(input_dir.glob("*.hmm")) or any((input_dir / "genome_prepare").glob("hmm_cache/*.hmm")) if (input_dir / "genome_prepare").exists() else any(input_dir.glob("*.hmm"))
required_pfams = str(params.get("requiredPfams") or params.get("required_pfams") or "").strip()
if gate == "preflight" and not has_hmm and not required_pfams:
add_issue(
"identify_missing_hmm_or_pfam",
"blocking",
"identify 缺少 HMM 文件或 required_pfams 参数。",
"上传 HMM,或提供 Pfam 号并确保 Pfam-A.hmm 可用。",
blocks_downstream=True,
)
if gate == "failure":
log_text = self._latest_stage_log_text(logs_dir, "identify", limit=8000)
if re.search(r"HTTPError|HTTP Error\s+404|urllib\.error\.HTTPError", log_text, re.IGNORECASE) and re.search(r"pfam|hmm|xfam|interpro", log_text, re.IGNORECASE):
add_issue(
"identify_pfam_remote_404",
"blocking",
"Identify 下载 Pfam/HMM 远程资源时遇到 HTTP 404。",
"这通常不是用户缺少 protein 输入,而是 family 名称未规范化为 PF accession、远程 Pfam URL 变化,或本地 HMM 缓存未优先使用。应优先把 SPL/SBP 等家族名映射到 PF03110,使用本地缓存 HMM;没有缓存时再访问远程。",
blocks_downstream=True,
evidence_items=[log_text[-1200:]],
)
if gate in {"postflight", "failure"}:
for status_path in sorted(final_dir.glob("*interproscan_domain_check.txt")):
text = status_path.read_text(encoding="utf-8", errors="ignore") if status_path.exists() else ""
if "status: failed" not in text:
continue
if "Java not found" in text or "broken symbolic link" in text or "unsupported reparse tag" in text:
add_issue(
"interproscan_java_unavailable",
"warning",
"Identify 已完成,但 InterProScan domain check 因 Java PATH/坏链接失败,成员确认表缺少结构域证据。",
"修复 interproscanpy311 的 JAVA_HOME/PATH,使 java 指向 $CONDA_PREFIX/lib/jvm/bin/java;然后重跑 identify 或补跑 InterProScan/domain_review。",
blocks_downstream=False,
evidence_items=[str(status_path), text[-1200:]],
)
break
def _gate_protein_stage(
self,
stage: str,
final_dir: Path,
prefix: str,
manifest: Optional[Dict[str, Any]],
add_issue,
params: Optional[Dict[str, Any]] = None,
) -> None:
protein_fasta = self._protein_fasta_for_prefix(final_dir, prefix or (manifest or {}).get("prefix", ""), manifest)
if not protein_fasta:
add_issue(
"missing_final_member_protein",
"blocking",
f"{stage} 缺少 final_members protein FASTA。",
"先完成 identify/identify_refine,或提供可用成员蛋白文件。",
blocks_downstream=True,
)
return
profile = self._fasta_profile(protein_fasta)
if profile.get("looks_nucleotide") and not profile.get("looks_protein"):
add_issue(
"protein_input_looks_nucleotide",
"blocking",
f"{stage} 的蛋白输入看起来像核酸序列。",
"不能继续建树/MEME/domain/PEP;应回到 genome_prepare/identify 修复蛋白来源。",
blocks_downstream=True,
evidence_items=[json.dumps(profile, ensure_ascii=False)],
)
stop_stats = self._protein_stop_marker_stats(protein_fasta)
if stop_stats.get("stop_marker_count", 0) > 0:
clean_candidate = protein_fasta.with_name(protein_fasta.name.replace(".final_members.pep.fa", ".final_members.pep.clean.fa"))
clean_stats = self._protein_stop_marker_stats(clean_candidate) if clean_candidate.exists() else {}
if (
clean_candidate.exists()
and clean_candidate.stat().st_mtime >= protein_fasta.stat().st_mtime
and clean_stats.get("stop_marker_count", 1) == 0
):
return
add_issue(
"protein_fasta_stop_markers",
"warning",
f"蛋白 FASTA 含 {stop_stats['stop_marker_count']} 个 '*' stop marker。",
"可自动生成 job-local clean FASTA,原始文件不变。",
runtime_repair=True,
evidence_items=[json.dumps(stop_stats, ensure_ascii=False)],
)
def _gate_gff_member_stage(
self,
stage: str,
final_dir: Path,
prefix: str,
manifest: Optional[Dict[str, Any]],
add_issue,
params: Optional[Dict[str, Any]] = None,
) -> None:
params = params or {}
if stage in {"synteny", "ppi", "pep"}:
self._gate_protein_stage(stage, final_dir, prefix, manifest, add_issue, params)
effective_prefix = prefix or (manifest or {}).get("prefix", "")
member_ids = self._read_final_member_ids(final_dir, effective_prefix, manifest)
gff3 = self._find_job_gff3(final_dir, effective_prefix, params)
if not member_ids:
add_issue("missing_final_member_ids", "blocking", f"{stage} 缺少 final_members.id。", "先完成 identify/identify_refine。", blocks_downstream=True)
return
if not gff3:
add_issue("missing_nonempty_gff", "blocking", f"{stage} 缺少非空 GFF3。", "先修复 genome_prepare 的 GFF 输出。", blocks_downstream=True)
def _gate_interspecies_synteny(self, input_dir: Path, final_dir: Path, add_issue) -> None:
peps = [p for p in list(input_dir.glob("*.pep.fasta")) + list(input_dir.glob("*.pep.fa")) if p.exists() and p.stat().st_size > 0]
gffs = [p for p in list(input_dir.glob("*_final.gff3")) + list(input_dir.glob("*.gff3")) + list(input_dir.glob("*.gff")) if p.exists() and p.stat().st_size > 0]
if len(peps) < 2:
add_issue("interspecies_missing_whole_proteomes", "blocking", "种间共线性至少需要两个物种的全基因组蛋白 FASTA。", "检查 config.xlsx 中 whole_genome pep 行。", blocks_downstream=True)
if len(gffs) < 2:
add_issue("interspecies_missing_gffs", "blocking", "种间共线性至少需要两个物种的非空 GFF。", "检查 genome_prepare 是否产生非空 *_final.gff3。", blocks_downstream=True)
def _gate_reference_classification(
self,
input_dir: Path,
final_dir: Path,
logs_dir: Path,
prefix: str,
params: Dict[str, Any],
add_issue,
) -> None:
scope_id = self._scope_id_from_params(params)
analysis_route = str(params.get("analysisRoute") or params.get("analysis_route") or "").strip()
target_species = params.get("targetSpecies") or params.get("target_species") or []
comparative_species = params.get("comparativeSpecies") or params.get("comparative_species") or []
reference_species = params.get("referenceSpecies") or params.get("reference_species") or []
if isinstance(target_species, str):
target_species = [item.strip() for item in re.split(r"[,;,;\s]+", target_species) if item.strip()]
if isinstance(comparative_species, str):
comparative_species = [item.strip() for item in re.split(r"[,;,;\s]+", comparative_species) if item.strip()]
if isinstance(reference_species, str):
reference_species = [item.strip() for item in re.split(r"[,;,;\s]+", reference_species) if item.strip()]
classifiable_species = []
for item in [*target_species, *comparative_species]:
if item and item not in classifiable_species:
classifiable_species.append(item)
family_for_identity = str(params.get("geneFamily") or "").strip()
if not family_for_identity and "." in prefix:
family_for_identity = prefix.split(".")[-1]
selected_species_codes = {str(item).strip().lower() for item in [*classifiable_species, *reference_species] if str(item).strip()}
if family_for_identity and family_for_identity.lower() in selected_species_codes:
add_issue(
"scope_family_species_code_conflict",
"blocking",
f"reference_classification 的 geneFamily={family_for_identity} 与物种代码冲突。",
"请以 config.xlsx 的 Analysis.Family 作为 family,并确认 prefix 形如 Lc.SPL 而不是 Lc.At。",
blocks_downstream=True,
)
if analysis_route == "subfamily_prediction" and not reference_species:
add_issue(
"subfamily_missing_reference_species",
"blocking",
"亚家族预测缺少 referenceSpecies,不能降级为 target_only。",
"请在 config.xlsx 中设置 seed_reference/known_family reference,或在参数中显式提供 referenceSpecies。",
blocks_downstream=True,
)
per_target_reference_route = (
analysis_route == "subfamily_prediction"
and bool(classifiable_species)
and bool(reference_species)
)
if per_target_reference_route:
# In this route reference_classification owns per-species target/comparative+reference
# phylogeny/evidence generation. Requiring the parent aggregate tree here
# would reintroduce meaningless multi-species+reference plots.
phylo_manifest = None
else:
phylo_manifest = self._load_stage_manifest(final_dir, "phylogeny", prefix, scope_id=scope_id)
tree_candidates: List[Path] = []
if phylo_manifest:
artifacts = phylo_manifest.get("artifacts") or {}
tree_artifact = artifacts.get("tree") if isinstance(artifacts, dict) else None
if isinstance(tree_artifact, dict):
for key in ("full_path", "path"):
value = tree_artifact.get(key)
if value:
p = Path(str(value))
tree_candidates.append(p if p.is_absolute() else PROJECT_ROOT / p)
tree_candidates.append(final_dir / f"{prefix}.tree.nwk")
if (not per_target_reference_route) and not any(p.exists() and p.stat().st_size > 0 for p in tree_candidates):
latest_phylogeny_log = self._latest_stage_log_text(logs_dir, "phylogeny", limit=1200)
evidence_items = [str(p) for p in tree_candidates]
if latest_phylogeny_log:
evidence_items.append(latest_phylogeny_log[-1200:])
add_issue(
"missing_phylogeny_tree",
"blocking",
"reference_classification 缺少同一分析范围的 phylogeny 树文件。",
"普通 reference_classification 需要同 scope 的 phylogeny;亚家族支线会按每个 target/comparative 物种分别生成 物种+reference bootstrap 树。",
blocks_downstream=True,
evidence_items=evidence_items,
)
reference_file = str(params.get("referenceSubfamilyFile") or params.get("reference_subfamily_file") or "").strip()
reference_prefix = str(params.get("referencePrefix") or params.get("reference_prefix") or "At").strip() or "At"
family = str(params.get("geneFamily") or "").strip()
if not family and "." in prefix:
family = prefix.split(".")[-1]
subfamily_candidates: List[Path] = []
if reference_file:
p = Path(reference_file)
subfamily_candidates.append(p if p.is_absolute() else PROJECT_ROOT / p)
if family:
for root in (final_dir, input_dir):
subfamily_candidates.extend([
root / f"{reference_prefix}.{family}.subfamily",
root / f"{reference_prefix}.{family}.subfamily.tsv",
root / f"{reference_prefix}.{family}.geneID.subfamily",
])
subfamily_candidates.extend(list(final_dir.glob("*subfamily*")) + list(input_dir.glob("*subfamily*")))
if not any(p.exists() and p.stat().st_size > 0 for p in subfamily_candidates):
add_issue(
"missing_reference_subfamily",
"blocking",
"reference_classification 缺少参考物种亚家族标注文件。",
"请提供参考物种亚家族文件,例如 At.SPL.subfamily,或在参数中指定 referenceSubfamilyFile。",
blocks_downstream=True,
evidence_items=[str(p) for p in subfamily_candidates[:12]],
)
def _load_stage_manifest(self, final_dir: Path, stage: str, prefix: str = "", scope_id: str = "") -> Optional[Dict[str, Any]]:
stage_aliases = {
"ncbi_fetch_sequences": ["ncbi_fetch_sequences", "ncbi_fetch"],
"sequence_extract_by_id": ["sequence_extract_by_id", "sequence_extract"],
}
aliases = stage_aliases.get(stage, [stage])
if scope_id and prefix:
try:
from gfa_core.gene_family_scope import sanitize_token
scoped_token = sanitize_token(scope_id)
except Exception:
scoped_token = re.sub(r"[^A-Za-z0-9_.-]+", "-", scope_id).strip(".-_")
for alias in aliases:
path = final_dir / f"{prefix}.{scoped_token}.{alias}.manifest.json"
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
data["_path"] = str(path)
return data
except Exception:
continue
patterns = []
for alias in aliases:
if prefix:
patterns.append(f"{prefix}.{alias}.manifest.json")
patterns.extend([f"*.{alias}.manifest.json", f"*{alias}*.manifest.json"])
for pattern in patterns:
for path in sorted(final_dir.glob(pattern), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True):
try:
data = json.loads(path.read_text(encoding="utf-8"))
manifest_stage = data.get("stage")
if scope_id and str(data.get("scope_id") or "") != scope_id:
continue
if manifest_stage == stage or manifest_stage in aliases or any(alias in path.name for alias in aliases):
data["_path"] = str(path)
return data
except Exception:
continue
return None
def _genome_prepare_manifest_stale(self, input_dir: Path, manifest: Optional[Dict[str, Any]]) -> bool:
"""Ignore old failed genome_prepare manifests after config/input changes."""
if not manifest:
return False
if str(manifest.get("status") or "").lower() == "success":
return False
raw_path = str(manifest.get("_path") or "")
if not raw_path:
return False
manifest_path = Path(raw_path)
if not manifest_path.exists():
return False
manifest_mtime = manifest_path.stat().st_mtime
for path in input_dir.rglob("*"):
if not path.is_file():
continue
try:
rel_parts = path.relative_to(input_dir).parts
except Exception:
rel_parts = ()
if rel_parts and rel_parts[0] in {"final", "genome_prepare"}:
continue
if path.stat().st_mtime > manifest_mtime:
return True
return False
def _audit_rnaseq_qc(self, input_dir: Path, final_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
config = self._find_rnaseq_config(input_dir)
duplicates = self._find_duplicate_fastq_pairs(config, input_dir) if config else []
if duplicates:
add_issue(
"duplicate_fastq_pair",
"blocking",
"RNA-seq 样本配置中存在多个样本复用同一对 FASTQ。",
"暂停流程,确认样本配置。可以提供一键命名/路径修订,但必须由用户确认正确文件。",
blocks_downstream=True,
evidence_items=duplicates,
)
metrics = (manifest or {}).get("metrics", {})
artifacts = (manifest or {}).get("artifacts", {})
if (manifest or {}).get("status") == "success" and artifacts:
if metrics.get("total_clean_reads", 0) == 0 or metrics.get("total_raw_reads", 0) == 0:
add_issue(
"success_with_zero_qc_reads",
"warning",
"QC 产物存在且 manifest 为 success,但 reads 统计为 0。",
"这通常是日志解析兼容问题。可在当前 job 修复副本中修正解析器并用已有 cutadapt 日志验证。",
requires_confirmation=True,
safe_patch_candidate=True,
evidence_items=[
f"total_raw_reads={metrics.get('total_raw_reads')}",
f"total_clean_reads={metrics.get('total_clean_reads')}",
f"artifact_count={len(artifacts)}",
],
)
def _audit_population_stage(self, stage: str, manifest: Optional[Dict[str, Any]], add_issue) -> None:
if not manifest:
return
if manifest.get("status") != "success":
add_issue(
f"{stage}_manifest_failed", "blocking",
f"{stage} failed: {str(manifest.get('error') or 'unknown error')[:500]}",
"Inspect the stage attempt log and repair the reported input/tool/postflight failure.",
requires_confirmation=False, blocks_downstream=True,
)
return
if not str(manifest.get("genotype_scope_id") or "").startswith("genotype:") or not str(manifest.get("analysis_scope_id") or "").startswith("population-analysis:"):
add_issue(
"population_identity_incomplete", "blocking",
f"{stage} success manifest lacks complete population scientific identity.",
"Rerun the stage through its canonical CLI.", requires_confirmation=False, blocks_downstream=True,
)
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
if not artifacts or any(not isinstance(item, dict) or not item.get("sha256") for item in artifacts.values()):
add_issue(
"population_artifact_evidence_incomplete", "blocking",
f"{stage} manifest lacks hashed artifact evidence.",
"Rerun the stage and require all postflight artifacts.", requires_confirmation=False, blocks_downstream=True,
)
def _audit_gwas_fmt(self, manifest: Optional[Dict[str, Any]], add_issue) -> None:
if not manifest:
return
if manifest.get("status") != "success":
detail = str(manifest.get("error") or manifest.get("message") or "unknown failure").strip()
detail_lower = detail.lower()
if "unsupported hapmap marker" in detail_lower:
code = "gwas_fmt_unsupported_markers_rejected"
suggestion = "输入含 HapMap 不可逆 marker;使用 mask 明确记录有损转换,或先过滤后保持 fail 策略。"
elif "genotype" in detail_lower or "locus" in detail_lower or "allele" in detail_lower:
code = "gwas_fmt_genotype_conservation_failed"
suggestion = "检查 manifest.error 指定的 locus/GT;不要将该失败降级为样本数或文件缺失问题。"
else:
code = "gwas_fmt_manifest_failed"
suggestion = "按 manifest.error 检查外部 VCF、TASSEL 路径或转换一致性后重跑。"
add_issue(
code,
"blocking",
f"gwas_fmt manifest failed: {detail[:500]}",
suggestion,
blocks_downstream=True,
evidence_items=[f"manifest_error={detail[:500]}"],
)
return
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
if metrics.get("sample_conservation") is not True or metrics.get("variant_conservation") is not True:
add_issue(
"conversion_cardinality_mismatch",
"blocking",
"GWAS 格式转换未证明样本和位点数量守恒。",
"检查 TASSEL 排序与 HapMap 输出,并重新运行 gwas_fmt。",
blocks_downstream=True,
)
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {}
if parameters.get("unsupported_marker_policy") == "mask" and metrics.get("lossy_conversion") is True:
if metrics.get("strict_genotype_conservation") is not True:
add_issue(
"gwas_fmt_strict_genotype_conservation_failed",
"blocking",
"GWAS mask 模式未证明可逆 marker 子集的 genotype conservation。",
"检查 manifest.error 指向的可逆 SNP locus/GT 并重跑;不可用全体 genotype_conservation 代替。",
blocks_downstream=True,
)
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), list) else []
roles = {str(item.get("role") or "") for item in artifacts if isinstance(item, dict)}
missing_roles = sorted({"sorted_vcf", "tassel_hapmap"} - roles)
unhashed = [str(item.get("role") or "unknown") for item in artifacts if isinstance(item, dict) and not item.get("sha256")]
if missing_roles or unhashed:
add_issue(
"gwas_fmt_artifact_evidence_incomplete",
"blocking",
"GWAS 格式转换产物角色或 SHA256 证据不完整。",
"重新运行 gwas_fmt 并使用 manifest 中的语义 artifact roles。",
blocks_downstream=True,
evidence_items=[f"missing_roles={missing_roles}", f"unhashed_roles={unhashed}"],
)
@staticmethod
def _audit_gwas_fmt_preflight(params: Dict[str, Any], add_issue) -> None:
vcf_text = str(params.get("gwasVcf") or params.get("gwas_vcf") or "").strip()
vcf_path = Path(vcf_text).expanduser() if vcf_text else None
if vcf_path is None or not vcf_path.is_absolute() or not vcf_path.is_file():
add_issue(
"gwas_fmt_external_vcf_missing",
"blocking",
f"gwas_fmt external VCF is missing or invalid: {vcf_text or 'not provided'}",
"提供存在的 VCF/VCF.GZ 全路径到 gwasVcf。",
blocks_downstream=True,
)
elif vcf_path.is_file():
from gfa_core.gwas_fmt_context import semantic_external_vcf_preflight
semantic = semantic_external_vcf_preflight(params)
for issue in semantic.get("issues") or []:
if issue.get("code") != "invalid_external_vcf":
continue
add_issue(
"gwas_fmt_external_vcf_semantic_invalid",
"blocking",
str(issue.get("message") or "VCF semantic validation failed"),
"确认 gzip 可读、存在 #CHROM、且 samples/sites 均大于 0。",
blocks_downstream=True,
)
tassel_text = str(params.get("tasselPipeline") or params.get("tassel_pipeline") or "run_pipeline.pl").strip()
tassel_path = Path(tassel_text).expanduser()
tassel_available = (
tassel_path.is_file() if (tassel_path.is_absolute() or "/" in tassel_text)
else shutil.which(tassel_text) is not None
)
if not tassel_available:
add_issue(
"gwas_fmt_tassel_missing",
"blocking",
f"TASSEL pipeline executable was not found: {tassel_text}",
"设置 tasselPipeline 为可执行 run_pipeline.pl 的全路径。",
blocks_downstream=True,
)
def _audit_rnaseq_align(self, final_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
metrics = (manifest or {}).get("metrics", {})
bams = list(final_dir.glob("*.sorted.bam"))
logs = list(final_dir.glob("*_hisat2.log"))
if (manifest or {}).get("status") == "success" and bams and metrics.get("average_alignment_rate", 0) == 0:
add_issue(
"success_with_zero_alignment_rate",
"warning",
"比对 BAM/HISAT2 日志存在且 manifest 为 success,但平均比对率为 0。",
"这通常是 HISAT2 日志格式解析问题。可在当前 job 修复副本中修正解析器并用现有 hisat2.log 验证。",
requires_confirmation=True,
safe_patch_candidate=True,
evidence_items=[
f"bam_count={len(bams)}",
f"hisat2_log_count={len(logs)}",
f"average_alignment_rate={metrics.get('average_alignment_rate')}",
],
)
def _audit_rnaseq_sample_qc(self, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
if (manifest or {}).get("status") == "failed":
error = str((manifest or {}).get("error", ""))
log_text = self._latest_stage_log_text(logs_dir, "rnaseq_sample_qc")
if "did not have" in log_text and "elements" in log_text:
add_issue(
"r_table_comment_char",
"warning",
"样本 QC 读取 count matrix 失败,R read.table 可能把基因 ID 中的 # 当成注释。",
"可在当前 job 修复副本中关闭 R 表格读取的 comment.char,并使用原 count matrix 验证。",
requires_confirmation=True,
safe_patch_candidate=True,
evidence_items=[error[:300], log_text[-500:]],
)
if "number of samples and the number of model coefficients are equal" in log_text or "no replicates to estimate the dispersion" in log_text:
add_issue(
"rnaseq_sample_qc_condition_scope",
"blocking",
"样本 QC 的分组 scope 可能把重复编号写进 condition,导致每个 condition 只有 1 个样本并触发 DESeq2 设计矩阵失败。",
"将 sample config 中的 condition 规范为生物条件(例如 Peel.1/Peel.2/Peel.3 -> Peel),样本 QC 使用 blind VST;差异分析前仍需人工确认真实分组。",
requires_confirmation=False,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=[error[:300], log_text[-700:]],
)
corr = final_dir / f"{(manifest or {}).get('prefix', '')}.rnaseq_sample_qc.sample_correlation.tsv"
if not corr.exists():
candidates = sorted(final_dir.glob("*.rnaseq_sample_qc.sample_correlation.tsv"))
corr = candidates[0] if candidates else corr
high_pairs = self._find_high_cross_sample_correlations(corr)
if high_pairs:
add_issue(
"near_duplicate_samples",
"blocking",
"样本相关性矩阵发现近乎重复样本,可能是样本复用、标签错误或重复上传。",
"暂停下游解释,人工确认样本配置和 FASTQ 来源;不能由 agent 自动改样本语义。",
blocks_downstream=True,
evidence_items=high_pairs[:10],
)
def _audit_rnaseq_quant(self, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
log_text = self._latest_stage_log_text(logs_dir, "rnaseq_quant", limit=8000)
if "DESeq2 FPKM/TPM calculation failed" not in log_text:
return
if re.search(r"line\s+\d+\s+did not have\s+\d+\s+elements", log_text, re.IGNORECASE):
add_issue(
"quant_fpkm_tpm_comment_char",
"warning",
"rnaseq_quant 主计数矩阵成功,但 FPKM/TPM 计算失败并回退,R 读取 count/GTF 时可能把基因 ID 中的 # 当成注释。",
"全局脚本应关闭 R 表格读取的 quote/comment.char;修复后用相同 count matrix 和 GTF 重新计算 FPKM/TPM。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=False,
evidence_items=[log_text[-1000:]],
)
def _audit_rnaseq_go_enrich(self, final_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
metrics = (manifest or {}).get("metrics", {})
if (manifest or {}).get("status") != "success":
return
overlap = metrics.get("go_foreground_background_overlap")
zero_overlap = False
if isinstance(overlap, dict) and overlap:
zero_overlap = any(
isinstance(item, dict)
and int(item.get("foreground_genes", 0) or 0) > 0
and int(item.get("annotated_background_genes", 0) or 0) > 0
and int(item.get("overlap_genes", 0) or 0) == 0
for item in overlap.values()
)
if not overlap and int(metrics.get("go_enriched_terms", 0) or 0) == 0:
zero_overlap = self._go_has_zero_foreground_background_overlap(final_dir)
if zero_overlap:
add_issue(
"go_foreground_background_id_mismatch",
"blocking",
"GO 富集前景 DE 基因与 gene2go 背景基因没有交集,通常是 gene/transcript ID 体系不一致。",
"富集脚本应自动执行安全 ID 规范化;若仍为 0 交集,应阻断下游解释并向用户展示前景/背景 ID 示例。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=[
f"go_enriched_terms={metrics.get('go_enriched_terms')}",
f"annotated_background_genes={metrics.get('annotated_background_genes')}",
f"overlap={json.dumps(overlap, ensure_ascii=False)[:500]}",
],
)
def _audit_rnaseq_fgsea(self, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
metrics = (manifest or {}).get("metrics", {})
params = (manifest or {}).get("parameters", {})
species_name = str(params.get("species_name", "") or "")
kegg_organism = str(params.get("kegg_organism", "") or "")
if species_name and any(ord(ch) > 127 for ch in species_name) and not kegg_organism:
add_issue(
"kegg_species_name_non_ascii",
"warning",
"fgsea 参数中的物种名含非 ASCII 字符,可能导致 KEGG organism 自动推断失败。",
"请用户确认物种名是否应为 'Oryza sativa' 或直接填写 KEGG organism 'osa';agent 不应静默修改实验物种参数。",
requires_confirmation=True,
safe_patch_candidate=False,
blocks_downstream=False,
evidence_items=[f"species_name={species_name}", "kegg_organism is empty"],
)
log_text = self._latest_stage_log_text(logs_dir, "rnaseq_fgsea", limit=8000)
failed = (manifest or {}).get("status") == "failed"
zero_mapping = int(metrics.get("genes_with_pathway_in_ranks", 0) or 0) == 0
suspicious_log = "No KEGG pathway gene sets passed min/max size filters" in log_text or "fgsea failed" in str((manifest or {}).get("error", ""))
if not (failed or zero_mapping or suspicious_log):
return
overlap = self._fgsea_de_gene2ko_overlap(final_dir)
if overlap and int(overlap.get("raw_overlap", 0)) == 0 and int(overlap.get("normalized_overlap", 0)) > 0:
add_issue(
"fgsea_rank_gene2ko_id_mismatch",
"blocking",
"fgsea 排名基因与 gene2ko 背景原始 ID 没有交集,但安全 ID 规范化后可恢复交集。",
"fgsea 脚本应自动执行与 GO/KEGG 富集一致的 ID 规范化;用户确认后可用当前 job 修复副本验证,再决定是否升级全局代码。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=[
f"raw_overlap={overlap.get('raw_overlap')}",
f"normalized_overlap={overlap.get('normalized_overlap')}",
f"de_file={overlap.get('de_file')}",
f"gene2ko={overlap.get('gene2ko')}",
f"foreground_examples={overlap.get('foreground_examples')}",
f"background_examples={overlap.get('background_examples')}",
],
)
elif failed:
add_issue(
"fgsea_failed_needs_review",
"blocking",
"fgsea 阶段失败,但未能确认是可自动修复的 ID 规范化问题。",
"展示日志尾部和输入 ID 示例,由用户确认后再进入副本修复或参数调整;不要静默重跑。",
requires_confirmation=True,
safe_patch_candidate=False,
blocks_downstream=True,
evidence_items=[str((manifest or {}).get("error", ""))[:500], log_text[-1000:]],
)
def _audit_rnaseq_wgcna(self, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
if (manifest or {}).get("status") != "failed":
return
log_text = self._latest_stage_log_text(logs_dir, "rnaseq_wgcna", limit=8000)
if re.search(r"line\s+\d+\s+did not have\s+\d+\s+elements", log_text, re.IGNORECASE):
add_issue(
"wgcna_matrix_comment_char",
"blocking",
"WGCNA 读取表达矩阵失败,R read.table 很可能把基因 ID 中的 # 当成注释导致行列数不一致。",
"WGCNA 脚本应关闭矩阵读取的 quote/comment.char;确认后可用当前 job 修复副本验证,再决定是否升级全局代码。",
requires_confirmation=True,
safe_patch_candidate=True,
blocks_downstream=True,
evidence_items=[log_text[-1000:]],
)
def _audit_generic_gene_family_stage(self, stage: str, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue, prefix: str = "") -> None:
log_text = self._latest_stage_log_text(logs_dir, stage)
if stage == "identify" and isinstance(manifest, dict):
hmm_models = manifest.get("hmm_models") if isinstance(manifest.get("hmm_models"), dict) else {}
missing_hmms = hmm_models.get("missing_requested_pfams") or []
hmm_status = str(hmm_models.get("status") or "").lower()
if missing_hmms or hmm_status == "failed":
missing_text = ",".join(str(x) for x in missing_hmms) if missing_hmms else "unknown"
add_issue(
"identify_pfam_partial_fetch",
"blocking",
f"Identify 请求的 Pfam/HMM 模型未全部加载:{missing_text}。",
"重新构建 job-local query HMM,并验证 requested Pfams 与 loaded ACC 完全一致后再重跑 identify。",
blocks_downstream=True,
evidence_items=[json.dumps(hmm_models, ensure_ascii=False)[:1200]],
)
text = log_text
if manifest:
try:
text = (text + "\n" + json.dumps(manifest, ensure_ascii=False))[-12000:]
except Exception:
pass
generic_patterns = [
("id_mismatch", r"No GFF IDs matched|No .*IDs matched|ID.*不匹配|无法匹配.*ID", "ID 匹配失败,可能是 FASTA/GFF 命名体系不一致。"),
("plantcare_upload_timeout", r"Timeout waiting for PlantCARE result|No PlantCARE result returned|No result file found", "PlantCARE 未返回本次任务结果,可能是上传 FASTA 超过官网大小限制或外部队列/邮件失败。"),
("plantcare_id_mismatch", r"PlantCARE result IDs do not match the extracted promoter FASTA IDs", "PlantCARE 结果文件与本次提取的启动子 FASTA ID 完全不重叠。"),
("multi_target_single_species_stage", r"一次只能分析一个目标物种|only.*one target species|single target", "单物种阶段收到了多个 target species,需要按目标物种拆分执行。"),
("interspecies_pair_no_hits", r"BLAST failed or produced no hits|produced no hits", "种间同源搜索没有产生有效 hits,可能是蛋白文件、压缩格式、阈值或物种距离问题。"),
("empty_required_output", r"No successful results|produced no hits|output.*empty|结果为空", "核心输出为空,需要检查输入、ID 转换或阈值。"),
("tool_version_format", r"line \d+ did not have|unexpected format|parse.*failed", "工具输出或输入表格式与解析器不兼容。"),
("hmm_or_pfam_missing", r"NO_HMM_FILE_ERROR|必须上传至少一个HMM文件|required_pfams is empty|No HMM file|找不到HMM|Pfam database not found|Missing Pfam index", "基因家族鉴定缺少 HMM 模型或 Pfam 参数/数据库索引。"),
("pfam_remote_404", r"HTTPError|HTTP Error\s+404|urllib\.error\.HTTPError", "远程 Pfam/HMM 资源访问失败,可能是 URL 变化或家族名没有规范化为 PF accession。"),
]
for code, pattern, message in generic_patterns:
search_text = text
if (manifest or {}).get("status") == "success":
# Runtime error signatures must be backed by runtime logs.
# Successful manifests may contain descriptive strings that match
# generic failure regexes without representing a failed output.
search_text = log_text
if code == "plantcare_upload_timeout" and stage != "promoter":
continue
if code == "multi_target_single_species_stage":
# This issue must come from an actual runtime error/log line.
# Successful multi-target identify manifests legitimately contain
# artifact descriptions like "Single target species final ...".
if stage == "identify":
continue
search_text = log_text
if re.search(pattern, search_text, re.IGNORECASE):
if code == "hmm_or_pfam_missing":
recommendation = "Identify 需要二选一:上传家族 HMM 文件,或提供 required_pfams(如 PF03110)并确保 Pfam-A.hmm/索引可用。"
elif code == "pfam_remote_404":
recommendation = "优先使用本地 Pfam/HMM 缓存;把家族名/结构域别名规范化为 PF accession,例如 SPL/SBP -> PF03110,再重试。"
elif code == "id_mismatch":
recommendation = "先生成 ID 匹配报告,确认 final_members.id、蛋白 FASTA header 与 GFF3 gene/mRNA/CDS ID 的对应关系;不要静默改 ID。"
elif code == "plantcare_upload_timeout":
recommendation = "检查 promoter/analysis/gene.upstream.changeID.fasta 大小;若超过约 100 KB,应按上传字节大小分批提交 PlantCARE 并合并结果,而不是只延长 timeout。"
elif code == "plantcare_id_mismatch":
recommendation = "不要复用其他物种或其他 gene set 的 PlantCARE tab;应使用本次生成的 promoter FASTA 重新提交 PlantCARE,或上传与本次 FASTA header 匹配的 tab。"
elif code == "multi_target_single_species_stage":
recommendation = "该阶段应自动拆成每个 target species 单独执行,并写回原 multi-target scope 的聚合 manifest;若仍失败,检查 scope 拆分逻辑。"
elif code == "interspecies_pair_no_hits":
recommendation = "先确认 target/reference 全蛋白 FASTA 已物化为 plain FASTA,避免 DIAMOND/BLAST 链路直接处理 .gz 造成空结果;再检查序列有效性、evalue 阈值,必要时记录该物种对为 partial_success。"
else:
recommendation = "先展示错误点和候选修复;若属于解析兼容问题,可确认使用当前 job 修复副本验证。"
is_identify_root_cause = stage == "identify" and code in {
"hmm_or_pfam_missing",
"pfam_remote_404",
}
add_issue(
code,
"blocking" if is_identify_root_cause else "warning",
message,
recommendation,
requires_confirmation=(code == "hmm_or_pfam_missing"),
safe_patch_candidate=(code == "tool_version_format"),
blocks_downstream=is_identify_root_cause,
evidence_items=[text[-800:]],
)
metrics = (manifest or {}).get("metrics", {})
artifacts = (manifest or {}).get("artifacts", {})
effective_prefix = (manifest or {}).get("prefix") or prefix
if stage == "combo" and manifest and str(manifest.get("scope_id") or "").strip():
try:
from gfa_core.mas.tools.stage_status import get_stage_status
status = get_stage_status(
job_id=str(manifest.get("job_id") or final_dir.parent.name),
prefix=str(effective_prefix),
stage="combo",
workspace_root=final_dir.parent.parent,
scope_id=str(manifest.get("scope_id") or ""),
)
scope_errors = [err for err in (status.errors or []) if "scope mismatch" in str(err).lower()]
if scope_errors:
add_issue(
"combo_artifact_scope_mismatch",
"blocking",
"Combo 组合图已生成,但产物文件名没有携带完整 scope token,结果校验无法确认它属于本次 target/reference 范围。",
"这是输出命名与 scoped 契约不一致的问题;应让 combo manifest 指向完整 scope 命名的 PDF/PNG,或同步放宽校验规则。",
requires_confirmation=False,
safe_patch_candidate=False,
blocks_downstream=True,
evidence_items=scope_errors,
)
except Exception as exc:
add_issue(
"combo_status_audit_failed",
"warning",
"Combo 结果状态复核失败,无法确认 scoped artifact 是否满足契约。",
"检查 stage_status 工具和 combo manifest 的 scope_id/artifacts 字段。",
evidence_items=[str(exc)],
)
if stage in {"ncbi_fetch_sequences", "ncbi_fetch"}:
self._audit_ncbi_family_reference_link(final_dir, effective_prefix, add_issue)
protein_fasta = self._protein_fasta_for_prefix(final_dir, effective_prefix, manifest)
if protein_fasta and stage in {"identify", "phylogeny", "motif", "domain", "pep", "ppi"}:
stop_stats = self._protein_stop_marker_stats(protein_fasta)
if stop_stats.get("stop_marker_count", 0) > 0:
clean_candidate = protein_fasta.with_name(protein_fasta.name.replace(".final_members.pep.fa", ".final_members.pep.clean.fa"))
clean_stats = self._protein_stop_marker_stats(clean_candidate) if clean_candidate.exists() else {}
clean_is_ready = (
clean_candidate.exists()
and clean_candidate.stat().st_mtime >= protein_fasta.stat().st_mtime
and clean_stats.get("stop_marker_count", 1) == 0
)
if not clean_is_ready:
add_issue(
"protein_fasta_stop_markers",
"warning",
f"蛋白 FASTA 含有 {stop_stats['stop_marker_count']} 个 '*' stop marker,涉及 {stop_stats['sequences_with_stop_markers']} 条序列。",
"生成 job-local clean FASTA,保留原始文件不变;下游 MAFFT/MEME/hmmscan/PEP 使用 clean FASTA。",
safe_patch_candidate=True,
evidence_items=[json.dumps(stop_stats, ensure_ascii=False)],
)
if stage in {"structure", "promoter", "synteny", "karyotype"}:
params = (manifest or {}).get("parameters", {})
member_ids = self._read_final_member_ids(final_dir, effective_prefix, manifest)
gff3 = self._find_job_gff3(final_dir, effective_prefix, params if isinstance(params, dict) else None)
if member_ids and gff3:
member_norm = {self._normalize_gene_id_for_match(item) for item in member_ids}
gff_norm = self._read_gff_gene_ids(gff3)
overlap = member_norm & gff_norm
ratio = len(overlap) / max(len(member_norm), 1)
if ratio < 0.8:
evidence = {
"final_member_ids": len(member_norm),
"gff_ids": len(gff_norm),
"overlap": len(overlap),
"overlap_ratio": round(ratio, 4),
"gff3": str(gff3),
"example_unmatched": sorted(member_norm - gff_norm)[:10],
}
add_issue(
"gene_gff_id_overlap_low",
"warning" if overlap else "blocking",
f"final_members.id 与 GFF3 的规范化 ID 重叠率偏低:{len(overlap)}/{len(member_norm)}。",
"这通常是蛋白ID/转录本ID/基因ID体系不一致。应先确认映射规则,再决定是否生成修订后的成员ID或规范化GFF;系统不会静默改ID。",
requires_confirmation=False,
safe_patch_candidate=False,
blocks_downstream=(overlap == 0),
evidence_items=[json.dumps(evidence, ensure_ascii=False)],
)
metric_required_stages = {
"identify", "phylogeny", "motif", "domain", "structure", "promoter",
"synteny", "interspecies_synteny", "karyotype", "ppi", "expression", "pep",
}
if stage in metric_required_stages and (manifest or {}).get("status") == "success" and artifacts and self._metrics_look_empty(metrics):
if stage == "identify" and self._identify_manifest_has_member_metrics(manifest or {}):
return
add_issue(
"success_with_empty_metrics",
"warning",
f"{stage} 产物存在且 manifest 为 success,但关键 metrics 看起来为空或为 0。",
"需要检查 manifest 统计逻辑;如果只是统计解析问题,可确认用当前 job 修复副本验证。",
safe_patch_candidate=True,
evidence_items=[f"metrics={json.dumps(metrics, ensure_ascii=False)[:500]}", f"artifact_count={len(artifacts)}"],
)
def _audit_ncbi_family_reference_link(self, final_dir: Path, prefix: str, add_issue) -> None:
candidates: List[Path] = []
if prefix:
candidates.extend([
final_dir / f"{prefix}.ncbi_fetch.protein.fa",
final_dir / f"{prefix}.ncbi_fetch.protein.fasta",
])
candidates.extend(sorted(final_dir.glob("*.ncbi_fetch.protein.fa")))
candidates.extend(sorted(final_dir.glob("*.ncbi_fetch.protein.fasta")))
seen: set[str] = set()
for protein_fa in candidates:
try:
key = str(protein_fa.resolve())
except Exception:
key = str(protein_fa)
if key in seen or not protein_fa.exists() or protein_fa.stat().st_size == 0:
continue
seen.add(key)
parts = protein_fa.name.split(".")
if len(parts) < 4 or parts[2] != "ncbi_fetch":
continue
species, family = parts[0], parts[1]
expected = final_dir / f"{species}.{family}.pep.fa"
if expected.exists() and expected.stat().st_size > 0:
continue
add_issue(
"ncbi_family_reference_link_missing",
"warning",
f"NCBI 下载蛋白已存在,但缺少标准家族参考名 {expected.name}。",
"创建 job-local 软链接/副本,统一为 {Species}.{Family}.pep.fa,供 identify/phylogeny/reference classification 复用。",
requires_confirmation=False,
safe_patch_candidate=True,
blocks_downstream=False,
evidence_items=[json.dumps({
"protein_fasta": str(protein_fa),
"expected_reference_pep": str(expected),
"species": species,
"family": family,
}, ensure_ascii=False)],
)
def _audit_rnaseq_de_auto_expression(self, final_dir: Path, logs_dir: Path, manifest: Optional[Dict[str, Any]], add_issue) -> None:
if not manifest or manifest.get("status") != "success":
return
prefix = str(manifest.get("prefix") or "").strip()
expression_manifest = final_dir / f"{prefix}.expression.manifest.json" if prefix else None
if expression_manifest is None:
matches = sorted(final_dir.glob("*.expression.manifest.json"))
expression_manifest = matches[0] if matches else None
if not manifest.get("rnaseq_scope_id"):
add_issue(
"rnaseq_context_missing",
"warning",
"rnaseq_de manifest 缺少 rnaseq_scope_id,后续表达图、富集和 WGCNA 难以稳定归属到同一 RNA-seq 实验。",
"使用当前 rnaseq context 逻辑重跑 rnaseq_de,或修复 manifest context 后再刷新状态。",
safe_patch_candidate=True,
)
if not expression_manifest or not expression_manifest.exists():
add_issue(
"rnaseq_auto_expression_missing",
"warning",
"rnaseq_de 已成功,但未找到自动触发生成的 Expression(使用FPKM) manifest。",
"检查 rnaseq_de 后端自动触发日志;必要时使用 run_expression.py --mode rnaseq_auto 基于真实 FPKM/TPM/VST 重跑。",
safe_patch_candidate=True,
evidence_items=[f"expected_expression_manifest={expression_manifest}"],
)
return
try:
expr_manifest = json.loads(expression_manifest.read_text(encoding="utf-8"))
except Exception as exc:
add_issue(
"rnaseq_auto_expression_failed",
"warning",
"Expression(使用FPKM) manifest 存在但无法解析。",
"查看 expression 日志并重跑 rnaseq_auto 表达绘图。",
safe_patch_candidate=True,
evidence_items=[f"manifest={expression_manifest}", f"parse_error={exc}"],
)
return
if expr_manifest.get("status") != "success":
add_issue(
"rnaseq_auto_expression_failed",
"warning",
"rnaseq_de 自动触发的 Expression(使用FPKM) 阶段未成功。",
"查看 expression 日志并使用完整真实 FPKM/TPM/VST 数据重跑 rnaseq_auto。",
safe_patch_candidate=True,
evidence_items=[f"manifest={expression_manifest}", f"status={expr_manifest.get('status')}", f"error={expr_manifest.get('error', '')}"],
)
def _go_has_zero_foreground_background_overlap(self, final_dir: Path) -> bool:
gene2go = self._find_gene2go(final_dir)
if not gene2go:
return False
background_raw, background_norm = self._read_gene2go_gene_sets(gene2go)
if not background_raw:
return False
for de_file in sorted(final_dir.glob("*.rnaseq_de.*.DE_genes.txt")):
foreground_raw, foreground_norm = self._read_de_gene_sets(de_file)
if not foreground_raw:
continue
raw_overlap = len(foreground_raw & background_raw)
normalized_overlap = len(foreground_norm & background_norm)
if raw_overlap == 0 and normalized_overlap >= 0:
return True
return False
def _find_gene2go(self, final_dir: Path) -> Optional[Path]:
preferred = final_dir / "gene2go.txt"
if preferred.exists():
return preferred
matches = sorted(final_dir.glob("*gene2go*.txt"))
return matches[0] if matches else None
def _find_gene2ko(self, final_dir: Path) -> Optional[Path]:
preferred = final_dir / "gene2ko.txt"
if preferred.exists():
return preferred
matches = sorted(final_dir.glob("*gene2ko*.txt"))
return matches[0] if matches else None
def _fgsea_de_gene2ko_overlap(self, final_dir: Path) -> Dict[str, Any]:
gene2ko = self._find_gene2ko(final_dir)
de_files = sorted(final_dir.glob("*.rnaseq_de.*.DE_results.txt"))
if not gene2ko or not de_files:
return {}
background_raw, background_norm = self._read_gene2ko_gene_sets(gene2ko)
if not background_raw:
return {}
best: Dict[str, Any] = {}
for de_file in de_files:
foreground_raw, foreground_norm = self._read_de_gene_sets(de_file)
if not foreground_raw:
continue
raw_overlap = len(foreground_raw & background_raw)
normalized_overlap = len(foreground_norm & background_norm)
item = {
"de_file": str(de_file),
"gene2ko": str(gene2ko),
"foreground_genes": len(foreground_raw),
"background_genes": len(background_raw),
"raw_overlap": raw_overlap,
"normalized_overlap": normalized_overlap,
"foreground_examples": sorted(foreground_raw)[:5],
"background_examples": sorted(background_raw)[:5],
}
if not best or normalized_overlap > int(best.get("normalized_overlap", -1)):
best = item
if raw_overlap == 0 and normalized_overlap > 0:
return item
return best
def _read_gene2go_gene_sets(self, path: Path) -> Tuple[set, set]:
raw_genes = set()
normalized_genes = set()
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if not line.strip() or line.startswith("#"):
continue
gene = re.split(r"[\t, ]+", line.strip())[0]
raw_genes.add(gene)
normalized_genes.add(self._normalize_enrichment_gene_id(gene))
except Exception:
return set(), set()
raw_genes.discard("")
normalized_genes.discard("")
return raw_genes, normalized_genes
def _read_gene2ko_gene_sets(self, path: Path) -> Tuple[set, set]:
raw_genes = set()
normalized_genes = set()
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if not line.strip() or line.startswith("#"):
continue
gene = re.split(r"[\t, ]+", line.strip())[0]
raw_genes.add(gene)
normalized_genes.add(self._normalize_enrichment_gene_id(gene))
except Exception:
return set(), set()
raw_genes.discard("")
normalized_genes.discard("")
return raw_genes, normalized_genes
def _read_de_gene_sets(self, path: Path) -> Tuple[set, set]:
raw_genes = set()
normalized_genes = set()
try:
for idx, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines()):
if idx == 0:
continue
if not line.strip() or line.startswith("#"):
continue
gene = line.split("\t", 1)[0].strip()
normalized = self._normalize_enrichment_gene_id(gene)
if normalized and normalized.lower() not in {"gene", "gene_id", "geneid", "id", "x"}:
raw_genes.add(gene)
normalized_genes.add(normalized)
except Exception:
return set(), set()
return raw_genes, normalized_genes
def _normalize_enrichment_gene_id(self, gene_id: str) -> str:
value = (gene_id or "").strip().strip('"').lstrip(">")
value = re.sub(r"^[a-z][a-z0-9]{2,4}:", "", value)
value = re.split(r"\s+", value, 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 value and value != previous:
previous = value
for pattern in suffix_patterns:
value = pattern.sub("", value)
match = RAP_TRANSCRIPT_RE.match(value)
if match:
return f"{match.group(1)}g{match.group(2)}"
return value
def _find_rnaseq_config(self, input_dir: Path) -> Optional[Path]:
for pattern in ("samples*.yaml", "samples*.yml", "*sample*.yaml", "*sample*.yml"):
matches = sorted(input_dir.glob(pattern))
if matches:
return matches[0]
return None
def _find_duplicate_fastq_pairs(self, config_path: Path, input_dir: Path) -> List[str]:
try:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
return []
samples = cfg.get("samples") or {}
seen: Dict[Tuple[str, str], str] = {}
duplicates = []
for sample, value in samples.items():
if isinstance(value, dict):
r1 = value.get("r1") or value.get("read1") or ""
r2 = value.get("r2") or value.get("read2") or ""
elif isinstance(value, list) and len(value) >= 2:
r1, r2 = value[0], value[1]
else:
continue
p1 = str((input_dir / r1).resolve()) if r1 and not Path(str(r1)).is_absolute() else str(r1)
p2 = str((input_dir / r2).resolve()) if r2 and not Path(str(r2)).is_absolute() else str(r2)
key = (p1, p2)
if key in seen:
duplicates.append(f"{seen[key]} 与 {sample} 复用: {Path(p1).name} / {Path(p2).name}")
else:
seen[key] = str(sample)
return duplicates
def _latest_stage_log_text(self, logs_dir: Path, stage: str, limit: int = 4000) -> str:
if not logs_dir.exists():
return ""
logs = sorted(logs_dir.glob(f"*_{stage}.log"), key=lambda p: p.stat().st_mtime, reverse=True)
if not logs:
return ""
try:
text = logs[0].read_text(encoding="utf-8", errors="ignore")
return text[-limit:]
except Exception:
return ""
def _find_high_cross_sample_correlations(self, path: Path, threshold: float = 0.999) -> List[str]:
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
if not lines:
return []
header = lines[0].split("\t")[1:]
pairs = []
for row in lines[1:]:
parts = row.split("\t")
sample = parts[0]
for other, value in zip(header, parts[1:]):
if sample >= other:
continue
try:
corr = float(value)
except Exception:
continue
if corr >= threshold:
pairs.append(f"{sample} vs {other}: correlation={corr}")
return pairs
except Exception:
return []
def _metrics_look_empty(self, metrics: Any) -> bool:
if not isinstance(metrics, dict) or not metrics:
return True
numeric_values = []
for value in metrics.values():
if isinstance(value, (int, float)):
numeric_values.append(value)
return bool(numeric_values) and all(value == 0 for value in numeric_values)
def _identify_manifest_has_member_metrics(self, manifest: Dict[str, Any]) -> bool:
species_results = manifest.get("species_results")
if not isinstance(species_results, list):
return False
for item in species_results:
if not isinstance(item, dict):
continue
final_members = item.get("final_members")
try:
if int(final_members) > 0:
return True
except Exception:
pass
review = item.get("domain_review")
if isinstance(review, dict):
try:
if int(review.get("default_keep_count") or 0) > 0:
return True
except Exception:
pass
return False
def _dedupe_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
deduped: List[Dict[str, Any]] = []
seen: set[Tuple[str, str]] = set()
for issue in issues:
key = (str(issue.get("code") or ""), str(issue.get("message") or ""))
if key in seen:
continue
seen.add(key)
deduped.append(issue)
return deduped
def _prioritize_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Put actionable root causes before derived manifest symptoms."""
if not issues:
return []
root_cause_codes = {
"hmm_or_pfam_missing",
"identify_missing_hmm_or_pfam",
"pfam_remote_404",
"identify_pfam_remote_404",
"identify_pfam_partial_fetch",
"identify_hmm_model_mismatch",
"python_import_path_error",
"stage_runtime_same_file_copy",
"known_family_missing_gene_family",
"id_mismatch",
"plantcare_id_mismatch",
"multi_target_single_species_stage",
"interspecies_pair_no_hits",
"genome_prepare_final_gff_empty",
"genome_prepare_pep_looks_like_genome",
"genome_prepare_cds_looks_like_genome",
"domain_scoped_identify_guard",
"kaks_scope_missing_reference",
}
has_specific_root = any(str(issue.get("code") or "") in root_cause_codes for issue in issues)
if has_specific_root:
issues = [
issue for issue in issues
if str(issue.get("code") or "") not in {"manifest_missing", "manifest_missing_stage_incomplete"}
]
severity_weight = {"blocking": 0, "warning": 1, "info": 2, "ok": 3}
code_weight = {
"hmm_or_pfam_missing": 0,
"identify_missing_hmm_or_pfam": 0,
"pfam_remote_404": 1,
"identify_pfam_remote_404": 1,
"identify_pfam_partial_fetch": 1,
"identify_hmm_model_mismatch": 1,
"python_import_path_error": 2,
"stage_runtime_same_file_copy": 2,
"known_family_missing_gene_family": 3,
"id_mismatch": 4,
"plantcare_id_mismatch": 5,
"multi_target_single_species_stage": 6,
"interspecies_pair_no_hits": 7,
"domain_scoped_identify_guard": 8,
"manifest_failed": 80,
"manifest_missing": 90,
"manifest_missing_stage_incomplete": 91,
}
return sorted(
issues,
key=lambda issue: (
ISSUE_LAYER_PRIORITY.get(
str(
issue.get("layer")
or issue.get("issue_layer")
or ISSUE_LAYER_BY_CODE.get(str(issue.get("code") or ""), "report")
),
50,
),
code_weight.get(str(issue.get("code") or ""), 50),
severity_weight.get(str(issue.get("severity") or "warning"), 1),
),
)
def _highest_severity(self, issues: List[Dict[str, Any]]) -> str:
order = {"ok": 0, "info": 1, "warning": 2, "blocking": 3}
highest = "ok"
for issue in issues:
sev = issue.get("severity", "warning")
if order.get(sev, 2) > order.get(highest, 0):
highest = sev
return highest
def _augment_failure_with_llm(
self,
*,
job_id: str,
stage: str,
prefix: str,
input_dir: Path,
final_dir: Path,
logs_dir: Path,
manifest: Optional[Dict[str, Any]],
params: Dict[str, Any],
issues: List[Dict[str, Any]],
evidence: List[str],
add_issue,
) -> None:
"""Use an LLM only for failure-log interpretation, after deterministic checks.
Deterministic gates still own file/manifest facts and safe repairs. The LLM
is a bounded classifier for unknown or under-specified runtime failures; it
cannot mark a safe patch candidate or modify data/code.
"""
if str(os.environ.get("GF_DIAGNOSIS_LLM_ENABLED", "1")).lower() in {"0", "false", "no", "off"}:
return
log_text = self._latest_stage_log_text(logs_dir, stage, limit=6000)
if len(log_text.strip()) < 40:
return
if any(str(issue.get("source") or "") == "llm" for issue in issues):
return
deterministic_codes = [
str(issue.get("code") or "")
for issue in issues
if issue.get("code")
]
# If a highly specific deterministic root cause already exists, do not
# spend LLM calls. Generic manifest/output symptoms still get LLM context.
generic_codes = {
"manifest_missing",
"manifest_failed",
"manifest_missing_stage_incomplete",
"empty_required_output",
"tool_version_format",
"success_with_empty_metrics",
}
specific_blockers = [
code for code in deterministic_codes
if code not in generic_codes
and not code.startswith("protein_fasta_stop_markers")
]
if specific_blockers and str(os.environ.get("GF_DIAGNOSIS_LLM_FORCE", "0")).lower() not in {"1", "true", "yes", "on"}:
return
self._last_llm_failure_skip_reason = ""
llm_issue = self._llm_failure_issue(
job_id=job_id,
stage=stage,
prefix=prefix,
log_text=log_text,
params=params,
manifest=manifest,
input_summary=self._summarize_dir_for_llm(input_dir),
final_summary=self._summarize_dir_for_llm(final_dir),
deterministic_issues=issues,
)
if not llm_issue:
reason = str(getattr(self, "_last_llm_failure_skip_reason", "") or "").strip()
if reason:
evidence.append(f"llm_failure_diagnosis=skipped:{reason[:240]}")
return
add_issue(
llm_issue["code"],
llm_issue["severity"],
llm_issue["message"],
llm_issue["recommendation"],
requires_confirmation=True,
safe_patch_candidate=False,
blocks_downstream=llm_issue["severity"] == "blocking",
evidence_items=llm_issue.get("evidence", []),
)
if issues:
issues[-1]["source"] = "llm"
issues[-1]["confidence"] = llm_issue.get("confidence")
issues[-1]["suspected_layer"] = llm_issue.get("suspected_layer", "unknown")
issues[-1]["suggested_rule"] = llm_issue.get("suggested_rule", {})
evidence.append("llm_failure_diagnosis=attempted")
self._remember_llm_diagnosis_case(job_id, stage, prefix, log_text, llm_issue)
def _summarize_dir_for_llm(self, path: Path, limit: int = 40) -> List[Dict[str, Any]]:
if not path.exists():
return []
rows: List[Dict[str, Any]] = []
try:
files = sorted([p for p in path.rglob("*") if p.is_file()], key=lambda p: p.stat().st_mtime, reverse=True)
except Exception:
return []
for item in files[:limit]:
try:
rows.append({
"path": str(item.relative_to(path)),
"size": item.stat().st_size,
})
except Exception:
continue
return rows
def _llm_failure_issue(
self,
*,
job_id: str,
stage: str,
prefix: str,
log_text: str,
params: Dict[str, Any],
manifest: Optional[Dict[str, Any]],
input_summary: List[Dict[str, Any]],
final_summary: List[Dict[str, Any]],
deterministic_issues: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
try:
from gfa_core.mas.config import config as mas_config
from gfa_core.mas.llm import LLMManager, LLMMessage
llm_manager = self.llm_manager or LLMManager(config=mas_config.load_llm_config())
manifest_digest = {}
if isinstance(manifest, dict):
manifest_digest = {
"status": manifest.get("status"),
"stage": manifest.get("stage"),
"prefix": manifest.get("prefix"),
"error": str(manifest.get("error") or manifest.get("message") or "")[:1000],
"path": manifest.get("_path"),
}
context = {
"job_id": job_id,
"stage": stage,
"prefix": prefix,
"params": {k: v for k, v in (params or {}).items() if k not in {"openai_api_key", "api_key", "password"}},
"manifest": manifest_digest,
"deterministic_issues": [
{
"code": item.get("code"),
"severity": item.get("severity"),
"message": item.get("message"),
}
for item in deterministic_issues[-8:]
],
"input_files_recent": input_summary[:30],
"final_files_recent": final_summary[:30],
"log_tail": log_text[-4000:],
}
prompt = (
"你是 bbav5 基因家族/RNA-seq 流水线的失败诊断器。"
"规则层已经做过文件、manifest 和基础日志检查;你只负责解释失败日志中规则未覆盖或不够具体的根因。"
"不要编造不存在的文件;证据必须来自给定 JSON。"
"不要提出直接修改全局代码的自动动作,只能给出候选根因和人工/工程修订建议。"
"请只返回一个 JSON 对象,字段如下:"
"root_cause_category, severity(info|warning|blocking), suspected_layer(input|parameter|environment|external_resource|code_compatibility|unknown), "
"message, recommendation, evidence(list), confidence(0-1), suggested_rule{issue_code, pattern_keywords, generalization}。\n\n"
f"诊断上下文 JSON:\n{json.dumps(context, ensure_ascii=False, indent=2)}"
)
response = llm_manager.call(
[LLMMessage(role="user", content=prompt)],
temperature=0.1,
max_tokens=1200,
)
parsed = self._parse_llm_json(response.content)
if not parsed:
return None
category = self._sanitize_issue_token(parsed.get("root_cause_category") or parsed.get("suggested_rule", {}).get("issue_code") or "unknown_failure")
severity = str(parsed.get("severity") or "warning").lower()
if severity not in {"info", "warning", "blocking"}:
severity = "warning"
confidence = parsed.get("confidence", 0.5)
try:
confidence = max(0.0, min(float(confidence), 1.0))
except Exception:
confidence = 0.5
message = str(parsed.get("message") or f"LLM 推断 {stage} 失败根因:{category}").strip()[:500]
recommendation = str(parsed.get("recommendation") or "请结合日志和产物检查该候选根因。").strip()[:800]
evidence_values = parsed.get("evidence") if isinstance(parsed.get("evidence"), list) else []
suggested_rule = parsed.get("suggested_rule") if isinstance(parsed.get("suggested_rule"), dict) else {}
return {
"code": f"llm_{category}",
"severity": severity,
"message": message,
"recommendation": recommendation,
"evidence": [str(item)[:500] for item in evidence_values[:5]],
"confidence": confidence,
"suspected_layer": str(parsed.get("suspected_layer") or "unknown"),
"suggested_rule": suggested_rule,
}
except Exception as exc:
self._last_llm_failure_skip_reason = str(exc)
print(f"[DiagnosisAgent] LLM failure diagnosis skipped: {exc}")
return None
def _parse_llm_json(self, text: str) -> Optional[Dict[str, Any]]:
raw = str(text or "").strip()
if not raw:
return None
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?", "", raw, flags=re.IGNORECASE).strip()
raw = re.sub(r"```$", "", raw).strip()
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except Exception:
pass
match = re.search(r"\{.*\}", raw, re.DOTALL)
if not match:
return None
try:
data = json.loads(match.group(0))
return data if isinstance(data, dict) else None
except Exception:
return None
def _sanitize_issue_token(self, value: Any) -> str:
token = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip().lower()).strip("_")
return token[:80] or "unknown_failure"
def _remember_llm_diagnosis_case(self, job_id: str, stage: str, prefix: str, log_text: str, issue: Dict[str, Any]) -> None:
try:
memory_dir = PROJECT_ROOT / "memory_storage"
memory_dir.mkdir(parents=True, exist_ok=True)
signature = re.sub(r"\s+", " ", log_text[-800:]).strip()[:800]
record = {
"created_at": datetime.now().isoformat(),
"job_id": job_id,
"stage": stage,
"prefix": prefix,
"log_signature": signature,
"issue": issue,
}
path = memory_dir / "diagnosis_cases.jsonl"
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as exc:
print(f"[DiagnosisAgent] Failed to remember LLM diagnosis case: {exc}")
def _match_error_pattern(self, error_text: str, language: str = "zh") -> Optional[Dict]:
"""匹配错误模式"""
error_lower = error_text.lower()
priority_order = [
"tool_not_found",
"dependency_failed",
"go_foreground_background_id_mismatch",
"memory_error",
"invalid_parameter",
"conda_env_error",
"hmm_not_found",
"blast_db_error",
"missing_input_file",
"permission_error",
"timeout_error"
]
for error_type in priority_order:
if error_type in self.ERROR_PATTERNS:
pattern_info = self.ERROR_PATTERNS[error_type]
for pattern in pattern_info["patterns"]:
if re.search(pattern, error_text, re.IGNORECASE):
suggestion_key = "suggestion_en" if language == "en" else "suggestion_zh"
return {
"error_type": error_type,
"suggestion": pattern_info.get(suggestion_key, pattern_info.get("suggestion_zh", "")),
"auto_fix": pattern_info.get("auto_fix", False),
"fix_action": pattern_info.get("fix_action"),
"severity": pattern_info.get("severity", "medium")
}
# 未匹配到已知模式,返回None,由调用方决定是否查询Knowledge
return None
async def _query_knowledge_for_error(self, error_text: str) -> Optional[Dict]:
"""调用 Knowledge Agent 查询错误解决方案"""
try:
from .knowledge import create_knowledge_agent
from gfa_core.mas.llm import LLMManager
from .base import AgentMessage, AgentState
import uuid
print(f"[DiagnosisAgent] 未知错误,调用 Knowledge Agent 查询...")
# 创建 Knowledge Agent
llm_manager = LLMManager()
knowledge_agent = create_knowledge_agent(llm_manager)
# 构建查询
query = f"基因家族分析错误:{error_text[:200]},如何解决?"
message = AgentMessage(
sender="diagnosis",
receiver="knowledge",
message_type="query",
payload={"query": query, "mode": "consultation"}
)
state = AgentState(
session_id=f"diagnosis_{uuid.uuid4().hex[:8]}",
results={},
messages=[]
)
# 执行查询
result_state = await knowledge_agent.execute(state, message)
answer = result_state.results.get("knowledge_response", "")
if answer and len(answer) > 20:
print(f"[DiagnosisAgent] Knowledge Agent 返回解决方案")
return {
"error_type": "unknown",
"suggestion": answer[:500],
"auto_fix": False,
"severity": "medium",
"source": "knowledge"
}
except Exception as e:
print(f"[DiagnosisAgent] Knowledge 查询失败: {e}")
return None
async def _attempt_auto_fix(self, fix_action: str, error_info: Dict,
error_text: str, diagnosis: Dict) -> Dict:
"""尝试自动修复"""
if fix_action == "reduce_cpu":
return self._fix_reduce_cpu(error_info)
elif fix_action == "fix_parameter_format":
return self._fix_parameter_format(error_info)
elif fix_action == "execute_dependencies":
return await self._fix_execute_dependencies(error_info, error_text)
else:
return {"success": False, "message": f"Unknown fix action: {fix_action}"}
def _fix_reduce_cpu(self, error_info: Dict) -> Dict:
"""修复:减少CPU使用"""
current_params = error_info.get("params", {})
current_cpu = current_params.get("cpu", 8)
new_cpu = max(1, current_cpu // 2)
return {
"success": True,
"action": "reduce_cpu",
"changes": {"cpu": {"from": current_cpu, "to": new_cpu}},
"message": f"将CPU从{current_cpu}减少到{new_cpu}",
"new_params": {**current_params, "cpu": new_cpu}
}
def _fix_parameter_format(self, error_info: Dict) -> Dict:
"""修复:参数格式"""
current_params = error_info.get("params", {})
fixed_params = {}
changes = {}
for key, value in current_params.items():
if key in self.PARAMETER_FIXES:
new_key = self.PARAMETER_FIXES[key]
fixed_params[new_key] = value
changes[key] = {"to": new_key}
else:
fixed_params[key] = value
return {
"success": True,
"action": "fix_parameter_format",
"changes": changes,
"message": f"修复了{len(changes)}个参数名称",
"new_params": fixed_params
}
async def _fix_execute_dependencies(self, error_info: Dict, error_text: str) -> Dict:
"""修复:自动执行依赖阶段"""
current_params = error_info.get("params", {})
stage = error_info.get("stage", "unknown")
# 从错误文本中提取缺失的依赖阶段
import re
missing_deps = []
dep_patterns = [
r"请先执行\s*(\w+)\s*阶段",
r"requires\s*(\w+)\s*stage",
r"依赖阶段\s*(\w+)\s*未完成",
r"(\w+)\s*stage\s*not completed"
]
for pattern in dep_patterns:
matches = re.findall(pattern, error_text, re.IGNORECASE)
missing_deps.extend(matches)
# 根据主规划目录推断依赖,避免诊断链路与 PlanningAgentV2 分叉。
if stage in CATALOG_STAGE_DEPENDENCIES and not missing_deps:
missing_deps = list(CATALOG_STAGE_DEPENDENCIES[stage])
return {
"success": True,
"action": "execute_dependencies",
"changes": {"added_stages": missing_deps},
"message": f"需要前置执行依赖阶段: {', '.join(missing_deps)}",
"new_params": {
**current_params,
"execute_dependencies_first": True,
"missing_dependencies": missing_deps
}
}
def _generate_notification(self, stage: str, diagnosis: Dict, fix_result: Dict) -> str:
"""生成前端通知消息"""
error_type = diagnosis.get("error_type", "unknown")
action = fix_result.get("action", "")
changes = fix_result.get("changes", {})
notification_parts = [
f"🔧 自动修复通知",
f"阶段: {stage}",
f"错误类型: {error_type}",
f"修复操作: {action}",
]
if changes:
notification_parts.append("参数变更:")
for key, change in changes.items():
if isinstance(change, dict) and "from" in change and "to" in change:
notification_parts.append(f" - {key}: {change['from']} → {change['to']}")
elif isinstance(change, dict) and "to" in change:
notification_parts.append(f" - {key} → {change['to']}")
else:
notification_parts.append(f" - {key}: {change}")
notification_parts.append("正在重新执行...")
return "\n".join(notification_parts)
def record_error(self, error_info: Dict, diagnosis: Dict):
"""记录错误到历史"""
self.error_history.append({
"error": error_info,
"diagnosis": diagnosis,
"timestamp": datetime.now().isoformat()
})
if len(self.error_history) > 100:
self.error_history = self.error_history[-100:]
def create_diagnosis_agent(llm_manager: LLMManager):
"""创建DiagnosisAgent实例"""
return DiagnosisAgent(llm_manager)