From 65f79207f050a89d8a3ae3adedb270927bdaa358 Mon Sep 17 00:00:00 2001 From: wtx Date: Sun, 9 Aug 2026 01:37:00 +0800 Subject: [PATCH 01/18] feat: add evidence-bound RNA-seq result reports --- apps/web/vue3/main.py | 138 +- gfa_core/mas/utils/intent_parser.py | 49 + gfa_core/rnaseq_report.py | 1304 ++++++++++++++++ tests/unit/test_rnaseq_report.py | 2131 +++++++++++++++++++++++++++ 4 files changed, 3620 insertions(+), 2 deletions(-) create mode 100644 gfa_core/rnaseq_report.py create mode 100644 tests/unit/test_rnaseq_report.py diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index b727ff36f..79490f6d9 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -6911,6 +6911,10 @@ class ExecuteRequest(BaseModel): speciesCode: str = "" rnaseqPrefix: str = "" rnaseqOutputPrefix: str = "" + rnaseqScopeId: str = "" + rnaseqExpectedStages: List[str] = Field(default_factory=list) + rnaseqReportAction: str = "generate" + rnaseqReportUseModel: bool = True # RNA-seq QC 参数 rnaseqDataMode: str = "upload" rnaseqDataPath: str = "" @@ -7794,6 +7798,10 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict): wgcna_powers=params.get("wgcnaPowers", "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"), candidate_max_deg_proteins=params.get("candidateMaxDegProteins", 500), candidate_run_interproscan=params.get("candidateRunInterproscan", "auto"), + rnaseq_scope_id=params.get("rnaseqScopeId", ""), + rnaseq_expected_stages=params.get("rnaseqExpectedStages") or params.get("expectedStages") or [], + rnaseq_report_action=params.get("rnaseqReportAction", "generate"), + rnaseq_report_use_model=_bool_param("rnaseqReportUseModel", "report_use_model", default=True), # PPI参数 ppi_species=params.get("ppiSpecies") or params.get("ppi_species") or "auto", normalization_species_label=params.get("normalizationSpeciesLabel") or params.get("normalization_species_label") or "", @@ -12093,6 +12101,10 @@ async def execute_stage(req: ExecuteRequest): "wgcnaPowers": getattr(req, 'wgcnaPowers', "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"), "candidateMaxDegProteins": getattr(req, 'candidateMaxDegProteins', 500), "candidateRunInterproscan": getattr(req, 'candidateRunInterproscan', "auto"), + "rnaseqScopeId": getattr(req, 'rnaseqScopeId', ""), + "rnaseqExpectedStages": getattr(req, 'rnaseqExpectedStages', []), + "rnaseqReportAction": getattr(req, 'rnaseqReportAction', "generate"), + "rnaseqReportUseModel": getattr(req, 'rnaseqReportUseModel', True), "ncbiQuery": getattr(req, 'ncbiQuery', ''), "ncbiGeneIds": getattr(req, 'ncbiGeneIds', ''), "ncbiMode": getattr(req, 'ncbiMode', 'gene'), @@ -13679,6 +13691,7 @@ async def get_results( "rnaseq_fgsea": {"images": [], "files": [], "elapsed_time": 0}, "rnaseq_wgcna": {"images": [], "files": [], "elapsed_time": 0}, "rnaseq_candidate_family": {"images": [], "files": [], "elapsed_time": 0}, + "rnaseq_report": {"images": [], "files": [], "elapsed_time": 0}, "genome_prepare": {"images": [], "files": [], "elapsed_time": 0}, "prepare_organize": {"images": [], "files": [], "elapsed_time": 0}, "prepare_normalize_gff": {"images": [], "files": [], "elapsed_time": 0}, @@ -13780,6 +13793,7 @@ async def get_results( "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family", + "rnaseq_report", "expression", } RNASEQ_FILE_TOKENS = ( @@ -14801,6 +14815,7 @@ async def get_results( "genome_prepare": ["genome_prepare", "species_config"], "msa_region_plot": ["msa_region", "region.tsv", "conservation_profile"], "subfamily_confirm": ["subfamily_confirm", "confirmed_subfamily"], + "rnaseq_report": ["rnaseq_report"], "rnaseq_fgsea": ["rnaseq_fgsea", "fgsea"], "rnaseq_go_enrich": ["rnaseq_go_enrich", ".go_enrich", "_go_"], "rnaseq_kegg_enrich": ["rnaseq_kegg_enrich", ".kegg_enrich", "_kegg_", "kegg_pathway", "kegg_pathview"], @@ -16029,6 +16044,7 @@ def parse_natural_language(message: str) -> dict: "rnaseq_align": ["rnaseq比对", "rna比对", "hisat2", "比对"], "rnaseq_quant": ["rnaseq定量", "rna定量", "stringtie", "定量"], "rnaseq_sample_qc": ["样本质量", "样本检测", "sample qc", "sample quality", "pca", "样本相关性"], + "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告"], "rnaseq_wgcna": ["wgcna", "共表达", "加权共表达", "模块性状", "module trait"], "rnaseq_de": ["rnaseq差异", "rna差异", "差异表达", "de分析"], "rnaseq_fgsea": ["fgsea", "gsea", "基因集富集", "排序富集"], @@ -16838,7 +16854,10 @@ async def _run_agent_chat( ) router_context_terms = dict(terms) - from gfa_core.mas.utils.intent_parser import parse_sequence_extract_request + from gfa_core.mas.utils.intent_parser import ( + deterministic_agent_chat_intent, + parse_sequence_extract_request, + ) utility_params = parse_sequence_extract_request(request.message) if utility_params: @@ -16884,6 +16903,58 @@ async def _run_agent_chat( "session_id": session_id, } + deterministic_intent = deterministic_agent_chat_intent( + request.message, + rnaseq_context=any( + str(stage).startswith("rnaseq_") for stage in completed_stages + ), + ) + if deterministic_intent is not None: + report_action = str( + (deterministic_intent.get("params") or {}).get("rnaseqReportAction") + or "generate" + ) + report_message = ( + "已识别为打开 RNA-seq 结果报告。" + if report_action == "open" + else "已识别为生成 RNA-seq 结果报告。" + ) + report_intent = { + **deterministic_intent, + "stage": "rnaseq_report", + "primary_stage": "rnaseq_report", + "taskType": "analysis", + "task_kind": "analysis", + "job_id": request.job_id, + "workspace": ( + str(PROJECT_ROOT / "workspace" / request.job_id) + if request.job_id + else "" + ), + "message": report_message, + } + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="propose_analysis", + intent=report_intent, + ) + return { + "success": True, + "status": "ok", + "action": "propose_analysis", + "intent": report_intent, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + "handoff_request": request.message, + } + global llm_manager active_llm_manager = llm_manager if active_llm_manager is None: @@ -20012,6 +20083,63 @@ async def agent_plan(request: AgentPlanRequest): request.intent["params"] = dict(request.intent.get("params") or {}) _apply_reconciled_species_to_intent(request.intent, plan_role_resolution) query_lower_for_context = (request.message or "").lower() + result_report_query = ( + "结果报告" in query_lower_for_context + and any(action in query_lower_for_context for action in ["生成", "打开"]) + ) + if result_report_query: + from gfa_core.rnaseq_evidence import is_rnaseq_evidence_stage, manifest_identity_value + + current_intent = dict(request.intent or {}) + current_params = dict(current_intent.get("params") or {}) + current_stages = [str(stage) for stage in (current_intent.get("stages") or [])] + explicit_rnaseq_report = any( + marker in query_lower_for_context + for marker in ["rnaseq", "rna-seq", "rna seq", "转录组"] + ) + rnaseq_context = any(is_rnaseq_evidence_stage(stage) for stage in current_stages) + latest_rnaseq_manifest = None + if request.job_id: + report_final_dir = WORKSPACE_ROOT / request.job_id / "final" + report_prefix = _nonempty_prefix(request.prefix or current_intent.get("prefix") or current_params.get("prefix")) + report_pattern = f"{report_prefix}.*.manifest.json" if report_prefix else "*.manifest.json" + candidates = [] + for candidate in report_final_dir.glob(report_pattern) if report_final_dir.exists() else []: + try: + payload = json.loads(candidate.read_text(encoding="utf-8")) + except Exception: + continue + if ( + is_rnaseq_evidence_stage(str(payload.get("stage") or "")) + and str(payload.get("status") or "") in {"success", "partial_success"} + ): + candidates.append((candidate.stat().st_mtime, candidate, payload)) + if candidates: + _mtime, _path, latest_rnaseq_manifest = max(candidates, key=lambda item: item[0]) + rnaseq_context = True + if explicit_rnaseq_report or rnaseq_context: + current_params["rnaseqReportAction"] = "open" if "打开" in query_lower_for_context else "generate" + if latest_rnaseq_manifest: + current_params.setdefault( + "rnaseqScopeId", + manifest_identity_value(latest_rnaseq_manifest, "rnaseq_scope_id"), + ) + report_prefix = _nonempty_prefix( + request.prefix + or current_intent.get("prefix") + or current_params.get("prefix") + or (latest_rnaseq_manifest or {}).get("prefix") + ) + request.intent = { + **current_intent, + "stages": ["rnaseq_report"], + "primary_stage": "rnaseq_report", + "task_type": "report", + "task_kind": "pipeline", + "prefix": report_prefix, + "params": {**current_params, "prefix": report_prefix}, + "confidence": 0.99, + } subfamily_confirm_query_for_context = any( kw in query_lower_for_context for kw in ["确认亚家族", "亚家族确认", "确认亚家族标签", "人工确认亚家族", "subfamily confirm", "confirmed subfamily"] @@ -20907,7 +21035,10 @@ async def agent_plan(request: AgentPlanRequest): "manifest_path": str(status.manifest_path) if status.manifest_path else "", "missing_count": len(status.missing_artifacts or []) + len(status.errors or []), } - if status.completed: + # Report requests are actions, not a request to skip an + # already completed computation. The CLI decides whether + # an "open" request can reuse the fresh snapshot. + if status.completed and stage_id != "rnaseq_report": completed_rnaseq_stages.append(stage_id) else: remaining_rnaseq_stages.append(stage_id) @@ -20923,6 +21054,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": "RNA-seq fgsea", "rnaseq_wgcna": "RNA-seq WGCNA", "rnaseq_candidate_family": "RNA-seq Candidate Family", + "rnaseq_report": "RNA-seq Result Report", } plan_prefix = effective_plan_prefix or request.prefix or incoming_intent.get("prefix", "") final_dir_for_rnaseq = PROJECT_ROOT / "workspace" / request.job_id / "final" @@ -20942,6 +21074,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": ["rnaseq_de"], "rnaseq_wgcna": ["rnaseq_sample_qc"], "rnaseq_candidate_family": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + "rnaseq_report": [], } if ( "rnaseq_fgsea" in remaining_rnaseq_stages @@ -20965,6 +21098,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": 10, "rnaseq_wgcna": 20, "rnaseq_candidate_family": 10, + "rnaseq_report": 2, } stages_detail = [] for order, stage_id in enumerate(remaining_rnaseq_stages, start=1): diff --git a/gfa_core/mas/utils/intent_parser.py b/gfa_core/mas/utils/intent_parser.py index f75329b21..48cc0f25a 100755 --- a/gfa_core/mas/utils/intent_parser.py +++ b/gfa_core/mas/utils/intent_parser.py @@ -87,6 +87,7 @@ STAGE_KEYWORDS = { "rnaseq_kegg_enrich": ["rnaseq kegg", "rnaseq_kegg", "rna kegg", "kegg富集", "kegg分析", "kegg", "富集分析", "enrich"], "rnaseq_enrich": ["富集分析", "enrich", "go分析", "kegg", "clusterprofiler"], "rnaseq_candidate_family": ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释", "差异基因家族", "candidate family"], + "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告", "rna-seq报告"], "ncbi_fetch_sequences": ["ncbi下载", "ncbi序列", "下载序列", "下载蛋白", "获取蛋白", "下载protein", "下载cds", "protein和cds", "蛋白和cds", "基因list", "gene list", "gene_list", "id列表", ".ids"], } @@ -256,6 +257,7 @@ RNASEQ_STAGES = { "rnaseq_kegg_enrich", "rnaseq_enrich", "rnaseq_candidate_family", + "rnaseq_report", } # 物种关键词映射 @@ -732,6 +734,10 @@ def parse_intent_from_query(query: str) -> Dict: "clarify_type": result.get("clarify_type"), "message": result.get("message") } + if parsed["primary_stage"] == "rnaseq_report": + parsed["params"] = { + "rnaseqReportAction": "open" if "打开" in query_lower else "generate" + } if ncbi_params: parsed["params"] = ncbi_params parsed["prefix"] = ncbi_params.get("prefix", "") @@ -781,6 +787,43 @@ def parse_intent_from_query(query: str) -> Dict: return parsed +def deterministic_agent_chat_intent( + query: str, + *, + rnaseq_context: bool = False, +) -> Optional[Dict]: + """Return explicit chat intents that must not depend on the Router model.""" + intent = parse_intent_from_query(query) + if intent.get("stages") == ["rnaseq_report"]: + return intent + query_lower = str(query or "").lower() + query_without_space = re.sub(r"\s+", "", query_lower) + is_generic_report_action = ( + rnaseq_context + and "结果报告" in query_without_space + and any(action in query_without_space for action in ("生成", "打开")) + ) + if is_generic_report_action: + return { + "gene_family": "", + "species": "", + "stages": ["rnaseq_report"], + "primary_stage": "rnaseq_report", + "task_type": "report", + "task_kind": "pipeline", + "confidence": 0.99, + "need_clarify": False, + "clarify_type": None, + "message": None, + "params": { + "rnaseqReportAction": ( + "open" if "打开" in query_without_space else "generate" + ) + }, + } + return None + + def parse_msa_region_plot_request(query: str) -> Dict: """Extract parameters for drawing a selected MSA alignment region.""" text = str(query or "").strip() @@ -1367,6 +1410,12 @@ def _extract_stages(query: str, query_lower: str) -> List[str]: stages.append("gwas_fmt") matched_keywords = [] # 记录匹配到的关键词长度,用于优先级判断 has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS) + requests_result_report = any( + keyword in query_lower + for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"] + ) + if has_rnaseq_marker and requests_result_report: + return ["rnaseq_report"] extraction_context = any(kw in query_lower for kw in ["提取", "extract", "gffread", "筛选", "subseq"]) explicit_gene_family_context = any(kw in query_lower for kw in ["基因家族", "家族表达", "表达量", "热图", "heatmap"]) curated_member_context = ( diff --git a/gfa_core/rnaseq_report.py b/gfa_core/rnaseq_report.py new file mode 100644 index 000000000..e7f42adf0 --- /dev/null +++ b/gfa_core/rnaseq_report.py @@ -0,0 +1,1304 @@ +"""Evidence-bound RNA-seq result report generation. + +This module deliberately keeps the formal report independent from the live +frontend interpretation cache. It reuses the same pure evidence builder, but +materializes a deterministic Markdown report and provenance artifacts under a +job's ``final/`` directory. +""" +from __future__ import annotations + +import hashlib +import ast +import csv +import json +import math +import os +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +from gfa_core.rnaseq_evidence import ( + RNASEQ_STAGE_NAMES, + build_rnaseq_evidence_snapshot, + resolve_artifact_path, + manifest_identity_value, + select_rnaseq_manifests, +) + + +REPORT_STAGE = "rnaseq_report" +REPORT_RENDERER_VERSION = "4" +SUCCESS_STATUSES = {"success", "partial_success"} +DEFAULT_EXPECTED_STAGES = [ + "rnaseq_qc", + "rnaseq_align", + "rnaseq_quant", + "rnaseq_sample_qc", + "rnaseq_de", +] +PREFERRED_STAGE_ORDER = [ + "rnaseq_qc", + "rnaseq_align", + "rnaseq_quant", + "rnaseq_sample_qc", + "rnaseq_de", + "annotation", + "annotation_go", + "annotation_kegg", + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_enrich", + "rnaseq_fgsea", + "rnaseq_wgcna", + "rnaseq_candidate_family", + "rnaseq_family_score", +] +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".svg", ".webp"} +STAGE_NAMES = RNASEQ_STAGE_NAMES + + +class NoSuccessfulRNASeqEvidence(RuntimeError): + """Raised when a report would otherwise be empty.""" + + +@dataclass(frozen=True) +class RNASeqReportResult: + report_path: Path + evidence_path: Path + model_path: Path + manifest_path: Path + coverage_status: str + included_stages: tuple[str, ...] + rnaseq_scope_id: str + reused: bool = False + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_text_atomic(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(text, encoding="utf-8") + temporary.replace(path) + + +def _identity(manifest: dict[str, Any], key: str) -> str: + return manifest_identity_value(manifest, key) + + +def is_rnaseq_report_source(stage: str) -> bool: + stage = str(stage or "").strip() + return stage != REPORT_STAGE and ( + stage.startswith("rnaseq_") or stage in {"annotation", "annotation_go", "annotation_kegg"} + ) + + +def _stage_sort_key(stage: str) -> tuple[int, str]: + try: + return (PREFERRED_STAGE_ORDER.index(stage), stage) + except ValueError: + return (len(PREFERRED_STAGE_ORDER), stage) + + +def _normalize_expected_stages(stages: Iterable[str] | None) -> list[str]: + raw = list(stages) if stages is not None else list(DEFAULT_EXPECTED_STAGES) + result: list[str] = [] + for stage in raw: + value = str(stage or "").strip() + if value and is_rnaseq_report_source(value) and value not in result: + result.append(value) + return result + + +def _source_selection( + final_dir: Path, + prefix: str, + rnaseq_scope_id: str, +) -> tuple[str, list[str], dict[str, dict[str, Any]]]: + selection = select_rnaseq_manifests( + final_dir, + prefix, + rnaseq_scope_id=rnaseq_scope_id, + ) + return selection.rnaseq_scope_id, list(selection.discovered_scope_ids), selection.manifests + + +def _source_records(manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for stage in sorted(manifests, key=_stage_sort_key): + manifest = manifests[stage] + path = Path(str(manifest.get("_manifest_path") or "")) + records.append( + { + "stage": stage, + "status": str(manifest.get("status") or "unknown"), + "rnaseq_scope_id": _identity(manifest, "rnaseq_scope_id"), + "path": str(path), + "sha256": _sha256(path) if path.is_file() else "", + "mtime": datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds") + if path.is_file() + else "", + } + ) + return records + + +def _fingerprint( + records: list[dict[str, Any]], expected_stages: list[str], use_model: bool +) -> tuple[str, str]: + source_payload = json.dumps(records, ensure_ascii=False, sort_keys=True).encode("utf-8") + source_fingerprint = hashlib.sha256(source_payload).hexdigest() + request_payload = json.dumps( + { + "source_fingerprint": source_fingerprint, + "expected_stages": expected_stages, + "use_model": use_model, + "report_renderer_version": REPORT_RENDERER_VERSION, + }, + ensure_ascii=False, + sort_keys=True, + ).encode("utf-8") + return source_fingerprint, hashlib.sha256(request_payload).hexdigest() + + +def _artifact_path(workspace: Path, manifest: dict[str, Any], item: dict[str, Any]) -> Path | None: + return resolve_artifact_path(workspace / "final", item, require_exists=True) + + +def _enrichment_figure_identity(stage: str, path: Path) -> tuple[str, str, str] | None: + name = path.name + lower = name.lower() + comparison = "" + category = "" + pathway_id = "" + if stage == "rnaseq_go_enrich" or (stage == "rnaseq_enrich" and ".go_enrich" in lower): + match = re.search(r"rnaseq_(?:go_enrich|enrich)\.(.+?)\.go_enrich", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + if ".go_enrich.ontology_barplot." in lower: + category = "go_ontology" + elif ".go_enrich.barplot." in lower: + category = "go_bar" + elif ".go_enrich.dotplot." in lower: + category = "go_dot" + elif re.search(r"\.go_enrich\.svg$", lower): + category = "go_bar" + elif stage == "rnaseq_kegg_enrich" or (stage == "rnaseq_enrich" and "kegg" in lower): + match = re.search(r"rnaseq_(?:kegg_enrich|enrich)\.(.+?)(?:\.kegg|_kegg)", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + pathway_match = re.search(r"\.kegg_pathway_map\.(ko\d+)\.", lower) + if pathway_match: + category = "kegg_pathway" + pathway_id = pathway_match.group(1) + elif re.search(r"(?:\.|_)kegg_enrich\.(?:png|jpe?g|webp|svg|pdf)$", lower): + category = "kegg_bar" + elif re.search(r"_kegg_dotplot\.(?:png|jpe?g|webp|svg|pdf)$", lower): + category = "kegg_dot" + elif stage == "rnaseq_fgsea": + match = re.search(r"rnaseq_fgsea\.(.+?)\.fgsea", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + if ".fgsea_kegg_dotplot." in lower: + category = "fgsea_dot" + elif ".fgsea_kegg_top_enrichment." in lower: + category = "fgsea_curve" + if not category: + return None + identity = f"{category}:{comparison}:{pathway_id}" if category == "kegg_pathway" else category + return identity, comparison, pathway_id + + +def _artifact_comparison(stage: str, path: Path, category: str = "") -> str: + patterns = { + "rnaseq_go_enrich": r"rnaseq_go_enrich\.(.+?)\.go_enrich", + "rnaseq_kegg_enrich": r"rnaseq_kegg_enrich\.(.+?)(?:\.kegg|_kegg)", + "rnaseq_fgsea": r"rnaseq_fgsea\.(.+?)\.fgsea", + } + if stage == "rnaseq_enrich": + pattern = ( + r"rnaseq_enrich\.(.+?)\.go_enrich" + if category.startswith("go_") + else r"rnaseq_enrich\.(.+?)(?:\.kegg|_kegg)" + ) + else: + pattern = patterns.get(stage) + match = re.search(pattern, path.name, flags=re.IGNORECASE) if pattern else None + return match.group(1) if match else "" + + +def _table_rows(path: Path) -> list[dict[str, str]]: + try: + with path.open("r", encoding="utf-8", errors="replace", newline="") as handle: + delimiter = "," if path.suffix.lower() == ".csv" else "\t" + return [ + {str(key): str(value or "") for key, value in row.items() if key is not None} + for row in csv.DictReader(handle, delimiter=delimiter) + ] + except OSError: + return [] + + +def _supporting_tables( + workspace: Path, + manifest: dict[str, Any], + comparison: str, + category: str = "", +) -> list[tuple[Path, list[dict[str, str]]]]: + tables: list[tuple[Path, list[dict[str, str]]]] = [] + for item in (manifest.get("artifacts") or {}).values(): + if not isinstance(item, dict): + continue + path = _artifact_path(workspace, manifest, item) + if ( + not path + or not path.is_file() + or path.suffix.lower() not in {".txt", ".tsv", ".csv"} + or ( + comparison + and _artifact_comparison(str(manifest.get("stage") or ""), path, category) != comparison + ) + ): + continue + lower_name = path.name.lower() + if category.startswith("go_") and not lower_name.endswith(".go_enrich.txt"): + continue + if category in {"kegg_bar", "kegg_dot"} and not lower_name.endswith(".kegg_enrich.txt"): + continue + if category == "kegg_pathway" and not ( + lower_name.endswith(".kegg_enrich.txt") + or ".kegg_pathway_gene_hits." in lower_name + or ".kegg_pathway_mapper." in lower_name + or ".kegg_pathway_map.ko_log2fc." in lower_name + ): + continue + if category.startswith("fgsea_") and not lower_name.endswith(".fgsea_kegg.tsv"): + continue + rows = _table_rows(path) + if rows: + tables.append((path, rows)) + return tables + + +def _best_adjusted_p(tables: list[tuple[Path, list[dict[str, str]]]]) -> float: + values: list[float] = [] + for _, rows in tables: + for row in rows: + raw = row.get("p.adjust") or row.get("padj") or row.get("FDR") or "" + try: + value = float(raw) + if math.isfinite(value) and 0.0 <= value <= 1.0: + values.append(value) + except (TypeError, ValueError): + continue + return min(values, default=float("inf")) + + +def _fgsea_selection_score(tables: list[tuple[Path, list[dict[str, str]]]]) -> tuple[float, float]: + scores: list[tuple[float, float]] = [] + for _, rows in tables: + for row in rows: + try: + padj = float(row.get("padj") or row.get("p.adjust") or "") + nes = float(row.get("NES") or "") + except (TypeError, ValueError): + continue + if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes): + scores.append((padj, -abs(nes))) + return min(scores, default=(float("inf"), float("inf"))) + + +def _support_labels(value: str) -> list[str]: + """Parse both the real semicolon serialization and legacy list encodings.""" + raw = str(value or "").strip() + if not raw: + return [] + if raw[:1] in {"[", "("}: + try: + parsed = json.loads(raw) if raw.startswith("[") else ast.literal_eval(raw) + except (ValueError, SyntaxError, json.JSONDecodeError): + try: + parsed = ast.literal_eval(raw) + except (ValueError, SyntaxError): + parsed = None + if isinstance(parsed, (list, tuple)): + return list(dict.fromkeys(str(item).strip() for item in parsed if str(item).strip())) + return list(dict.fromkeys(item.strip() for item in raw.split(";") if item.strip())) + + +def _support_identity(label: str) -> tuple[str, str]: + raw = str(label or "").strip() + go_match = re.match(r"(?i)^GO:(GO:\d+)(?::.*)?$", raw) + if go_match: + return "go", go_match.group(1) + match = re.match(r"(?i)^(KEGG|fgsea):([^:]+)(?::.*)?$", raw) + if match: + return match.group(1).lower(), match.group(2).lower() + return "", "" + + +def _candidate_rows(workspace: Path, manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + manifest = manifests.get("rnaseq_family_score") or {} + item = (manifest.get("artifacts") or {}).get("analysis_ready_family") + if not isinstance(item, dict): + return [] + path = _artifact_path(workspace, manifest, item) + if not path or not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + for index, row in enumerate(_table_rows(path), 1): + family = str(row.get("family_id") or row.get("family_name") or "").strip() + comparison = str(row.get("best_comparison") or "").strip() + if not family or not comparison: + continue + try: + rank = int(str(row.get("rank") or index)) + except ValueError: + rank = index + rows.append( + { + "family": family, + "family_name": str(row.get("family_name") or "").strip(), + "rank": rank, + "comparison": comparison, + "support_labels": _support_labels(str(row.get("best_support_labels") or "")), + "source_table": str(path), + } + ) + return sorted(rows, key=lambda row: (row["rank"], row["family"])) + + +def _pathway_adjusted_p( + tables: list[tuple[Path, list[dict[str, str]]]], pathway_id: str +) -> float: + row = _first_matching_row(tables, pathway_id=pathway_id) + try: + value = float(row.get("p.adjust") or row.get("padj") or row.get("FDR") or "") + except (TypeError, ValueError): + return float("inf") + return value if math.isfinite(value) and 0.0 <= value <= 1.0 else float("inf") + + +def _figures( + workspace: Path, manifests: dict[str, dict[str, Any]] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]: + candidates: list[dict[str, Any]] = [] + seen: set[str] = set() + for stage in sorted(manifests, key=_stage_sort_key): + artifacts = manifests[stage].get("artifacts") or {} + if not isinstance(artifacts, dict): + continue + for key, item in sorted(artifacts.items()): + if not isinstance(item, dict): + continue + path = _artifact_path(workspace, manifests[stage], item) + if not path or not path.is_file(): + continue + classified = _enrichment_figure_identity(stage, path) + if path.suffix.lower() not in IMAGE_SUFFIXES and not (path.suffix.lower() == ".pdf" and classified): + continue + identity = str(path.resolve()) + if identity in seen: + continue + seen.add(identity) + row = {"stage": stage, "artifact": str(key), "path": str(path)} + if classified: + identity, comparison, pathway_id = classified + row.update( + { + "selection_identity": identity, + "category": identity.split(":", 1)[0], + "comparison": comparison, + "pathway_id": pathway_id, + } + ) + candidates.append(row) + + selected: list[dict[str, Any]] = [] + enrichment_candidates: list[dict[str, Any]] = [] + for row in candidates: + if not row.get("selection_identity"): + row.update( + candidate_families=[], support_labels=[], selection_reason="all_stage_figures", + source_tables=[], + ) + selected.append(row) + else: + enrichment_candidates.append(row) + + formal_candidates = _candidate_rows(workspace, manifests) + format_rank = {".png": 0, ".jpg": 1, ".jpeg": 1, ".webp": 2, ".svg": 3, ".pdf": 4} + table_cache: dict[tuple[str, str, str], list[tuple[Path, list[dict[str, str]]]]] = {} + + def tables_for(row: dict[str, Any]) -> list[tuple[Path, list[dict[str, str]]]]: + cache_key = (row["stage"], row.get("comparison", ""), row.get("category", "")) + if cache_key not in table_cache: + table_cache[cache_key] = _supporting_tables( + workspace, manifests[row["stage"]], cache_key[1], cache_key[2] + ) + return table_cache[cache_key] + + def table_contains(category: str, identifier: str, row: dict[str, Any]) -> bool: + target_identifier = identifier.lower() + if category == "kegg_pathway": + return True + tables = tables_for(row) + if category == "fgsea_curve": + top = _best_fgsea_row(tables) + top_identifier = str(top.get("pathway") or top.get("PathwayID") or top.get("ID") or "") + return bool(top_identifier) and top_identifier.lower() == target_identifier + for _, rows in tables: + for table_row in rows: + value = str( + table_row.get("ID") + or table_row.get("PathwayID") + or table_row.get("pathway") + or "" + ) + if value.lower() == target_identifier: + return True + return False + + def row_support(row: dict[str, Any]) -> tuple[list[str], list[str], int, list[str]]: + category = row.get("category", "") + wanted_kind = ( + "go" if category.startswith("go_") else "fgsea" if category.startswith("fgsea_") else "kegg" + ) + pathway_id = str(row.get("pathway_id") or "").lower() + matched: list[tuple[int, str, str, str]] = [] + for candidate in formal_candidates: + if candidate["comparison"] != row.get("comparison"): + continue + for label in candidate["support_labels"]: + kind, identifier = _support_identity(label) + if ( + kind != wanted_kind + or (category == "kegg_pathway" and identifier != pathway_id) + or not table_contains(category, identifier, row) + ): + continue + matched.append( + (candidate["rank"], candidate["family"], label, candidate["source_table"]) + ) + matched.sort(key=lambda value: (value[0], value[1], value[2])) + return ( + list(dict.fromkeys(value[1] for value in matched)), + list(dict.fromkeys(value[2] for value in matched)), + matched[0][0] if matched else 10**9, + list(dict.fromkeys(value[3] for value in matched)), + ) + + def evidence_score(row: dict[str, Any]) -> tuple[Any, ...]: + tables = tables_for(row) + if row.get("category") == "fgsea_curve": + evidence: tuple[Any, ...] = _fgsea_selection_score(tables) + elif row.get("category") == "kegg_pathway": + evidence = (_pathway_adjusted_p(tables, row.get("pathway_id", "")),) + else: + evidence = (_best_adjusted_p(tables),) + return (*evidence, format_rank.get(Path(row["path"]).suffix.lower(), 9), Path(row["path"]).name) + + for row in enrichment_candidates: + families, labels, candidate_rank, candidate_tables = row_support(row) + row["candidate_families"] = families + row["support_labels"] = labels + row["candidate_rank"] = candidate_rank + row["source_tables"] = candidate_tables + [str(path) for path, _ in tables_for(row)] + + selection_mode = ( + "candidate_guided" + if any(row.get("support_labels") for row in enrichment_candidates) + else "representative_fallback" + ) + if selection_mode == "representative_fallback": + for row in enrichment_candidates: + row["candidate_families"] = [] + row["support_labels"] = [] + row["candidate_rank"] = 10**9 + row["source_tables"] = [str(path) for path, _ in tables_for(row)] + + groups: dict[str, list[dict[str, Any]]] = {} + for row in enrichment_candidates: + category = row.get("category", "") + if selection_mode == "candidate_guided" and not row.get("support_labels"): + continue + key = ( + f"{category}:{row.get('comparison', '')}:{row.get('pathway_id', '')}" + if selection_mode == "candidate_guided" and category == "kegg_pathway" + else f"{category}:{row.get('pathway_id', '')}" + if category == "kegg_pathway" + else category + ) + groups.setdefault(key, []).append(row) + + for rows in groups.values(): + rows.sort( + key=lambda row: ( + row.get("candidate_rank", 10**9) if selection_mode == "candidate_guided" else 0, + *evidence_score(row), + ) + ) + chosen = rows[0] + chosen["selection_reason"] = ( + "candidate_family_support" if selection_mode == "candidate_guided" else "representative_fallback" + ) + selected.append(chosen) + + selected_paths = {row["path"] for row in selected} + omitted: list[dict[str, Any]] = [] + for row in enrichment_candidates: + if row["path"] in selected_paths: + continue + row.setdefault("candidate_families", []) + row.setdefault("support_labels", []) + row.setdefault("source_tables", []) + row["selection_reason"] = ( + "not_candidate_supported" + if selection_mode == "candidate_guided" and not row["support_labels"] + else "lower_ranked_representative" + ) + omitted.append(row) + stage_rank = {stage: index for index, stage in enumerate(PREFERRED_STAGE_ORDER)} + ordered = sorted( + selected, + key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name), + ) + omitted.sort(key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name)) + return ordered, omitted, selection_mode + + +def _first_matching_row( + tables: list[tuple[Path, list[dict[str, str]]]], + *, + pathway_id: str = "", +) -> dict[str, str]: + rows = [row for _, table_rows in tables for row in table_rows] + if pathway_id: + for row in rows: + identifier = row.get("ID") or row.get("PathwayID") or row.get("pathway") or "" + if identifier.lower() == pathway_id.lower(): + return row + return {} + return rows[0] if rows else {} + + +def _best_fgsea_row(tables: list[tuple[Path, list[dict[str, str]]]]) -> dict[str, str]: + ranked: list[tuple[float, float, int, dict[str, str]]] = [] + for _, rows in tables: + for index, row in enumerate(rows): + try: + padj = float(row.get("padj") or row.get("p.adjust") or "") + nes = float(row.get("NES") or "") + except (TypeError, ValueError): + continue + if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes): + ranked.append((padj, -abs(nes), index, row)) + return min(ranked, default=(float("inf"), float("inf"), 0, {}))[-1] + + +def _value(row: dict[str, str], *keys: str) -> str: + for key in keys: + value = str(row.get(key) or "").strip() + if value: + return value + return "未登记" + + +def _probability_value(row: dict[str, str], *keys: str) -> str: + raw = _value(row, *keys) + try: + value = float(raw) + except (TypeError, ValueError): + return "未登记" + return raw if math.isfinite(value) and 0.0 <= value <= 1.0 else "未登记" + + +def _field_items(row: dict[str, str], *keys: str) -> set[str]: + value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "") + return {item for item in re.split(r"[/,;\s]+", value) if item} + + +def _ordered_field_items(row: dict[str, str], *keys: str) -> list[str]: + value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "") + return list(dict.fromkeys(item for item in re.split(r"[/,;\s]+", value) if item)) + + +def _item_summary(label: str, items: set[str]) -> str: + ordered = sorted(items) + preview = "/".join(ordered[:12]) if ordered else "未登记" + return f"{label}总数={len(ordered)},前12项={preview}" + + +def _source_table_links(report_path: Path, tables: list[tuple[Path, list[dict[str, str]]]], markers: tuple[str, ...]) -> str: + links = [ + f"[{path.name}]({_relative_link(report_path, path)})" + for path, _ in tables + if any(marker in path.name.lower() for marker in markers) + ] + return ";完整源表:" + "、".join(links) if links else "" + + +def _figure_interpretation( + figure: dict[str, str], workspace: Path, manifests: dict[str, dict[str, Any]], report_path: Path +) -> list[str]: + category = figure.get("category", "") + if not category: + return [f"该图来自 `{figure['stage']}` 阶段。具体趋势应与该阶段的数值表和 manifest 指标联合判断。"] + comparison = figure.get("comparison", "") + tables = _supporting_tables(workspace, manifests[figure["stage"]], comparison, category) + pathway_id = figure.get("pathway_id", "") + row = ( + _best_fgsea_row(tables) + if category.startswith("fgsea_") + else _first_matching_row(tables, pathway_id=pathway_id) + ) + if not row: + result = f"比较组 `{comparison or '未识别'}` 证据不足:缺少可匹配的数值表,无法可靠判断本次数据的具体趋势。" + elif category == "go_ontology": + ontology_rows: dict[str, dict[str, str]] = {} + for _, table_rows in tables: + for candidate in table_rows: + ontology_value = str(candidate.get("Ontology") or "").strip().lower() + ontology = { + "bp": "BP", + "biological process": "BP", + "cc": "CC", + "cellular component": "CC", + "mf": "MF", + "molecular function": "MF", + }.get(ontology_value, "") + if ontology in {"BP", "CC", "MF"} and ontology not in ontology_rows: + ontology_rows[ontology] = candidate + summaries = [ + f"{ontology}={_value(ontology_rows[ontology], 'Description')}" + for ontology in ("BP", "CC", "MF") + if ontology in ontology_rows + ] + result = ( + f"比较组 `{comparison}` 的三类本体代表条目为" + f"{';'.join(summaries) if summaries else '未登记'}。" + ) + elif category.startswith("go_"): + result = ( + f"比较组 `{comparison}` 的代表条目为 {_value(row, 'ID')}({_value(row, 'Description')})," + f"Ontology={_value(row, 'Ontology')},GeneRatio={_value(row, 'GeneRatio')}," + f"Count={_value(row, 'Count')},p.adjust={_probability_value(row, 'p.adjust')}。" + ) + elif category in {"kegg_bar", "kegg_dot"}: + genes = _field_items(row, "geneID", "Genes") + kos = set(re.findall(r"K\d+", " ".join(str(row.get(key) or "") for key in ("KO", "KOs", "ko")))) + result = ( + f"比较组 `{comparison}` 的代表通路为 {_value(row, 'ID')}({_value(row, 'Description')})," + f"GeneRatio={_value(row, 'GeneRatio')},Count={_value(row, 'Count')}," + f"p.adjust={_probability_value(row, 'p.adjust', 'FDR')}," + f"{_item_summary('命中基因', genes)},{_item_summary('KO', kos)}" + f"{_source_table_links(report_path, tables, ('.kegg_enrich.txt',))}。" + ) + elif category == "kegg_pathway": + pathway_genes: set[str] = set() + pathway_kos: set[str] = set() + for _, table_rows in tables: + for candidate in table_rows: + identifier = candidate.get("PathwayID") or candidate.get("ID") or candidate.get("pathway") or "" + if identifier.lower() == pathway_id.lower(): + pathway_genes.update( + gene + for gene in re.split(r"[/,;\s]+", candidate.get("Genes") or candidate.get("geneID") or "") + if gene + ) + pathway_kos.update(re.findall(r"K\d+", candidate.get("KO") or "")) + if not pathway_genes: + pathway_genes.update( + gene for gene in re.split(r"[/,;\s]+", _value(row, "Genes", "geneID")) if gene != "未登记" + ) + ko_changes: list[float] = [] + if pathway_kos: + for table_path, table_rows in tables: + if "ko_log2fc" not in table_path.name.lower(): + continue + for candidate in table_rows: + ko = candidate.get("KO") or candidate.get("ko") or "" + if ko not in pathway_kos: + continue + try: + ko_changes.append(float(candidate.get("log2FoldChange") or candidate.get("log2FC") or "")) + except (TypeError, ValueError): + continue + direction = "" + if ko_changes: + direction = f",KO变化中上调{sum(value > 0 for value in ko_changes)}个、下调{sum(value < 0 for value in ko_changes)}个" + elif not pathway_kos: + direction = ",KO对应关系证据不足,未汇总KO表达方向" + result = ( + f"比较组 `{comparison}` 的通路 {pathway_id}({_value(row, 'PathwayName', 'Description', 'pathway_name')})," + f"p.adjust={_probability_value(row, 'p.adjust', 'padj', 'FDR')}," + f"{_item_summary('命中基因', pathway_genes)},{_item_summary('KO', pathway_kos)}" + f"{direction}" + f"{_source_table_links(report_path, tables, ('kegg_pathway_gene_hits', 'ko_log2fc'))}。" + ) + else: + leading_edge = _ordered_field_items(row, "leadingEdge") + leading_edge_preview = "/".join(leading_edge[:12]) if leading_edge else "未登记" + result = ( + f"比较组 `{comparison}` 的通路 {_value(row, 'pathway', 'ID')}({_value(row, 'pathway_name', 'Description')})," + f"NES={_value(row, 'NES')},padj={_probability_value(row, 'padj', 'p.adjust')}," + f"leadingEdge总数={len(leading_edge)},原顺序前12项={leading_edge_preview}" + f"{_source_table_links(report_path, tables, ('.fgsea_kegg.tsv',))}。" + ) + + meanings = { + "go_bar": "GO富集条形图概括显著功能条目及其富集强度。", + "go_dot": "GO富集气泡图同时呈现功能条目、富集比例、基因数和显著性。", + "go_ontology": "GO本体分面图并列展示BP、CC和MF三类功能条目。", + "kegg_bar": "KEGG富集条形图概括显著通路及其富集比例。", + "kegg_dot": "KEGG富集气泡图同时呈现通路富集比例、命中基因数和显著性。", + "kegg_pathway": "KEGG Pathway图将该比较组的KO表达变化映射到具体通路节点。", + "fgsea_dot": "FGSEA气泡图汇总基于全基因排序的KEGG通路NES及显著性。", + "fgsea_curve": "FGSEA富集曲线展示代表通路成员在全基因排序中的累积富集位置。", + } + readings = { + "go_bar": "结合条目名称、柱长、Count及校正后P值阅读。", + "go_dot": "结合横轴富集比例、点大小所示Count及颜色所示显著性阅读。", + "go_ontology": "分别比较BP、CC和MF分面中的条目、比例和显著性。", + "kegg_bar": "结合通路名称、柱长、GeneRatio、Count及校正后P值阅读。", + "kegg_dot": "结合GeneRatio、点大小所示Count及颜色所示显著性阅读。", + "kegg_pathway": "按图例读取节点颜色,并结合命中基因、KO及log2FoldChange表判断变化方向。", + "fgsea_dot": "NES正负表示富集方向,点大小和颜色需结合size与padj阅读。", + "fgsea_curve": "结合运行富集分数、成员命中位置和leadingEdge基因阅读。", + } + boundary = ( + "富集结果表示统计关联,不足以单独证明功能或通路被激活、抑制或具有因果调控关系。" + if not category.startswith("fgsea_") + else "FGSEA基于全基因排序,不能与仅使用显著差异基因的ORA结果混为一谈,也不证明因果关系。" + ) + return [ + f"图形含义:{meanings[category]}", + f"阅读方法:{readings[category]}", + f"本次结果:{result}", + f"解释边界:{boundary}", + ] + + +def _relative_link(report_path: Path, target: Path) -> str: + try: + return os.path.relpath(target, report_path.parent).replace(os.sep, "/") + except ValueError: + return str(target) + + +def _render_deterministic_markdown( + *, + prefix: str, + scope_id: str, + evidence: dict[str, Any], + manifests: dict[str, dict[str, Any]], + expected_stages: list[str], + stage_statuses: dict[str, str], + coverage_status: str, + report_path: Path, + workspace: Path, + generated_at: str, + figures: list[dict[str, Any]], + model_summary: str = "", +) -> str: + metadata = evidence.get("metadata") or {} + included = list(metadata.get("included_stages") or []) + flow_rows = {row.get("id"): row for row in (evidence.get("stage_flow") or {}).get("stages", [])} + findings = {row.get("stage"): row for row in evidence.get("key_findings") or []} + reviews = {row.get("stage"): row for row in evidence.get("stage_reviews") or []} + evidence_cards = evidence.get("evidence_cards") or [] + risks = evidence.get("risk_flags") or [] + missing = [stage for stage in expected_stages if stage_statuses.get(stage) not in SUCCESS_STATUSES] + stage_status_text = "; ".join( + f"{stage}={stage_statuses.get(stage, 'missing')}" for stage in expected_stages + ) + + lines = [ + f"# {prefix} RNA-seq 结果报告", + "", + "> 报告生成原则:结果判断只来自脚本、Manifest 和结果表;API 仅参与已有证据语句的排序和组织,不参与结果分析或推断。", + "", + "## 0. 报告摘要", + "", + f"- 生成时间:{generated_at}", + f"- RNA-seq scope:{scope_id or 'legacy/unscoped'}", + f"- 证据覆盖:{coverage_status}(成功/可用阶段 {len(included)} 个)", + f"- 已纳入阶段:{', '.join(included)}", + f"- 当前缺失或未成功阶段:{', '.join(missing) if missing else '无'}", + "", + str(evidence.get("biological_story") or "现有证据不足以形成跨阶段生物学叙事,请查看分阶段结果。"), + ] + major_claims = evidence.get("major_claims") or [] + if major_claims: + lines.extend(["", "### 主要证据结论", ""]) + for claim in major_claims[:10]: + text = claim.get("claim") or claim.get("text") or claim.get("title") or str(claim) + lines.append(f"- {text}") + lines.extend( + [ + "", + "### 模型辅助语言润色(不作为新增证据)", + "", + "> API 仅参与已有证据语句的排序和组织,不参与结果分析或推断;不能新增数字、基因、通路、显著性、变化方向、机制或因果结论。", + "", + model_summary or "本次未采用模型组织结果;正式结论以脚本生成的确定性正文和可追溯结果数据为准。", + ] + ) + lines.extend( + [ + "", + "## 1. 分析范围与证据边界", + "", + f"- 计划覆盖阶段:{', '.join(expected_stages)}", + f"- 阶段状态:{stage_status_text}", + f"- 解读边界:{metadata.get('interpretation_boundary') or '仅陈述可由当前产物支持的结果,不推断调控因果。'}", + "- API 边界:只允许排序和分组脚本批准的证据语句;任何自由文本、未知证据编号或无依据内容均被拒绝并回退。", + "- 本报告不读取前端会话缓存,也不调用“深度解读”接口;二者只共享纯证据构建逻辑。", + "", + "## 2. 数据质量与流程概览", + "", + (evidence.get("stage_flow") or {}).get("visualization", " -> ".join(included)), + ] + ) + for stage in included: + flow = flow_rows.get(stage) or {} + finding = findings.get(stage) or {} + review = reviews.get(stage) or {} + stage_cards = [item for item in evidence_cards if item.get("stage") == stage] + metric_cards = [item for item in stage_cards if str(item.get("claim") or "").startswith("指标 ")] + artifact_cards = [item for item in stage_cards if str(item.get("claim") or "").startswith("产物 ")] + displayed_cards = (metric_cards[:8] + artifact_cards[:4])[:12] + lines.extend(["", f"## 3.{included.index(stage) + 1} {STAGE_NAMES.get(stage, stage)}", ""]) + lines.append(f"- 状态:{flow.get('status') or manifests.get(stage, {}).get('status', 'unknown')}") + if flow.get("key_result"): + lines.append(f"- 关键结果:{flow['key_result']}") + description = finding.get("description") or finding.get("summary") or "" + if description: + lines.extend(["", str(description)]) + for card in displayed_cards: + lines.append( + f"- 证据 {card.get('id', '')}:{card.get('claim', '')};{card.get('evidence', '')}" + ) + limitations = review.get("limitations") or [] + if limitations: + lines.append(f"- 阶段局限:{';'.join(map(str, limitations))}") + + lines.extend(["", "## 4. 图表与产物说明", ""]) + selection_mode = (evidence.get("formal_report") or {}).get("selection_mode") + if selection_mode == "representative_fallback": + lines.extend( + [ + "> 候选家族驱动筛选不可用;当前富集图按图形类型与统计证据选取代表,不声称与候选家族相关。", + "", + ] + ) + elif selection_mode == "candidate_guided": + lines.extend([ + "> GO/KEGG/FGSEA 图按正式候选家族榜的比较组与功能支持标签筛选。", + "", + ]) + if figures: + for index, figure in enumerate(figures, 1): + path = Path(figure["path"]) + media_line = ( + f"[{figure['artifact']}]({_relative_link(report_path, path)})" + if path.suffix.lower() == ".pdf" + else f"![{figure['artifact']}]({_relative_link(report_path, path)})" + ) + lines.extend( + [ + f"### 图 {index}. {STAGE_NAMES.get(figure['stage'], figure['stage'])} — {figure['artifact']}", + "", + media_line, + "", + ] + ) + if figure.get("candidate_families"): + lines.append(f"- 候选家族关联:{', '.join(figure['candidate_families'])}") + if figure.get("support_labels"): + lines.append(f"- 正式支持标签:{'; '.join(figure['support_labels'])}") + lines.extend([*_figure_interpretation(figure, workspace, manifests, report_path), ""]) + else: + lines.append("当前成功阶段的 manifest 未登记可嵌入图像;请以分阶段表格和日志为准。") + + lines.extend(["", "## 5. 风险、局限性与解释约束", ""]) + if risks: + for risk in risks: + lines.append(f"- [{risk.get('severity', 'unknown')}] {risk.get('message', '')}") + else: + lines.append("- 自动证据审计未发现已登记风险;这不等同于不存在实验设计或批次效应风险。") + if missing: + lines.append(f"- 当前报告为部分覆盖,尚不能替代缺失阶段:{', '.join(missing)}。") + + lines.extend(["", "## 6. 下一步建议", ""]) + next_steps = evidence.get("next_steps") or [] + if next_steps: + for item in next_steps: + if isinstance(item, dict): + text = item.get("action") or item.get("recommendation") or item.get("text") or str(item) + else: + text = str(item) + lines.append(f"- {text}") + else: + lines.append("- 根据缺失阶段和风险项补齐分析,再重新生成报告以刷新证据快照。") + + lines.extend(["", "## 7. 证据追溯", ""]) + for stage in included: + manifest_path = manifests.get(stage, {}).get("_manifest_path") or "" + lines.append(f"- `{stage}`:`{manifest_path}`") + lines.extend(["", "---", "", "报告正文结束。完整结构化证据见同名 `.evidence.json`。", ""]) + return "\n".join(lines) + + +def _model_claim_catalog(evidence: dict[str, Any]) -> list[dict[str, str]]: + catalog: list[dict[str, str]] = [] + + def add(claim_id: str, text: Any, source_type: str, stage: str = "") -> None: + value = str(text or "").strip() + if value: + catalog.append( + { + "claim_id": claim_id, + "text": value, + "source_type": source_type, + "stage": stage, + } + ) + + add("story_1", evidence.get("biological_story"), "biological_story") + for index, row in enumerate(evidence.get("key_findings") or [], 1): + if not isinstance(row, dict): + continue + add( + f"finding_{index}", + row.get("description") or row.get("summary") or row.get("claim") or row.get("text"), + "key_finding", + str(row.get("stage") or ""), + ) + for index, row in enumerate(evidence.get("major_claims") or [], 1): + if isinstance(row, dict): + text = row.get("claim") or row.get("text") or row.get("title") + stage = str(row.get("stage") or "") + else: + text = row + stage = "" + add(f"claim_{index}", text, "major_claim", stage) + for index, row in enumerate(evidence.get("risk_flags") or [], 1): + if isinstance(row, dict): + text = row.get("message") or row.get("text") + stage = str(row.get("stage") or "") + else: + text = row + stage = "" + add(f"risk_{index}", text, "risk_flag", stage) + return catalog + + +def _render_claim_groups( + plan: dict[str, Any], catalog: list[dict[str, str]] +) -> tuple[str, list[str]]: + groups = plan.get("paragraph_groups") + if not isinstance(groups, list) or not groups or len(groups) > 4: + raise ValueError("paragraph_groups must contain between 1 and 4 groups") + claim_text = {row["claim_id"]: row["text"] for row in catalog} + selected: list[str] = [] + paragraphs: list[str] = [] + for group in groups: + if not isinstance(group, list) or not group: + raise ValueError("each paragraph group must be a non-empty claim-id list") + sentences: list[str] = [] + for raw_claim_id in group: + claim_id = str(raw_claim_id or "").strip() + if claim_id not in claim_text: + raise ValueError(f"unknown evidence claim id: {claim_id}") + if claim_id in selected: + raise ValueError(f"duplicate evidence claim id: {claim_id}") + selected.append(claim_id) + text = claim_text[claim_id] + sentences.append(text if text.endswith(("。", "!", "?", ".", "!", "?")) else f"{text}。") + paragraphs.append("".join(sentences)) + return "\n\n".join(paragraphs), selected + + +def _model_enhancement(evidence: dict[str, Any], enabled: bool) -> tuple[str, dict[str, Any]]: + claim_catalog = _model_claim_catalog(evidence) + metadata: dict[str, Any] = { + "schema": "rnaseq_report_model_v2", + "enabled": enabled, + "status": "disabled" if not enabled else "pending", + "provider": "", + "model": "", + "content": "", + "error": "", + "generated_at": _now(), + "policy": "evidence_claim_ordering_only", + "claim_catalog": claim_catalog, + "organization_plan": {}, + "selected_claim_ids": [], + } + if not enabled: + return "", metadata + if not claim_catalog: + metadata.update({"status": "fallback", "error": "no approved evidence claims available"}) + return "", metadata + try: + from gfa_core.mas.config import config + from gfa_core.mas.llm import LLMManager + from gfa_core.mas.llm.base import LLMMessage + + manager = LLMManager(config.load_llm_config()) + response = manager.call( + [ + LLMMessage( + role="system", + content=( + "你只能组织已批准的RNA-seq证据语句,不能撰写或改写结论。" + "只返回JSON对象:paragraph_groups为1至4个段落,每个段落只能列出输入中的claim_id。" + "不得返回自由文本、未知claim_id、数字、基因、通路、显著性、方向、机制或因果推断。" + ), + ), + LLMMessage( + role="user", + content=json.dumps( + {"task": "order_and_group_evidence_claims", "claim_catalog": claim_catalog}, + ensure_ascii=False, + )[:30000], + ), + ], + allow_fallback=True, + max_tokens=600, + track_agent_name="RNASeqReport", + ) + content = str(response.content or "").strip() + metadata.update( + { + "provider": str(response.provider or ""), + "model": str(response.model or ""), + "content": content, + } + ) + cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE) + plan = json.loads(cleaned) + if not isinstance(plan, dict): + raise ValueError("model organization plan must be a JSON object") + rendered, selected_claim_ids = _render_claim_groups(plan, claim_catalog) + metadata.update( + { + "status": "embedded_evidence_only", + "organization_plan": plan, + "selected_claim_ids": selected_claim_ids, + } + ) + return rendered, metadata + except Exception as exc: # deterministic report remains authoritative + metadata.update({"status": "fallback", "error": f"{type(exc).__name__}: {exc}"}) + return "", metadata + + +def _artifact(workspace: Path, path: Path, kind: str) -> dict[str, Any]: + return { + "artifact": kind, + "path": str(path.relative_to(workspace)), + "full_path": str(path.absolute()), + "type": "markdown" if path.suffix == ".md" else "json", + "size": path.stat().st_size, + "sha256": _sha256(path), + } + + +def generate_rnaseq_report( + workspace: Path | str, + prefix: str, + *, + rnaseq_scope_id: str = "", + expected_stages: Iterable[str] | None = None, + use_model: bool = True, + action: str = "generate", + job_id: str = "", +) -> RNASeqReportResult: + workspace = Path(workspace).absolute() + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + prefix = str(prefix or "").strip() + if not prefix: + raise ValueError("prefix is required") + if action not in {"generate", "open"}: + raise ValueError("action must be 'generate' or 'open'") + + expected = _normalize_expected_stages(expected_stages) + resolved_scope, discovered_scopes, all_sources = _source_selection(final_dir, prefix, rnaseq_scope_id) + successful = { + stage: manifest + for stage, manifest in all_sources.items() + if str(manifest.get("status") or "") in SUCCESS_STATUSES + } + if not successful: + raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:未找到当前 prefix/scope 下成功的 RNA-seq 阶段。") + + included = sorted(successful, key=_stage_sort_key) + source_records = _source_records(all_sources) + source_fingerprint, request_fingerprint = _fingerprint(source_records, expected, use_model) + report_path = final_dir / f"{prefix}.rnaseq_report.md" + evidence_path = final_dir / f"{prefix}.rnaseq_report.evidence.json" + model_path = final_dir / f"{prefix}.rnaseq_report.model.json" + manifest_path = final_dir / f"{prefix}.rnaseq_report.manifest.json" + + stage_statuses = {stage: str(all_sources.get(stage, {}).get("status") or "missing") for stage in expected} + coverage_status = "complete" if expected and all(stage_statuses[s] == "success" for s in expected) else "partial" + if action == "open" and all(path.is_file() for path in (report_path, evidence_path, model_path, manifest_path)): + previous = _read_json(manifest_path) + metrics = previous.get("metrics") or {} + if ( + previous.get("status") == "success" + and _identity(previous, "rnaseq_scope_id") == resolved_scope + and metrics.get("request_fingerprint") == request_fingerprint + and metrics.get("report_renderer_version") == REPORT_RENDERER_VERSION + ): + return RNASeqReportResult( + report_path, + evidence_path, + model_path, + manifest_path, + str(metrics.get("coverage_status") or coverage_status), + tuple(metrics.get("included_stages") or included), + resolved_scope, + reused=True, + ) + + evidence = build_rnaseq_evidence_snapshot( + final_dir, + prefix, + rnaseq_scope_id=resolved_scope, + include_stages=included, + ) + if not evidence: + raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:成功 manifest 未能构建有效证据。") + generated_at = _now() + figures, omitted_figures, selection_mode = _figures(workspace, successful) + enrichment_figures = [figure for figure in figures if figure.get("category")] + + def inventory_row(figure: dict[str, Any]) -> dict[str, Any]: + return { + "stage": figure["stage"], + "artifact": figure["artifact"], + "filename": Path(figure["path"]).name, + "category": figure.get("category", "other"), + "comparison": figure.get("comparison", ""), + "pathway_id": figure.get("pathway_id", ""), + "candidate_families": list(figure.get("candidate_families") or []), + "support_labels": list(figure.get("support_labels") or []), + "selection_reason": figure.get("selection_reason", ""), + "source_tables": list(figure.get("source_tables") or []), + } + + figure_inventory = [inventory_row(figure) for figure in figures] + omitted_figure_inventory = [inventory_row(figure) for figure in omitted_figures] + evidence["formal_report"] = { + "schema": "rnaseq_formal_report_evidence_v1", + "report_renderer_version": REPORT_RENDERER_VERSION, + "generated_at": generated_at, + "prefix": prefix, + "rnaseq_scope_id": resolved_scope, + "discovered_scope_ids": discovered_scopes, + "expected_stages": expected, + "stage_statuses": stage_statuses, + "coverage_status": coverage_status, + "source_fingerprint": source_fingerprint, + "request_fingerprint": request_fingerprint, + "source_manifests": source_records, + "selection_mode": selection_mode, + "figure_inventory": figure_inventory, + "omitted_figure_inventory": omitted_figure_inventory, + "omitted_figure_count": len(omitted_figure_inventory), + "figure_count": len(figures), + "enrichment_figure_count": len(enrichment_figures), + } + model_summary, model_metadata = _model_enhancement(evidence, use_model) + markdown = _render_deterministic_markdown( + prefix=prefix, + scope_id=resolved_scope, + evidence=evidence, + manifests=successful, + expected_stages=expected, + stage_statuses=stage_statuses, + coverage_status=coverage_status, + report_path=report_path, + workspace=workspace, + generated_at=generated_at, + figures=figures, + model_summary=model_summary, + ) + _write_text_atomic(evidence_path, json.dumps(evidence, ensure_ascii=False, indent=2) + "\n") + _write_text_atomic(model_path, json.dumps(model_metadata, ensure_ascii=False, indent=2) + "\n") + _write_text_atomic(report_path, markdown) + + manifest = { + "manifest_version": "1.0", + "stage": REPORT_STAGE, + "status": "success", + "job_id": job_id or workspace.name, + "prefix": prefix, + "rnaseq_scope_id": resolved_scope, + "generated_at": generated_at, + "parameters": { + "action": action, + "model_enhancement_requested": use_model, + "expected_stages": expected, + "rnaseq_scope_id": resolved_scope, + }, + "metrics": { + "coverage_status": coverage_status, + "included_stage_count": len(included), + "included_stages": included, + "expected_stages": expected, + "stage_statuses": stage_statuses, + "missing_or_failed_stages": [stage for stage in expected if stage_statuses.get(stage) != "success"], + "model_status": model_metadata["status"], + "source_fingerprint": source_fingerprint, + "request_fingerprint": request_fingerprint, + "report_renderer_version": REPORT_RENDERER_VERSION, + "selection_mode": selection_mode, + "figure_count": len(figures), + "enrichment_figure_count": len(enrichment_figures), + "figure_inventory": figure_inventory, + "omitted_figure_inventory": omitted_figure_inventory, + "omitted_figure_count": len(omitted_figure_inventory), + "figure_categories": { + category: sum(1 for figure in enrichment_figures if figure.get("category") == category) + for category in sorted({figure.get("category", "") for figure in enrichment_figures}) + }, + }, + "provenance": {"source_manifests": source_records}, + "artifacts": { + "rnaseq_report": _artifact(workspace, report_path, "rnaseq_report"), + "rnaseq_report_evidence": _artifact(workspace, evidence_path, "rnaseq_report_evidence"), + "rnaseq_report_model": _artifact(workspace, model_path, "rnaseq_report_model"), + }, + } + _write_text_atomic(manifest_path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + return RNASeqReportResult( + report_path, + evidence_path, + model_path, + manifest_path, + coverage_status, + tuple(included), + resolved_scope, + ) diff --git a/tests/unit/test_rnaseq_report.py b/tests/unit/test_rnaseq_report.py new file mode 100644 index 000000000..aa3f2579b --- /dev/null +++ b/tests/unit/test_rnaseq_report.py @@ -0,0 +1,2131 @@ +from __future__ import annotations + +import json +import importlib.util +import re +from pathlib import Path + +import pytest + +import gfa_core.rnaseq_report as rnaseq_report_module +from gfa_core.rnaseq_report import NoSuccessfulRNASeqEvidence, generate_rnaseq_report +from gfa_core.mas.utils import intent_parser as intent_parser_module +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +def _fast_intent_recognition(query: str) -> dict | None: + module_path = Path(__file__).resolve().parents[2] / "gfa_core" / "mas" / "core" / "fast_intent.py" + spec = importlib.util.spec_from_file_location("rnaseq_report_fast_intent", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.fast_intent_recognition(query) + + +def _write_manifest( + workspace: Path, + prefix: str, + stage: str, + status: str, + scope_id: str, + *, + suffix: str = "", +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + data_path = final_dir / f"{prefix}.{stage}{suffix}.table.tsv" + data_path.write_text("metric\tvalue\nreads\t100\n", encoding="utf-8") + manifest_path = final_dir / f"{prefix}.{stage}{suffix}.manifest.json" + manifest = { + "manifest_version": "1.0", + "stage": stage, + "status": status, + "prefix": prefix, + "rnaseq_scope_id": scope_id, + "metrics": {"sample_count": 2, "total_reads": 100}, + "parameters": {"rnaseq_scope_id": scope_id}, + "artifacts": { + "result_table": { + "artifact": "result_table", + "path": f"final/{data_path.name}", + "full_path": str(data_path), + "type": "table", + } + }, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return manifest_path + + +def _write_artifact_manifest( + workspace: Path, + prefix: str, + stage: str, + scope_id: str, + artifacts: dict[str, tuple[str, str]], +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + manifest_artifacts = {} + for key, (filename, content) in artifacts.items(): + artifact_path = final_dir / filename + artifact_path.write_text(content, encoding="utf-8") + manifest_artifacts[key] = { + "artifact": key, + "path": f"final/{filename}", + "full_path": str(artifact_path), + "type": artifact_path.suffix.lstrip("."), + } + manifest_path = final_dir / f"{prefix}.{stage}.manifest.json" + manifest_path.write_text( + json.dumps( + { + "manifest_version": "1.0", + "stage": stage, + "status": "success", + "prefix": prefix, + "rnaseq_scope_id": scope_id, + "parameters": {"rnaseq_scope_id": scope_id}, + "metrics": {"comparison_count": 2}, + "artifacts": manifest_artifacts, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return manifest_path + + +def _figure_section(markdown: str, filename: str) -> str: + figure_area = markdown.index("## 4. 图表与产物说明") + image_at = markdown.index(filename, figure_area) + section_start = markdown.rfind("### 图 ", 0, image_at) + assert section_start >= 0, f"missing figure heading for {filename}" + next_figure = markdown.find("### 图 ", image_at) + section_end = next_figure if next_figure >= 0 else markdown.find("\n## 5.", image_at) + return markdown[section_start : section_end if section_end >= 0 else None] + + +def _embedded_image_names(markdown: str) -> list[str]: + return [Path(link).name for link in re.findall(r"!\[[^]]*]\(([^)]+)\)", markdown)] + + +def _write_enrichment_manifests(workspace: Path) -> None: + prefix = "Os" + scope = "scope-enrichment" + go_a = f"{prefix}.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich" + go_b = f"{prefix}.rnaseq_go_enrich.IR29__6_vs_IR29__0.GO_enrich" + go_table_header = "ID\tDescription\tOntology\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_go_enrich", + scope, + { + "go_a_table": (f"{go_a}.txt", go_table_header + "GO:0009651\tsalt stress response\tBP\t4/20\t20/1000\t0.0001\t0.001\t4\tOs01/Os02/Os03/Os04\n"), + "go_b_table": (f"{go_b}.txt", go_table_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t18/1000\t0.01\t0.04\t3\tOs05/Os06/Os07\n"), + "go_a_bar_png": (f"{go_a}.barplot.png", "png"), + "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"), + "go_a_bar_svg": (f"{go_a}.barplot.svg", ""), + "go_b_bar_png": (f"{go_b}.barplot.png", "png"), + "go_a_dot": (f"{go_a}.dotplot.png", "png"), + "go_b_dot": (f"{go_b}.dotplot.png", "png"), + "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"), + "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"), + }, + ) + + kegg_a = f"{prefix}.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + kegg_b = f"{prefix}.rnaseq_kegg_enrich.HH11__48_vs_HH11__0" + kegg_header = "ID\tDescription\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_kegg_enrich", + scope, + { + "kegg_a_table": ( + f"{kegg_a}.KEGG_enrich.txt", + kegg_header + + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t10/1000\t0.0001\t0.001\t2\tOsWax1/OsWax2\n" + + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t12/1000\t0.0002\t0.002\t2\tOsLight1/OsLight2\n", + ), + "kegg_b_table": ( + f"{kegg_b}.KEGG_enrich.txt", + kegg_header + + "ko01200\tCarbon metabolism\t3/20\t30/1000\t0.01\t0.04\t3\tOsC1/OsC2/OsC3\n" + + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t10/1000\t0.02\t0.05\t1\tOsWaxH\n", + ), + "kegg_a_hits": ( + f"{kegg_a}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tKOName\tGeneCount\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK00073\twax enzyme\t2\tOsWax1/OsWax2\n" + "ko00196\tPhotosynthesis - antenna proteins\tK00196\tantenna protein\t2\tOsLight1/OsLight2\n", + ), + "kegg_a_fc": ( + f"{kegg_a}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\nK00073\t2.5\nK00196\t-1.5\n", + ), + "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"), + "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"), + "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"), + "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"), + "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"), + "pathway_a_00073": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"), + "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"), + "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + fgsea_a = f"{prefix}.rnaseq_fgsea.IR29__6_vs_IR29__0" + fgsea_b = f"{prefix}.rnaseq_fgsea.HH11__48_vs_HH11__0" + fgsea_header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_fgsea", + scope, + { + "fgsea_a_table": ( + f"{fgsea_a}.fgsea_kegg.tsv", + fgsea_header + + "ko00196\tPhotosynthesis - antenna proteins\t0.0001\t0.001\t0.7\t2.1\t18\tOsLight1,OsLight2\n", + ), + "fgsea_b_table": ( + f"{fgsea_b}.fgsea_kegg.tsv", + fgsea_header + + "ko01200\tCarbon metabolism\t0.01\t0.04\t-0.4\t-1.2\t30\tOsC1,OsC2\n", + ), + "fgsea_a_dot": (f"{fgsea_a}.fgsea_kegg_dotplot.png", "png"), + "fgsea_b_dot": (f"{fgsea_b}.fgsea_kegg_dotplot.png", "png"), + "fgsea_a_curve": (f"{fgsea_a}.fgsea_kegg_top_enrichment.png", "png"), + "fgsea_b_curve": (f"{fgsea_b}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + +def _write_analysis_ready_family_manifest( + workspace: Path, + *, + prefix: str, + scope_id: str, + rows: list[tuple[str, str, str, str]], +) -> Path: + """Write the real analysis-ready TSV shape used by rnaseq_family_score.""" + header = "rank\tfamily_id\tfamily_name\tbest_comparison\tbest_support_labels\n" + body = "".join( + f"{rank}\t{family_id}\t{family_name}\t{comparison}\t{support_labels}\n" + for rank, (family_id, family_name, comparison, support_labels) in enumerate(rows, 1) + ) + return _write_artifact_manifest( + workspace, + prefix, + "rnaseq_family_score", + scope_id, + { + "analysis_ready_family": ( + f"{prefix}.rnaseq_family_score.analysis_ready_family.tsv", + header + body, + ) + }, + ) + + +def _write_candidate_guided_enrichment_manifests( + workspace: Path, + *, + combined: bool = False, + include_fgsea_support: bool = True, +) -> dict[str, str]: + """Create arbitrary identifiers so selection tests cannot rely on the sample job.""" + prefix = "Zm" + scope = "scope-random-candidate-guided" + candidate_comparison = "LineX__17_vs_LineX__0" + unrelated_comparison = "LineY__29_vs_LineY__0" + supported_pathway_a = "ko54321" + supported_pathway_b = "ko67890" + unrelated_pathway = "ko13579" + go_id = "GO:9876543" + enrich_stage = "rnaseq_enrich" if combined else "" + go_stage = enrich_stage or "rnaseq_go_enrich" + kegg_stage = enrich_stage or "rnaseq_kegg_enrich" + + go_candidate = f"{prefix}.{go_stage}.{candidate_comparison}.GO_enrich" + go_unrelated = f"{prefix}.{go_stage}.{unrelated_comparison}.GO_enrich" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + go_artifacts = { + "candidate_go_table": ( + f"{go_candidate}.txt", + go_header + + f"{go_id}\trandom candidate function\tBP\t3/20\t0.02\t3\tGeneA/GeneB/GeneC\n", + ), + "unrelated_go_table": ( + f"{go_unrelated}.txt", + go_header + + "GO:1111111\tunrelated stronger function\tBP\t8/20\t0.000001\t8\tOther1/Other2\n", + ), + "candidate_go_bar": (f"{go_candidate}.barplot.png", "png"), + "candidate_go_dot": (f"{go_candidate}.dotplot.png", "png"), + "candidate_go_ontology": (f"{go_candidate}.ontology_barplot.png", "png"), + "unrelated_go_bar": (f"{go_unrelated}.barplot.png", "png"), + "unrelated_go_dot": (f"{go_unrelated}.dotplot.png", "png"), + "unrelated_go_ontology": (f"{go_unrelated}.ontology_barplot.png", "png"), + } + + kegg_candidate = f"{prefix}.{kegg_stage}.{candidate_comparison}" + kegg_unrelated = f"{prefix}.{kegg_stage}.{unrelated_comparison}" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_artifacts = { + "candidate_kegg_table": ( + f"{kegg_candidate}.KEGG_enrich.txt", + kegg_header + + f"{supported_pathway_a}\trandom pathway alpha\t2/20\t0.02\t2\tGeneA/GeneB\n" + + f"{supported_pathway_b}\trandom pathway beta\t2/20\t0.03\t2\tGeneB/GeneC\n", + ), + "unrelated_kegg_table": ( + f"{kegg_unrelated}.KEGG_enrich.txt", + kegg_header + + f"{supported_pathway_a}\trandom pathway alpha\t7/20\t0.000001\t7\tOther1/Other2\n" + + f"{unrelated_pathway}\tunrelated pathway\t9/20\t0.0000001\t9\tOther3/Other4\n", + ), + "candidate_kegg_bar": (f"{kegg_candidate}_KEGG_enrich.png", "png"), + "candidate_kegg_dot": (f"{kegg_candidate}_KEGG_dotplot.png", "png"), + "unrelated_kegg_bar": (f"{kegg_unrelated}_KEGG_enrich.png", "png"), + "unrelated_kegg_dot": (f"{kegg_unrelated}_KEGG_dotplot.png", "png"), + "candidate_pathway_a": ( + f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_a}.png", + "png", + ), + "candidate_pathway_b": ( + f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_b}.png", + "png", + ), + "other_time_supported_pathway": ( + f"{kegg_unrelated}.KEGG_pathway_map.{supported_pathway_a}.png", + "png", + ), + "unrelated_pathway": ( + f"{kegg_unrelated}.KEGG_pathway_map.{unrelated_pathway}.png", + "png", + ), + } + + if combined: + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_enrich", + scope, + {**go_artifacts, **kegg_artifacts}, + ) + else: + _write_artifact_manifest(workspace, prefix, go_stage, scope, go_artifacts) + _write_artifact_manifest(workspace, prefix, kegg_stage, scope, kegg_artifacts) + + fgsea_candidate = f"{prefix}.rnaseq_fgsea.{candidate_comparison}" + fgsea_unrelated = f"{prefix}.rnaseq_fgsea.{unrelated_comparison}" + fgsea_header = "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_fgsea", + scope, + { + "candidate_fgsea_table": ( + f"{fgsea_candidate}.fgsea_kegg.tsv", + fgsea_header + + f"{supported_pathway_b}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n", + ), + "unrelated_fgsea_table": ( + f"{fgsea_unrelated}.fgsea_kegg.tsv", + fgsea_header + + f"{unrelated_pathway}\tunrelated pathway\t0.0001\t3.0\t20\tOther1,Other2\n", + ), + "candidate_fgsea_dot": (f"{fgsea_candidate}.fgsea_kegg_dotplot.png", "png"), + "candidate_fgsea_curve": ( + f"{fgsea_candidate}.fgsea_kegg_top_enrichment.png", + "png", + ), + "unrelated_fgsea_dot": (f"{fgsea_unrelated}.fgsea_kegg_dotplot.png", "png"), + "unrelated_fgsea_curve": ( + f"{fgsea_unrelated}.fgsea_kegg_top_enrichment.png", + "png", + ), + }, + ) + + support = ( + f"GO:{go_id}:random candidate function;" + f"KEGG:{supported_pathway_a}:random pathway alpha;" + f"KEGG:{supported_pathway_b}:random pathway beta" + ) + if include_fgsea_support: + support += f";fgsea:{supported_pathway_b}:random pathway beta" + _write_analysis_ready_family_manifest( + workspace, + prefix=prefix, + scope_id=scope, + rows=[ + ("FAM_RANDOM_ALPHA", "random alpha family", candidate_comparison, support), + ( + "FAM_RANDOM_BETA", + "random beta family", + candidate_comparison, + f"KEGG:{supported_pathway_a}:random pathway alpha", + ), + ], + ) + return { + "prefix": prefix, + "scope": scope, + "candidate_comparison": candidate_comparison, + "unrelated_comparison": unrelated_comparison, + "supported_pathway_a": supported_pathway_a, + "supported_pathway_b": supported_pathway_b, + "unrelated_pathway": unrelated_pathway, + "go_stage": go_stage, + "kegg_stage": kegg_stage, + } + +def test_report_is_deterministic_scope_bound_and_reusable(tmp_path: Path) -> None: + workspace = tmp_path / "job_scope" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-alpha") + _write_manifest(workspace, "Os", "rnaseq_de", "success", "scope-beta") + _write_manifest(workspace, "Os", "rnaseq_report", "success", "scope-alpha", suffix=".old") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-alpha", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + + assert result.coverage_status == "complete" + assert result.included_stages == ("rnaseq_qc",) + assert result.rnaseq_scope_id == "scope-alpha" + markdown = result.report_path.read_text(encoding="utf-8") + assert "## 0. 报告摘要" in markdown + assert "## 7. 证据追溯" in markdown + assert "rnaseq_de" not in markdown + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + assert evidence["formal_report"]["source_fingerprint"] + assert {item["stage"] for item in evidence["formal_report"]["source_manifests"]} == {"rnaseq_qc"} + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert manifest["status"] == "success" + assert manifest["metrics"]["coverage_status"] == "complete" + assert manifest["metrics"]["model_status"] == "disabled" + + reopened = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-alpha", + expected_stages=["rnaseq_qc"], + use_model=False, + action="open", + ) + assert reopened.reused is True + + +def test_successful_report_can_have_partial_coverage(tmp_path: Path) -> None: + workspace = tmp_path / "job_partial" + _write_manifest(workspace, "Ft", "rnaseq_qc", "success", "scope-1") + _write_manifest(workspace, "Ft", "rnaseq_de", "failed", "scope-1") + + result = generate_rnaseq_report( + workspace, + "Ft", + rnaseq_scope_id="scope-1", + expected_stages=["rnaseq_qc", "rnaseq_de"], + use_model=False, + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert manifest["status"] == "success" + assert manifest["metrics"]["coverage_status"] == "partial" + assert manifest["metrics"]["stage_statuses"]["rnaseq_de"] == "failed" + assert result.included_stages == ("rnaseq_qc",) + + +def test_report_relocates_stale_absolute_artifact_paths(tmp_path: Path) -> None: + workspace = tmp_path / "relocated_job" + manifest_path = _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-moved") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["artifacts"]["result_table"]["full_path"] = "/old/server/workspace/final/result.tsv" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-moved", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "指标 sample_count" in markdown + assert "manifest 登记但文件不存在" not in markdown + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + result_card = next(card for card in evidence["evidence_cards"] if card["claim"] == "产物 result_table") + assert "exists=True" in result_card["evidence"] + + +def test_no_successful_rnaseq_stage_does_not_create_empty_report(tmp_path: Path) -> None: + workspace = tmp_path / "job_empty" + _write_manifest(workspace, "At", "rnaseq_de", "failed", "scope-empty") + + with pytest.raises(NoSuccessfulRNASeqEvidence, match="暂无可生成"): + generate_rnaseq_report( + workspace, + "At", + rnaseq_scope_id="scope-empty", + expected_stages=["rnaseq_de"], + use_model=False, + ) + + assert not (workspace / "final" / "At.rnaseq_report.md").exists() + + +def test_fallback_selects_one_of_each_enrichment_plot_and_deduplicates_pathway_ids( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_enrichment_figures" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + + assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".fgsea_kegg_dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".fgsea_kegg_top_enrichment.png") for name in embedded) == 1 + assert not any(name.endswith("GO_enrich.barplot.pdf") for name in embedded) + assert not any(name.endswith("GO_enrich.barplot.svg") for name in embedded) + assert not any(name.endswith("KEGG_enrich.pdf") for name in embedded) + + required_pathway_maps = [ + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png", + ] + for filename in required_pathway_maps: + assert embedded.count(filename) == 1 + assert not any( + name == "Os.rnaseq_kegg_enrich.HH11__48_vs_HH11__0.KEGG_pathway_map.ko00073.png" + for name in embedded + ) + + +def test_each_selected_enrichment_figure_has_four_part_table_bound_interpretation(tmp_path: Path) -> None: + workspace = tmp_path / "job_enrichment_explanations" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + expected_evidence = { + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.barplot.png": "salt stress response", + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.dotplot.png": "GO:0009651", + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.ontology_barplot.png": "BP", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_dotplot.png": "ko00073", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png": "OsWax1", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png": "OsLight1", + "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_dotplot.png": "NES", + "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_top_enrichment.png": "leadingEdge", + } + for filename, evidence_value in expected_evidence.items(): + section = _figure_section(markdown, filename) + assert "图形含义:" in section + assert "阅读方法:" in section + assert "本次结果:" in section + assert "解释边界:" in section + assert evidence_value in section + + +def test_combined_rnaseq_enrich_stage_keeps_all_plot_forms_and_deduplicates_pathway_ids( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_combined_enrichment" + go_a = "Os.rnaseq_enrich.HH11__24_vs_HH11__0.GO_enrich" + go_b = "Os.rnaseq_enrich.IR29__6_vs_IR29__0.GO_enrich" + kegg_a = "Os.rnaseq_enrich.IR29__6_vs_IR29__0" + kegg_b = "Os.rnaseq_enrich.HH11__48_vs_HH11__0" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_enrich", + "scope-combined-enrichment", + { + "go_a_table": ( + f"{go_a}.txt", + go_header + + "GO:0009651\tsalt stress response\tBP\t4/20\t0.001\t4\tOsSalt1/OsSalt2\n" + + "GO:0009507\tchloroplast\tCC\t3/20\t0.002\t3\tOsChl1/OsChl2\n" + + "GO:0016491\toxidoreductase activity\tMF\t2/20\t0.003\t2\tOsOx1/OsOx2\n", + ), + "go_b_table": ( + f"{go_b}.txt", + go_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t0.04\t3\tOsLight1/OsLight2\n", + ), + "go_a_bar_png": (f"{go_a}.barplot.png", "png"), + "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"), + "go_b_bar": (f"{go_b}.barplot.png", "png"), + "go_a_dot": (f"{go_a}.dotplot.png", "png"), + "go_b_dot": (f"{go_b}.dotplot.png", "png"), + "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"), + "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"), + "kegg_a_table": ( + f"{kegg_a}.KEGG_enrich.txt", + kegg_header + + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n" + + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t0.002\t2\tOsLight1/OsLight2\n", + ), + "kegg_b_table": ( + f"{kegg_b}.KEGG_enrich.txt", + kegg_header + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t0.04\t1\tOsWaxH\n", + ), + "kegg_a_hits": ( + f"{kegg_a}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK00073\tOsWax1/OsWax2\n" + "ko00196\tPhotosynthesis - antenna proteins\tK00196\tOsLight1/OsLight2\n", + ), + "kegg_b_hits": ( + f"{kegg_b}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK10073\tOsWaxH\n", + ), + "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"), + "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"), + "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"), + "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"), + "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"), + "pathway_a_00073_png": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"), + "pathway_a_00073_pdf": (f"{kegg_a}.KEGG_pathway_map.ko00073.pdf", "pdf"), + "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"), + "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-combined-enrichment", + expected_stages=["rnaseq_enrich"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + + assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1 + assert not any(name.endswith((".pdf", ".svg")) for name in embedded) + assert { + category: sum(item["category"] == category for item in inventory) + for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot", "kegg_pathway") + } == { + "go_bar": 1, + "go_dot": 1, + "go_ontology": 1, + "kegg_bar": 1, + "kegg_dot": 1, + "kegg_pathway": 2, + } + assert not any(item["filename"].endswith(".pdf") for item in inventory) + + pathway_names = [name for name in embedded if ".KEGG_pathway_map.ko" in name] + assert pathway_names == sorted( + [ + f"{kegg_a}.KEGG_pathway_map.ko00073.png", + f"{kegg_a}.KEGG_pathway_map.ko00196.png", + ] + ) + expected_evidence = { + f"{go_a}.barplot.png": "salt stress response", + f"{go_a}.dotplot.png": "GO:0009651", + f"{go_a}.ontology_barplot.png": "MF=oxidoreductase activity", + f"{kegg_a}_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis", + f"{kegg_a}_KEGG_dotplot.png": "ko00073", + f"{kegg_a}.KEGG_pathway_map.ko00073.png": "OsWax1", + f"{kegg_a}.KEGG_pathway_map.ko00196.png": "OsLight1", + } + for filename, evidence_value in expected_evidence.items(): + section = _figure_section(markdown, filename) + assert all( + label in section + for label in ("图形含义:", "阅读方法:", "本次结果:", "解释边界:") + ) + assert evidence_value in section + + +def test_enrichment_selection_does_not_remove_de_or_other_stage_figures(tmp_path: Path) -> None: + workspace = tmp_path / "job_non_enrichment_figures" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_de", + "scope-all-figures", + { + "de_volcano_a": ("Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png", "png"), + "de_volcano_b": ("Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png", "png"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_qc", + "scope-all-figures", + { + "qc_before": ("Os.rnaseq_qc.before.png", "png"), + "qc_after": ("Os.rnaseq_qc.after.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-all-figures", + expected_stages=["rnaseq_qc", "rnaseq_de"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + + for filename in ( + "Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png", + "Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png", + "Os.rnaseq_qc.before.png", + "Os.rnaseq_qc.after.png", + ): + assert embedded.count(filename) == 1 + + +def test_open_regenerates_v3_report_with_renderer_v4(tmp_path: Path) -> None: + workspace = tmp_path / "job_legacy_report" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-renderer") + generated = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-renderer", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + generated.report_path.write_text("STALE LEGACY REPORT\n", encoding="utf-8") + old_manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8")) + old_manifest["metrics"]["report_renderer_version"] = "3" + generated.manifest_path.write_text(json.dumps(old_manifest, ensure_ascii=False), encoding="utf-8") + + reopened = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-renderer", + expected_stages=["rnaseq_qc"], + use_model=False, + action="open", + ) + + assert reopened.reused is False + assert "STALE LEGACY REPORT" not in reopened.report_path.read_text(encoding="utf-8") + refreshed_manifest = json.loads(reopened.manifest_path.read_text(encoding="utf-8")) + assert refreshed_manifest["metrics"]["report_renderer_version"] == "4" + + +def test_pathway_without_matching_kegg_row_reports_insufficient_evidence(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_mismatch" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-mismatch", + { + "kegg_table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n", + ), + "unmatched_pathway": (f"{base}.KEGG_pathway_map.ko99999.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-mismatch", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko99999.png") + + assert "证据不足" in section + assert "Cutin, suberine and wax biosynthesis" not in section + + +def test_pathway_without_ko_mapping_does_not_summarize_all_ko_fold_changes(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_without_ko_mapping" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-no-ko-map", + { + "kegg_table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n", + ), + "pathway_hits_without_ko": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tOsWax1/OsWax2\n", + ), + "unscoped_ko_fc": ( + f"{base}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\nK11111\t2.5\nK22222\t-1.5\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-no-ko-map", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "KO对应关系证据不足" in section + assert "KO变化中上调1个、下调1个" not in section + + +def test_kegg_representative_selection_ignores_raw_enrichment_tables(tmp_path: Path) -> None: + workspace = tmp_path / "job_kegg_raw_selection" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + a = "Os.rnaseq_kegg_enrich.AA__1_vs_AA__0" + b = "Os.rnaseq_kegg_enrich.BB__1_vs_BB__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-kegg-raw-selection", + { + "a_filtered": (f"{a}.KEGG_enrich.txt", header + "ko00001\tFiltered A\t1/20\t0.05\t1\tOsA\n"), + "a_raw": (f"{a}.KEGG_enrich.raw.txt", header + "ko00001\tRaw A\t1/20\t0.000000001\t1\tOsA\n"), + "a_bar": (f"{a}_KEGG_enrich.png", "png"), + "b_filtered": (f"{b}.KEGG_enrich.txt", header + "ko00002\tFiltered B\t2/20\t0.01\t2\tOsB1/OsB2\n"), + "b_raw": (f"{b}.KEGG_enrich.raw.txt", header + "ko00002\tRaw B\t2/20\t0.9\t2\tOsB1/OsB2\n"), + "b_bar": (f"{b}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-kegg-raw-selection", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + + assert f"{b}_KEGG_enrich.png" in embedded + assert f"{a}_KEGG_enrich.png" not in embedded + + +def test_kegg_figure_interpretation_uses_filtered_table_not_raw_table(tmp_path: Path) -> None: + workspace = tmp_path / "job_kegg_raw_interpretation" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-kegg-raw-interpretation", + { + "raw_first": (f"{base}.KEGG_enrich.raw.txt", header + "ko99998\tRaw-only pathway\t9/20\t0.0001\t9\tRawGene\n"), + "filtered": (f"{base}.KEGG_enrich.txt", header + "ko00073\tFiltered pathway\t2/20\t0.01\t2\tOsWax1/OsWax2\n"), + "bar": (f"{base}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-kegg-raw-interpretation", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png") + + assert "Filtered pathway" in section + assert "Raw-only pathway" not in section + + +def test_go_ontology_interpretation_covers_bp_cc_and_mf_rows(tmp_path: Path) -> None: + workspace = tmp_path / "job_go_three_ontologies" + base = "Os.rnaseq_go_enrich.MixedCase__6_vs_MixedCase__0.GO_enrich" + header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-go-ontologies", + { + "table": ( + f"{base}.txt", + header + + "GO:0000001\tbiological process theme\tBP\t3/20\t0.001\t3\tA/B/C\n" + + "GO:0000002\tcellular component theme\tCC\t2/20\t0.002\t2\tD/E\n" + + "GO:0000003\tmolecular function theme\tMF\t1/20\t0.003\t1\tF\n", + ), + "ontology": (f"{base}.ontology_barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-go-ontologies", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png") + + assert "biological process theme" in section + assert "cellular component theme" in section + assert "molecular function theme" in section + + +def test_svg_fallbacks_are_classified_and_selected_once_per_plot_form(tmp_path: Path) -> None: + workspace = tmp_path / "job_svg_fallbacks" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-svg-fallback", + { + "go_a_table": ("Os.rnaseq_go_enrich.AA.GO_enrich.txt", go_header + "GO:1\tA\tBP\t1/2\t0.01\t1\tA\n"), + "go_b_table": ("Os.rnaseq_go_enrich.BB.GO_enrich.txt", go_header + "GO:2\tB\tBP\t1/2\t0.02\t1\tB\n"), + "go_a_svg": ("Os.rnaseq_go_enrich.AA.GO_enrich.svg", ""), + "go_b_svg": ("Os.rnaseq_go_enrich.BB.GO_enrich.svg", ""), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-svg-fallback", + { + "kegg_a_table": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.txt", kegg_header + "ko1\tA\t1/2\t0.01\t1\tA\n"), + "kegg_b_table": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.txt", kegg_header + "ko2\tB\t1/2\t0.02\t1\tB\n"), + "kegg_a_svg": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.svg", ""), + "kegg_b_svg": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.svg", ""), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-svg-fallback", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich"], + use_model=False, + ) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + categories = [item["category"] for item in evidence["formal_report"]["figure_inventory"]] + + assert categories.count("go_bar") == 1 + assert categories.count("kegg_bar") == 1 + + +def test_comparison_case_is_preserved_in_figure_interpretation(tmp_path: Path) -> None: + workspace = tmp_path / "job_comparison_case" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-case", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n", + ), + "bar": (f"{base}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-case", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png") + + assert "本次结果:比较组 `IR29__6_vs_IR29__0`" in section + + +def test_invalid_adjusted_p_values_do_not_participate_in_representative_selection(tmp_path: Path) -> None: + workspace = tmp_path / "job_invalid_padj" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + artifacts: dict[str, tuple[str, str]] = {} + for label, padj in (("NEG", "-0.1"), ("NAN", "NaN"), ("INF", "Inf"), ("VALID", "0.02")): + base = f"Os.rnaseq_kegg_enrich.{label}__1_vs_{label}__0" + artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", header + f"ko00001\t{label}\t1/20\t{padj}\t1\tGene\n") + artifacts[f"{label}_bar"] = (f"{base}_KEGG_enrich.png", "png") + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-invalid-padj", + artifacts, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-invalid-padj", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + + assert "Os.rnaseq_kegg_enrich.VALID__1_vs_VALID__0_KEGG_enrich.png" in embedded + + +def test_fgsea_curve_tied_padj_uses_largest_absolute_nes_and_matching_pathway(tmp_path: Path) -> None: + workspace = tmp_path / "job_fgsea_tie" + header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n" + aa = "Os.rnaseq_fgsea.AA__1_vs_AA__0" + zz = "Os.rnaseq_fgsea.ZZ__1_vs_ZZ__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-fgsea-tie", + { + "aa_table": (f"{aa}.fgsea_kegg.tsv", header + "ko00001\tAA pathway\t0.001\t0.01\t0.4\t2.0\t10\tA,B\n"), + "aa_curve": (f"{aa}.fgsea_kegg_top_enrichment.png", "png"), + "zz_table": ( + f"{zz}.fgsea_kegg.tsv", + header + + "ko00002\tZZ weaker tied pathway\t0.001\t0.01\t0.2\t1.0\t10\tC,D\n" + + "ko00003\tZZ strongest tied pathway\t0.001\t0.01\t-0.7\t-3.0\t12\tE,F\n", + ), + "zz_curve": (f"{zz}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-fgsea-tie", + expected_stages=["rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + + assert f"{zz}.fgsea_kegg_top_enrichment.png" in embedded + section = _figure_section(markdown, f"{zz}.fgsea_kegg_top_enrichment.png") + assert "ko00003" in section + assert "NES=-3.0" in section + + +def test_pdf_only_pathway_is_linked_or_recorded_in_figure_inventory(tmp_path: Path) -> None: + workspace = tmp_path / "job_pdf_pathway" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + pdf_name = f"{base}.KEGG_pathway_map.ko00073.pdf" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pdf-pathway", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n", + ), + "pdf_pathway": (pdf_name, "pdf"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pdf-pathway", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]} + linked = re.search(rf"\[[^]]*\]\([^)]*{re.escape(pdf_name)}[^)]*\)", markdown) is not None + + assert linked or pdf_name in inventory_names + + +def test_renderer_and_figure_inventory_are_consistent_in_evidence_and_manifest(tmp_path: Path) -> None: + workspace = tmp_path / "job_report_metadata" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + formal = evidence["formal_report"] + metrics = manifest["metrics"] + + assert formal["report_renderer_version"] == metrics["report_renderer_version"] + assert formal["figure_inventory"] + assert metrics.get("figure_inventory") == formal["figure_inventory"] + + +def test_go_ontology_interpretation_accepts_real_long_ontology_names(tmp_path: Path) -> None: + workspace = tmp_path / "job_go_real_ontology_names" + base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-go-real-ontology", + { + "table": ( + f"{base}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000001\tlong-name BP theme\tBiological process\t3/20\t0.001\t3\tA/B/C\n" + "GO:0000002\tlong-name CC theme\tCellular component\t2/20\t0.002\t2\tD/E\n" + "GO:0000003\tlong-name MF theme\tMolecular function\t1/20\t0.003\t1\tF\n", + ), + "ontology": (f"{base}.ontology_barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-go-real-ontology", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png") + + assert "BP=long-name BP theme" in section + assert "CC=long-name CC theme" in section + assert "MF=long-name MF theme" in section + + +def test_pathway_interpretation_merges_genes_from_all_matching_ko_rows(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_multi_ko_hits" + base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-multi-ko", + { + "enrichment": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tWax pathway\t3/20\t0.001\t3\tGeneA/GeneB/GeneC\n", + ), + "hits": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tWax pathway\tK00001\tGeneA/GeneB\n" + "ko00073\tWax pathway\tK00002\tGeneC\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-multi-ko", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "GeneA" in section + assert "GeneB" in section + assert "GeneC" in section + + +def test_figure_uses_exact_comparison_table_when_names_contain_each_other(tmp_path: Path) -> None: + workspace = tmp_path / "job_exact_comparison" + base_a = "Os.rnaseq_go_enrich.A.GO_enrich" + base_aa = "Os.rnaseq_go_enrich.AA.GO_enrich" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-exact-comparison", + { + "aa_table": ( + f"{base_aa}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000002\tAA-only evidence\tBP\t2/20\t0.0001\t2\tAA1/AA2\n", + ), + "a_table": ( + f"{base_a}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000001\tA exact evidence\tBP\t1/20\t0.01\t1\tA1\n", + ), + "a_bar": (f"{base_a}.barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-exact-comparison", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base_a}.barplot.png") + + assert "A exact evidence" in section + assert "AA-only evidence" not in section + + +@pytest.mark.parametrize("invalid_padj", ["NaN", "Inf", "-0.1"]) +def test_mandatory_pathway_does_not_render_invalid_padj_as_significance( + tmp_path: Path, invalid_padj: str +) -> None: + workspace = tmp_path / f"job_invalid_pathway_padj_{invalid_padj.replace('-', 'neg')}" + base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-invalid-pathway-padj", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + f"ko00073\tWax pathway\t2/20\t{invalid_padj}\t2\tA/B\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-invalid-pathway-padj", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "p.adjust=未登记" in section + assert f"p.adjust={invalid_padj}" not in section + + +def test_pdf_only_non_pathway_enrichment_forms_are_linked_and_in_inventory(tmp_path: Path) -> None: + workspace = tmp_path / "job_pdf_only_enrichment_forms" + go_base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich" + kegg_base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + fgsea_base = "Os.rnaseq_fgsea.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-pdf-forms", + { + "table": ( + f"{go_base}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:1\tGO evidence\tBP\t1/20\t0.01\t1\tA\n", + ), + "bar": (f"{go_base}.barplot.pdf", "pdf"), + "dot": (f"{go_base}.dotplot.pdf", "pdf"), + "ontology": (f"{go_base}.ontology_barplot.pdf", "pdf"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pdf-forms", + { + "table": ( + f"{kegg_base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00001\tKEGG evidence\t1/20\t0.01\t1\tA\n", + ), + "bar": (f"{kegg_base}_KEGG_enrich.pdf", "pdf"), + "dot": (f"{kegg_base}_KEGG_dotplot.pdf", "pdf"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-pdf-forms", + { + "table": ( + f"{fgsea_base}.fgsea_kegg.tsv", + "pathway\tpathway_name\tpadj\tNES\tleadingEdge\n" + "ko00001\tFGSEA evidence\t0.01\t2.0\tA,B\n", + ), + "dot": (f"{fgsea_base}.fgsea_kegg_dotplot.pdf", "pdf"), + "curve": (f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf", "pdf"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pdf-forms", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + expected = { + f"{go_base}.barplot.pdf", + f"{go_base}.dotplot.pdf", + f"{go_base}.ontology_barplot.pdf", + f"{kegg_base}_KEGG_enrich.pdf", + f"{kegg_base}_KEGG_dotplot.pdf", + f"{fgsea_base}.fgsea_kegg_dotplot.pdf", + f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf", + } + inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]} + + assert expected <= inventory_names + for filename in expected: + assert re.search(rf"\[[^]]*\]\([^)]*{re.escape(filename)}[^)]*\)", markdown) + + +def test_representative_selection_requires_legal_nonempty_evidence_before_format( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_representative_evidence_priority" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + artifacts: dict[str, tuple[str, str]] = {} + cases = ( + ("EMPTY", "", "png"), + ("INVALID", header + "ko1\tinvalid\t1/20\t-0.1\t1\tA\n", "png"), + ("VALID_SVG", header + "ko2\tvalid svg\t1/20\t0.04\t1\tB\n", "svg"), + ("VALID_PNG", header + "ko3\tvalid png\t1/20\t0.03\t1\tC\n", "png"), + ) + for label, table_content, extension in cases: + base = f"Os.rnaseq_kegg_enrich.{label}" + artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", table_content) + artifacts[f"{label}_figure"] = (f"{base}_KEGG_enrich.{extension}", "figure") + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-representative-priority", + artifacts, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-representative-priority", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]["figure_inventory"] + selected = [item["filename"] for item in inventory if item["category"] == "kegg_bar"] + + assert selected == ["Os.rnaseq_kegg_enrich.VALID_PNG_KEGG_enrich.png"] + + +def test_model_enhancement_rejects_numbers_and_activation_claims_absent_from_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gfa_core.mas import llm as llm_module + from gfa_core.mas.config import config + + class FakeManager: + def __init__(self, _config: object) -> None: + pass + + def call(self, *_args: object, **_kwargs: object) -> object: + return type( + "Response", + (), + { + "content": ( + "模型声称42个基因显著变化,因此该通路被激活并直接导致抗逆性增强;" + "同时还声称处理后产量提高了37%,这些数字与因果结论均不在输入证据中。" + ), + "provider": "fake", + "model": "fake-model", + }, + )() + + monkeypatch.setattr(config, "load_llm_config", lambda: {}) + monkeypatch.setattr(llm_module, "LLMManager", FakeManager) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "检测到KEGG富集结果,具体证据见结果表。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["status"] == "fallback" + assert metadata["error"] + + +def _install_fake_model_response(monkeypatch: pytest.MonkeyPatch, content: str) -> None: + from gfa_core.mas import llm as llm_module + from gfa_core.mas.config import config + + class FakeManager: + def __init__(self, _config: object) -> None: + pass + + def call(self, *_args: object, **_kwargs: object) -> object: + return type( + "Response", + (), + {"content": content, "provider": "fake", "model": "fake-model"}, + )() + + monkeypatch.setattr(config, "load_llm_config", lambda: {}) + monkeypatch.setattr(llm_module, "LLMManager", FakeManager) + + +def test_free_form_model_output_is_rejected_and_never_embedded_in_markdown( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "job_model_not_embedded" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-not-embedded") + model_text = ( + "现有证据仅支持对已完成阶段进行描述性总结,各项结果仍需结合实验设计和原始结果表审慎解释," + "不应扩展为未被数据支持的生物学结论。" + ) + _install_fake_model_response(monkeypatch, model_text) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-model-not-embedded", + expected_stages=["rnaseq_qc"], + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + model_record = json.loads(result.model_path.read_text(encoding="utf-8")) + assert model_text not in markdown + assert "### 模型辅助语言润色(不作为新增证据)" in markdown + assert "API 仅参与已有证据语句的排序和组织" in markdown + assert model_record["content"] == model_text + assert model_record["status"] == "fallback" + assert model_record["policy"] == "evidence_claim_ordering_only" + assert model_record["error"] + + +def test_noncausal_free_form_model_text_is_still_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model_text = ( + "这些富集结果只能说明统计关联,不能证明因果关系,也无法认定相关通路被激活," + "仍需结合独立实验进行验证。" + ) + _install_fake_model_response(monkeypatch, model_text) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "检测到KEGG富集结果,具体证据见结果表。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["content"] == model_text + assert metadata["status"] == "fallback" + assert metadata["policy"] == "evidence_claim_ordering_only" + assert metadata["error"] + + +def test_model_cannot_embed_mechanistic_claims_even_when_all_numbers_exist_in_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model_text = ( + "已有结果中的12个基因显著上调并促进耐盐,揭示了该通路控制抗逆性的调控机制," + "因此可作为后续育种的确定依据。" + ) + _install_fake_model_response(monkeypatch, model_text) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "结果表登记12个命中基因。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["content"] == model_text + assert metadata["status"] == "fallback" + assert metadata["policy"] == "evidence_claim_ordering_only" + + +def test_model_can_only_order_and_group_approved_evidence_claims( + monkeypatch: pytest.MonkeyPatch, +) -> None: + evidence = { + "metadata": {"included_stages": ["rnaseq_qc"]}, + "biological_story": "质控阶段已经完成,具体质量指标见结果表。", + "key_findings": [ + {"stage": "rnaseq_qc", "description": "成功阶段提供了可追溯的质控结果。"} + ], + "major_claims": [], + "risk_flags": [], + } + organization = { + "paragraph_groups": [["finding_1", "story_1"]], + } + _install_fake_model_response(monkeypatch, json.dumps(organization, ensure_ascii=False)) + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "成功阶段提供了可追溯的质控结果。质控阶段已经完成,具体质量指标见结果表。" + assert metadata["status"] == "embedded_evidence_only" + assert metadata["policy"] == "evidence_claim_ordering_only" + assert metadata["organization_plan"] == organization + assert metadata["selected_claim_ids"] == ["finding_1", "story_1"] + assert {row["claim_id"] for row in metadata["claim_catalog"]} == {"story_1", "finding_1"} + + +def test_unknown_model_claim_id_forces_deterministic_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + evidence = { + "metadata": {"included_stages": ["rnaseq_qc"]}, + "biological_story": "质控阶段已经完成。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + _install_fake_model_response( + monkeypatch, + json.dumps({"paragraph_groups": [["story_1", "invented_claim"]]}, ensure_ascii=False), + ) + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["status"] == "fallback" + assert "invented_claim" in metadata["error"] + + +def test_report_always_states_the_api_evidence_boundary(tmp_path: Path) -> None: + workspace = tmp_path / "job_model_boundary" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-boundary") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-model-boundary", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + + assert "### 模型辅助语言润色(不作为新增证据)" in markdown + assert "API 仅参与已有证据语句的排序和组织" in markdown + assert "不参与结果分析或推断" in markdown + assert "未采用模型组织结果" in markdown + + +def test_pathway_interpretation_previews_only_twelve_genes_and_kos_and_links_full_tables( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_pathway_long_lists" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + genes = [f"Gene{index:03d}" for index in range(1, 456)] + kos = [f"K{index:05d}" for index in range(1, 456)] + hit_rows = "".join( + f"ko00073\tWax pathway\t{ko}\t{gene}\n" for ko, gene in zip(kos, genes) + ) + ko_rows = "".join(f"{ko}\t1.0\n" for ko in kos) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-long-lists", + { + "filtered": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + f"ko00073\tWax pathway\t455/1000\t0.001\t455\t{'/'.join(genes)}\n", + ), + "hits": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + hit_rows, + ), + "ko_log2fc": ( + f"{base}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\n" + ko_rows, + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-long-lists", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "命中基因总数=455" in section + assert "KO总数=455" in section + assert all(gene in section for gene in genes[:12]) + assert all(ko in section for ko in kos[:12]) + assert genes[12] not in section + assert kos[12] not in section + assert f"[{base}.KEGG_pathway_gene_hits.txt]" in section + assert f"[{base}.KEGG_pathway_map.ko_log2fc.tsv]" in section + + +def test_fgsea_leading_edge_previews_twelve_genes_and_links_full_result_table( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_fgsea_long_leading_edge" + base = "Os.rnaseq_fgsea.IR29__6_vs_IR29__0" + leading_edge = [f"LeadGene{index:03d}" for index in range(1, 31)] + table_name = f"{base}.fgsea_kegg.tsv" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-fgsea-long-leading-edge", + { + "fgsea_table": ( + table_name, + "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + f"ko00196\tPhotosynthesis - antenna proteins\t0.001\t2.1\t30\t{','.join(leading_edge)}\n", + ), + "fgsea_curve": (f"{base}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-fgsea-long-leading-edge", + expected_stages=["rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + section = _figure_section(markdown, f"{base}.fgsea_kegg_top_enrichment.png") + + assert "leadingEdge总数=30" in section + assert "前12项=" in section + assert all(gene in section for gene in leading_edge[:12]) + assert all(gene not in section for gene in leading_edge[12:]) + assert f"[{table_name}](" in section + assert ( + "> 报告生成原则:结果判断只来自脚本、Manifest 和结果表;API 仅参与已有证据语句的排序和组织," + "不参与结果分析或推断。" in markdown + ) + + +def test_candidate_guided_pathways_keep_all_supported_contents_and_omit_unrelated( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_candidate_guided_pathways" + ids = _write_candidate_guided_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + formal = evidence["formal_report"] + pathway_rows = [ + row for row in formal["figure_inventory"] if row["category"] == "kegg_pathway" + ] + + assert formal["selection_mode"] == "candidate_guided" + assert {row["pathway_id"] for row in pathway_rows} == { + ids["supported_pathway_a"], + ids["supported_pathway_b"], + } + assert all(row["comparison"] == ids["candidate_comparison"] for row in pathway_rows) + assert not any(ids["unrelated_pathway"] in name for name in embedded) + assert not any( + ids["unrelated_comparison"] in name and ids["supported_pathway_a"] in name + for name in embedded + ) + + shared = next( + row for row in pathway_rows if row["pathway_id"] == ids["supported_pathway_a"] + ) + assert shared["candidate_families"] == ["FAM_RANDOM_ALPHA", "FAM_RANDOM_BETA"] + assert any( + label.startswith(f"KEGG:{ids['supported_pathway_a']}:") + for label in shared["support_labels"] + ) + assert shared["selection_reason"] == "candidate_family_support" + assert sum(ids["supported_pathway_a"] in name for name in embedded) == 1 + + omitted = formal["omitted_figure_inventory"] + assert any(row["pathway_id"] == ids["unrelated_pathway"] for row in omitted) + assert formal["omitted_figure_count"] == len(omitted) + assert manifest["metrics"]["selection_mode"] == formal["selection_mode"] + assert manifest["metrics"]["omitted_figure_count"] == formal["omitted_figure_count"] + + +def test_candidate_guided_representatives_use_candidate_comparison_and_supported_fgsea( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_candidate_guided_forms" + ids = _write_candidate_guided_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + selected_categories = { + category: [row for row in inventory if row["category"] == category] + for category in ( + "go_bar", + "go_dot", + "go_ontology", + "kegg_bar", + "kegg_dot", + "fgsea_dot", + "fgsea_curve", + ) + } + + assert all(len(rows) == 1 for rows in selected_categories.values()) + assert all( + rows[0]["comparison"] == ids["candidate_comparison"] + for rows in selected_categories.values() + ) + assert all(rows[0]["candidate_families"] for rows in selected_categories.values()) + assert all( + rows[0]["selection_reason"] == "candidate_family_support" + for rows in selected_categories.values() + ) + fgsea_curve = selected_categories["fgsea_curve"][0] + assert fgsea_curve["candidate_families"] == ["FAM_RANDOM_ALPHA"] + assert fgsea_curve["support_labels"] == [ + f"fgsea:{ids['supported_pathway_b']}:random pathway beta" + ] + + +def test_fgsea_curve_requires_exact_candidate_support_for_its_actual_top_pathway( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_fgsea_curve_exact_identity" + ids = _write_candidate_guided_enrichment_manifests(workspace) + fgsea_table = ( + workspace + / "final" + / f"{ids['prefix']}.rnaseq_fgsea.{ids['candidate_comparison']}.fgsea_kegg.tsv" + ) + fgsea_table.write_text( + "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + "ko99999\tnon-candidate top pathway\t0.000001\t3.5\t20\tOtherA,OtherB\n" + f"{ids['supported_pathway_b']}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n", + encoding="utf-8", + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + selected = formal["figure_inventory"] + omitted = formal["omitted_figure_inventory"] + fgsea_dots = [row for row in selected if row["category"] == "fgsea_dot"] + fgsea_curves = [row for row in selected if row["category"] == "fgsea_curve"] + + assert len(fgsea_dots) == 1 + assert fgsea_dots[0]["comparison"] == ids["candidate_comparison"] + assert fgsea_dots[0]["candidate_families"] == ["FAM_RANDOM_ALPHA"] + assert fgsea_curves == [] + assert any(row["category"] == "fgsea_curve" for row in omitted) + + +def test_fgsea_is_excluded_from_candidate_mainline_without_fgsea_support(tmp_path: Path) -> None: + workspace = tmp_path / "job_candidate_without_fgsea" + ids = _write_candidate_guided_enrichment_manifests( + workspace, + include_fgsea_support=False, + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + categories = {row["category"] for row in formal["figure_inventory"]} + omitted_categories = {row["category"] for row in formal["omitted_figure_inventory"]} + + assert "fgsea_dot" not in categories + assert "fgsea_curve" not in categories + assert {"fgsea_dot", "fgsea_curve"}.issubset(omitted_categories) + + +@pytest.mark.parametrize("analysis_ready_state", ["missing", "empty"]) +def test_missing_or_empty_analysis_ready_table_uses_non_candidate_fallback( + tmp_path: Path, + analysis_ready_state: str, +) -> None: + workspace = tmp_path / f"job_fallback_{analysis_ready_state}" + _write_enrichment_manifests(workspace) + expected_stages = ["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"] + if analysis_ready_state == "empty": + _write_analysis_ready_family_manifest( + workspace, + prefix="Os", + scope_id="scope-enrichment", + rows=[], + ) + expected_stages.append("rnaseq_family_score") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=expected_stages, + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + inventory = formal["figure_inventory"] + categories = [row["category"] for row in inventory] + pathway_rows = [row for row in inventory if row["category"] == "kegg_pathway"] + + assert formal["selection_mode"] == "representative_fallback" + assert { + category: categories.count(category) + for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot") + } == { + "go_bar": 1, + "go_dot": 1, + "go_ontology": 1, + "kegg_bar": 1, + "kegg_dot": 1, + } + assert {(row["comparison"], row["pathway_id"]) for row in pathway_rows} == { + ("IR29__6_vs_IR29__0", "ko00073"), + ("IR29__6_vs_IR29__0", "ko00196"), + } + assert all(row["candidate_families"] == [] for row in inventory) + assert all(row["support_labels"] == [] for row in inventory) + assert all( + row["selection_reason"] == "representative_fallback" + for row in inventory + if row["category"] + ) + assert "候选家族驱动筛选不可用" in result.report_path.read_text(encoding="utf-8") + + +def test_candidate_guided_selection_supports_combined_rnaseq_enrich_stage(tmp_path: Path) -> None: + workspace = tmp_path / "job_candidate_combined" + ids = _write_candidate_guided_enrichment_manifests(workspace, combined=True) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=["rnaseq_enrich", "rnaseq_fgsea", "rnaseq_family_score"], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + enrichment_rows = [row for row in formal["figure_inventory"] if row["category"]] + + assert formal["selection_mode"] == "candidate_guided" + assert {row["category"] for row in enrichment_rows} == { + "go_bar", + "go_dot", + "go_ontology", + "kegg_bar", + "kegg_dot", + "kegg_pathway", + "fgsea_dot", + "fgsea_curve", + } + assert all(row["comparison"] == ids["candidate_comparison"] for row in enrichment_rows) + assert { + row["pathway_id"] + for row in enrichment_rows + if row["category"] == "kegg_pathway" + } == {ids["supported_pathway_a"], ids["supported_pathway_b"]} + + +@pytest.mark.parametrize( + "support_labels", + ["", "KEGG:ko99998:pathway absent from all selected comparison outputs"], + ids=["empty-support-labels", "unmatched-support-labels"], +) +def test_analysis_ready_rows_without_matchable_support_use_representative_fallback( + tmp_path: Path, + support_labels: str, +) -> None: + workspace = tmp_path / "job_analysis_ready_without_matchable_support" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_WITHOUT_MATCHABLE_EVIDENCE", + "family without usable evidence", + ids["candidate_comparison"], + support_labels, + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + + assert formal["selection_mode"] == "representative_fallback" + assert all(row["candidate_families"] == [] for row in formal["figure_inventory"]) + markdown = result.report_path.read_text(encoding="utf-8") + assert "候选家族驱动筛选不可用" in markdown + assert "candidate_guided" not in markdown + + +def test_unmatched_fgsea_only_support_uses_fallback_and_does_not_mark_dot_candidate( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_unmatched_fgsea_only" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_FGSEA_PATHWAY_ABSENT", + "family with absent fgsea pathway", + ids["candidate_comparison"], + "fgsea:ko99998:pathway absent from comparison fgsea table", + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + fgsea_dots = [ + row for row in formal["figure_inventory"] if row["category"] == "fgsea_dot" + ] + + assert formal["selection_mode"] == "representative_fallback" + assert len(fgsea_dots) == 1 + assert fgsea_dots[0]["candidate_families"] == [] + assert fgsea_dots[0]["support_labels"] == [] + assert fgsea_dots[0]["selection_reason"] == "representative_fallback" + + +def test_candidate_mode_omits_fgsea_dot_when_only_fgsea_label_is_unmatched( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_go_candidate_with_unmatched_fgsea" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_GO_ONLY_USABLE", + "family with usable go only", + ids["candidate_comparison"], + "GO:GO:9876543:random candidate function;" + "fgsea:ko99998:pathway absent from comparison fgsea table", + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + selected = formal["figure_inventory"] + omitted = formal["omitted_figure_inventory"] + + assert formal["selection_mode"] == "candidate_guided" + assert any( + row["category"] == "go_bar" + and row["candidate_families"] == ["FAM_GO_ONLY_USABLE"] + for row in selected + ) + assert not any(row["category"] == "fgsea_dot" for row in selected) + assert any( + row["category"] == "fgsea_dot" + and row["comparison"] == ids["candidate_comparison"] + for row in omitted + ) + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成rnaseq结果报告", "generate"), + ("生成 RNA-seq 结果报告", "generate"), + ("打开rnaseq结果报告", "open"), + ("生成转录组结果报告", "generate"), + ], +) +def test_explicit_rnaseq_report_intent_is_provider_independent(query: str, action: str) -> None: + parsed = parse_intent_from_query(query) + fast = _fast_intent_recognition(query) + + assert parsed["stages"] == ["rnaseq_report"] + assert parsed["params"]["rnaseqReportAction"] == action + assert fast is not None + assert fast["stages"] == ["rnaseq_report"] + assert fast["params"]["rnaseqReportAction"] == action + + +def test_generic_report_without_rnaseq_context_is_not_stolen() -> None: + parsed = parse_intent_from_query("生成结果报告") + assert parsed["stages"] != ["rnaseq_report"] + + +def test_explicit_report_chat_intent_does_not_require_router_model() -> None: + assert hasattr(intent_parser_module, "deterministic_agent_chat_intent") + parser = intent_parser_module.deterministic_agent_chat_intent + + opened = parser("打开rnaseq结果报告") + generated = parser("生成 RNA-seq 结果报告") + + assert opened["stages"] == ["rnaseq_report"] + assert opened["params"]["rnaseqReportAction"] == "open" + assert generated["params"]["rnaseqReportAction"] == "generate" + assert parser("解释一下差异表达结果") is None + + +def test_generic_report_chat_intent_requires_rnaseq_job_context() -> None: + parser = intent_parser_module.deterministic_agent_chat_intent + + assert parser("生成结果报告") is None + generated = parser("生成结果报告", rnaseq_context=True) + opened = parser("打开结果报告", rnaseq_context=True) + + assert generated["stages"] == ["rnaseq_report"] + assert generated["params"]["rnaseqReportAction"] == "generate" + assert opened["params"]["rnaseqReportAction"] == "open" From cb122fd088a2b074d14ae35343ab723ce9bcc599 Mon Sep 17 00:00:00 2001 From: wtx Date: Sun, 9 Aug 2026 04:22:38 +0800 Subject: [PATCH 02/18] feat: add gene family result reports --- apps/cli/run_gene_family_report.py | 70 ++ apps/web/pipeline_backend.py | 62 +- apps/web/vue3/index.html | 76 +- apps/web/vue3/main.py | 310 ++++++- gfa_core/gene_family_report.py | 333 +++++++ gfa_core/gene_family_report_data.py | 773 ++++++++++++++++ .../mas/agents/interpretation_evidence.py | 24 +- gfa_core/mas/core/fast_intent.py | 8 +- gfa_core/mas/stage_capabilities.py | 17 + gfa_core/mas/stage_catalog.py | 4 + gfa_core/mas/tools/stage_tools.py | 22 + gfa_core/mas/utils/intent_parser.py | 33 + .../stages/gene_family_report_contract_v1.md | 82 ++ gfa_core/templates/gene_family_report_v1.md | 170 ++++ tasks.py | 2 +- .../e2e/verify_gene_family_report_journey.py | 226 +++++ tests/unit/test_gene_family_report.py | 844 ++++++++++++++++++ tests/unit/test_gene_family_report_intent.py | 112 +++ .../unit/test_gene_family_report_triggers.py | 181 ++++ tests/unit/test_gene_family_report_wiring.py | 347 +++++++ 20 files changed, 3676 insertions(+), 20 deletions(-) create mode 100644 apps/cli/run_gene_family_report.py create mode 100644 gfa_core/gene_family_report.py create mode 100644 gfa_core/gene_family_report_data.py create mode 100644 gfa_core/schemas/stages/gene_family_report_contract_v1.md create mode 100644 gfa_core/templates/gene_family_report_v1.md create mode 100644 tests/e2e/verify_gene_family_report_journey.py create mode 100644 tests/unit/test_gene_family_report.py create mode 100644 tests/unit/test_gene_family_report_intent.py create mode 100644 tests/unit/test_gene_family_report_triggers.py create mode 100644 tests/unit/test_gene_family_report_wiring.py diff --git a/apps/cli/run_gene_family_report.py b/apps/cli/run_gene_family_report.py new file mode 100644 index 000000000..a2edf8ab2 --- /dev/null +++ b/apps/cli/run_gene_family_report.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""CLI for the plan-scoped formal gene-family result report.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Sequence + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from gfa_core.gene_family_report import GeneFamilyReportResult, generate_gene_family_report + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate or open a gene-family result report") + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--prefix", required=True) + parser.add_argument("--job-id", default="") + parser.add_argument("--expected-stages", default="") + parser.add_argument("--stage-statuses-json", default="{}") + parser.add_argument("--scope-id", default="") + parser.add_argument("--analysis-route", default="") + parser.add_argument("--member-set-id", default="") + parser.add_argument("--rnaseq-scope-id", default="") + parser.add_argument("--action", choices=("generate", "open"), default="generate") + parser.add_argument("--no-model", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> GeneFamilyReportResult: + args = _parser().parse_args(argv) + statuses = json.loads(args.stage_statuses_json) + if not isinstance(statuses, dict): + raise ValueError("--stage-statuses-json must contain a JSON object") + result = generate_gene_family_report( + args.workspace, + args.prefix, + expected_stages=[value.strip() for value in args.expected_stages.split(",") if value.strip()], + stage_statuses={str(key): str(value) for key, value in statuses.items()}, + scope_id=args.scope_id, + analysis_route=args.analysis_route, + member_set_id=args.member_set_id, + rnaseq_scope_id=args.rnaseq_scope_id, + use_model=not args.no_model, + action=args.action, + job_id=args.job_id, + ) + print( + json.dumps( + { + "status": "success", + "report_path": str(result.report_path), + "manifest_path": str(result.manifest_path), + "report_status": result.report_status, + "generation_mode": result.generation_mode, + "reused": result.reused, + }, + ensure_ascii=False, + ) + ) + return result + + +if __name__ == "__main__": + main() diff --git a/apps/web/pipeline_backend.py b/apps/web/pipeline_backend.py index 7834670b4..105056efd 100644 --- a/apps/web/pipeline_backend.py +++ b/apps/web/pipeline_backend.py @@ -6054,8 +6054,9 @@ def run_stage( target_species = kwargs.get("target_species") interspecies_ref_species = kwargs.get("interspecies_ref_species") - scope_file = str(kwargs.get("scope_file") or kwargs.get("scopeFile") or "").strip() - analysis_route = str(kwargs.get("analysis_route") or kwargs.get("analysisRoute") or "").strip() + scope_file = str(kwargs.get("scope_file") or kwargs.get("scopeFile") or "").strip() + scope_id = str(kwargs.get("scope_id") or kwargs.get("scopeId") or "").strip() + analysis_route = str(kwargs.get("analysis_route") or kwargs.get("analysisRoute") or "").strip() analysis_mode = str(kwargs.get("analysis_mode") or kwargs.get("analysisMode") or "").strip() member_set_id = str(kwargs.get("member_set_id") or kwargs.get("memberSetId") or "").strip() combo_output_label = str(combo_output_label or kwargs.get("comboOutputLabel") or kwargs.get("combo_output_label") or "").strip() @@ -8374,7 +8375,62 @@ def run_stage( else: return f"❌ PEP failed (exit code: {code}). Log: {log_file}", manifest - if stage == "summary": + if stage == "gene_family_report": + expected_stages = kwargs.get("expected_stages") or kwargs.get("expectedStages") or [] + if isinstance(expected_stages, str): + expected_stages = [value.strip() for value in expected_stages.split(",") if value.strip()] + else: + expected_stages = [str(value).strip() for value in expected_stages if str(value).strip()] + report_action = str(kwargs.get("report_action") or kwargs.get("reportAction") or "generate").strip() + if report_action == "generate" and not expected_stages: + return "❌ 基因家族结果报告生成需要非空 expected_stages", manifest + stage_statuses = kwargs.get("stage_statuses") or kwargs.get("stageStatuses") or {} + if isinstance(stage_statuses, str): + try: + stage_statuses = json.loads(stage_statuses) + except json.JSONDecodeError: + return "❌ 基因家族结果报告的 stage_statuses 不是合法 JSON", manifest + if not isinstance(stage_statuses, dict): + return "❌ 基因家族结果报告的 stage_statuses 必须是对象", manifest + report_use_model_raw = kwargs.get("report_use_model", kwargs.get("reportUseModel", True)) + rnaseq_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip() + cmd = [ + sys.executable, + "apps/cli/run_gene_family_report.py", + "--workspace", + str(ws), + "--prefix", + prefix, + "--job-id", + job.job_id, + "--expected-stages", + ",".join(expected_stages), + "--stage-statuses-json", + json.dumps(stage_statuses, ensure_ascii=False, separators=(",", ":")), + "--action", + report_action, + ] + for flag, value in ( + ("--scope-id", scope_id), + ("--analysis-route", analysis_route), + ("--member-set-id", member_set_id), + ("--rnaseq-scope-id", rnaseq_scope_id), + ): + if value: + cmd += [flag, value] + if not _truthy(report_use_model_raw): + cmd.append("--no-model") + code = run_cmd_logged(cmd, log_file, cwd=Path(".")) + report_manifests = sorted( + final_dir.glob(f"{prefix}*.gene_family_report.manifest.json"), + key=lambda path: path.stat().st_mtime, + ) + report_manifest = report_manifests[-1] if report_manifests else manifest + if code != 0: + return f"❌ 基因家族结果报告生成失败,日志:{log_file}", report_manifest + return f"✅ 基因家族结果报告已生成。日志:{log_file}", report_manifest + + if stage == "summary": summary_manifest_seed = ( ws / "final" / f"{prefix}.identify.manifest.json" if (ws / "final" / f"{prefix}.identify.manifest.json").exists() diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index d87445fe9..8f97daaae 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -7950,6 +7950,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 pop_snp_density: 'SNP密度分析', rnaseq_de: 'RNA-seq DE', combo: '组合图', + gene_family_report: '基因家族结果报告', reference_classification: '参考亚家族分类', subfamily_confirm: '亚家族标签确认', ppi_kaks_reference_data: 'PPI/KaKs 参考数据', @@ -10111,6 +10112,7 @@ compare: [[flesh, peel]]`; { name: 'pep', label: '1️⃣2️⃣ PEP', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'expression', label: '1️⃣3️⃣ Expression (FPKM)', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'combo', label: '6️⃣ Combo', status: 'pending', images: [], files: [], elapsed_time: 0 }, + { name: 'gene_family_report', label: '📄 基因家族结果报告', status: 'pending', images: [], files: [], elapsed_time: 0 }, // RNA-seq stages { name: 'rnaseq_qc', label: '1️⃣5️⃣ RNA-seq QC', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'rnaseq_align', label: '1️⃣6️⃣ RNA-seq Align', status: 'pending', images: [], files: [], elapsed_time: 0 }, @@ -15080,6 +15082,54 @@ compare: [[flesh, peel]]`; return `⏸️ 批量执行已暂停:成功 ${completedCount}/${totalCount},暂停 ${pausedCount}/${totalCount}。真实原因:${reasonText}。`; }; + const openGeneratedGeneFamilyReport = async () => { + await refreshResults(jobInfo.job_id, 'gene_family_report'); + const reportStage = stages.find(stage => stage.name === 'gene_family_report'); + const reportFile = (reportStage?.files || []).find(file => + file.role === 'gene_family_report' || String(file.name || '').endsWith('.gene_family_report.md') + ); + if (!reportFile) return false; + rightPanelTab.value = 'results'; + selectFile('gene_family_report', reportFile); + return true; + }; + + const submitGeneFamilyReportForBatch = async (stages, pausedCount) => { + if (pausedCount > 0) return false; + const plannedIds = (stages || []).map(stage => getStageId(stage)).filter( + stageId => stageId && !['summary', 'gene_family_report', 'subfamily_confirm'].includes(stageId) + ); + const geneFamilyStages = new Set([ + 'identify', 'phylogeny', 'motif', 'domain', 'structure', 'promoter', + 'synteny', 'interspecies_synteny', 'karyotype', 'combo', 'ppi', 'expression', 'pep' + ]); + if (!plannedIds.some(stageId => geneFamilyStages.has(stageId))) return false; + const stageStatuses = Object.fromEntries(plannedIds.map(stageId => { + const status = stageExecutionStatus.value[stageId]; + return [stageId, status === 'completed' ? 'success' : status === 'failed' ? 'failed' : 'missing']; + })); + const batchParams = batchExecuteParams.value || {}; + const planParams = batchParams.plan?.params || {}; + const stageParams = (stages || []).find(stage => stage && typeof stage === 'object' && stage.params)?.params || {}; + try { + await executeSingleStageWithParams('gene_family_report', { + expectedStages: plannedIds, + stageStatuses, + reportAction: 'generate', + reportUseModel: true, + scopeId: batchParams.scopeId || planParams.scopeId || stageParams.scopeId || '', + analysisRoute: batchParams.analysisRoute || planParams.analysisRoute || stageParams.analysisRoute || '', + memberSetId: batchParams.memberSetId || planParams.memberSetId || stageParams.memberSetId || '', + rnaseqScopeId: batchParams.rnaseqScopeId || planParams.rnaseqScopeId || stageParams.rnaseqScopeId || '' + }); + addAgentMessage('assistant', '📄 基因家族结果报告已生成。'); + return true; + } catch (err) { + addAgentMessage('assistant', `⚠️ 基因家族结果报告生成失败:${err.message || err}`); + return false; + } + }; + const confirmAndStartBatchExecute = async (event) => { if (!event?.isTrusted) return; batchExecutionUserConfirmed.value = true; @@ -15195,6 +15245,7 @@ compare: [[flesh, peel]]`; }).length; const totalCount = stages.length; + let continuationOpened = false; if (failedCount > 0) { addAgentMessage('assistant', `⚠️ 批量执行完成:成功 ${completedCount}/${totalCount},失败 ${failedCount}/${totalCount}`); } else if (pausedCount > 0) { @@ -15202,13 +15253,16 @@ compare: [[flesh, peel]]`; } else { addAgentMessage('assistant', `🎉 批量执行完成!成功: ${completedCount}/${totalCount}`); if (isMethodologyDataPreparationContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openMethodologyDataPreparationContinuation(); + continuationOpened = await openMethodologyDataPreparationContinuation(); } else if (isMethodologyPostConfirmContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openMethodologyPostConfirmContinuation(); + continuationOpened = await openMethodologyPostConfirmContinuation(); } else if (isContextualPostConfirmContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openContextualPostConfirmContinuation(); + continuationOpened = await openContextualPostConfirmContinuation(); } } + if (pausedCount === 0 && !continuationOpened) { + await submitGeneFamilyReportForBatch(stages, pausedCount); + } } }; @@ -17496,6 +17550,16 @@ compare: [[flesh, peel]]`; requestBody.geoId = confirmParams.geoId || ''; requestBody.idTransform = confirmParams.idTransform || 'remove_T01'; } + if (stageName === 'gene_family_report') { + requestBody.expectedStages = activeStageParams.expectedStages || []; + requestBody.stageStatuses = activeStageParams.stageStatuses || {}; + requestBody.reportAction = activeStageParams.reportAction || 'generate'; + requestBody.reportUseModel = activeStageParams.reportUseModel !== false; + requestBody.scopeId = activeStageParams.scopeId || ''; + requestBody.analysisRoute = activeStageParams.analysisRoute || ''; + requestBody.memberSetId = activeStageParams.memberSetId || ''; + requestBody.rnaseqScopeId = activeStageParams.rnaseqScopeId || ''; + } if (stageName === 'promoter_plot_revise') { const elements = Array.isArray(confirmParams.promoterElements) ? confirmParams.promoterElements : []; const categories = Array.isArray(confirmParams.promoterCategories) ? confirmParams.promoterCategories : []; @@ -17586,6 +17650,9 @@ compare: [[flesh, peel]]`; } else if (data.async === false) { // 同步执行完成,直接返回结果 console.log(`[DEBUG-EXEC] ${stageName}: sync completed`); + if (stageName === 'gene_family_report') { + await openGeneratedGeneFamilyReport(); + } return data; } else { throw createStructuredExecuteError(data); @@ -18851,6 +18918,9 @@ compare: [[flesh, peel]]`; result: data }); await handleStageCompleted(stageName, data, {}); + if (stageName === 'gene_family_report') { + await openGeneratedGeneFamilyReport(); + } // 更新阶段结果 - 如果有分stage的文件,则更新所有stage的文件 if (stageIdx >= 0 && data.result) { diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 79490f6d9..0da4410c9 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -6876,9 +6876,14 @@ class ExecuteRequest(BaseModel): domCoverage: float = 30.0 idTransform: str = "none" scopeFile: str = "" + scopeId: str = "" scopeMode: str = "" analysisRoute: str = "" memberSetId: str = "" + expectedStages: List[str] = [] + stageStatuses: Dict[str, str] = {} + reportAction: str = "generate" + reportUseModel: bool = True contextRoute: str = "" subfamilyLabelSource: str = "" requiresConfirmedSubfamily: bool = False @@ -7816,8 +7821,13 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict): # Genome Prepare参数 genome_prepare_type=params.get("genomePrepareType", "auto"), scope_file=params.get("scopeFile", ""), + scope_id=params.get("scopeId", ""), analysis_route=params.get("analysisRoute", ""), member_set_id=params.get("memberSetId", ""), + expected_stages=params.get("expectedStages", []), + stage_statuses=params.get("stageStatuses", {}), + report_action=params.get("reportAction", "generate"), + report_use_model=params.get("reportUseModel", True), analysis_mode=params.get("analysisMode", ""), species_queue=params.get("speciesQueue", []), subfamily_label_source=params.get("subfamilyLabelSource", ""), @@ -11605,7 +11615,7 @@ async def execute_stage(req: ExecuteRequest): }) if req.stage == "gwas_fmt": stage_context["genotype_scope_id"] = trusted_genotype_scope - if req.stage in {"genome_prepare", "genome_prepare_v2"} or req.stage in RNA_SEQ_STAGES: + if req.stage in {"genome_prepare", "genome_prepare_v2", "gene_family_report"} or req.stage in RNA_SEQ_STAGES: stage_context.pop("scope_id", None) stage_context.pop("scope_mode", None) stage_context.pop("analysis_route", None) @@ -11738,6 +11748,7 @@ async def execute_stage(req: ExecuteRequest): skip_completed_check = ( is_utility_stage(req.stage) or is_repair_stage(req.stage) + or req.stage == "gene_family_report" or force_rerun_requested or (req.stage == "gwas_fmt" and not trusted_genotype_scope) or (req.stage == "gwas_mlm" and not gwas_mlm_full_identity) @@ -11966,9 +11977,15 @@ async def execute_stage(req: ExecuteRequest): "domCoverage": req.domCoverage, "idTransform": req.idTransform, "scopeFile": scope_params.get("scopeFile", ""), + "scopeId": getattr(req, "scopeId", ""), + "rnaseqScopeId": getattr(req, "rnaseqScopeId", ""), "scopeMode": scope_params.get("scopeMode", ""), "analysisRoute": stage_context.get("analysis_route") or getattr(req, "analysisRoute", ""), "memberSetId": stage_context.get("member_set_id") or getattr(req, "memberSetId", ""), + "expectedStages": list(getattr(req, "expectedStages", []) or []), + "stageStatuses": dict(getattr(req, "stageStatuses", {}) or {}), + "reportAction": getattr(req, "reportAction", "generate"), + "reportUseModel": bool(getattr(req, "reportUseModel", True)), "contextRoute": getattr(req, "contextRoute", ""), "subfamilyLabelSource": getattr(req, "subfamilyLabelSource", ""), "requiresConfirmedSubfamily": bool(getattr(req, "requiresConfirmedSubfamily", False)), @@ -12728,6 +12745,69 @@ def _confirmed_execute_request( return ExecuteRequest(**params) +def _confirmed_plan_identity(ordered: List[ConfirmedPlanStage]) -> Dict[str, str]: + identity: Dict[str, str] = {} + for stage in reversed(ordered): + params = stage.params or {} + for target, keys in { + "scopeId": ("scopeId", "scope_id"), + "analysisRoute": ("analysisRoute", "analysis_route"), + "memberSetId": ("memberSetId", "member_set_id"), + "rnaseqScopeId": ("rnaseqScopeId", "rnaseq_scope_id"), + }.items(): + if identity.get(target): + continue + identity[target] = next((str(params.get(key) or "").strip() for key in keys if params.get(key)), "") + return identity + + +def _confirmed_plan_report_request( + request: ConfirmedPlanExecutionRequest, + ordered: List[ConfirmedPlanStage], + status: Dict[str, Any], +) -> Optional[ExecuteRequest]: + """Build the one formal-report submission for a terminal confirmed plan.""" + terminal = str(status.get("status") or "") + if terminal not in {"success", "failed"}: + return None + planned = [stage.id for stage in ordered if stage.id not in {"summary", "gene_family_report", "subfamily_confirm"}] + if not planned: + return None + completed = set(status.get("completed_stages") or []) + failed_stage = str((status.get("failure") or {}).get("stage") or "") + stage_statuses = { + stage: "success" if stage in completed else "failed" if stage == failed_stage else "not_run" + for stage in planned + } + if terminal == "success": + stage_statuses = {stage: "success" for stage in planned} + return ExecuteRequest( + stage="gene_family_report", + session_id=request.session_id, + job_id=request.job_id, + prefix=request.prefix, + expectedStages=planned, + stageStatuses=stage_statuses, + reportAction="generate", + reportUseModel=True, + **_confirmed_plan_identity(ordered), + ) + + +async def _submit_confirmed_plan_report( + request: ConfirmedPlanExecutionRequest, + ordered: List[ConfirmedPlanStage], + status: Dict[str, Any], +) -> None: + report_request = _confirmed_plan_report_request(request, ordered, status) + if report_request is None: + return + try: + status["report_submission"] = await execute_stage(report_request) + except Exception as exc: + status["report_submission"] = {"success": False, "error_type": type(exc).__name__} + + async def _wait_for_submitted_stage( task_id: str, stage_id: str, @@ -12777,6 +12857,8 @@ async def _run_confirmed_plan_execution( "run_id": run_id, "plan_id": request.plan_id, "job_id": request.job_id, + "prefix": request.prefix, + **_confirmed_plan_identity(ordered), "status": "running", "stages": [stage.id for stage in ordered], "completed_stages": [], @@ -12820,9 +12902,11 @@ async def _run_confirmed_plan_execution( }, } ) + await _submit_confirmed_plan_report(request, ordered, status) _write_confirmed_plan_status(status_path, status) return status.update({"status": "success", "current_stage": ""}) + await _submit_confirmed_plan_report(request, ordered, status) _write_confirmed_plan_status(status_path, status) @@ -12850,6 +12934,8 @@ async def agent_execute_confirmed(request: ConfirmedPlanExecutionRequest): "run_id": run_id, "plan_id": request.plan_id, "job_id": request.job_id, + "prefix": request.prefix, + **_confirmed_plan_identity(_confirmed_plan_stages(request)), "status": "queued", "stages": [stage.id for stage in _confirmed_plan_stages(request)], } @@ -16759,6 +16845,93 @@ def _answer_methodology_sandbox_qa(message: str) -> dict[str, Any]: return payload +def _gene_family_report_plan_context(job_id: str, prefix: str = "") -> Optional[Dict[str, Any]]: + """Recover only a user-confirmed or previously reported stage plan.""" + safe_job_id = str(job_id or "").strip() + requested_prefix = str(prefix or "").strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", safe_job_id): + return None + final_dir = WORKSPACE_ROOT / safe_job_id / "final" + excluded = {"summary", "gene_family_report", "subfamily_confirm"} + + confirmed: list[tuple[float, Dict[str, Any]]] = [] + confirmed_dir = final_dir / ".confirmed_plans" + for path in confirmed_dir.glob("*.json") if confirmed_dir.is_dir() else (): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + terminal = str(payload.get("status") or "") + plan_prefix = str(payload.get("prefix") or "").strip() + if ( + terminal not in {"success", "failed"} + or str(payload.get("job_id") or "") != safe_job_id + or not plan_prefix + or (requested_prefix and plan_prefix != requested_prefix) + ): + continue + planned = [ + str(stage).strip() + for stage in (payload.get("stages") or []) + if str(stage).strip() and str(stage).strip() not in excluded + ] + if not planned: + continue + completed = {str(stage) for stage in (payload.get("completed_stages") or [])} + failed_stage = str((payload.get("failure") or {}).get("stage") or "") + statuses = { + stage: "success" if terminal == "success" or stage in completed else "failed" if stage == failed_stage else "not_run" + for stage in planned + } + context = { + "prefix": plan_prefix, + "expectedStages": planned, + "stageStatuses": statuses, + "scopeId": str(payload.get("scopeId") or payload.get("scope_id") or ""), + "analysisRoute": str(payload.get("analysisRoute") or payload.get("analysis_route") or ""), + "memberSetId": str(payload.get("memberSetId") or payload.get("member_set_id") or ""), + "rnaseqScopeId": str(payload.get("rnaseqScopeId") or payload.get("rnaseq_scope_id") or ""), + } + confirmed.append((path.stat().st_mtime, context)) + if confirmed: + return max(confirmed, key=lambda item: item[0])[1] + + pattern = f"{requested_prefix}.gene_family_report.manifest.json" if requested_prefix else "*.gene_family_report.manifest.json" + reports: list[tuple[float, Dict[str, Any]]] = [] + for path in final_dir.glob(pattern) if final_dir.is_dir() else (): + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + report_prefix = str(manifest.get("prefix") or "").strip() + parameters = manifest.get("parameters") or {} + planned = [ + str(stage).strip() + for stage in (parameters.get("expected_stages") or []) + if str(stage).strip() and str(stage).strip() not in excluded + ] + if ( + manifest.get("stage") != "gene_family_report" + or manifest.get("status") != "success" + or not report_prefix + or (requested_prefix and report_prefix != requested_prefix) + or not planned + ): + continue + recorded_statuses = parameters.get("stage_statuses") or {} + context = { + "prefix": report_prefix, + "expectedStages": planned, + "stageStatuses": {stage: str(recorded_statuses.get(stage) or "missing") for stage in planned}, + "scopeId": "", + "analysisRoute": "", + "memberSetId": "", + "rnaseqScopeId": "", + } + reports.append((path.stat().st_mtime, context)) + return max(reports, key=lambda item: item[0])[1] if reports else None + + async def _run_agent_chat( request: AgentChatRequest, progress_callback=None, @@ -16857,6 +17030,7 @@ async def _run_agent_chat( from gfa_core.mas.utils.intent_parser import ( deterministic_agent_chat_intent, parse_sequence_extract_request, + resolve_gene_family_report_intent, ) utility_params = parse_sequence_extract_request(request.message) @@ -16903,11 +17077,10 @@ async def _run_agent_chat( "session_id": session_id, } - deterministic_intent = deterministic_agent_chat_intent( + compact_message = re.sub(r"\s+", "", str(request.message or "").lower()) + deterministic_intent = None if "基因家族" in compact_message else deterministic_agent_chat_intent( request.message, - rnaseq_context=any( - str(stage).startswith("rnaseq_") for stage in completed_stages - ), + rnaseq_context=any(str(stage).startswith("rnaseq_") for stage in completed_stages), ) if deterministic_intent is not None: report_action = str( @@ -16955,6 +17128,76 @@ async def _run_agent_chat( "handoff_request": request.message, } + gene_report_intent = resolve_gene_family_report_intent(request.message) + if gene_report_intent is not None: + plan_context = _gene_family_report_plan_context( + request.job_id, + str(router_context_terms.get("prefix") or ""), + ) + report_action = str((gene_report_intent.get("params") or {}).get("reportAction") or "generate") + if plan_context is None: + report_message = "已识别为基因家族结果报告请求,但当前 job 缺少可验证的分析计划,无法生成或打开正式报告。" + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="reply", + intent=None, + ) + return { + "success": False, + "status": "report_plan_missing", + "action": None, + "intent": None, + "message": report_message, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + } + report_message = ( + "已识别为打开基因家族结果报告。" + if report_action == "open" + else "已识别为生成基因家族结果报告。" + ) + report_params = {**(gene_report_intent.get("params") or {}), **plan_context} + resolved_intent = { + **gene_report_intent, + "stage": "gene_family_report", + "primary_stage": "gene_family_report", + "taskType": "analysis", + "task_kind": "report", + "prefix": plan_context["prefix"], + "params": report_params, + "job_id": request.job_id, + "workspace": str(WORKSPACE_ROOT / request.job_id) if request.job_id else "", + "message": report_message, + } + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="propose_analysis", + intent=resolved_intent, + ) + return { + "success": True, + "status": "ok", + "action": "propose_analysis", + "intent": resolved_intent, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + "handoff_request": request.message, + } + global llm_manager active_llm_manager = llm_manager if active_llm_manager is None: @@ -20060,6 +20303,63 @@ async def agent_plan(request: AgentPlanRequest): extension_intent = _match_extension_stage_intent(request.message, request.job_id or "") if extension_intent: return _extension_stage_plan_response(extension_intent, session_id, request.job_id or "") + report_intent = dict(request.intent or {}) + if report_intent.get("stages") == ["gene_family_report"]: + report_params = dict(report_intent.get("params") or {}) + report_prefix = str( + report_intent.get("prefix") + or report_params.get("prefix") + or request.prefix + or "" + ).strip() + if not report_params.get("expectedStages"): + recovered = _gene_family_report_plan_context(request.job_id, report_prefix) + if recovered: + report_params.update(recovered) + report_prefix = recovered["prefix"] + if not report_params.get("expectedStages"): + return { + "success": False, + "mode": "gene_family_report_deterministic", + "status": "report_plan_missing", + "message": "当前 job 缺少可验证的基因家族分析计划,无法生成或打开正式报告。", + "session_id": session_id, + } + report_params["prefix"] = report_prefix + report_intent.update( + { + "stage": "gene_family_report", + "primary_stage": "gene_family_report", + "stages": ["gene_family_report"], + "prefix": report_prefix, + "params": report_params, + } + ) + detail = _stage_detail_defaults("gene_family_report") + detail.update({"order": 1, "dependencies": [], "params": dict(report_params)}) + return { + "success": True, + "mode": "gene_family_report_deterministic", + "plan": { + "analysis_type": "gene_family", + "prefix": report_prefix, + "description": "生成或打开基因家族结果报告", + "estimated_time": "2分钟", + "estimated_total_time": "2分钟", + "execution_strategy": "sequential", + "max_parallel": 1, + "total_stages": 1, + "stages": ["gene_family_report"], + "stages_detail": [detail], + "all_stages": ["gene_family_report"], + "all_stages_detail": [detail], + "parallel_groups": [], + "params": report_params, + }, + "intent": report_intent, + "completed_stages": [], + "session_id": session_id, + } plan_role_resolution = ( _reconcile_confirmed_gene_family_species( request.job_id, diff --git a/gfa_core/gene_family_report.py b/gfa_core/gene_family_report.py new file mode 100644 index 000000000..5fd145909 --- /dev/null +++ b/gfa_core/gene_family_report.py @@ -0,0 +1,333 @@ +"""Generate the formal report for one gene-family analysis plan.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +from pathlib import Path +import re +from typing import Any, Iterable, Mapping + +from gfa_core.gene_family_report_data import ( + ReportData, + ReportSection, + build_report_data, +) +from gfa_core.mas.agents.interpretation_evidence import load_stage_manifests + + +REPORT_STAGE = "gene_family_report" +REPORT_TEMPLATE_VERSION = "gene_family_report_template_v1" +EXCLUDED_PLAN_STAGES = {"summary", REPORT_STAGE, "subfamily_confirm"} +TEMPLATE_PATH = Path(__file__).with_name("templates") / "gene_family_report_v1.md" +CONNECTORS = { + "also": "此外,", + "however": "不过,", + "therefore": "因此,", +} + + +class NoSuccessfulGeneFamilyEvidence(RuntimeError): + """Raised when the requested plan has no successful result to report.""" + + +@dataclass(frozen=True) +class GeneFamilyReportResult: + report_path: Path + manifest_path: Path + report_status: str + generation_mode: str + included_stages: tuple[str, ...] + reused: bool = False + + +def _normalized_stages(stages: Iterable[str] | None) -> list[str]: + result: list[str] = [] + for raw in stages or (): + stage = str(raw).strip() + if stage and stage not in EXCLUDED_PLAN_STAGES and stage not in result: + result.append(stage) + return result + + +def _parse_json_object(text: str) -> dict[str, Any]: + value = text.strip() + if value.startswith("```"): + value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE) + value = re.sub(r"\s*```$", "", value) + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise ValueError("model output must be a JSON object") + return parsed + + +def _api_payload(report_data: ReportData) -> dict[str, Any]: + return { + "sections": [ + { + "section_id": section.section_id, + "sentences": [ + {"sentence_id": sentence.id, "text": sentence.text} + for sentence in section.sentences + ], + } + for section in report_data.sections + ], + "allowed_connectors": list(CONNECTORS), + } + + +def _generate_api_narrative(payload: dict, stages: list[str]) -> tuple[dict[str, Any], dict[str, Any]]: + from gfa_core.mas.config import config as mas_config + from gfa_core.mas.llm.base import LLMMessage + from gfa_core.mas.llm.manager import LLMManager + + prompt = ( + "仅编排以下不可改写句子。输出 JSON 字段只能是 sections;每节只能含 section_id、paragraphs," + "每段只能含 sentence_ids、connector_ids。必须保留所有章节和句子且各出现一次," + "句子不得跨章节;connector_ids 只能来自 allowed_connectors。计划阶段为:" + f"{','.join(stages)}。\n" + + json.dumps(payload, ensure_ascii=False) + ) + response = LLMManager(config=mas_config.load_llm_config()).call( + [ + LLMMessage(role="system", content="你是证据约束的生物信息学报告编辑器,只返回 JSON。"), + LLMMessage(role="user", content=prompt), + ], + track_agent_name="GeneFamilyReport", + ) + return _parse_json_object(response.content), { + "status": "generated", + "provider": response.provider, + "model": response.model, + } + + +def _default_arrangement(report_data: ReportData) -> dict[str, Any]: + return { + "sections": [ + { + "section_id": section.section_id, + "paragraphs": [ + {"sentence_ids": [sentence.id], "connector_ids": []} + for sentence in section.sentences + ], + } + for section in report_data.sections + ] + } + + +def _validate_arrangement(arrangement: Any, report_data: ReportData) -> None: + if not isinstance(arrangement, dict) or set(arrangement) != {"sections"}: + raise ValueError("API response has unknown or missing root fields") + sections = arrangement["sections"] + if not isinstance(sections, list): + raise ValueError("API sections must be a list") + expected = {section.section_id: {sentence.id for sentence in section.sentences} for section in report_data.sections} + seen_sections: set[str] = set() + for section in sections: + if not isinstance(section, dict) or set(section) != {"section_id", "paragraphs"}: + raise ValueError("API section has unknown or missing fields") + section_id = section["section_id"] + if not isinstance(section_id, str) or section_id not in expected or section_id in seen_sections: + raise ValueError("API changed or repeated a report section") + seen_sections.add(section_id) + paragraphs = section["paragraphs"] + if not isinstance(paragraphs, list): + raise ValueError("API paragraphs must be a list") + seen_sentences: list[str] = [] + for paragraph in paragraphs: + if not isinstance(paragraph, dict) or set(paragraph) != {"sentence_ids", "connector_ids"}: + raise ValueError("API paragraph has unknown or missing fields") + sentence_ids = paragraph["sentence_ids"] + connector_ids = paragraph["connector_ids"] + if not isinstance(sentence_ids, list) or not sentence_ids or any(not isinstance(value, str) for value in sentence_ids): + raise ValueError("API sentence_ids must be a non-empty string list") + if not isinstance(connector_ids, list) or len(connector_ids) != len(sentence_ids) - 1: + raise ValueError("API connector count does not match its paragraph") + if any(not isinstance(value, str) or value not in CONNECTORS for value in connector_ids): + raise ValueError("API used an unknown connector") + if any(value not in expected[section_id] for value in sentence_ids): + raise ValueError("API used an unknown or cross-section sentence") + seen_sentences.extend(sentence_ids) + if len(seen_sentences) != len(set(seen_sentences)) or set(seen_sentences) != expected[section_id]: + raise ValueError("API omitted or repeated a required sentence") + if seen_sections != set(expected): + raise ValueError("API omitted a report section") + + +def _template_blocks() -> list[tuple[str, str]]: + template = TEMPLATE_PATH.read_text(encoding="utf-8") + marker = re.compile(r"(?m)^\s*$") + matches = list(marker.finditer(template)) + if not matches: + raise ValueError(f"报告模板没有章节边界:{TEMPLATE_PATH}") + return [ + ( + match.group(1), + template[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(template)].strip(), + ) + for index, match in enumerate(matches) + ] + + +def _arranged_content(section: ReportSection, arranged: Mapping[str, Any]) -> str: + sentences = {sentence.id: sentence.text for sentence in section.sentences} + paragraphs: list[str] = [] + for paragraph in arranged["paragraphs"]: + sentence_ids = paragraph["sentence_ids"] + text = sentences[sentence_ids[0]] + for connector_id, sentence_id in zip(paragraph["connector_ids"], sentence_ids[1:]): + text += f" {CONNECTORS[connector_id]}{sentences[sentence_id]}" + paragraphs.append(text) + return "\n\n".join(paragraphs) + + +def _render_markdown( + report_data: ReportData, + *, + job_id: str, + generation_mode: str, + arrangement: Mapping[str, Any], +) -> str: + section_data = {section.section_id: section for section in report_data.sections} + arranged = {section["section_id"]: section for section in arrangement["sections"]} + rendered: list[str] = [] + for section_id, block in _template_blocks(): + if section_id not in section_data: + continue + rendered.append(block.replace("{{SECTION_CONTENT}}", _arranged_content(section_data[section_id], arranged[section_id]))) + markdown = "\n\n".join(rendered) + fallback_notice = ( + "> 生成模式:候补脚本。模型 API 不可用或输出越过语言编排边界;报告事实仍来自确定性结果数据。" + if generation_mode == "fallback_script" + else "" + ) + replacements = { + "{{PREFIX}}": report_data.prefix, + "{{JOB_ID}}": job_id or "未指定", + "{{GENERATION_MODE}}": "API 编排" if generation_mode == "api" else "候补脚本", + "{{GENERATED_AT}}": datetime.now().isoformat(timespec="seconds"), + "{{FALLBACK_NOTICE}}": fallback_notice, + } + for placeholder, value in replacements.items(): + markdown = markdown.replace(placeholder, value) + if "{{" in markdown or "【填写】" in markdown: + raise ValueError("报告模板仍含未替换占位符") + return markdown.rstrip() + "\n" + + +def generate_gene_family_report( + workspace: Path | str, + prefix: str, + *, + expected_stages: Iterable[str] | None = None, + stage_statuses: Mapping[str, str] | None = None, + scope_id: str = "", + analysis_route: str = "", + member_set_id: str = "", + rnaseq_scope_id: str = "", + use_model: bool = True, + action: str = "generate", + job_id: str = "", +) -> GeneFamilyReportResult: + if action not in {"generate", "open"}: + raise ValueError("action must be 'generate' or 'open'") + final_dir = Path(workspace).expanduser().resolve() / "final" + report_path = final_dir / f"{prefix}.gene_family_report.md" + manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json" + if action == "open" and report_path.is_file() and manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + metrics = manifest.get("metrics") or {} + return GeneFamilyReportResult( + report_path, + manifest_path, + str(metrics.get("report_status") or "partial"), + str(metrics.get("generation_mode") or "fallback_script"), + tuple(metrics.get("included_stages") or []), + reused=True, + ) + + planned = _normalized_stages(expected_stages) + if not planned: + raise ValueError("expected_stages is required when generating a gene-family report") + manifests = load_stage_manifests( + final_dir, + prefix, + scope_id=scope_id, + analysis_route=analysis_route, + member_set_id=member_set_id, + rnaseq_scope_id=rnaseq_scope_id, + ) + statuses = {str(key): str(value) for key, value in (stage_statuses or {}).items()} + for stage in planned: + statuses.setdefault(stage, str((manifests.get(stage) or {}).get("status") or "missing")) + report_data = build_report_data(final_dir, prefix, planned, statuses, manifests) + if not report_data.included: + raise NoSuccessfulGeneFamilyEvidence("暂无可生成的基因家族分析结果") + + arrangement = _default_arrangement(report_data) + generation_mode = "fallback_script" + model_metadata: dict[str, Any] = {"status": "disabled"} + if use_model: + try: + candidate, model_metadata = _generate_api_narrative(_api_payload(report_data), list(report_data.included)) + _validate_arrangement(candidate, report_data) + arrangement = candidate + generation_mode = "api" + except Exception: + model_metadata = { + "status": "fallback", + "reason": "api_unavailable_or_boundary_violation", + } + + markdown = _render_markdown( + report_data, + job_id=job_id, + generation_mode=generation_mode, + arrangement=arrangement, + ) + final_dir.mkdir(parents=True, exist_ok=True) + report_path.write_text(markdown, encoding="utf-8") + manifest = { + "manifest_version": "1.0", + "stage": REPORT_STAGE, + "status": "success", + "prefix": prefix, + "job_id": job_id, + "scope_id": scope_id, + "analysis_route": analysis_route, + "member_set_id": member_set_id, + "rnaseq_scope_id": rnaseq_scope_id, + "parameters": {"expected_stages": planned, "stage_statuses": {stage: statuses[stage] for stage in planned}}, + "metrics": { + "report_status": report_data.report_status, + "generation_mode": generation_mode, + "included_stages": list(report_data.included), + "unsupported_stages": list(report_data.unsupported_stages), + "failed_or_missing_stages": [stage for stage in planned if stage not in report_data.included], + }, + "model": model_metadata, + "artifacts": { + "gene_family_report": { + "path": f"final/{report_path.name}", + "full_path": str(report_path), + "type": "markdown", + }, + "gene_family_report_manifest": { + "path": f"final/{manifest_path.name}", + "full_path": str(manifest_path), + "type": "manifest", + }, + }, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return GeneFamilyReportResult( + report_path, + manifest_path, + report_data.report_status, + generation_mode, + report_data.included, + ) diff --git a/gfa_core/gene_family_report_data.py b/gfa_core/gene_family_report_data.py new file mode 100644 index 000000000..968765ac8 --- /dev/null +++ b/gfa_core/gene_family_report_data.py @@ -0,0 +1,773 @@ +"""Deterministic data model for the formal gene-family report.""" + +from __future__ import annotations + +from dataclasses import dataclass +import csv +import json +from pathlib import Path +import re +from typing import Any, Mapping, Sequence + + +@dataclass(frozen=True) +class MissingMetric: + """A metric that is genuinely absent from every declared source.""" + + label: str + checked_sources: tuple[str, ...] + + def render(self) -> str: + checked = "、".join(self.checked_sources) + return f"{self.label}:源结果未提供此指标(已检查:{checked})。" + + +@dataclass(frozen=True) +class Sentence: + """One immutable report sentence decided by deterministic code.""" + + id: str + text: str + kind: str + evidence_ids: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ReportSection: + section_id: str + stage: str | None + facts: tuple[Sentence, ...] = () + observations: tuple[Sentence, ...] = () + interpretations: tuple[Sentence, ...] = () + limitations: tuple[Sentence, ...] = () + recommendations: tuple[Sentence, ...] = () + + @property + def sentences(self) -> tuple[Sentence, ...]: + return ( + self.facts + + self.observations + + self.interpretations + + self.limitations + + self.recommendations + ) + + +@dataclass(frozen=True) +class ReportData: + prefix: str + planned: tuple[str, ...] + statuses: Mapping[str, str] + included: tuple[str, ...] + unsupported_stages: tuple[str, ...] + report_status: str + sections: tuple[ReportSection, ...] + + +STAGE_SECTION_IDS = { + "identify": "stage.identify", + "phylogeny": "stage.phylogeny", + "reference_classification": "stage.reference_classification", + "motif": "stage.motif", + "domain": "stage.domain", + "structure": "stage.structure", + "combo": "stage.combo", + "promoter": "stage.promoter", + "tfbs": "stage.tfbs", + "synteny": "stage.synteny", + "karyotype": "stage.karyotype", + "interspecies_synteny": "stage.interspecies_synteny", + "pep": "stage.pep", +} + + +_REQUIRED_METRICS = { + "identify": (("member_count", "最终成员数"),), + "phylogeny": ( + ("sequences_count", "序列数"), + ("msa_length", "多序列比对长度"), + ), + "motif": ( + ("motif_count", "Motif 类别数"), + ("total_sites", "Motif 总位点数"), + ("motif_width_range", "Motif 宽度范围"), + ), + "pep": ( + ("protein_count", "蛋白数"), + ("tasks_success", "成功子任务数"), + ("tasks_total", "子任务总数"), + ), +} + + +_ARTIFACT_METRIC_ROLES = { + ("identify", "member_count"): ("identified_members", "identified_members_id", "member_ids"), + ("pep", "protein_count"): ("pep_properties", "properties_table"), +} + + +def _manifest_source(manifest: Mapping[str, Any], field: str) -> str: + path = str(manifest.get("_manifest_path") or "stage manifest") + return f"{Path(path).name}#metrics.{field}" + + +def _artifact_path(final_dir: Path, artifact: Any) -> Path | None: + if not isinstance(artifact, Mapping): + return None + raw = artifact.get("full_path") or artifact.get("path") + if not raw: + return None + path = Path(str(raw)) + if path.is_file(): + return path + candidates = [final_dir / path.name] + if path.is_absolute(): + job_index = next((index for index, part in enumerate(path.parts) if part.startswith("job_")), None) + if job_index is not None: + candidates.append(final_dir.parent.joinpath(*path.parts[job_index + 1 :])) + else: + candidates.append(final_dir.parent / path) + return next((candidate for candidate in candidates if candidate.is_file()), None) + + +def _artifact_by_suffix(final_dir: Path, manifest: Mapping[str, Any], suffix: str) -> Path | None: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {} + for role in sorted(artifacts): + if str(role).endswith(suffix): + path = _artifact_path(final_dir, artifacts[role]) + if path is not None: + return path + return None + + +def _count_named_records(path: Path) -> int: + suffix = path.suffix.lower() + try: + if suffix in {".fa", ".faa", ".fasta", ".pep"}: + return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.startswith(">")) + if suffix in {".tsv", ".csv"}: + delimiter = "\t" if suffix == ".tsv" else "," + with path.open("r", encoding="utf-8", newline="") as handle: + return sum(1 for _ in csv.DictReader(handle, delimiter=delimiter)) + if suffix == ".json": + value = json.loads(path.read_text(encoding="utf-8")) + if isinstance(value, list): + return len(value) + if isinstance(value, Mapping): + for key in ("members", "proteins", "records"): + if isinstance(value.get(key), list): + return len(value[key]) + raise ValueError("JSON does not contain a declared record list") + return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip()) + except (OSError, UnicodeError, csv.Error, json.JSONDecodeError, ValueError) as exc: + raise ValueError(f"无法解析声明的报告数据源 {path}: {exc}") from exc + + +def _metric( + final_dir: Path, + stage: str, + manifest: Mapping[str, Any], + field: str, + label: str, +) -> tuple[Any | MissingMetric, tuple[str, ...]]: + metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), Mapping) else {} + manifest_source = _manifest_source(manifest, field) + if stage == "identify" and field == "member_count": + for alias in ("member_count", "identified_members_count", "final_members"): + if metrics.get(alias) is not None: + source = _manifest_source(manifest, alias) + return metrics[alias], (source,) + elif field == "motif_width_range": + motifs = metrics.get("motifs") + if isinstance(motifs, Sequence) and not isinstance(motifs, (str, bytes)): + widths = [item.get("width") for item in motifs if isinstance(item, Mapping) and item.get("width") is not None] + if widths: + try: + numeric = [int(value) for value in widths] + except (TypeError, ValueError) as exc: + raise ValueError(f"无法解析 {manifest_source.replace(field, 'motifs[].width')}") from exc + source = _manifest_source(manifest, "motifs[].width") + return (min(numeric), max(numeric)), (source,) + elif field in metrics and metrics[field] is not None: + return metrics[field], (manifest_source,) + + checked = [manifest_source] + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {} + for role in _ARTIFACT_METRIC_ROLES.get((stage, field), ()): + checked.append(f"artifact:{role}") + path = _artifact_path(final_dir, artifacts.get(role)) + if path is not None: + return _count_named_records(path), (str(path),) + return MissingMetric(label, tuple(checked)), tuple(checked) + + +def _gene_aliases(gene_id: str) -> tuple[str, ...]: + value = str(gene_id).strip() + aliases = {value, re.sub(r"\.T\d+$", "", value), re.sub(r"\.\d+$", "", value)} + return tuple(alias for alias in aliases if alias) + + +def _parse_interspecies_artifacts( + final_dir: Path, + manifest: Mapping[str, Any], +) -> tuple[int, int, tuple[str, ...]] | None: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {} + mapping_path = _artifact_path(final_dir, artifacts.get("gene_to_species")) + collinearity_path = _artifact_by_suffix(final_dir, manifest, "_collinearity") + if mapping_path is None or collinearity_path is None: + return None + groups: dict[str, str] = {} + try: + with mapping_path.open("r", encoding="utf-8", newline="") as handle: + for row in csv.DictReader(handle, delimiter="\t"): + gene_id = str(row.get("gene_id") or "").strip() + group = str(row.get("group") or "").strip().lower() + if gene_id and group in {"target", "ref"}: + for alias in _gene_aliases(gene_id): + groups[alias] = group + alignment_blocks = 0 + target_ref_pairs = 0 + with collinearity_path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.startswith("## Alignment"): + alignment_blocks += 1 + continue + match = re.search(r":\s*(\S+)\s+(\S+)", line) if line.startswith(" ") else None + if not match: + continue + pair_groups = { + groups[alias] + for gene_id in match.groups() + for alias in _gene_aliases(gene_id) + if alias in groups + } + if pair_groups == {"target", "ref"}: + target_ref_pairs += 1 + except (OSError, UnicodeError, csv.Error) as exc: + raise ValueError(f"无法解析种间共线性命名产物:{mapping_path};{collinearity_path}: {exc}") from exc + return alignment_blocks, target_ref_pairs, (str(collinearity_path), str(mapping_path)) + + +def _sentence(sentence_id: str, text: str, kind: str, *evidence_ids: str) -> Sentence: + return Sentence(sentence_id, text, kind, tuple(evidence_ids)) + + +def _metrics(manifest: Mapping[str, Any]) -> Mapping[str, Any]: + value = manifest.get("metrics") + return value if isinstance(value, Mapping) else {} + + +def _artifacts(manifest: Mapping[str, Any]) -> Mapping[str, Any]: + value = manifest.get("artifacts") + return value if isinstance(value, Mapping) else {} + + +def _artifact_for_roles( + final_dir: Path, + manifest: Mapping[str, Any], + *roles: str, +) -> tuple[Path | None, str | None]: + artifacts = _artifacts(manifest) + for role in roles: + path = _artifact_path(final_dir, artifacts.get(role)) + if path is not None: + return path, role + for role, artifact in artifacts.items(): + if any(token in str(role) for token in roles): + path = _artifact_path(final_dir, artifact) + if path is not None: + return path, str(role) + return None, None + + +def _relative_artifact(final_dir: Path, manifest: Mapping[str, Any], *roles: str) -> tuple[str, str] | None: + artifacts = _artifacts(manifest) + for role in roles: + artifact = artifacts.get(role) + path = _artifact_path(final_dir, artifact) + if path is not None: + try: + relative = path.relative_to(final_dir.parent) + except ValueError: + relative = Path("final") / path.name + return relative.as_posix(), f"artifact:{role}" + if isinstance(artifact, Mapping): + raw = artifact.get("path") + if raw and not Path(str(raw)).is_absolute() and ".." not in Path(str(raw)).parts: + return Path(str(raw)).as_posix(), f"artifact:{role}" + return None + + +def _tabular_rows(path: Path) -> list[dict[str, str]]: + try: + lines = [ + line + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + if not lines: + return [] + return [dict(row) for row in csv.DictReader(lines, delimiter="\t")] + except (OSError, UnicodeError, csv.Error) as exc: + raise ValueError(f"无法解析命名表格 {path}: {exc}") from exc + + +def _element_counts(path: Path) -> list[tuple[str, int]]: + result: list[tuple[str, int]] = [] + try: + for line in path.read_text(encoding="utf-8").splitlines(): + parts = line.split("\t", 1) + if len(parts) != 2: + continue + try: + count = int(parts[0]) + except ValueError: + continue + result.append((parts[1].strip(), count)) + except (OSError, UnicodeError) as exc: + raise ValueError(f"无法解析启动子元件计数 {path}: {exc}") from exc + return sorted(result, key=lambda item: (-item[1], item[0])) + + +def _key_artifact_sentence( + final_dir: Path, + manifest: Mapping[str, Any], + section_id: str, + label: str, + *roles: str, +) -> Sentence | None: + artifact = _relative_artifact(final_dir, manifest, *roles) + if artifact is None: + return None + path, evidence = artifact + return _sentence(f"{section_id}.artifact.{roles[0]}", f"关键产物:{label} `{path}`。", "fact", evidence) + + +def _stage_section( + final_dir: Path, + prefix: str, + stage: str, + status: str, + manifest: Mapping[str, Any], +) -> ReportSection: + section_id = STAGE_SECTION_IDS[stage] + facts: list[Sentence] = [] + observations: list[Sentence] = [] + interpretations: list[Sentence] = [] + limitations: list[Sentence] = [] + recommendations: list[Sentence] = [] + + if status != "success" or str(manifest.get("status") or "") != "success": + facts.append(_sentence(f"{section_id}.status", f"阶段状态:`{status}`。", "fact", _manifest_source(manifest, "status"))) + limitations.append( + _sentence( + f"{section_id}.not_completed", + "该阶段未成功完成,因此本报告不生成结果推断。", + "limitation", + ) + ) + return ReportSection(section_id, stage, tuple(facts), limitations=tuple(limitations)) + + metrics = _metrics(manifest) + if stage == "identify": + member_count, sources = _metric(final_dir, stage, manifest, "member_count", "最终成员数") + family = prefix.split(".", 1)[1] if "." in prefix else prefix + facts.append(_sentence(f"{section_id}.object", "分析对象:目标物种完整蛋白集中的候选成员。", "fact")) + if isinstance(member_count, MissingMetric): + facts.append(_sentence(f"{section_id}.missing.member_count", member_count.render(), "fact", *sources)) + else: + hmm = metrics.get("hmm_members") + blast = metrics.get("blast_members") + search = f";HMM 与 BLAST 各支持 {hmm} 和 {blast} 个候选" if hmm is not None and blast is not None else "" + observations.append(_sentence( + f"{section_id}.observation.members", + f"目标物种最终成员数:{member_count}。本阶段形成 {member_count} 个 {family}家族成员{search}。", + "observation", *sources, + )) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:最终名单是后续阶段的目标物种成员集合;搜索方法的一致数量支持集合稳定,但不等同于实验功能确认。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:成员边界仍受蛋白注释、搜索阈值和转录本选择规则影响。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "目标成员 FASTA", "identified_members", "target_Ft_identified_members_pep") + elif stage == "phylogeny": + sequences = metrics.get("sequences_count") + msa_length = metrics.get("msa_length") + species = metrics.get("scope_species_counts") if isinstance(metrics.get("scope_species_counts"), Mapping) else {} + object_text = ( + f"分析对象:目标物种 {species.get('Ft')} 条与参考物种 {species.get('At')} 条蛋白合并的 {sequences} 条序列。" + if species + else f"分析对象:目标与参考成员合并集合中的 {sequences} 条蛋白序列。" + ) + facts.append(_sentence(f"{section_id}.object", object_text, "fact", _manifest_source(manifest, "scope_species_counts"))) + observations.append(_sentence(f"{section_id}.observation", f"序列数:{sequences};多序列比对长度:{msa_length}。目标与参考成员共 {sequences} 条序列进入比对,本阶段使用 {sequences} 条序列,比对长度为 {msa_length}。", "observation", _manifest_source(manifest, "sequences_count"), _manifest_source(manifest, "msa_length"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:该树用于比较序列关系并辅助分组;它分析的是目标加参考集合,不应与 24 个目标成员的鉴定集合混称。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:源 Manifest 未汇总各主要分支的支持值,不能据此报告小组稳定性等级。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "关系树", "tree") + elif stage == "reference_classification": + target_count = int(metrics.get("target_gene_count") or 0) + all_rows = int(metrics.get("subfamily_rows") or 0) + assignment_path, assignment_role = _artifact_for_roles(final_dir, manifest, "assignment_table") + rows = _tabular_rows(assignment_path) if assignment_path else [] + counts: dict[str, int] = {} + subfamilies: set[str] = set() + for row in rows: + confidence = str(row.get("confidence") or "").lower() + if confidence: + counts[confidence] = counts.get(confidence, 0) + 1 + label = str(row.get("assigned_subfamily") or "").strip() + if label: + subfamilies.add(label) + facts.append(_sentence(f"{section_id}.object", f"分析对象:{target_count} 个目标成员与 {max(0, all_rows - target_count)} 个参考成员的联合分类。", "fact")) + if rows: + observations.append(_sentence(f"{section_id}.observation", f"分类结果:{len(rows)} 个目标成员分入 {len(subfamilies)} 个小组;高信心 {counts.get('high', 0)} 个、中信心 {counts.get('medium', 0)} 个、低信心 {counts.get('low', 0)} 个。", "observation", f"artifact:{assignment_role}")) + else: + missing = MissingMetric("分类信心分布", ("artifact:assignment_table",)) + facts.append(_sentence(f"{section_id}.missing", missing.render(), "fact", *missing.checked_sources)) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:参考成员为目标成员提供小组标签,其中低信心标签只能作为后续分组线索。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:分类依赖参考成员覆盖和树拓扑;低信心成员不宜用于强分组结论。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "分类明细", "assignment_table") + elif stage == "motif": + motifs = metrics.get("motifs") if isinstance(metrics.get("motifs"), Sequence) else [] + widths = [int(item["width"]) for item in motifs if isinstance(item, Mapping) and item.get("width") is not None] + count = metrics.get("motif_count") + total_sites = metrics.get("total_sites") + sequence_count = metrics.get("sequence_count") or metrics.get("protein_record_count") + if sequence_count is None: + facts.append(_sentence(f"{section_id}.object", "分析对象:当前 Motif Manifest 声明的蛋白集合。", "fact")) + missing = MissingMetric("Motif 输入序列数", (_manifest_source(manifest, "sequence_count"),)) + facts.append(_sentence(f"{section_id}.missing.sequence_count", missing.render(), "fact", *missing.checked_sources)) + else: + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {sequence_count} 条蛋白序列。", "fact")) + if widths: + observations.append(_sentence(f"{section_id}.observation", f"共识别 {count} 个 motif,总位点数为 {total_sites},宽度范围为 {min(widths)}–{max(widths)}。", "observation", _manifest_source(manifest, "motifs"))) + else: + missing = MissingMetric("Motif 宽度范围", (_manifest_source(manifest, "motifs[].width"),)) + facts.append(_sentence(f"{section_id}.missing", missing.render(), "fact", *missing.checked_sources)) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:这些统计描述短片段的类别、总命中与长度范围;总位点数不能替代逐成员覆盖率。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:源结果未汇总同组排列一致性或异常成员,不能从总数推断特定小组功能分化。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Motif 排列图", "motif_plot_png") + elif stage == "domain": + total = metrics.get("proteins_total") + hit = metrics.get("proteins_with_domains") + top_domains = metrics.get("top_domains") if isinstance(metrics.get("top_domains"), Sequence) else [] + top_text = "、".join(f"{item[0]} {item[1]} 次" for item in top_domains if isinstance(item, Sequence) and len(item) >= 2) + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {total} 条蛋白。", "fact")) + observations.append(_sentence(f"{section_id}.observation", f"Domain 覆盖为 {hit}/{total} 条蛋白;主要命中为 {top_text}。", "observation", _manifest_source(manifest, "proteins_with_domains"), _manifest_source(manifest, "top_domains"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:全部蛋白均有 Domain 命中,SBP 的普遍命中支持该合并集合保留家族典型区域;Ig_SPL7 只出现在部分命中记录中。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:命中次数不等于独立蛋白数,Ig_SPL7 的 12 次命中不能直接解释为 12 个功能不同成员。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Domain 图", "domain_plot_png") + elif stage == "structure": + total = metrics.get("genes_total") + covered = metrics.get("genes_with_structure") + outliers = metrics.get("long_gene_outliers") if isinstance(metrics.get("long_gene_outliers"), Sequence) else [] + outlier = next((item for item in outliers if isinstance(item, Mapping)), None) + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考合并集合的 {total} 个成员;结构注释实际匹配 {covered} 个目标成员。", "fact")) + detail = f";长基因异常 1 个,为 {outlier.get('gene_id')}({outlier.get('length_bp')} bp,{outlier.get('cds_count')} 个 CDS)" if outlier else "" + observations.append(_sentence(f"{section_id}.observation", f"结构覆盖为 {covered}/{total}{detail}。", "observation", _manifest_source(manifest, "genes_with_structure"), _manifest_source(manifest, "long_gene_outliers"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:24/40 的覆盖差异主要反映参考成员未纳入当前目标物种 GFF3;超长基因需要回到原始注释核对。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:未匹配的参考成员不能作为目标物种结构缺失;单个长基因也不能单独证明功能变化。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "结构明细", "structure_tsv") + elif stage == "combo": + parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), Mapping) else {} + panels = [str(value).capitalize() for value in parameters.get("panels", [])] + panel_text = "、".join(panels) if panels else "Motif、Domain、Structure" + facts.append(_sentence(f"{section_id}.object", "分析对象:目标与参考成员合并集合的关系树及其多类结构证据。", "fact")) + observations.append(_sentence(f"{section_id}.observation", f"组合图已将关系树与 {panel_text} 三类面板汇总到同一图中。", "observation", _manifest_source(manifest, "parameters.panels"))) + facts.append(_sentence(f"{section_id}.missing", "组合证据一致性数值:源结果未提供此指标(已检查:Combo Manifest、combo_plot_png、combo_plot_pdf)。", "fact")) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:该图适合逐行核对多类证据,但当前命名源没有可机器追溯的一致性评分。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:没有逐成员一致性表时,不指定稳定小组、异常成员或候选基因。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "组合图", "combo_plot_png") + elif stage == "promoter": + genes = metrics.get("genes_with_elements") + total = metrics.get("total_elements") + unique = metrics.get("unique_elements") + counts_path, counts_role = _artifact_for_roles(final_dir, manifest, "element_counts") + counts = _element_counts(counts_path) if counts_path else [] + category_path, category_role = _artifact_for_roles(final_dir, manifest, "position_distribution_summary", "promoter_category_summary") + categories = _tabular_rows(category_path) if category_path else [] + enrichment_path, enrichment_role = _artifact_for_roles(final_dir, manifest, "enrichment_analysis") + enrichment_rows = _tabular_rows(enrichment_path) if enrichment_path else [] + co_path, co_role = _artifact_for_roles(final_dir, manifest, "cooccurrence_analysis") + co_rows = _tabular_rows(co_path) if co_path else [] + complexity_path, complexity_role = _artifact_for_roles(final_dir, manifest, "complexity_analysis") + complexity_rows = _tabular_rows(complexity_path) if complexity_path else [] + facts.append(_sentence(f"{section_id}.object", f"分析对象:检测到顺式元件的 {genes} 个目标基因启动子。", "fact")) + top_elements = "、".join(f"{name} {count} 个" for name, count in counts[:3]) or "源结果未提供主要元件排行" + top_category = categories[0] if categories else {} + category_name = top_category.get("big7") or top_category.get("Category") + category_count = top_category.get("element_instances") or top_category.get("Count") + category_text = "" + if category_name and category_count and total: + category_text = f";主要类别为 {category_name} {category_count} 个({int(category_count) / int(total) * 100:.1f}%)" + enrichment_top = enrichment_rows[0] if enrichment_rows else {} + enrichment_text = "" + if enrichment_top: + enrichment_text = f";最高列示富集比为小组 {enrichment_top.get('Subfamily')} 的 {enrichment_top.get('Element')}({enrichment_top.get('EnrichmentRatio')})" + co_top = co_rows[0] if co_rows else {} + co_text = "" + if co_top: + co_text = f";最常见共现为 {co_top.get('Element1')}—{co_top.get('Element2')}({co_top.get('Co-occurrenceCount')} 个基因)" + complex_top = complexity_rows[0] if complexity_rows else {} + complex_text = "" + if complex_top: + complex_text = f";复杂度最高的候选为 {complex_top.get('Gene')}(得分 {complex_top.get('ComplexityScore')})" + observations.append(_sentence(f"{section_id}.observation", f"{genes} 个目标基因共记录 {total} 个元件、{unique} 种类型;主要元件为 {top_elements}{category_text}{enrichment_text}{co_text}{complex_text}。", "observation", *(f"artifact:{role}" for role in (counts_role, category_role, enrichment_role, co_role, complexity_role) if role))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:类别比例、共现和复杂度描述的是上游序列预测模式;富集比可用于定位值得复核的小组—元件组合。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:仅 23 个目标基因有元件记录,且序列片段出现、共现或高复杂度都不能证明真实调控。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "启动子汇总图", "viz_Ft.SPL_promoter_v6_combined", "promoter_v6_combined") + elif stage == "tfbs": + promoters = metrics.get("family_promoter_count") + sites = metrics.get("family_site_count") + motif_count = metrics.get("family_motif_count") + background = metrics.get("background_promoter_count") + summary_path, summary_role = _artifact_for_roles(final_dir, manifest, "motif_summary") + rows = _tabular_rows(summary_path) if summary_path else [] + rows.sort(key=lambda row: int(float(row.get("family_sites") or 0)), reverse=True) + top = rows[0] if rows else {} + facts.append(_sentence(f"{section_id}.object", f"分析对象:{promoters} 条目标成员启动子;Motif 模型来自 {metrics.get('motif_database', '声明数据库')}。", "fact")) + top_text = f";最高频 Motif {top.get('motif_id')} 有 {top.get('family_sites')} 个位点,覆盖 {top.get('family_genes_with_site')} 个基因" if top else "" + observations.append(_sentence(f"{section_id}.observation", f"{promoters} 条目标启动子共得到 {sites} 个预测位点、{motif_count} 个有命中的 Motif{top_text}。", "observation", _manifest_source(manifest, "family_site_count"), *(tuple([f"artifact:{summary_role}"]) if summary_role else ()))) + if int(background or 0) == 0: + facts.append(_sentence(f"{section_id}.no_background", "背景启动子为 0,不能据此进行家族相对背景的富集判断。", "fact", _manifest_source(manifest, "background_promoter_count"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:高频位点表示序列扫描中出现较多,只能作为潜在结合线索,不能写成转录因子已调控目标基因。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:Motif 使用最近分类物种投射且缺少背景集合,统计富集和物种特异调控均无法确认。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Motif 汇总表", "motif_summary") + elif stage == "synteny": + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种全基因组 {metrics.get('gene_count')} 个基因,而非仅家族成员。", "fact")) + observations.append(_sentence(f"{section_id}.observation", f"全基因组结果记录 {metrics.get('synteny_blocks')} 个共线性区块、{metrics.get('gene_pairs')} 个基因对,并绘制 {metrics.get('circos_chromosomes_plotted')} 条染色体。", "observation", _manifest_source(manifest, "synteny_blocks"), _manifest_source(manifest, "gene_pairs"))) + facts.append(_sentence(f"{section_id}.missing", "源结果未提供家族专属复制基因对(已检查:Synteny Manifest、collinearity artifact、dup_class_log artifact)。", "fact")) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:这些数字刻画全基因组共线性背景,不能直接归因于 SPL 家族扩张。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:缺少家族成员专属 links 或复制类型表,本节不判断大片段复制或串联复制孰为家族主要来源。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Circos 图", "circos_png") + elif stage == "karyotype": + species = metrics.get("species") if isinstance(metrics.get("species"), Mapping) else {} + item = next(iter(species.values()), {}) if species else metrics + genes = item.get("gene_count") if isinstance(item, Mapping) else None + chromosomes = item.get("chromosome_count") if isinstance(item, Mapping) else None + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种的 {genes} 个家族成员。", "fact")) + observations.append(_sentence(f"{section_id}.observation", f"核型分析将{genes}个家族成员定位到{chromosomes}条染色体。", "observation", _manifest_source(manifest, "species.*.gene_count"), _manifest_source(manifest, "species.*.chromosome_count"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:染色体位置图确认了目标成员的基因组分布范围,可与家族专属复制证据联合阅读。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:Manifest 未汇总每条染色体的成员数,不能仅凭 24/8 判断分布均匀性或成簇关系。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "核型图", "Ft_karyotype_png") + elif stage == "interspecies_synteny": + parsed = _parse_interspecies_artifacts(final_dir, manifest) + target = metrics.get("target_family_count") + reference = metrics.get("ref_family_count") + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种 {target} 个与参考物种 {reference} 个家族成员,并结合两物种全基因组共线性。", "fact")) + if parsed is None: + missing = MissingMetric( + "种间共线性区块与家族成员配对", + ("artifact:gene_to_species", "artifact:*_collinearity"), + ) + facts.append( + _sentence( + f"{section_id}.missing.collinearity_summary", + missing.render(), + "fact", + *missing.checked_sources, + ) + ) + else: + alignment_blocks, target_ref_pairs, sources = parsed + observations.append( + _sentence( + f"{section_id}.observation.summary", + f"共线性区块数:{alignment_blocks}(全基因组 block {alignment_blocks} 个);" + f"家族成员目标—参考配对数:{target_ref_pairs}(目标-参考家族 {target_ref_pairs} 对)。", + "observation", + *sources, + ) + ) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:全基因组区块提供跨物种背景,2 个目标—参考家族配对是当前命名产物中可直接追溯的家族关系。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:共线性配对只能支持保留关系线索,不能直接移植参考物种功能或断言一一对应。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "标记版种间共线性图", "synteny_marked_png") + elif stage == "pep": + protein_count = metrics.get("protein_count") + success = metrics.get("tasks_success") + total_tasks = metrics.get("tasks_total") + counts = metrics.get("subcellular_counts") if isinstance(metrics.get("subcellular_counts"), Mapping) else {} + facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {protein_count} 条蛋白。", "fact")) + localization = "" + if counts: + localization = f";亚细胞定位预测为 Nucleus {counts.get('Nucleus', 0)}、Cytoplasm {counts.get('Cytoplasm', 0)}、Chloroplast {counts.get('Chloroplast', 0)}" + else: + missing = MissingMetric("亚细胞定位分类计数", (_manifest_source(manifest, "subcellular_counts"),)) + facts.append(_sentence(f"{section_id}.missing.localization", missing.render(), "fact", *missing.checked_sources)) + observations.append(_sentence(f"{section_id}.observation", f"蛋白数:{protein_count};子任务成功数:{success}/{total_tasks}。本阶段覆盖 {protein_count} 个蛋白,子任务成功数为 {success}/{total_tasks}{localization}。", "observation", _manifest_source(manifest, "protein_count"), _manifest_source(manifest, "subcellular_counts"))) + interpretations.append(_sentence(f"{section_id}.interpretation", "解释:核定位预测占多数,与转录因子研究方向相容,但这里只能作为定位线索。", "interpretation")) + limitations.append(_sentence(f"{section_id}.limitation", "限制:理化性质、跨膜与亚细胞定位均为计算预测;当前汇总不能替代逐蛋白实验验证。", "limitation")) + artifact = _key_artifact_sentence(final_dir, manifest, section_id, "亚细胞定位表", "subcellular_data") + else: + artifact = None + + if artifact is not None: + facts.append(artifact) + return ReportSection( + section_id, + stage, + tuple(facts), + tuple(observations), + tuple(interpretations), + tuple(limitations), + tuple(recommendations), + ) + + +def build_report_data( + final_dir: Path | str, + prefix: str, + planned: Sequence[str], + statuses: Mapping[str, str], + manifests: Mapping[str, Mapping[str, Any]], +) -> ReportData: + """Build the only deterministic data source used by both render modes.""" + + final_path = Path(final_dir) + unsupported_stages = tuple(stage for stage in planned if stage not in STAGE_SECTION_IDS) + included = tuple( + stage + for stage in planned + if stage in STAGE_SECTION_IDS + and statuses.get(stage) == "success" + and str((manifests.get(stage) or {}).get("status") or "") == "success" + ) + report_status = "partial" if len(included) != len(planned) else "final" + success_count = len(included) + failed_count = len(planned) - success_count + stage_sections = [ + _stage_section(final_path, prefix, stage, str(statuses.get(stage) or "missing"), manifests.get(stage) or {}) + for stage in planned + if stage in STAGE_SECTION_IDS + ] + stage_sections_by_name = {section.stage: section for section in stage_sections} + summary_texts = [ + f"本次按用户计划检查 {len(planned)} 个阶段,其中 {success_count} 个成功结果纳入正文。", + f"报告状态为{'最终报告' if report_status == 'final' else '部分报告'},未纳入阶段数为 {failed_count}。", + ] + for stage in ( + "identify", "phylogeny", "reference_classification", "domain", "structure", + "promoter", "karyotype", "pep", + ): + section = stage_sections_by_name.get(stage) + if section and section.observations and len(summary_texts) < 9: + summary_texts.append(section.observations[0].text.replace("。", ";").rstrip(";") + "。") + for stage in ("identify", "phylogeny"): + section = stage_sections_by_name.get(stage) + if section: + for sentence in section.facts: + if sentence.text.startswith("分析对象:") and len(summary_texts) < 5: + summary_texts.append(sentence.text) + while len(summary_texts) < 5: + summary_texts.append("正文只纳入计划内成功阶段,并在各节分别声明实际分析对象。") + summary_texts.append("本摘要只组合下文可追溯的阶段事实,不据此指定候选基因或推断功能因果。") + summary_facts = tuple( + _sentence(f"common.front.summary.{index}", text, "fact") + for index, text in enumerate(summary_texts[:10], start=1) + ) + completion_facts = [ + _sentence( + "common.completeness.counts", + f"本次计划 {len(planned)} 个阶段,纳入解读 {success_count} 个,未纳入 {failed_count} 个。", + "fact", + ) + ] + for stage in planned: + supported = stage in STAGE_SECTION_IDS + completion_facts.append( + _sentence( + f"common.completeness.stage.{stage}", + f"计划阶段 `{stage}`:源状态 `{statuses.get(stage, 'missing')}`;" + f"模板支持:{'是' if supported else '不支持'};" + f"纳入解读:{'是' if stage in included else '否'}。", + "fact", + ) + ) + sections: list[ReportSection] = [ + ReportSection( + "common.front", + None, + facts=summary_facts, + ), + ReportSection( + "common.route", + None, + facts=( + _sentence("common.route.stages", f"本报告按计划阶段顺序解读:{'、'.join(planned)}。", "fact"), + ), + ), + ReportSection( + "common.completeness", + None, + facts=tuple(completion_facts), + ), + ] + sections.extend(stage_sections) + integrated_observations = [ + section.observations[0] + for section in stage_sections + if section.observations + ][:3] + sections.extend( + [ + ReportSection( + "common.integrated", + None, + facts=tuple( + _sentence( + f"common.integrated.fact.{index}", + sentence.text, + "fact", + *sentence.evidence_ids, + ) + for index, sentence in enumerate(integrated_observations, start=1) + ), + interpretations=( + _sentence( + "common.integrated.boundary", + "综合结论只并列上述已提取事实;当前源结果不足以无偏地指定候选基因,也不据此建立功能因果。", + "interpretation", + ), + ), + ), + ReportSection( + "common.limitations", + None, + limitations=( + _sentence( + "common.limitations.general", + "基因组与注释质量、分析阈值和计算预测边界都会影响结果,缺少实验信息时不能确定具体生物学功能。", + "limitation", + ), + ), + ), + ReportSection( + "common.recommendations", + None, + recommendations=( + _sentence( + "common.recommendations.general", + "优先核对异常成员的序列与注释,并用表达、定位或功能实验验证由至少两类独立证据支持的候选。", + "recommendation", + ), + ), + ), + ReportSection( + "common.checklist", + None, + recommendations=( + _sentence( + "common.checklist.traceability", + "交付前应确认每项结论均能追溯到当前身份的 Manifest 或命名结果文件。", + "recommendation", + ), + ), + ), + ] + ) + return ReportData( + prefix, + tuple(planned), + dict(statuses), + included, + unsupported_stages, + report_status, + tuple(sections), + ) diff --git a/gfa_core/mas/agents/interpretation_evidence.py b/gfa_core/mas/agents/interpretation_evidence.py index fee2edf27..d85442f36 100755 --- a/gfa_core/mas/agents/interpretation_evidence.py +++ b/gfa_core/mas/agents/interpretation_evidence.py @@ -193,14 +193,24 @@ def _read_json(path: Path) -> Dict[str, Any]: def _artifact_path(manifest: Dict[str, Any], artifact_key: str) -> Optional[Path]: artifact = (manifest.get("artifacts") or {}).get(artifact_key) or {} - path_text = artifact.get("full_path") or artifact.get("path") - if path_text: + manifest_path = Path(str(manifest.get("_manifest_path") or "")) + job_dir = manifest_path.parent.parent if manifest_path else None + for path_text in (artifact.get("full_path"), artifact.get("path")): + if not path_text: + continue path = Path(str(path_text)) - if not path.is_absolute() and manifest.get("_manifest_path"): - job_dir = Path(str(manifest["_manifest_path"])).parent.parent - path = job_dir / path - if path.exists(): - return path + candidates = [path] + if job_dir: + if not path.is_absolute(): + candidates.append(job_dir / path) + else: + job_index = next((index for index, part in enumerate(path.parts) if part.startswith("job_")), None) + if job_index is not None: + candidates.append(job_dir.joinpath(*path.parts[job_index + 1 :])) + candidates.append(manifest_path.parent / path.name) + for candidate in candidates: + if candidate.exists(): + return candidate return None diff --git a/gfa_core/mas/core/fast_intent.py b/gfa_core/mas/core/fast_intent.py index fe0600f23..abeea717b 100755 --- a/gfa_core/mas/core/fast_intent.py +++ b/gfa_core/mas/core/fast_intent.py @@ -4,7 +4,7 @@ import re from typing import Optional, Dict, Any, List from gfa_core.mas.stage_catalog import complete_dependencies as catalog_complete_dependencies -from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gwas_intent, resolve_population_intent +from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gene_family_report_intent, resolve_gwas_intent, resolve_population_intent # 分析类型关键词映射(注意:key必须与orchestrator.py中的analysis_type匹配) ANALYSIS_KEYWORDS = { @@ -96,6 +96,12 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]: None: 无法识别 """ query_lower = query.lower() + report_intent = resolve_gene_family_report_intent(query) + if report_intent: + return { + "stage": "gene_family_report", + **report_intent, + } population = resolve_population_intent(query) if population: return { diff --git a/gfa_core/mas/stage_capabilities.py b/gfa_core/mas/stage_capabilities.py index e2035a0cd..8349971d5 100755 --- a/gfa_core/mas/stage_capabilities.py +++ b/gfa_core/mas/stage_capabilities.py @@ -812,6 +812,23 @@ CAPABILITIES: Dict[str, StageCapability] = { repair_candidates=["manifest_recovery", "artifact_reresolve"], rerun_policy="auto_after_manifest_recovery", ), + "gene_family_report": StageCapability( + stage="gene_family_report", + cli="apps/cli/run_gene_family_report.py", + env="test4py311", + category="report", + consumes=[], + provides=[ + ArtifactProduct("gene_family_report", "markdown", "{prefix}.gene_family_report.md"), + ArtifactProduct("gene_family_report_evidence", "json", "{prefix}.gene_family_report.evidence.json"), + ArtifactProduct("gene_family_report_model", "json", "{prefix}.gene_family_report.model.json"), + ArtifactProduct("gene_family_report_manifest", "manifest", "{prefix}.gene_family_report.manifest.json"), + ], + scope_modes=["target_only", "target_plus_reference", "all_selected"], + preflight_checks=["at_least_one_successful_planned_manifest"], + postflight_checks=["formal_report_four_files_exist"], + rerun_policy="open_reuses_fresh_generate_rebuilds", + ), } diff --git a/gfa_core/mas/stage_catalog.py b/gfa_core/mas/stage_catalog.py index 9ce115883..487bac47b 100755 --- a/gfa_core/mas/stage_catalog.py +++ b/gfa_core/mas/stage_catalog.py @@ -78,6 +78,7 @@ PIPELINE_STAGES = { "ppi", "kaks", "combo", + "gene_family_report", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc", @@ -391,6 +392,7 @@ STAGE_ORDER = [ "rnaseq_fgsea", "rnaseq_candidate_family", "rnaseq_enrich", + "gene_family_report", ] STAGE_INFO = { @@ -441,6 +443,7 @@ STAGE_INFO = { "msa": {"name": "多序列比对", "estimated_time": 10, "cpu": 4}, "enrichment": {"name": "富集分析", "estimated_time": 15, "cpu": 4}, "combo": {"name": "组合图", "estimated_time": 10, "cpu": 4}, + "gene_family_report": {"name": "基因家族结果报告", "estimated_time": 2, "cpu": 1}, "genome_prepare": {"name": "基因组准备", "estimated_time": 10, "cpu": 4}, "ncbi_fetch_sequences": {"name": "NCBI序列下载", "estimated_time": 5, "cpu": 4}, "sequence_extract_by_id": {"name": "按ID提取序列", "estimated_time": 5, "cpu": 4}, @@ -529,6 +532,7 @@ EXECUTABLE_STAGES = { "ppi", "kaks", "combo", + "gene_family_report", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc", diff --git a/gfa_core/mas/tools/stage_tools.py b/gfa_core/mas/tools/stage_tools.py index cc76e2f87..b2579bdab 100755 --- a/gfa_core/mas/tools/stage_tools.py +++ b/gfa_core/mas/tools/stage_tools.py @@ -558,9 +558,15 @@ class StageToolExecutor: tfbs_top_motifs=(extra_params or {}).get("tfbsTopMotifs", 30), tfbs_scan_both_strands=bool((extra_params or {}).get("tfbsScanBothStrands", True)), scope_file=(extra_params or {}).get("scopeFile", "") or (extra_params or {}).get("scope_file", ""), + scope_id=param_value("scopeId", "scope_id", default=""), + rnaseq_scope_id=param_value("rnaseqScopeId", "rnaseq_scope_id", default=""), analysis_route=(extra_params or {}).get("analysisRoute", "") or (extra_params or {}).get("analysis_route", ""), member_set_id=(extra_params or {}).get("memberSetId", "") or (extra_params or {}).get("member_set_id", ""), analysis_mode=(extra_params or {}).get("analysisMode", "") or (extra_params or {}).get("analysis_mode", ""), + expected_stages=param_value("expectedStages", "expected_stages", default=[]), + stage_statuses=param_value("stageStatuses", "stage_statuses", default={}), + report_action=param_value("reportAction", "report_action", default="generate"), + report_use_model=bool_param("reportUseModel", "report_use_model", default=True), species_queue=(extra_params or {}).get("speciesQueue", []) or (extra_params or {}).get("species_queue", []), target_species=target_species, interspecies_ref_species=interspecies_ref_species, @@ -1047,6 +1053,22 @@ class StageToolRegistry: "parameters": {"scope_aware": True}, "timeout": 1800 }, + { + "id": "gene_family_report", + "name": "Gene Family Result Report", + "script": "run_gene_family_report.py", + "category": "report", + "description": "Generate or open the formal report for exactly the confirmed gene-family stages", + "required_inputs": [], + "outputs": [ + "gene_family_report", + "gene_family_report_evidence", + "gene_family_report_model", + "gene_family_report_manifest", + ], + "parameters": {"action": "generate", "useModel": True}, + "timeout": 1800 + }, # 15. Genome Prepare { "id": "genome_prepare", diff --git a/gfa_core/mas/utils/intent_parser.py b/gfa_core/mas/utils/intent_parser.py index 48cc0f25a..7524543e8 100755 --- a/gfa_core/mas/utils/intent_parser.py +++ b/gfa_core/mas/utils/intent_parser.py @@ -260,6 +260,35 @@ RNASEQ_STAGES = { "rnaseq_report", } + +def resolve_gene_family_report_intent(query: str) -> Optional[Dict]: + """Resolve only explicit gene-family report generate/open requests.""" + text = str(query or "").lower() + compact = re.sub(r"\s+", "", text) + if any(marker.replace(" ", "") in compact for marker in RNASEQ_MARKERS + ["转录组"]): + return None + action = "" + if any(phrase in compact for phrase in ("打开基因家族结果报告", "打开基因家族报告", "openresultreport")): + action = "open" + elif any(phrase in compact for phrase in ("生成基因家族结果报告", "生成基因家族报告", "生成结果报告", "generateresultreport")): + action = "generate" + if not action: + return None + return { + "gene_family": "", + "species": "", + "stages": ["gene_family_report"], + "primary_stage": "gene_family_report", + "task_type": "report", + "task_kind": "report", + "params": {"geneFamilyReportAction": action, "reportAction": action}, + "confidence": 1.0, + "need_clarify": False, + "clarify_type": None, + "message": None, + "method": "gene_family_report_deterministic", + } + # 物种关键词映射 SPECIES_KEYWORDS = { "Ft": ["ft", "tartary", "苦荞", "荞麦"], @@ -648,6 +677,10 @@ def parse_intent_from_query(query: str) -> Dict: query_lower = query.lower() query_without_space = re.sub(r"\s+", "", query_lower) + report_intent = resolve_gene_family_report_intent(query) + if report_intent: + return report_intent + if any(token.replace(" ", "") in query_without_space for token in ( "wgs全流程", "vcf全流程", "wgs full pipeline", "vcf full pipeline" )): diff --git a/gfa_core/schemas/stages/gene_family_report_contract_v1.md b/gfa_core/schemas/stages/gene_family_report_contract_v1.md new file mode 100644 index 000000000..806bbde11 --- /dev/null +++ b/gfa_core/schemas/stages/gene_family_report_contract_v1.md @@ -0,0 +1,82 @@ +# Gene Family Report Stage Contract v1.0 + +## 阶段信息 + +- **阶段名称**: `gene_family_report` +- **脚本**: `apps/cli/run_gene_family_report.py` +- **职责**: 按用户确认的计划阶段,生成或打开可持久化的基因家族分析结果报告。 + +## 输入契约 + +| 字段 | 类型 | 必需 | 描述 | +|---|---|---|---| +| workspace | directory | 是 | 当前 job 工作目录 | +| prefix | string | 是 | 结果前缀 | +| expected_stages | non-empty stage list | 生成时是 | 用户确认的计划阶段,保持原顺序;不得从目录中已有 Manifest 反推计划 | +| stage_statuses | JSON object | 是 | 每个计划阶段的终态或当前状态 | +| job_id | string | 否 | job 身份 | +| scope_id / analysis_route / member_set_id | string | 否 | 严格证据身份 | +| rnaseq_scope_id | string | 否 | 可选 RNA-seq 证据身份 | +| action | enum | 否 | `generate` 生成;`open` 有现成报告时复用,否则生成 | + +阶段不启动任何上游生物分析。至少一个同身份、同计划范围的成功 Manifest 才能生成报告。 + +`action=generate`,以及 `action=open` 但不存在可复用报告时,`expected_stages` 必须为非空;否则明确拒绝生成。`action=open` 且 Markdown 与报告 Manifest 已存在时可以直接复用,不要求再次提供计划。 + +## 输出契约 + +| artifact key | 类型 | 文件 | +|---|---|---| +| gene_family_report | markdown | `*.gene_family_report.md` | +| gene_family_report_manifest | manifest | `*.gene_family_report.manifest.json` | + +Manifest 记录 `final|partial` 报告状态、`api|fallback_script` 生成模式和当前分析身份。`included_stages` 只包含模板支持且成功的计划阶段;成功但不在模板 13 个阶段中的计划项写入 `unsupported_stages`,在公共完成表标记“不支持”,不生成推断章节,并使报告状态保持 `partial`。API 不可用或编排越界时,`model.reason` 固定为 `api_unavailable_or_boundary_violation`。 + +## 确定性数据接口 + +`build_report_data(final_dir, prefix, planned, statuses, manifests) -> ReportData` 是模板渲染和 API 编排共用的唯一结果模型。每节把脚本确定的内容分为 `facts`、`observations`、`interpretations`、`limitations`、`recommendations`,并汇总为带稳定 `sentence_id`、原文、类型和证据编号的不可变句子。 + +指标优先读取同身份 Manifest 的嵌套 `metrics`。仅当模板所需指标缺失时,集中提取器才读取该指标声明的命名 artifact;不得从文件名、其他阶段或相似字段猜值。真缺失使用 `MissingMetric(label, checked_sources)`,渲染为“源结果未提供此指标(已检查:…)”。声明的数据源存在但解析失败时必须报错,不得静默写成“未解析”、`N/A` 或 `None`。 + +当前真实 schema 映射包括:Identify 的 `member_count` / `identified_members_count` / `final_members`(按此前顺序取首个已提供值),Phylogeny 的 `sequences_count` 与 `msa_length`,PEP 的 `protein_count`、`tasks_success` 与 `tasks_total`。Interspecies Synteny 在没有直接语义指标时只读取 Manifest 声明的 `gene_to_species` 与 `*_collinearity` 命名 artifact:以 `## Alignment` 计共线性区块,并按 TSV 的 `group=target|ref` 名单统计目标—参考家族直接配对。 + +## API 语言编排接口 + +API 输入字段仅限: + +```json +{ + "sections": [ + { + "section_id": "stage.identify", + "sentences": [{"sentence_id": "stage.identify.status", "text": "不可改写原句"}] + } + ], + "allowed_connectors": ["also", "however", "therefore"] +} +``` + +API 输出字段仅限: + +```json +{ + "sections": [ + { + "section_id": "stage.identify", + "paragraphs": [ + {"sentence_ids": ["stage.identify.status"], "connector_ids": []} + ] + } + ] +} +``` + +输出必须保持完整章节集合;每个不可变句子必须在原章节中恰好出现一次;连接语必须来自白名单,数量必须与段内句间位置一致。未知、遗漏或重复 `sentence_id`,未知连接语,额外字段,自由文本字段,章节遗漏、重复或改变,均使整份 API 编排作废并回退 `fallback_script`,不能局部采用。 + +## 报告生成原则 + +1. Manifest、结果表格和结果文件是报告事实的唯一来源。成员数、统计值、比较结果、证据等级、限制、可下结论范围和后续建议均由确定性脚本提取或生成。 +2. 模型 API 默认启用,但只负责排列脚本已生成的 `sentence_id`、组织段落并选择受控 `connector_id`;API 不直接返回报告文本。不得补充、推断或修改任何数值、结果判断、生物学解释、因果关系、候选基因、证据等级、限制或建议。 +3. API 输出必须通过上述严格结构与集合校验;任一边界错误都使整份 API 编排作废,并自动使用同一 `ReportData` 的默认顺序。 +4. API 不可用、超时或校验失败时,不影响报告事实和章节完整性;报告明确标注 `fallback_script`,但仍使用同一模板和同一份确定性结果数据。 +5. 只纳入用户计划中的阶段。源结果确实没有提供某项指标时,明确写“源结果未提供此指标”并指出所检查的来源;不得用“未解析”、`N/A` 或模型猜测代替可提取的数据。 diff --git a/gfa_core/templates/gene_family_report_v1.md b/gfa_core/templates/gene_family_report_v1.md new file mode 100644 index 000000000..effa63973 --- /dev/null +++ b/gfa_core/templates/gene_family_report_v1.md @@ -0,0 +1,170 @@ + +# {{PREFIX}} 基因家族分析结果解读报告 + +- 任务:{{JOB_ID}} +- 分析前缀:{{PREFIX}} +- 生成模式:{{GENERATION_MODE}} +- 生成时间:{{GENERATED_AT}} + +{{FALLBACK_NOTICE}} + +## 一、报告首页 + +### 1.1 项目信息 + +本报告只覆盖用户计划中的成功阶段;不同阶段的目标集合在各节单独标明。 + +### 1.2 研究目的 + +本报告用于确认家族成员,比较目标成员及必要参考成员的关系、结构、调控序列与蛋白预测特征,并界定结果能够支持的结论范围。 + +### 1.3 一页式结论摘要 + +{{SECTION_CONTENT}} + +### 1.4 结论可信度说明 + +成员数和命名表中的统计可直接复核;树、启动子、TFBS、共线性和蛋白定位只提供计算线索,不等同于功能或因果证据。 + + +## 二、分析路线与阅读顺序 + +{{SECTION_CONTENT}} + +### 2.1 统一解读顺序 + +各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。 + +### 2.2 语言边界 + +预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。 + + +## 三、结果总览与完整性检查 + +### 3.1 阶段完成情况 + +{{SECTION_CONTENT}} + +### 3.2 结果完整性概述 + +报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。 + + +## 四、基因家族鉴定(Identify) (`identify`) + +### 4.3 最终成员数量与命名 + +{{SECTION_CONTENT}} + + +## 五、亲缘关系分析(Phylogeny) (`phylogeny`) + +### 5.5 亲缘关系阶段小结 + +{{SECTION_CONTENT}} + + +## 六、参考物种辅助的小组分类(Reference Classification) (`reference_classification`) + +### 6.2 分类可信度表述 + +{{SECTION_CONTENT}} + + +## 七、短保守片段分析(Motif) (`motif`) + +### 7.2 Motif 数量与出现频率 + +{{SECTION_CONTENT}} + + +## 八、主要功能区域分析(Domain) (`domain`) + +### 8.1 Domain 整体统计 + +{{SECTION_CONTENT}} + + +## 九、基因内部结构分析(Structure) (`structure`) + +### 9.1 结构覆盖与异常结构 + +{{SECTION_CONTENT}} + + +## 十、综合组合图(Combo) (`combo`) + +### 10.1 树 + Motif + Domain + Structure 组合图 + +{{SECTION_CONTENT}} + + +## 十一、启动子元件分析(Promoter) (`promoter`) + +### 11.1 元件总量、类别与高级统计 + +{{SECTION_CONTENT}} + + +## 十二、转录因子结合位点预测(TFBS) (`tfbs`) + +### 12.1 高频结合片段与统计边界 + +{{SECTION_CONTENT}} + + +## 十三、种内共线性分析(Synteny) (`synteny`) + +### 13.1 全基因组共线性背景与家族证据缺口 + +{{SECTION_CONTENT}} + + +## 十四、染色体分布(Karyotype) (`karyotype`) + +### 14.1 染色体位置概述 + +{{SECTION_CONTENT}} + + +## 十五、种间共线性与跨物种关系(Interspecies Synteny) (`interspecies_synteny`) + +### 15.6 Interspecies Synteny 阶段小结 + +{{SECTION_CONTENT}} + + +## 十六、蛋白基本特征分析(PEP) (`pep`) + +### 16.1 蛋白理化性质分布图 + +逐蛋白理化性质保留在命名结果表中;源 Manifest 没有汇总长度、分子量或等电点范围时,正文不从图片反推数值。 + +### 16.4 亚细胞定位与阶段小结 + +{{SECTION_CONTENT}} + + +## 十七、多证据综合分析 + +### 17.5 综合结论 + +{{SECTION_CONTENT}} + + +## 十八、主要限制 + +{{SECTION_CONTENT}} + + +## 十九、后续验证建议 + +{{SECTION_CONTENT}} + + +## 二十、报告交付前检查清单 + +### 20.1 可追溯性与语言检查 + +{{SECTION_CONTENT}} diff --git a/tasks.py b/tasks.py index 6afc4df46..511c1e5c4 100755 --- a/tasks.py +++ b/tasks.py @@ -411,7 +411,7 @@ def validate_single_stage_artifacts(job_id: str, prefix: str, stage: str, result stage_status = get_stage_status_for_identity( job_id=job_id, identity=identity, - strict_identity=bool( + strict_identity=actual_stage != "gene_family_report" and bool( identity.scope_id or identity.analysis_route or identity.member_set_id diff --git a/tests/e2e/verify_gene_family_report_journey.py b/tests/e2e/verify_gene_family_report_journey.py new file mode 100644 index 000000000..a8028fd6d --- /dev/null +++ b/tests/e2e/verify_gene_family_report_journey.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Small real-data journey for the gene-family report module.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import shutil +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from apps.cli.run_gene_family_report import main as report_cli_main +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +PREFIX = "Real.SPL" +SCOPE_ID = "scope-real-spl" +ANALYSIS_ROUTE = "post_subfamily_confirm" +MEMBER_SET_ID = "members-real-spl" + + +def _sequence_count(path: Path) -> int: + count = sum(line.startswith(">") for line in path.read_text(encoding="utf-8").splitlines()) + if not count: + raise AssertionError("representative FASTA is empty") + return count + + +def _write_identify_manifest(workspace: Path, fasta: Path, count: int) -> None: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "manifest_version": "1.0", + "stage": "identify", + "status": "success", + "prefix": PREFIX, + "scope_id": SCOPE_ID, + "analysis_route": ANALYSIS_ROUTE, + "member_set_id": MEMBER_SET_ID, + "parameters": { + "scope_id": SCOPE_ID, + "analysis_route": ANALYSIS_ROUTE, + "member_set_id": MEMBER_SET_ID, + }, + "metrics": {"member_count": count, "final_members": count, "total_proteins": count}, + "artifacts": { + "final_members": {"path": str(fasta), "full_path": str(fasta), "type": "protein_fasta"} + }, + } + (final_dir / f"{PREFIX}.identify.manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _run(workspace: Path, action: str): + return report_cli_main( + [ + "--workspace", str(workspace), + "--prefix", PREFIX, + "--job-id", "representative-gene-family-report", + "--expected-stages", "identify", + "--stage-statuses-json", '{"identify":"success"}', + "--scope-id", SCOPE_ID, + "--analysis-route", ANALYSIS_ROUTE, + "--member-set-id", MEMBER_SET_ID, + "--action", action, + "--no-model", + ] + ) + + +def _stage_section(report: str, stage: str) -> str: + heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", report) + if heading is None: + raise AssertionError(f"missing report chapter for {stage}") + next_heading = re.search(r"(?m)^## (?!#)", report[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(report) + return report[heading.start():end] + + +def _subsection(report: str, number: str) -> str: + heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", report) + if heading is None: + raise AssertionError(f"missing report subsection {number}") + next_heading = re.search(r"(?m)^#{2,3} ", report[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(report) + return report[heading.start():end] + + +def _verify_real_job(workspace: Path, prefix: str) -> None: + final_dir = workspace / "final" + report_path = final_dir / f"{prefix}.gene_family_report.md" + manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json" + report = report_path.read_text(encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expected_stages = [ + "identify", + "phylogeny", + "reference_classification", + "motif", + "domain", + "structure", + "combo", + "promoter", + "tfbs", + "synteny", + "karyotype", + "interspecies_synteny", + "pep", + ] + + assert report.startswith(f"# {prefix} 基因家族分析结果解读报告\n") + for placeholder in ("【填写】", "未解析", "N/A", "None"): + assert placeholder not in report + assert "/home/xzq/" not in report + assert not re.search(r"(?m)^- `[a-zA-Z0-9_.-]+`:\s*", report) + assert manifest["parameters"]["expected_stages"] == expected_stages + assert manifest["metrics"]["included_stages"] == expected_stages + assert manifest["metrics"]["generation_mode"] == "fallback_script" + assert sorted(path.name for path in final_dir.glob(f"{prefix}.gene_family_report*")) == [ + f"{prefix}.gene_family_report.manifest.json", + f"{prefix}.gene_family_report.md", + ] + for stage in expected_stages: + section = _stage_section(report, stage) + for chunk in re.split(r"(?m)(?=^### )", section)[1:]: + assert "\n".join(chunk.splitlines()[1:]).strip(), chunk.splitlines()[0] + assert "分析对象:" in section + assert "限制:" in section + assert report.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1 + summary = _subsection(report, "1.3") + assert 6 <= summary.count("。") <= 10 + assert re.search(r"目标物种.*24.*家族成员", summary) + assert re.search(r"形成\s*24\s*个\s*SPL家族", _subsection(report, "4.3")) + assert re.search( + r"使用\s*40\s*条序列.*比对长度为\s*1449", _stage_section(report, "phylogeny") + ) + motif = _stage_section(report, "motif") + assert re.search(r"识别\s*10\s*个\s*motif", motif) + assert re.search(r"总位点数为\s*105", motif) + assert re.search(r"宽度范围为\s*27–138", motif) + assert re.search(r"将\s*24\s*个家族成员定位到\s*8\s*条染色体", _stage_section(report, "karyotype")) + assert re.search( + r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", + _stage_section(report, "interspecies_synteny"), + ) + assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", _stage_section(report, "pep")) + assert re.search(r"24\s*个目标成员.*8\s*个小组.*高信心\s*14.*中信心\s*7.*低信心\s*3", _subsection(report, "6.2")) + assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(report, "8.1")) + assert re.search(r"24/40.*FtPinG0003446200\.01\.T01.*12454", _subsection(report, "9.1")) + assert "Motif、Domain、Structure" in _subsection(report, "10.1") + promoter = _subsection(report, "11.1") + assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter) + assert "CAAT-box—MYB" in promoter and "FtPinG0006337300.01.T01" in promoter + tfbs = _subsection(report, "12.1") + assert re.search(r"24\s*条目标启动子.*3734.*AHYPO_015621.*153", tfbs) + assert re.search(r"背景启动子为\s*0.*不能.*富集", tfbs) + synteny = _subsection(report, "13.1") + assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", synteny) + assert "源结果未提供家族专属复制基因对" in synteny + assert re.search(r"Nucleus\s*37.*Cytoplasm\s*2.*Chloroplast\s*1", _subsection(report, "16.4")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("data", "e2e")) + parser.add_argument("--workspace", type=Path) + parser.add_argument("--prefix") + args = parser.parse_args() + + if args.workspace is not None: + if not args.prefix: + parser.error("--prefix is required with --workspace") + _verify_real_job(args.workspace, args.prefix) + print(json.dumps({"status": "passed", "workspace": str(args.workspace)}, ensure_ascii=False)) + return 0 + if args.mode is None: + parser.error("--mode is required when --workspace is not provided") + + output_dir = PROJECT_ROOT / "test-results/gene-family-report" / args.mode + if output_dir.exists(): + shutil.rmtree(output_dir) + workspace = output_dir / "job" + fasta = PROJECT_ROOT / "tests/ft_data/SPL_Ath.fasta" + count = _sequence_count(fasta) + _write_identify_manifest(workspace, fasta, count) + + generated = _run(workspace, "generate") + report = generated.report_path.read_text(encoding="utf-8") + manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8")) + assert generated.report_path.name == f"{PREFIX}.gene_family_report.md" + assert generated.manifest_path.name == f"{PREFIX}.gene_family_report.manifest.json" + assert "生成模式:候补脚本" in report + assert f"最终成员数:{count}" in report + assert "## 基因家族鉴定 (`identify`)" in report + assert "(`motif`)" not in report + assert manifest["scope_id"] == SCOPE_ID + assert manifest["analysis_route"] == ANALYSIS_ROUTE + assert manifest["member_set_id"] == MEMBER_SET_ID + assert manifest["metrics"]["included_stages"] == ["identify"] + assert set(manifest["artifacts"]) == {"gene_family_report", "gene_family_report_manifest"} + + reopened = _run(workspace, "open") + assert reopened.reused is True + intent = parse_intent_from_query("打开基因家族结果报告") + assert intent["stages"] == ["gene_family_report"] + assert intent["params"]["reportAction"] == "open" + + print(json.dumps({ + "status": "passed", + "mode": args.mode, + "sequence_count": count, + "report": str(generated.report_path), + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_gene_family_report.py b/tests/unit/test_gene_family_report.py new file mode 100644 index 000000000..45a996914 --- /dev/null +++ b/tests/unit/test_gene_family_report.py @@ -0,0 +1,844 @@ +from __future__ import annotations + +import json +from pathlib import Path +import re + +import pytest + +import gfa_core.gene_family_report as report_module +from gfa_core.gene_family_report import ( + NoSuccessfulGeneFamilyEvidence, + generate_gene_family_report, +) + + +def _write_manifest( + workspace: Path, + *, + prefix: str, + stage: str, + status: str = "success", + scope_id: str = "scope-a", + analysis_route: str = "post_subfamily_confirm", + member_set_id: str = "members-a", + metric_value: int = 1, +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + token = f"{scope_id}.{analysis_route}.{member_set_id}" + table_path = final_dir / f"{prefix}.{token}.{stage}.tsv" + table_path.write_text(f"gene\tvalue\n{scope_id}_gene\t{metric_value}\n", encoding="utf-8") + manifest_path = final_dir / f"{prefix}.{token}.{stage}.manifest.json" + manifest = { + "manifest_version": "1.0", + "stage": stage, + "status": status, + "prefix": prefix, + "scope_id": scope_id, + "analysis_route": analysis_route, + "member_set_id": member_set_id, + "parameters": { + "scope_id": scope_id, + "analysis_route": analysis_route, + "member_set_id": member_set_id, + }, + "metrics": {"member_count": metric_value}, + "artifacts": { + "result_table": { + "path": f"final/{table_path.name}", + "full_path": str(table_path), + "type": "table", + } + }, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return manifest_path + + +def _generate( + workspace: Path, + *, + expected_stages: list[str], + stage_statuses: dict[str, str], + use_model: bool = False, + action: str = "generate", +): + return generate_gene_family_report( + workspace, + "Ft.SPL", + expected_stages=expected_stages, + stage_statuses=stage_statuses, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + use_model=use_model, + action=action, + job_id="job-report", + ) + + +def _stage_section(markdown: str, stage: str) -> str: + heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", markdown) + if heading is None: + raise AssertionError(f"missing report chapter for {stage}") + next_heading = re.search(r"(?m)^## (?!#)", markdown[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(markdown) + return markdown[heading.start():end] + + +def _subsection(markdown: str, number: str) -> str: + heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", markdown) + if heading is None: + raise AssertionError(f"missing report subsection {number}") + next_heading = re.search(r"(?m)^#{2,3} ", markdown[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(markdown) + return markdown[heading.start():end] + + +def _valid_api_arrangement(payload: dict, *, reverse: bool = False) -> dict: + sections = [] + for section in payload["sections"]: + sentence_ids = [sentence["sentence_id"] for sentence in section["sentences"]] + if reverse: + sentence_ids.reverse() + sections.append( + { + "section_id": section["section_id"], + "paragraphs": [ + { + "sentence_ids": sentence_ids, + "connector_ids": ["also"] * max(0, len(sentence_ids) - 1), + } + ], + } + ) + return {"sections": sections} + + +def test_report_uses_only_planned_stages_and_current_identity(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + other = _write_manifest( + workspace, + prefix="Ft.SPL", + stage="identify", + scope_id="scope-b", + analysis_route="target_only", + member_set_id="members-b", + metric_value=99, + ) + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + report_text = result.report_path.read_text(encoding="utf-8") + assert "最终成员数:3" in report_text + assert other.name not in report_text + generated = {path.name for path in (workspace / "final").glob("Ft.SPL.gene_family_report.*")} + assert generated == { + "Ft.SPL.gene_family_report.md", + "Ft.SPL.gene_family_report.manifest.json", + } + + +def test_report_renders_template_subsections_for_only_planned_stages(tmp_path: Path) -> None: + workspace = tmp_path / "job" + for stage in ("identify", "motif", "pep"): + _write_manifest(workspace, prefix="Ft.SPL", stage=stage) + + result = _generate( + workspace, + expected_stages=["identify", "motif", "pep"], + stage_statuses={"identify": "success", "motif": "success", "pep": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + expected_subsections = { + "identify": "### 4.3 最终成员数量与命名", + "motif": "### 7.2 Motif 数量与出现频率", + "pep": "### 16.1 蛋白理化性质分布图", + } + for stage, subsection in expected_subsections.items(): + assert subsection in _stage_section(markdown, stage) + assert "(`synteny`)" not in markdown + assert "【填写】" not in markdown + + +def test_report_renders_nested_motif_metrics_without_placeholder_values(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="motif") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = { + "motif_count": 10, + "total_sites": 105, + "motifs": [ + {"width": width} + for width in (78, 76, 80, 113, 57, 29, 52, 138, 27, 29) + ], + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["motif"], + stage_statuses={"motif": "success"}, + ) + + motif_section = _stage_section(result.report_path.read_text(encoding="utf-8"), "motif") + assert re.search(r"识别\s*10\s*个\s*motif", motif_section) + assert re.search(r"总位点数为\s*105", motif_section) + assert re.search(r"宽度范围为\s*27–138", motif_section) + assert "未解析" not in motif_section + assert "N/A" not in motif_section + assert "None" not in motif_section + + +def test_identify_uses_real_manifest_member_count_aliases(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"identified_members_count": 24, "final_members": 24} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "identify") + assert "最终成员数:24" in section + assert re.search(r"形成\s*24\s*个\s*SPL家族", section) + assert "源结果未提供此指标" not in section + + +def test_phylogeny_renders_real_manifest_schema_with_explicit_labels(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["phylogeny"], + stage_statuses={"phylogeny": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "phylogeny") + assert "序列数:40" in section + assert "多序列比对长度:1449" in section + assert re.search(r"使用\s*40\s*条序列.*比对长度为\s*1449", section) + + +def test_interspecies_synteny_reads_only_named_real_schema_artifacts(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="interspecies_synteny") + final_dir = workspace / "final" + gene_to_species = final_dir / "Ft.SPL.gene_to_species.tsv" + gene_to_species.write_text( + "gene_id\tspecies\tgroup\n" + "FtGene1.01.T01\tFt\ttarget\n" + "FtGene2.01.T01\tFt\ttarget\n" + "AT1G00010\tAT\tref\n" + "AT1G00020\tAT\tref\n", + encoding="utf-8", + ) + collinearity = final_dir / "Ft.At.collinearity" + lines = [] + for index in range(521): + lines.append(f"## Alignment {index}: score=1 e_value=1e-10 N=1 Ft&At plus") + if index == 0: + lines.append(" 0- 0:\tAT1G00010.1\tFtGene1.01.T01\t1e-20") + elif index == 1: + lines.append(" 1- 0:\tAT1G00020.1\tFtGene2.01.T01\t1e-20") + collinearity.write_text("\n".join(lines) + "\n", encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"pair_success_count": 1, "pair_total_count": 1} + manifest["artifacts"] = { + "gene_to_species": {"path": f"final/{gene_to_species.name}", "type": "tsv"}, + "Ft.At_collinearity": {"path": f"final/{collinearity.name}", "type": "data"}, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["interspecies_synteny"], + stage_statuses={"interspecies_synteny": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "interspecies_synteny") + assert "共线性区块数:521" in section + assert "家族成员目标—参考配对数:2" in section + assert re.search(r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", section) + + +def test_pep_renders_real_manifest_task_summary_with_explicit_labels(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="pep") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"protein_count": 40, "tasks_success": 3, "tasks_total": 3} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["pep"], + stage_statuses={"pep": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "pep") + assert "蛋白数:40" in section + assert "子任务成功数:3/3" in section + assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", section) + + +def test_report_has_no_empty_third_level_subsections_or_repeated_generic_interpretation( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job" + for stage in ("identify", "phylogeny", "motif"): + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage=stage) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if stage == "phylogeny": + manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449} + elif stage == "motif": + manifest["metrics"] = { + "sequence_count": 40, + "motif_count": 10, + "total_sites": 105, + "motifs": [{"width": 27}, {"width": 138}], + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify", "phylogeny", "motif"], + stage_statuses={stage: "success" for stage in ("identify", "phylogeny", "motif")}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + for stage in ("identify", "phylogeny", "motif"): + section = _stage_section(markdown, stage) + chunks = re.split(r"(?m)(?=^### )", section)[1:] + assert chunks + for chunk in chunks: + body = "\n".join(chunk.splitlines()[1:]).strip() + assert body, f"empty subsection: {chunk.splitlines()[0]}" + assert markdown.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1 + assert markdown.count("结果范围受当前输入、参数和源文件完整性限制。") <= 1 + + +def test_front_summary_contains_project_numbers_in_six_to_ten_sentences(tmp_path: Path) -> None: + workspace = tmp_path / "job" + identify_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + identify = json.loads(identify_path.read_text(encoding="utf-8")) + identify["metrics"] = {"identified_members_count": 24, "final_members": 24} + identify_path.write_text(json.dumps(identify, ensure_ascii=False), encoding="utf-8") + phylogeny_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny") + phylogeny = json.loads(phylogeny_path.read_text(encoding="utf-8")) + phylogeny["metrics"] = {"sequences_count": 40, "msa_length": 1449} + phylogeny_path.write_text(json.dumps(phylogeny, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify", "phylogeny"], + stage_statuses={"identify": "success", "phylogeny": "success"}, + ) + + summary = _subsection(result.report_path.read_text(encoding="utf-8"), "1.3") + assert re.search(r"目标物种.*24.*家族成员", summary) + assert re.search(r"目标.*参考.*40.*1449", summary) + assert 6 <= summary.count("。") <= 10 + + +def test_stage_specific_key_facts_land_in_their_semantic_subsections(tmp_path: Path) -> None: + workspace = tmp_path / "job" + final_dir = workspace / "final" + stages = [ + "reference_classification", "domain", "structure", "combo", + "promoter", "tfbs", "synteny", + ] + manifests = {stage: _write_manifest(workspace, prefix="Ft.SPL", stage=stage) for stage in stages} + + assignment = final_dir / "Ft.SPL.reference_classification.assignment.tsv" + assignment.write_text( + "target_gene\tassigned_subfamily\tconfidence\n" + + "\n".join( + f"FtGene{i}\t{'I' if i < 2 else 'II'}\t{confidence}" + for i, confidence in enumerate(("high", "high", "medium", "low"), start=1) + ) + + "\n", + encoding="utf-8", + ) + reference = json.loads(manifests["reference_classification"].read_text(encoding="utf-8")) + reference["metrics"] = {"target_gene_count": 4, "subfamily_rows": 6} + reference["artifacts"] = {"assignment_table": {"path": f"final/{assignment.name}", "type": "tsv"}} + manifests["reference_classification"].write_text(json.dumps(reference), encoding="utf-8") + + domain = json.loads(manifests["domain"].read_text(encoding="utf-8")) + domain["metrics"] = { + "proteins_total": 40, "proteins_with_domains": 40, "domain_coverage_rate": 1.0, + "top_domains": [["SBP", 40], ["Ig_SPL7", 12]], + } + manifests["domain"].write_text(json.dumps(domain), encoding="utf-8") + + structure = json.loads(manifests["structure"].read_text(encoding="utf-8")) + structure["metrics"] = { + "genes_total": 40, "genes_with_structure": 24, "structure_coverage_rate": 0.6, + "long_gene_outlier_count": 1, + "long_gene_outliers": [{"gene_id": "FtLong1", "length_bp": 12454, "cds_count": 10}], + } + manifests["structure"].write_text(json.dumps(structure), encoding="utf-8") + + combo = json.loads(manifests["combo"].read_text(encoding="utf-8")) + combo["metrics"] = None + combo["parameters"] = {"panels": ["motif", "domain", "structure"]} + combo["artifacts"] = { + "combo_plot_png": {"path": "final/Ft.SPL.combo.png", "type": "image"}, + "combo_plot_pdf": {"path": "final/Ft.SPL.combo.pdf", "type": "image"}, + } + manifests["combo"].write_text(json.dumps(combo), encoding="utf-8") + + element_counts = final_dir / "Ft.SPL.element_counts.txt" + element_counts.write_text("1460\tTATA-box\n852\tCAAT-box\n72\tMYB\n", encoding="utf-8") + category_summary = final_dir / "Ft.SPL.promoter_category_summary.tsv" + category_summary.write_text("big7\telement_instances\nCore promoter\t2600\nLight response\t188\n", encoding="utf-8") + enrichment = final_dir / "Ft.SPL.enrichment_analysis.txt" + enrichment.write_text( + "Subfamily\tElement\tCount\tSubfamilyTotal\tGlobalCount\tEnrichmentRatio\nI\tAACA_motif\t1\t222\t1\t16.66\n", + encoding="utf-8", + ) + cooccurrence = final_dir / "Ft.SPL.cooccurrence_analysis.txt" + cooccurrence.write_text( + "Element1\tElement2\tCo-occurrenceCount\tGenes\nCAAT-box\tMYB\t23\tFtGene1,FtGene2\n", + encoding="utf-8", + ) + complexity = final_dir / "Ft.SPL.complexity_analysis.txt" + complexity.write_text( + "Gene\tTotalElements\tUniqueElements\tComplexityScore\tComplexityClass\nFtComplex1\t278\t33\t361.44\tVery High\n", + encoding="utf-8", + ) + promoter = json.loads(manifests["promoter"].read_text(encoding="utf-8")) + promoter["metrics"] = {"total_elements": 3699, "unique_elements": 87, "genes_with_elements": 23} + promoter["artifacts"] = { + "element_counts": {"path": f"final/{element_counts.name}"}, + "viz_promoter_position_distribution_summary": {"path": f"final/{category_summary.name}"}, + "enrichment_analysis": {"path": f"final/{enrichment.name}"}, + "cooccurrence_analysis": {"path": f"final/{cooccurrence.name}"}, + "complexity_analysis": {"path": f"final/{complexity.name}"}, + } + manifests["promoter"].write_text(json.dumps(promoter), encoding="utf-8") + + motif_summary = final_dir / "Ft.SPL.tfbs.motif_summary.tsv" + motif_summary.write_text( + "motif_id\tmotif_alt_id\tfamily_sites\tfamily_genes_with_site\tbackground_sites\tbackground_genes_with_site\n" + "MOTIF_A\t\t153\t18\t0\t0\n", + encoding="utf-8", + ) + tfbs = json.loads(manifests["tfbs"].read_text(encoding="utf-8")) + tfbs["metrics"] = { + "family_promoter_count": 24, "background_promoter_count": 0, + "family_site_count": 3734, "family_motif_count": 230, "motif_database": "PlantTFDB", + } + tfbs["artifacts"] = {"motif_summary": {"path": f"final/{motif_summary.name}"}} + manifests["tfbs"].write_text(json.dumps(tfbs), encoding="utf-8") + + synteny = json.loads(manifests["synteny"].read_text(encoding="utf-8")) + synteny["metrics"] = {"gene_count": 31679, "synteny_blocks": 316, "gene_pairs": 3267, "circos_chromosomes_plotted": 8} + manifests["synteny"].write_text(json.dumps(synteny), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=stages, + stage_statuses={stage: "success" for stage in stages}, + ) + markdown = result.report_path.read_text(encoding="utf-8") + assert re.search(r"4\s*个目标成员.*2\s*个小组.*高信心\s*2", _subsection(markdown, "6.2")) + assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(markdown, "8.1")) + assert re.search(r"24/40.*FtLong1.*12454", _subsection(markdown, "9.1")) + assert "Motif、Domain、Structure" in _subsection(markdown, "10.1") + promoter_text = _subsection(markdown, "11.1") + assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter_text) + assert "FtComplex1" in promoter_text and "CAAT-box—MYB" in promoter_text + assert re.search(r"24\s*条目标启动子.*3734.*MOTIF_A.*153", _subsection(markdown, "12.1")) + assert re.search(r"背景启动子为\s*0.*不能.*富集", _subsection(markdown, "12.1")) + assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", _subsection(markdown, "13.1")) + assert "源结果未提供家族专属复制基因对" in _subsection(markdown, "13.1") + + +def test_all_planned_success_is_final(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="motif") + + result = _generate( + workspace, + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "success"}, + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "final" + assert manifest["metrics"]["report_status"] == "final" + assert manifest["metrics"]["included_stages"] == ["identify", "motif"] + + +def test_unsupported_successful_stage_is_visible_but_not_included_or_final(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="ppi") + + result = _generate( + workspace, + expected_stages=["identify", "ppi"], + stage_statuses={"identify": "success", "ppi": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "partial" + assert result.included_stages == ("identify",) + assert "ppi" in markdown + assert "不支持" in markdown + assert "(`ppi`)" not in markdown + assert manifest["metrics"]["included_stages"] == ["identify"] + assert manifest["metrics"]["unsupported_stages"] == ["ppi"] + + +@pytest.mark.parametrize("expected_stages", [None, []]) +def test_generate_rejects_missing_plan_instead_of_inferring_historical_manifests( + tmp_path: Path, expected_stages: list[str] | None +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + with pytest.raises(ValueError, match="expected_stages"): + generate_gene_family_report( + workspace, + "Ft.SPL", + expected_stages=expected_stages, + stage_statuses={"identify": "success"}, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + use_model=False, + action="generate", + ) + + assert not list((workspace / "final").glob("*.gene_family_report.*")) + + +def test_report_selects_key_facts_without_dumping_manifest_or_stale_paths(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"].update({"total_proteins": 31679, "blast_enabled": True}) + manifest["artifacts"]["result_table"]["path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv" + manifest["artifacts"]["result_table"]["full_path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "最终成员数:3" in _subsection(markdown, "4.3") + assert not re.search(r"(?m)^- `(?:member_count|total_proteins|blast_enabled)\s*`:", markdown) + assert "/home/xzq/" not in markdown + + +def test_fallback_uses_nested_karyotype_metrics(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="karyotype") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = { + "species": { + "Ft": { + "gene_count": 24, + "chromosome_count": 8, + "chromosomes": [f"Ft{i}" for i in range(1, 9)], + "tandem_count": 0, + } + } + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["karyotype"], + stage_statuses={"karyotype": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "24个家族成员定位到8条染色体" in markdown + assert "核型分析将N/A" not in markdown + + +def test_artifact_path_remaps_remote_job_to_current_mount(tmp_path: Path) -> None: + from gfa_core.mas.agents.interpretation_evidence import _artifact_path + + final_dir = tmp_path / "job" / "final" + final_dir.mkdir(parents=True) + manifest_path = final_dir / "Ft.SPL.stage.manifest.json" + local_artifact = final_dir / "Ft.SPL.result.tsv" + local_artifact.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8") + manifest = { + "_manifest_path": str(manifest_path), + "artifacts": { + "result": { + "full_path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv", + "path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv", + } + }, + } + + assert _artifact_path(manifest, "result") == local_artifact + + +def test_failed_or_missing_planned_stage_is_partial(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="motif", status="failed") + + result = _generate( + workspace, + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "failed"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "partial" + assert "motif" in markdown.lower() + assert "failed" in markdown.lower() + assert manifest["metrics"]["failed_or_missing_stages"] == ["motif"] + + +def test_model_failure_creates_labeled_fallback_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + def fail_model(_evidence: dict, _stages: list[str]): + raise TimeoutError("provider timeout") + + monkeypatch.setattr(report_module, "_generate_api_narrative", fail_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "生成模式:候补脚本" in markdown + assert manifest["metrics"]["generation_mode"] == "fallback_script" + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + assert "{'action':" not in markdown + + +def test_valid_model_sections_create_api_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + seen_payload: dict = {} + + def valid_model(payload: dict, _stages: list[str]): + seen_payload.update(payload) + return ( + _valid_api_arrangement(payload, reverse=True), + {"status": "generated", "provider": "test", "model": "test-model"}, + ) + + monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "api" + assert set(seen_payload) == {"sections", "allowed_connectors"} + assert all(set(section) == {"section_id", "sentences"} for section in seen_payload["sections"]) + assert all( + set(sentence) == {"sentence_id", "text"} + for section in seen_payload["sections"] + for sentence in section["sentences"] + ) + assert "最终成员数:1" in markdown + assert manifest["metrics"]["generation_mode"] == "api" + assert "【填写】" not in markdown + + +def test_unknown_model_sentence_id_falls_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + response["sections"][0]["paragraphs"][0]["sentence_ids"][0] = "unknown.sentence" + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +def test_missing_and_duplicate_model_sentence_ids_fall_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + paragraph = next( + item["paragraphs"][0] + for item in response["sections"] + if len(item["paragraphs"][0]["sentence_ids"]) >= 2 + ) + paragraph["sentence_ids"][-1] = paragraph["sentence_ids"][0] + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +@pytest.mark.parametrize( + "violation", + ["unknown_connector", "extra_field", "free_text_field", "changed_section"], +) +def test_model_schema_or_connector_boundary_violation_falls_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, violation: str +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + if violation == "unknown_connector": + paragraph = next( + item["paragraphs"][0] + for item in response["sections"] + if item["paragraphs"][0]["connector_ids"] + ) + paragraph["connector_ids"][0] = "invented" + elif violation == "extra_field": + response["unexpected"] = True + elif violation == "free_text_field": + response["sections"][0]["paragraphs"][0]["text"] = "API 自由文本" + else: + response["sections"][0]["section_id"] = "changed.section" + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert "API 自由文本" not in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +def test_open_reuses_existing_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + calls = {"count": 0} + + def valid_model(payload: dict, _stages: list[str]): + calls["count"] += 1 + return ( + _valid_api_arrangement(payload), + {"status": "generated", "provider": "test", "model": "test-model"}, + ) + + monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model) + first = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + reopened = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + action="open", + ) + assert first.reused is False + assert reopened.reused is True + assert calls["count"] == 1 + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"]["member_count"] = 2 + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + reopened_after_source_change = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + action="open", + ) + assert reopened_after_source_change.reused is True + assert calls["count"] == 1 + + +def test_no_successful_evidence_creates_no_empty_report(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", status="failed") + + with pytest.raises(NoSuccessfulGeneFamilyEvidence, match="暂无可生成"): + _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "failed"}, + ) + + assert not list((workspace / "final").glob("*.gene_family_report.*")) diff --git a/tests/unit/test_gene_family_report_intent.py b/tests/unit/test_gene_family_report_intent.py new file mode 100644 index 000000000..f5dcad547 --- /dev/null +++ b/tests/unit/test_gene_family_report_intent.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import asyncio +import json + +import pytest + +from gfa_core.mas.core.fast_intent import fast_intent_recognition +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成结果报告", "generate"), + ("生成基因家族结果报告", "generate"), + ("打开基因家族结果报告", "open"), + ], +) +def test_explicit_gene_family_report_intent_is_provider_independent(query: str, action: str) -> None: + for parsed in (parse_intent_from_query(query), fast_intent_recognition(query)): + assert parsed is not None + assert parsed["stages"] == ["gene_family_report"] + assert parsed["params"]["geneFamilyReportAction"] == action + assert parsed["params"]["reportAction"] == action + assert parsed["method"] == "gene_family_report_deterministic" + + +def test_rnaseq_report_intent_is_not_stolen() -> None: + for parsed in ( + parse_intent_from_query("打开 RNA-seq 结果报告"), + fast_intent_recognition("打开 RNA-seq 结果报告"), + ): + assert not parsed or parsed.get("stages") != ["gene_family_report"] + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成结果报告", "generate"), + ("生成基因家族结果报告", "generate"), + ("打开基因家族结果报告", "open"), + ], +) +def test_gene_family_report_chat_bypasses_router( + monkeypatch, + tmp_path, + query: str, + action: str, +) -> None: + from apps.web.vue3 import main as web_main + from gfa_core.mas.agents import router as router_module + + final_dir = tmp_path / "workspace" / "job-report" / "final" + final_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "parameters": { + "expected_stages": ["identify", "motif"], + "stage_statuses": {"identify": "success", "motif": "success"}, + }, + } + ), + encoding="utf-8", + ) + + async def forbidden_router(*_args, **_kwargs): + raise AssertionError("report intent must return before Router/LLM") + + class ForbiddenOrchestrator: + async def plan(self, *_args, **_kwargs): + raise AssertionError("report plan must not call the LLM orchestrator") + + monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path / "workspace") + monkeypatch.setattr(web_main, "agent_conversation_store", None, raising=False) + monkeypatch.setattr(web_main, "llm_manager", object()) + monkeypatch.setattr(web_main, "orchestrator", ForbiddenOrchestrator()) + monkeypatch.setattr(web_main, "MAS_AVAILABLE", True) + monkeypatch.setattr(router_module.RouterAgent, "execute", forbidden_router) + + result = asyncio.run( + web_main._run_agent_chat( + web_main.AgentChatRequest(message=query, job_id="job-report") + ) + ) + + assert result["action"] == "propose_analysis" + assert result["model_used"] == "rule" + assert result["intent"]["stages"] == ["gene_family_report"] + assert result["intent"]["prefix"] == "Ft.SPL" + assert result["intent"]["params"]["reportAction"] == action + assert result["intent"]["params"]["expectedStages"] == ["identify", "motif"] + + plan = asyncio.run( + web_main.agent_plan( + web_main.AgentPlanRequest( + message=query, + job_id="job-report", + prefix="Ft.SPL", + intent=result["intent"], + ) + ) + ) + assert plan["success"] is True + assert plan["mode"] == "gene_family_report_deterministic" + assert plan["plan"]["stages"] == ["gene_family_report"] + assert plan["plan"]["params"]["expectedStages"] == ["identify", "motif"] diff --git a/tests/unit/test_gene_family_report_triggers.py b/tests/unit/test_gene_family_report_triggers.py new file mode 100644 index 000000000..ae3b53a67 --- /dev/null +++ b/tests/unit/test_gene_family_report_triggers.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def _request_and_stages(): + from apps.web.vue3.main import ConfirmedPlanExecutionRequest, ConfirmedPlanStage + + stages = [ + ConfirmedPlanStage( + id="identify", + params={ + "scopeId": "scope-a", + "analysisRoute": "post_subfamily_confirm", + "memberSetId": "members-a", + }, + ), + ConfirmedPlanStage(id="motif", dependencies=["identify"]), + ConfirmedPlanStage(id="pep", dependencies=["motif"]), + ] + request = ConfirmedPlanExecutionRequest( + session_id="session-a", + job_id="job-a", + prefix="Ft.SPL", + plan_id="plan-a", + stages=stages, + ) + return request, stages + + +def test_confirmed_plan_terminal_success_requests_final_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + {"status": "success", "completed_stages": ["identify", "motif", "pep"]}, + ) + + assert report is not None + assert report.stage == "gene_family_report" + assert report.expectedStages == ["identify", "motif", "pep"] + assert report.stageStatuses == {"identify": "success", "motif": "success", "pep": "success"} + assert report.reportAction == "generate" + + +def test_confirmed_plan_failure_requests_partial_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + { + "status": "failed", + "completed_stages": ["identify"], + "failure": {"stage": "motif"}, + }, + ) + + assert report is not None + assert report.stageStatuses == {"identify": "success", "motif": "failed", "pep": "not_run"} + assert report.scopeId == "scope-a" + assert report.analysisRoute == "post_subfamily_confirm" + assert report.memberSetId == "members-a" + + +def test_paused_plan_does_not_auto_finalize_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + {"status": "paused", "completed_stages": ["identify"]}, + ) + assert report is None + + +def test_frontend_batch_terminal_path_calls_report_stage_with_planned_statuses() -> None: + source = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") + + assert "submitGeneFamilyReportForBatch" in source + assert "executeSingleStageWithParams('gene_family_report'" in source + assert "expectedStages: plannedIds" in source + assert "stageStatuses" in source + assert "if (pausedCount > 0) return" in source + assert "continuationOpened" in source + assert "name: 'gene_family_report'" in source + assert "openGeneratedGeneFamilyReport" in source + + +def test_report_context_prefers_terminal_confirmed_plan(monkeypatch, tmp_path: Path) -> None: + from apps.web.vue3 import main as web_main + + final_dir = tmp_path / "job-a" / "final" + confirmed_dir = final_dir / ".confirmed_plans" + confirmed_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "parameters": { + "expected_stages": ["identify"], + "stage_statuses": {"identify": "success"}, + }, + } + ), + encoding="utf-8", + ) + (confirmed_dir / "0123456789abcdefabcd.json").write_text( + json.dumps( + { + "status": "failed", + "job_id": "job-a", + "prefix": "Ft.SPL", + "stages": ["identify", "motif", "pep", "gene_family_report"], + "completed_stages": ["identify", "motif"], + "failure": {"stage": "pep"}, + "scopeId": "scope-a", + "memberSetId": "members-a", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path) + + context = web_main._gene_family_report_plan_context("job-a") + + assert context == { + "prefix": "Ft.SPL", + "expectedStages": ["identify", "motif", "pep"], + "stageStatuses": {"identify": "success", "motif": "success", "pep": "failed"}, + "scopeId": "scope-a", + "analysisRoute": "", + "memberSetId": "members-a", + "rnaseqScopeId": "", + } + + +def test_report_context_reuses_formal_report_plan_only(monkeypatch, tmp_path: Path) -> None: + from apps.web.vue3 import main as web_main + + final_dir = tmp_path / "job-a" / "final" + final_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.motif.manifest.json").write_text( + json.dumps({"stage": "motif", "status": "success", "prefix": "Ft.SPL"}), + encoding="utf-8", + ) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path) + assert web_main._gene_family_report_plan_context("job-a") is None + + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "scope_id": "scope-old", + "parameters": { + "expected_stages": ["identify", "motif"], + "stage_statuses": {"identify": "success", "motif": "success"}, + }, + } + ), + encoding="utf-8", + ) + + context = web_main._gene_family_report_plan_context("job-a", "Ft.SPL") + + assert context is not None + assert context["expectedStages"] == ["identify", "motif"] + assert context["stageStatuses"] == {"identify": "success", "motif": "success"} + assert context["scopeId"] == "" + assert context["analysisRoute"] == "" + assert context["memberSetId"] == "" + assert context["rnaseqScopeId"] == "" diff --git a/tests/unit/test_gene_family_report_wiring.py b/tests/unit/test_gene_family_report_wiring.py new file mode 100644 index 000000000..c8555aee0 --- /dev/null +++ b/tests/unit/test_gene_family_report_wiring.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + + +def _write_identify_manifest(workspace: Path, prefix: str = "Ft.SPL") -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + table = final_dir / f"{prefix}.identify.tsv" + table.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8") + manifest = final_dir / f"{prefix}.identify.manifest.json" + manifest.write_text( + json.dumps( + { + "manifest_version": "1.0", + "stage": "identify", + "status": "success", + "prefix": prefix, + "metrics": {"member_count": 1}, + "artifacts": { + "members": { + "path": f"final/{table.name}", + "full_path": str(table), + "type": "table", + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return manifest + + +def _flag_value(command: list[str], flag: str) -> str: + return command[command.index(flag) + 1] + + +def test_report_stage_is_registered_without_analysis_dependencies() -> None: + from gfa_core.mas.stage_capabilities import CAPABILITIES + from gfa_core.mas.stage_catalog import STAGE_ORDER, is_stage_executable + from gfa_core.mas.tools.stage_tools import create_stage_registry + + capability = CAPABILITIES["gene_family_report"] + stage_tool = create_stage_registry().get("gene_family_report") + + assert "gene_family_report" in STAGE_ORDER + assert is_stage_executable("gene_family_report") + assert capability.consumes == [] + assert stage_tool is not None + assert stage_tool.required_inputs == [] + assert stage_tool.script == "run_gene_family_report.py" + + +def test_report_cli_can_start_as_a_script() -> None: + root = Path(__file__).resolve().parents[2] + completed = subprocess.run( + [sys.executable, "apps/cli/run_gene_family_report.py", "--help"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_report_cli_writes_report_and_manifest(tmp_path: Path) -> None: + from apps.cli.run_gene_family_report import main + + workspace = tmp_path / "job" + _write_identify_manifest(workspace) + result = main( + [ + "--workspace", + str(workspace), + "--prefix", + "Ft.SPL", + "--job-id", + "job-wiring", + "--expected-stages", + "identify", + "--stage-statuses-json", + '{"identify":"success"}', + "--no-model", + ] + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_path.is_file() + assert result.manifest_path.is_file() + assert "gene_family_report" in manifest["artifacts"] + + +def test_pipeline_backend_passes_plan_status_and_identity_to_report_cli( + tmp_path: Path, monkeypatch +) -> None: + from apps.web import pipeline_backend + + workspace = tmp_path / "job" + input_dir = workspace / "input" + logs_dir = workspace / "logs" + input_dir.mkdir(parents=True) + logs_dir.mkdir(parents=True) + _write_identify_manifest(workspace) + captured: dict[str, list[str]] = {} + + def capture(command, _log_file, cwd=None): + captured["command"] = [str(value) for value in command] + return 0 + + monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, "")) + monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture) + job = pipeline_backend.JobInfo( + job_id="job-backend", + input_dir=str(input_dir), + workspace_dir=str(workspace), + logs_dir=str(logs_dir), + created_at="2026-08-08 00:00:00", + ) + + pipeline_backend.run_stage( + "gene_family_report", + job, + "Ft.SPL", + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "failed"}, + report_action="open", + report_use_model=False, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + rnaseq_scope_id="rna-a", + ) + + command = captured["command"] + assert command[1:3] == ["apps/cli/run_gene_family_report.py", "--workspace"] + assert _flag_value(command, "--expected-stages") == "identify,motif" + assert json.loads(_flag_value(command, "--stage-statuses-json")) == { + "identify": "success", + "motif": "failed", + } + assert _flag_value(command, "--action") == "open" + assert _flag_value(command, "--job-id") == "job-backend" + assert _flag_value(command, "--scope-id") == "scope-a" + assert _flag_value(command, "--analysis-route") == "post_subfamily_confirm" + assert _flag_value(command, "--member-set-id") == "members-a" + assert _flag_value(command, "--rnaseq-scope-id") == "rna-a" + assert "--no-model" in command + assert not any("run_identify.py" in value or "run_motif.py" in value for value in command) + + +def test_pipeline_backend_rejects_empty_plan_before_starting_report_cli( + tmp_path: Path, monkeypatch +) -> None: + from apps.web import pipeline_backend + + workspace = tmp_path / "job" + input_dir = workspace / "input" + logs_dir = workspace / "logs" + input_dir.mkdir(parents=True) + logs_dir.mkdir(parents=True) + invoked = False + + def capture(*_args, **_kwargs): + nonlocal invoked + invoked = True + return 0 + + monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, "")) + monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture) + job = pipeline_backend.JobInfo( + job_id="job-empty-plan", + input_dir=str(input_dir), + workspace_dir=str(workspace), + logs_dir=str(logs_dir), + created_at="2026-08-08 00:00:00", + ) + + message, _manifest = pipeline_backend.run_stage( + "gene_family_report", + job, + "Ft.SPL", + expected_stages=[], + report_action="generate", + report_use_model=False, + ) + + assert "expected_stages" in message + assert invoked is False + + +def test_stage_tool_executor_forwards_gene_family_report_context( + tmp_path: Path, monkeypatch +) -> None: + import asyncio + import types + from dataclasses import dataclass + + from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor + + captured = {} + + @dataclass + class FakeJobInfo: + job_id: str + input_dir: str + workspace_dir: str + logs_dir: str + created_at: str + + def fake_run_stage(**kwargs): + captured.update(kwargs) + manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.gene_family_report.manifest.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps({"stage": "gene_family_report", "status": "success"}), + encoding="utf-8", + ) + return "报告已生成", manifest + + fake_backend = types.ModuleType("apps.web.pipeline_backend") + fake_backend.JobInfo = FakeJobInfo + fake_backend.run_stage = fake_run_stage + monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_backend) + + result = asyncio.run( + StageToolExecutor(project_root=tmp_path).execute( + StageTool( + "gene_family_report", + "Gene Family Report", + "run_gene_family_report.py", + "report", + "test", + timeout=5, + ), + "job-report", + "Ft.SPL", + { + "expectedStages": ["identify", "motif"], + "stageStatuses": {"identify": "success", "motif": "failed"}, + "reportAction": "open", + "reportUseModel": False, + "scopeId": "scope-a", + "rnaseqScopeId": "rna-a", + }, + ) + ) + + assert result["status"] == "success" + assert captured["expected_stages"] == ["identify", "motif"] + assert captured["stage_statuses"] == {"identify": "success", "motif": "failed"} + assert captured["report_action"] == "open" + assert captured["report_use_model"] is False + assert captured["scope_id"] == "scope-a" + assert captured["rnaseq_scope_id"] == "rna-a" + + +def test_execute_report_does_not_invent_scope_identity(monkeypatch) -> None: + import asyncio + from unittest.mock import Mock + + from apps.web.vue3 import main as web_main + from gfa_core.mas import resource_governor + from tasks import execute_single_stage_task + + submitted = Mock() + submitted.return_value.id = "report-task" + monkeypatch.setattr(execute_single_stage_task, "apply_async", submitted) + monkeypatch.setattr( + web_main, + "_ensure_celery_queue_health", + lambda *_args, **_kwargs: ({"available": True}, None), + ) + monkeypatch.setattr( + web_main, + "_ensure_gene_family_scope_file", + lambda **_kwargs: ("", ""), + ) + monkeypatch.setattr(resource_governor, "record_task", lambda **_kwargs: {}) + + request = web_main.ExecuteRequest( + stage="gene_family_report", + job_id="job-report-no-invented-identity", + prefix="Ft.SPL", + expectedStages=["identify", "motif"], + stageStatuses={"identify": "success", "motif": "success"}, + ) + result = asyncio.run(web_main.execute_stage(request)) + + assert result["success"] is True + params = submitted.call_args.kwargs["kwargs"]["params"] + assert params["scopeId"] == "" + assert params["analysisRoute"] == "" + assert params["memberSetId"] == "" + assert params["rnaseqScopeId"] == "" + + +def test_report_artifact_validation_does_not_require_invented_identity(monkeypatch) -> None: + from types import SimpleNamespace + + import tasks + from gfa_core.mas.tools import stage_identity + + captured = {} + identity = SimpleNamespace( + scope_id="", + analysis_route="custom", + member_set_id="Ft.SPL:custom:all_members", + wgs_sample_set_id="", + wgs_reference_id="", + wgs_analysis_id="", + genotype_scope_id="", + phenotype_scope_id="", + covariate_scope_id="", + model_scope_id="", + source_model_scope_id="", + analysis_scope_id="", + ) + monkeypatch.setattr(stage_identity, "resolve_stage_identity", lambda **_kwargs: identity) + + def capture_status(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completed=True) + + monkeypatch.setattr(stage_identity, "get_stage_status_for_identity", capture_status) + + failure = tasks.validate_single_stage_artifacts( + "job-report", + "Ft.SPL", + "gene_family_report", + { + "manifest": { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "analysis_route": "", + "member_set_id": "", + } + }, + ) + + assert failure is None + assert captured["strict_identity"] is False From 8f7e992b841e90e97e9615fe6b9ff8f5361533c8 Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 04:07:38 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E9=A6=96=E9=A1=B5=E5=BC=95=E7=94=A8=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- apps/web/vue3/agent_status_component_ml.html | 416 + apps/web/vue3/index-with-sidebar_ml.html | 391 + apps/web/vue3/index.html | 129 +- apps/web/vue3/index_ml.html | 23874 ++++++++++++++++ apps/web/vue3/kg_ml.html | 712 + apps/web/vue3/lightWeightExtensionTest.js | 304 + apps/web/vue3/main.py | 210 +- apps/web/vue3/static/cdn/en.js | 165 + apps/web/vue3/static/cdn/en.mjs | 165 + apps/web/vue3/static/cdn/zh-cn.js | 130 + apps/web/vue3/static/cdn/zh-cn.mjs | 130 + apps/web/vue3/static/conversation-sidebar.js | 728 +- apps/web/vue3/static/i18n.js | 1911 ++ apps/web/vue3/static/i18n_en.js | 380 + gfa_core/extension_stage_lightweight/api.py | 4 + .../extension_stage_lightweight/service.py | 20 +- 17 files changed, 29290 insertions(+), 382 deletions(-) create mode 100755 apps/web/vue3/agent_status_component_ml.html create mode 100755 apps/web/vue3/index-with-sidebar_ml.html create mode 100644 apps/web/vue3/index_ml.html create mode 100755 apps/web/vue3/kg_ml.html create mode 100644 apps/web/vue3/lightWeightExtensionTest.js create mode 100644 apps/web/vue3/static/cdn/en.js create mode 100644 apps/web/vue3/static/cdn/en.mjs create mode 100644 apps/web/vue3/static/cdn/zh-cn.js create mode 100644 apps/web/vue3/static/cdn/zh-cn.mjs create mode 100644 apps/web/vue3/static/i18n.js create mode 100644 apps/web/vue3/static/i18n_en.js diff --git a/.gitignore b/.gitignore index d5e2ea7f0..eedcfcbf5 100755 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ outputs/ input/ workspace/ paper/ -memory/ +#memory/ memory_storage/ backup/ backups/ @@ -29,3 +29,4 @@ nohup.out Rplots.pdf *.pyc *.log +.gitignore diff --git a/apps/web/vue3/agent_status_component_ml.html b/apps/web/vue3/agent_status_component_ml.html new file mode 100755 index 000000000..dead607b1 --- /dev/null +++ b/apps/web/vue3/agent_status_component_ml.html @@ -0,0 +1,416 @@ + + + + +// Agent状态组件 +const AgentStatusComponent = { + template: '#agent-status-component', + + data() { + return { + messages: [], + userInput: '', + isProcessing: false, + currentMode: '', + sessionId: '', + tasks: [], + currentTask: null, + pollingInterval: null + }; + }, + + mounted() { + this.sessionId = 'session_' + Date.now(); + this.currentMode = t('standby'); + this.loadTasks(); + this.startPolling(); + }, + + beforeUnmount() { + this.stopPolling(); + }, + + methods: { + async sendMessage() { + if (!this.userInput.trim() || this.isProcessing) return; + + const message = this.userInput.trim(); + this.userInput = ''; + this.isProcessing = true; + + // 添加用户消息 + this.addMessage('user', message); + + try { + // 调用Agent执行API + const response = await fetch('/api/agent/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: message, + session_id: this.sessionId, + model: 'kimi' + }) + }); + + const data = await response.json(); + + if (data.success) { + this.currentTask = data.task_id; + this.addMessage('system', t('msg-task-submitted') + data.task_id); + this.loadTasks(); + } else { + this.addMessage('error', data.message || t('msg-submit-failed')); + } + } catch (error) { + this.addMessage('error', t('msg-request-failed') + error.message); + } + + this.isProcessing = false; + }, + + async loadTasks() { + try { + const response = await fetch('/api/agent/tasks'); + const data = await response.json(); + + if (data.success) { + this.tasks = data.tasks; + } + } catch (error) { + console.error(t('msg-load-task-failed'), error); + } + }, + + async checkTaskStatus(taskId) { + try { + const response = await fetch(`/api/agent/status/${taskId}`); + const data = await response.json(); + + if (data.success && data.task) { + const task = data.task; + + // 更新任务状态 + const taskIndex = this.tasks.findIndex(t => t.task_id === taskId); + if (taskIndex >= 0) { + this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...task }; + } + + // 如果任务完成,显示结果 + if (task.status === 'completed' && task.result) { + this.addMessage('assistant', task.result.response || t('msg-task-completed')); + + // 显示执行结果摘要 + if (task.result.execution_result) { + const execResult = task.result.execution_result; + if (execResult.status === 'success') { + const manifest = execResult.manifest || {}; + const metrics = manifest.metrics || {}; + this.addMessage('system', + t('msg-analysis-complete') + (metrics.final_members || 0) + t('msg-gene-members')); + } + } + } else if (task.status === 'failed') { + this.addMessage('error', task.result?.response || t('msg-task-failed')); + } + } + } catch (error) { + console.error(t('msg-check-task-status-failed'), error); + } + }, + + selectTask(task) { + this.currentTask = task.task_id; + this.checkTaskStatus(task.task_id); + }, + + startPolling() { + this.pollingInterval = setInterval(() => { + if (this.currentTask) { + this.checkTaskStatus(this.currentTask); + } + this.loadTasks(); + }, 3000); // 每3秒轮询一次 + }, + + stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + } + }, + + addMessage(role, content) { + this.messages.push({ + role: role, + content: content, + timestamp: new Date().toISOString() + }); + + // 滚动到底部 + setTimeout(() => { + const container = this.$refs.messageContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }, 100); + }, + + getRoleLabel(role) { + const keys = { + 'user': 'user-role', + 'assistant': 'agent-role', + 'system': 'system-role', + 'error': 'error-role', + 'execution': 'execution-role', + 'planning': 'planning-role', + 'diagnosis': 'diagnosis-role' + }; + return keys[role] ? t(keys[role]) : role; + }, + + getStatusIcon(status) { + const icons = { + 'pending': '⏳', + 'running': '🔄', + 'completed': '✅', + 'failed': '❌', + 'cancelled': '🚫' + }; + return icons[status] || '❓'; + }, + + formatMessage(content) { + // 简单的Markdown格式化 + return content + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\n/g, '
'); + }, + + formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, + + t(key) { + return window.t ? window.t(key) : key; + } + } +}; + + + \ No newline at end of file diff --git a/apps/web/vue3/index-with-sidebar_ml.html b/apps/web/vue3/index-with-sidebar_ml.html new file mode 100755 index 000000000..05f6b5d3b --- /dev/null +++ b/apps/web/vue3/index-with-sidebar_ml.html @@ -0,0 +1,391 @@ + + + + + + + BotanicalBaseAgent + + + + + + + + +
+ +
+

{{ t('gf-analysis-system') }}

+
+ +
+ +
+ +
+ + + + +
+
+
{{ t('conv-list') }}
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }}{{ t('msg-count') }} +
+
+
+
+
+
+ + +
+
{{ t('analysis-stage') }}
+ + + + + + + + + {{ t('execute-analysis') }} + +
+ + +
+
{{ t('param-settings') }}
+ + + + + + + + +
+
+ + +
+
+
+
+ {{ msg.role === 'user' ? t('user') : t('ai-assistant') }} · {{ msg.time }} +
+
{{ msg.content }}
+
+
+ +

{{ t('start-new-chat') }}

+

{{ t('or-continue') }}

+
+
+ +
+ + + + {{ t('send') }} + +
+
+ + +
+
📊 Analysis Results
+ +
+
+ {{ stage.label }} + {{ stage.statusText }} +
+ +
+ + + +
+ +

{{ t('results-here') }}

+
+
+
+
+ + + + diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index 8f97daaae..7b228b2cc 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -1389,10 +1389,13 @@ 名称 原始命令 环境 - - - - + + + 原始代码 @@ -1419,6 +1422,10 @@
+
+ 拖入拓展包到此自动填充 +
+ +
+
拖入固定结构拓展包
+
要求包含 raw_code/run.sh 与 raw_test_data/,系统会自动创建 Job、解析环境并开始 lightweight upload。
+ +
+ {{ lightweightExtensionTestStatus }} + 选择文件夹 +
+
+ @@ -7774,6 +7795,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 working_dir: '', output_dir: '' }); + const lightweightExtensionTestStatus = ref('拖入固定结构文件夹,系统将自动创建 Job、解析环境并开始 upload。'); const activeRnaseqScopeId = ref(''); const activeGenotypeScopeId = ref(''); @@ -9128,6 +9150,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 statusDialogVisible: false, statusEvents: [] }); + const extensionEnvOptions = ref(['test4py311', 'rnaseq', 'rnaR']); const lightweightExtensions = ref([]); const lightweightExtensionConversation = reactive({ cards: [], @@ -19489,6 +19512,10 @@ compare: [[flesh, peel]]`; }; const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`; + const extensionUploadPath = (file) => { + const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, ''); + return relative || String(file?.name || '').trim(); + }; const appendExtensionFiles = (target, files) => { const current = Array.isArray(target) ? target : []; const merged = [...current]; @@ -19514,6 +19541,53 @@ compare: [[flesh, peel]]`; if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`); event.target.value = ''; }; + const handleLightweightExtensionFolderDrop = (event) => { + const droppedFiles = Array.from(event?.dataTransfer?.files || []); + if (!droppedFiles.length) { + ElMessage.warning('未检测到可用文件'); + return; + } + + const codeExtPattern = /\.(py|r|sh|bash|pl|js|ts|java|c|cc|cpp|go|rs|jl|rb|m|smk|nf|ipynb)$/i; + const dataExtPattern = /\.(fastq|fq|gz|fa|fasta|fna|faa|pep|cds|gff|gff3|gtf|csv|tsv|txt|xlsx|xls|vcf|sam|bam)$/i; + + const normalized = droppedFiles.map((file) => { + const relativePath = file.webkitRelativePath || file.name; + if (!file.lightweightRelativePath) { + Object.defineProperty(file, 'lightweightRelativePath', { + value: relativePath, + configurable: true + }); + } + return file; + }); + + const codeFiles = []; + const dataFiles = []; + normalized.forEach((file) => { + const filePath = extensionUploadPath(file).toLowerCase(); + if (codeExtPattern.test(filePath)) { + codeFiles.push(file); + } else if (dataExtPattern.test(filePath)) { + dataFiles.push(file); + } else { + codeFiles.push(file); + } + }); + + extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, codeFiles); + extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, dataFiles); + + if (!extensionBuild.displayName) { + const firstPath = normalized[0]?.webkitRelativePath || normalized[0]?.name || ''; + const folder = firstPath.includes('/') ? firstPath.split('/')[0] : ''; + if (folder) { + extensionBuild.displayName = folder.replace(/[^a-zA-Z0-9_-]+/g, '_'); + } + } + + ElMessage.success(`已拖入 ${normalized.length} 个文件,已自动填充拓展包`); + }; const removeExtensionFile = (kind, file) => { const key = extensionFileKey(file); if (kind === 'code') { @@ -19546,9 +19620,10 @@ compare: [[flesh, peel]]`; }; const extensionApiUrl = (path) => { const value = String(path || ''); - if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs')) return value; + if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs') && !value.startsWith('/api/lightweight-extension-stages')) return value; const host = window.location.hostname || '127.0.0.1'; - return `${window.location.protocol}//${host}:7880${value}`; + const port = window.location.port; + return port ? `${window.location.protocol}//${host}:${port}${value}` : `${window.location.protocol}//${host}${value}`; }; const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options); const pushExtensionStatus = (message, type = 'info', showDialog = false) => { @@ -19909,9 +19984,10 @@ compare: [[flesh, peel]]`; form.append('extension_id', extensionId); if (hasCommand) form.append('original_command', extensionBuild.command); form.append('operation_token', token); - extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file)); - extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file)); + extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file, extensionUploadPath(file))); + extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file))); if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths); + form.append('runtime_env', extensionBuild.runtimeEnv); const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form }); if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败')); ElMessage.success('文件上传成功,已开始生成并验收'); @@ -20156,6 +20232,18 @@ compare: [[flesh, peel]]`; console.warn('[lightweight extension] list failed', err); } }; + const loadExtensionEnvOptions = async () => { + try { + const response = await extensionApiFetch('/api/extension-stages/envs'); + if (!response.ok) return; + const data = await response.json(); + if (data.success && Array.isArray(data.envs) && data.envs.length) { + extensionEnvOptions.value = data.envs; + } + } catch (err) { + console.warn('[extension env options] load failed', err); + } + }; const answerLightweightExtensionHelp = async (message) => { const query = String(message || ''); const matched = (lightweightExtensions.value || []).find(item => query.includes(item.extension_id)); @@ -23388,6 +23476,7 @@ compare: [[flesh, peel]]`; startRuntimeMonitor(); loadExtensionStages(); loadLightweightExtensionStages(); + loadExtensionEnvOptions(); resumeLightweightOperation(); loadExtensionRuns(); loadMethodologyJobs(); @@ -23718,6 +23807,7 @@ compare: [[flesh, peel]]`; intentHistory, uploadedFiles, extensionBuild, + extensionEnvOptions, extensionStages, extensionCompletedStages, lightweightExtensionConversation, @@ -23736,6 +23826,7 @@ compare: [[flesh, peel]]`; extensionFileKey, onExtensionCodeFilesChange, onExtensionDataFilesChange, + handleLightweightExtensionFolderDrop, removeExtensionFile, buildExtensionStage, confirmLightweightCandidate, @@ -23755,6 +23846,7 @@ compare: [[flesh, peel]]`; validateExtensionStage, publishExtensionStage, loadExtensionStages, + loadExtensionEnvOptions, loadExtensionRuns, runExtensionStage, runExtensionStageFromIntent, @@ -23874,6 +23966,24 @@ compare: [[flesh, peel]]`; openPreview, redrawImage, createNewJob, + lightweightExtensionTestStatus, + handleLightweightExtensionFolderDrop: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handleDrop({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(), // Working DirectorySelect existingJobs, jobSelectorVisible, @@ -23990,6 +24100,9 @@ compare: [[flesh, peel]]`; document.getElementById('app').style.display = 'block'; console.log('App mounted successfully'); + + + + + + +
+ Loading… +
+ + + + + + + + + + + diff --git a/apps/web/vue3/kg_ml.html b/apps/web/vue3/kg_ml.html new file mode 100755 index 000000000..be6f9fc76 --- /dev/null +++ b/apps/web/vue3/kg_ml.html @@ -0,0 +1,712 @@ + + + + + + Knowledge Graph + + + + + + + + + +
+
+
+

{{ t('kg-visualization') }}

+
{{ t('kg-subtitle') }}
+
+ + {{ t('back-to-main') }} + +
+ +
+ +
+
+

{{ t('kg-stats') }}

+
+ {{ t('node-count') }} + {{ stats.nodes }} +
+
+ {{ t('edge-count') }} + {{ stats.edges }} +
+
+ {{ t('node-types') }} + {{ stats.nodeTypes }} +
+
+ {{ t('edge-types') }} + {{ stats.edgeTypes }} +
+
+ +
+

{{ t('node-search') }}

+ + + +
+ +
+

{{ t('relation-filter-pc') }}

+
+
+
+ {{ category === 'activation' ? t('activation-induction') : + category === 'repression' ? t('repression-inhibition') : + category === 'regulation' ? t('regulation-control') : + category === 'expression' ? t('expression-detection') : + category === 'association' ? t('association-interaction') : + category === 'localization' ? t('localization') : + category === 'containment' ? t('containment-composition') : + category === 'participation' ? t('participation-function') : t('other-rel') }} +
+ + {{ type }} ({{ relationCounts[type] }}) + +
+
+
+ +
+

{{ t('node-legend-pc') }}

+
Gene/Protein
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Phenotype
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Cell/Organ/Organism
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Biological Process
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Genomic
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+

🔗 {{ t('edge-legend-pc') }}

+
+
+
+ {{ cat === 'activation' ? 'Activation/Induction' : + cat === 'repression' ? 'Repression/Inhibition' : + cat === 'regulation' ? 'Regulation/Control' : + cat === 'expression' ? 'Expression/Detection' : + cat === 'association' ? 'Association/Interaction' : + cat === 'localization' ? 'Localization' : + cat === 'containment' ? 'Containment/Composition' : + cat === 'participation' ? 'Participation/Function' : 'Other' }} +
+
+
+
+ + +
+
+ + + {{ t('fit-view') }} + + + {{ t('reset-filters') }} + + + + + {{ t('click-drag-hint') }} + +
+
+
+ + +
+

{{ t('node-detail') }}

+
+
+
{{ t('node-name') }}
+
{{ selectedNode.name }}
+
+
+
{{ t('node-type-pc') }}
+
+ + {{ nodeTypeLabels[selectedNode.category] || selectedNode.category }} + +
+
+
+
{{ t('connection-count') }}
+
{{ selectedNode.degree }}{{ t('num-connections') }}
+
+
+
+ {{ t('click-node-detail') }} +
+ + +
+
+
+ + + + diff --git a/apps/web/vue3/lightWeightExtensionTest.js b/apps/web/vue3/lightWeightExtensionTest.js new file mode 100644 index 000000000..f93af52ca --- /dev/null +++ b/apps/web/vue3/lightWeightExtensionTest.js @@ -0,0 +1,304 @@ +(function () { + const PY_EXTENSIONS = new Set(['.py']); + const R_EXTENSIONS = new Set(['.r']); + + function normalizedRelativePath(file) { + const relative = String(file.webkitRelativePath || file.relativePath || file.name || '').replace(/^\/+/, ''); + return relative.split('\\').join('/'); + } + + function parsePythonImports(content) { + const imports = new Set(); + const lines = String(content || '').split(/\r?\n/); + for (const line of lines) { + let match = line.match(/^\s*import\s+([A-Za-z0-9_.,\s]+)/); + if (match) { + match[1].split(',').forEach((item) => { + const token = item.trim().split(/\s+as\s+/i)[0].split('.')[0].trim(); + if (token) imports.add(token); + }); + } + match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+/); + if (match) { + const token = match[1].split('.')[0].trim(); + if (token) imports.add(token); + } + } + return Array.from(imports); + } + + function parseRPackages(content) { + const packages = new Set(); + const pattern = /(?:library|require)\(\s*["']?([A-Za-z0-9_.-]+)["']?\s*\)/g; + let match; + const text = String(content || ''); + while ((match = pattern.exec(text)) !== null) { + if (match[1]) packages.add(match[1]); + } + return Array.from(packages); + } + + function commonPathPrefix(files) { + const paths = files.map((f) => normalizedRelativePath(f).split('/')).filter((parts) => parts.length > 0); + if (!paths.length) return ''; + const first = paths[0]; + let prefixLen = 0; + for (let i = 0; i < first.length; i++) { + if (!paths.every((parts) => parts[i] === first[i])) break; + prefixLen = i + 1; + } + return first.slice(0, prefixLen).join('/'); + } + + function sanitizeDisplayName(raw) { + return String(raw || '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'lightweight_extension'; + } + + function extractRootName(files) { + const prefix = commonPathPrefix(files); + const base = (prefix && prefix !== 'raw_code' && prefix !== 'raw_test_data') + ? sanitizeDisplayName(prefix.split('/')[0]) + : 'lightweight_extension_drop'; + const ts = Date.now(); + return base + '_' + ts; + } + + function splitFixedStructure(files) { + const codeFiles = []; + const dataFiles = []; + let hasRunScript = false; + let prefix = commonPathPrefix(files); + if (prefix && !prefix.endsWith('/')) prefix += '/'; + + const sampled = []; + for (const file of files) { + let relativePath = normalizedRelativePath(file); + if (prefix && relativePath.startsWith(prefix)) { + relativePath = relativePath.slice(prefix.length); + } + if (sampled.length < 10) sampled.push(relativePath); + if (relativePath.startsWith('raw_code/')) { + file.lightweightRelativePath = relativePath.slice('raw_code/'.length) || file.name; + codeFiles.push(file); + if (relativePath === 'raw_code/run.sh') { + hasRunScript = true; + } + } else if (relativePath.startsWith('raw_test_data/')) { + file.lightweightRelativePath = relativePath.slice('raw_test_data/'.length) || file.name; + dataFiles.push(file); + } + } + if (!codeFiles.length) { + const preview = sampled.length ? sampled.slice(0, 10).join(', ') : '(无)'; + throw new Error('缺少 raw_code/ 目录(检测到的路径样例: ' + preview + ')'); + } + if (!dataFiles.length) { + throw new Error('缺少 raw_test_data/ 目录'); + } + if (!hasRunScript) { + throw new Error('缺少 raw_code/run.sh'); + } + return { codeFiles, dataFiles }; + } + + async function extractDependencies(files) { + const pythonImports = new Set(); + const rPackages = new Set(); + for (const file of files) { + const path = normalizedRelativePath(file); + const lower = path.toLowerCase(); + const extension = lower.includes('.') ? lower.slice(lower.lastIndexOf('.')) : ''; + if (!PY_EXTENSIONS.has(extension) && !R_EXTENSIONS.has(extension)) { + continue; + } + const content = await file.text(); + if (PY_EXTENSIONS.has(extension)) { + parsePythonImports(content).forEach((name) => pythonImports.add(name)); + } else { + parseRPackages(content).forEach((name) => rPackages.add(name)); + } + } + return { + python_imports: Array.from(pythonImports), + r_packages: Array.from(rPackages), + }; + } + + async function encodeBase64(file) { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); + } + + async function importFolderToJob(hooks, rootName, files) { + const payload = { + job_id: String(hooks.jobInfo.job_id || '').trim(), + root_name: rootName, + files: [], + relative_paths: [], + }; + for (const file of files) { + payload.files.push(await encodeBase64(file)); + payload.relative_paths.push(normalizedRelativePath(file)); + } + const response = await fetch('/api/job/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.detail || data.error || data.message || 'Job 导入失败'); + } + return data; + } + + async function resolveRuntimeEnv(files) { + const dependencies = await extractDependencies(files); + console.log('[lightWeightExtensionTest] 提取的依赖:', JSON.stringify(dependencies), '| 扫描文件数:', files.length); + try { + const response = await fetch('/api/extension-stages/envs/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dependencies), + }); + const data = await response.json(); + console.log('[lightWeightExtensionTest] 后端 env/resolve 原始响应:', response.status, JSON.stringify(data).slice(0, 500)); + if (!response.ok || !data.success) { + throw new Error(data.detail || data.error || data.message || '运行环境解析失败'); + } + return data; + } catch (e) { + console.error('[lightWeightExtensionTest] env/resolve 调用失败:', e); + throw e; + } + } + + function notifyError(message) { + if (window.ElementPlus && window.ElementPlus.ElMessage) { + window.ElementPlus.ElMessage.error(message); + } + } + + async function ensureJob(hooks) { + if (String(hooks.jobInfo.job_id || '').trim() && String(hooks.jobInfo.job_id) !== 'default_job') { + return; + } + await hooks.createNewJob(); + if (!String(hooks.jobInfo.job_id || '').trim() || String(hooks.jobInfo.job_id) === 'default_job') { + throw new Error('自动创建 Job 失败'); + } + } + + async function processFolderFiles(hooks, inputFiles) { + const files = Array.from(inputFiles || []).filter((file) => normalizedRelativePath(file)); + if (!files.length) { + throw new Error('未读取到任何文件'); + } + hooks.lightweightExtensionTestStatus.value = '正在校验目录结构...'; + const rootName = extractRootName(files); + const fixed = splitFixedStructure(files); + hooks.lightweightExtensionTestStatus.value = '正在确保 Job 可用...'; + await ensureJob(hooks); + hooks.lightweightExtensionTestStatus.value = '正在解析运行环境...'; + const envResult = await resolveRuntimeEnv(files); + hooks.extensionBuild.runtimeEnv = envResult.runtime_env || 'test4py311'; + console.log( + '[lightWeightExtensionTest] ✓ 选定环境: ' + hooks.extensionBuild.runtimeEnv, + '| 原因: ' + (envResult.reason || 'unknown'), + '| 兜底: ' + (envResult.fallback ? '是' : '否'), + envResult.matches ? '| 候选: ' + JSON.stringify(envResult.matches.map(m => m.env + '(py:' + (m.python_hits || []).join(',') + ' r:' + (m.r_hits || []).join(',') + ')')) : '', + ); + hooks.lightweightExtensionTestStatus.value = '正在镜像导入当前 Job...'; + await importFolderToJob(hooks, rootName, files); + hooks.extensionBuild.displayName = rootName; + hooks.extensionBuild.command = 'bash run.sh'; + hooks.extensionBuild.codeFiles = hooks.appendExtensionFiles([], fixed.codeFiles); + hooks.extensionBuild.dataFiles = hooks.appendExtensionFiles([], fixed.dataFiles); + hooks.extensionBuild.dataPaths = ''; + hooks.lightweightExtensionTestStatus.value = `运行环境 ${hooks.extensionBuild.runtimeEnv} 已确定,正在触发 upload...`; + await hooks.buildLightweightExtensionStage(); + hooks.lightweightExtensionTestStatus.value = '已开始 upload,系统正在自动轮询并等待候选卡。'; + } + + async function readEntry(entry, basePath) { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + Object.defineProperty(file, 'webkitRelativePath', { + value: basePath ? basePath + '/' + entry.name : entry.name, + writable: false, + }); + resolve([file]); + }); + }); + } + if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await new Promise((resolve) => { + reader.readEntries(resolve); + }); + const subPath = basePath ? basePath + '/' + entry.name : entry.name; + const nested = await Promise.all(entries.map((e) => readEntry(e, subPath))); + return nested.flat(); + } + return []; + } + + async function readDroppedFiles(dataTransfer) { + if (!dataTransfer || !dataTransfer.items) { + return Array.from(dataTransfer?.files || []); + } + const items = Array.from(dataTransfer.items); + const entries = items + .map((item) => item.webkitGetAsEntry && item.webkitGetAsEntry()) + .filter(Boolean); + if (!entries.length) { + return Array.from(dataTransfer.files || []); + } + const results = await Promise.all(entries.map((entry) => readEntry(entry, ''))); + return results.flat(); + } + + function mountApi() { + window.LightWeightExtensionTest = { + async handleDrop(hooks, event) { + try { + const files = await readDroppedFiles(event?.dataTransfer); + await processFolderFiles(hooks, files); + } catch (error) { + const message = `失败:${error.message || '目录处理失败'}`; + hooks.lightweightExtensionTestStatus.value = message; + notifyError(message); + } + }, + async handlePick(hooks, event) { + const files = event?.target?.files || []; + try { + await processFolderFiles(hooks, files); + } catch (error) { + const message = `失败:${error.message || '目录处理失败'}`; + hooks.lightweightExtensionTestStatus.value = message; + notifyError(message); + } finally { + if (event?.target) { + event.target.value = ''; + } + } + }, + openPicker() { + const input = document.getElementById('lightweight-extension-test-folder-input'); + if (input) { + input.click(); + } + }, + }; + } + + mountApi(); +})(); diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 0da4410c9..f1f63a684 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -6,6 +6,7 @@ BotanicalBaseAgent - FastAPI Backend import os import sys import json +import base64 import csv import asyncio import hashlib @@ -41,6 +42,7 @@ _builtins.print = _safe_print # 添加项目路径 PROJECT_ROOT = Path(__file__).parent.parent.parent.parent +DEFAULT_RUNTIME_ENV = os.environ.get("BBAV5_DEFAULT_RUNTIME_ENV", "test4py311") sys.path.insert(0, str(PROJECT_ROOT)) from gfa_core.project_paths import get_conda_env_bin, get_conda_env_rscript, get_configured_path, get_wgs_bcftools, get_wgs_java, get_wgs_samtools @@ -8573,6 +8575,19 @@ async def root(): ) +@app.get("/ml") +async def root_ml(): + """返回Vue3前端多语言页面""" + return FileResponse( + Path(__file__).parent / "index_ml.html", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + + @app.post("/api/new_job") async def create_new_job(): """创建新工作目录""" @@ -8780,6 +8795,56 @@ async def upload_file(files: List[UploadFile] = File(...), job_id: str = Form("" } +class JobImportFolderRequest(BaseModel): + job_id: str + root_name: str = "" + files: List[str] = Field(default_factory=list) + relative_paths: List[str] = Field(default_factory=list) + + +@app.post("/api/job/import-folder") +async def import_job_folder(req: JobImportFolderRequest): + job_id = str(req.job_id or "").strip() + if not job_id: + raise HTTPException(status_code=400, detail="job_id is required") + if len(req.files) != len(req.relative_paths): + raise HTTPException(status_code=400, detail="files and relative_paths length mismatch") + + root_name = _safe_upload_name(req.root_name or "lightweight_extension_drop") + import_root = INPUT_ROOT / job_id / root_name + import_root.mkdir(parents=True, exist_ok=True) + saved_files: List[str] = [] + + for encoded, raw_relative in zip(req.files, req.relative_paths): + relative = Path(str(raw_relative or "").strip()) + if not raw_relative or relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): + raise HTTPException(status_code=400, detail=f"invalid relative path: {raw_relative}") + try: + content = base64.b64decode(str(encoded or "").encode("utf-8"), validate=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"invalid base64 payload for {raw_relative}: {exc}") from exc + destination = import_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + saved_files.append(relative.as_posix()) + + return { + "success": True, + "job_id": job_id, + "root_name": root_name, + "import_root": str(import_root), + "saved_files": saved_files, + } + + +@app.get("/lightWeightExtensionTest.js") +async def get_lightweight_extension_test_script(): + script_path = WEB_DIR / "lightWeightExtensionTest.js" + if not script_path.exists(): + raise HTTPException(status_code=404, detail="lightWeightExtensionTest.js not found") + return FileResponse(str(script_path), media_type="application/javascript") + + class ExtensionStageRunRequest(BaseModel): extension_id: str job_id: str = "" @@ -8850,6 +8915,137 @@ class ExtensionStageResolveIntentRequest(BaseModel): job_id: str = "" +class ExtensionStageEnvResolveRequest(BaseModel): + python_imports: List[str] = Field(default_factory=list) + r_packages: List[str] = Field(default_factory=list) + + + +@app.get("/api/extension-stages/envs") +async def list_extension_stage_envs(): + """Return available conda environment names for the extension stage runtime dropdown.""" + import json as _json + try: + proc = await asyncio.create_subprocess_exec( + "conda", "env", "list", "--json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + # fallback: try parsing plain-text output + proc2 = await asyncio.create_subprocess_exec( + "conda", "env", "list", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout2, _ = await proc2.communicate() + envs = [] + for line in stdout2.decode("utf-8", errors="replace").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # format: "env_name /path/to/env" + parts = stripped.split() + if parts: + envs.append(parts[0]) + return {"success": True, "envs": envs} + data = _json.loads(stdout.decode("utf-8")) + envs = [entry.split("/")[-1] if "/" in str(entry) else str(entry) for entry in data.get("envs", [])] + return {"success": True, "envs": envs} + except Exception as exc: + return {"success": False, "envs": ["test4py311", "rnaseq", "rnaR"], "error": str(exc)} + + +def _normalized_dependency_names(values: List[str]) -> List[str]: + normalized: List[str] = [] + seen: set[str] = set() + stdlib_names = set(getattr(sys, "stdlib_module_names", set())) + for raw in values or []: + token = re.split(r"[<>=\[\]()\s]", str(raw or "").strip(), maxsplit=1)[0].strip() + if not token: + continue + top_level = token.split(".")[0].strip() + key = top_level.lower().replace("_", "-") + if top_level in stdlib_names or key in seen: + continue + seen.add(key) + normalized.append(top_level) + return normalized + + +async def _available_extension_env_names() -> List[str]: + result = await list_extension_stage_envs() + envs = result.get("envs") if isinstance(result, dict) else [] + if isinstance(envs, list) and envs: + return [str(item) for item in envs if str(item).strip()] + return ["test4py311", "rnaseq", "rnaR"] + + +async def _python_packages_for_env(env_name: str) -> set[str]: + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", env_name, "python", "-c", + "import json, importlib.metadata as m; print(json.dumps(sorted({dist.metadata.get('Name', '') for dist in m.distributions() if dist.metadata.get('Name', '')})))", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _stderr = await proc.communicate() + if proc.returncode != 0: + return set() + try: + values = json.loads(stdout.decode("utf-8", errors="replace")) + except Exception: + return set() + return {str(item).strip().lower().replace("_", "-") for item in values if str(item).strip()} + + +async def _r_packages_for_env(env_name: str) -> set[str]: + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", env_name, "Rscript", "-e", + "cat(paste(sort(rownames(installed.packages())), collapse='\n'))", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _stderr = await proc.communicate() + if proc.returncode != 0: + return set() + return {line.strip().lower() for line in stdout.decode("utf-8", errors="replace").splitlines() if line.strip()} + + +@app.post("/api/extension-stages/envs/resolve") +async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest): + python_imports = _normalized_dependency_names(req.python_imports) + r_packages = _normalized_dependency_names(req.r_packages) + if not python_imports and not r_packages: + return { + "success": True, + "runtime_env": "test4py311", + "reason": "no_dependencies_detected", + "fallback": True, + } + + for dep in list(dict.fromkeys(d.lower() for d in python_imports + r_packages)): + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", dep, "python", "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + if proc.returncode == 0: + return { + "success": True, + "runtime_env": dep, + "reason": "env_name_matches_dependency", + "dependency": dep, + "fallback": False, + } + + return { + "success": True, + "runtime_env": "test4py311", + "reason": "no_conda_env_matches_dependency", + "fallback": True, + } @app.post("/api/extension-stages/build") async def build_extension_stage( display_name: str = Form(...), @@ -9750,7 +9946,7 @@ async def get_config_autofill_template(): async def draft_config_autofill(req: AutofillDraftRequest): job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_config_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -9779,7 +9975,7 @@ async def draft_config_autofill(req: AutofillDraftRequest): async def draft_samples_autofill(req: AutofillDraftRequest): job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_samples_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -9910,7 +10106,7 @@ async def confirm_config_autofill(req: AutofillDraftRequest): _write_config_draft_rows(draft_path, req.rows, family, primary_reference_species, identify_blast_reference_species) draft_to_use = str(draft_path) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_config_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -9958,7 +10154,7 @@ async def confirm_samples_autofill(req: AutofillDraftRequest): req, ) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_samples_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -10284,7 +10480,7 @@ async def confirm_identify_domain_review(req: IdentifyDomainReviewConfirmRequest curated_file.parent.mkdir(parents=True, exist_ok=True) curated_file.write_text("\n".join(selected) + "\n", encoding="utf-8") cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_identify_refine.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -10767,7 +10963,7 @@ def _split_joint_subfamily_confirmation(job_id: str, prefix: str, rows: list[dic f"{row.get('confidence','')}\t{row.get('note','')}\n" ) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_subfamily_confirm.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -11104,7 +11300,7 @@ async def confirm_subfamily_review(req: SubfamilyReviewConfirmRequest): member_set_changed = bool(current_ids and confirmed_ids and not current_ids.issubset(confirmed_ids)) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_subfamily_confirm.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), diff --git a/apps/web/vue3/static/cdn/en.js b/apps/web/vue3/static/cdn/en.js new file mode 100644 index 000000000..1894bbb3f --- /dev/null +++ b/apps/web/vue3/static/cdn/en.js @@ -0,0 +1,165 @@ +// global wrapper +/*! Element Plus v2.3.14 */ + +window.en = { + name: "en", + el: { + colorpicker: { + confirm: "OK", + clear: "Clear", + defaultLabel: "color picker", + description: "current color is {color}. press enter to select a new color." + }, + datepicker: { + now: "Now", + today: "Today", + cancel: "Cancel", + clear: "Clear", + confirm: "OK", + dateTablePrompt: "Use the arrow keys and enter to select the day of the month", + monthTablePrompt: "Use the arrow keys and enter to select the month", + yearTablePrompt: "Use the arrow keys and enter to select the year", + selectedDate: "Selected date", + selectDate: "Select date", + selectTime: "Select time", + startDate: "Start Date", + startTime: "Start Time", + endDate: "End Date", + endTime: "End Time", + prevYear: "Previous Year", + nextYear: "Next Year", + prevMonth: "Previous Month", + nextMonth: "Next Month", + year: "", + month1: "January", + month2: "February", + month3: "March", + month4: "April", + month5: "May", + month6: "June", + month7: "July", + month8: "August", + month9: "September", + month10: "October", + month11: "November", + month12: "December", + week: "week", + weeks: { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat" + }, + weeksFull: { + sun: "Sunday", + mon: "Monday", + tue: "Tuesday", + wed: "Wednesday", + thu: "Thursday", + fri: "Friday", + sat: "Saturday" + }, + months: { + jan: "Jan", + feb: "Feb", + mar: "Mar", + apr: "Apr", + may: "May", + jun: "Jun", + jul: "Jul", + aug: "Aug", + sep: "Sep", + oct: "Oct", + nov: "Nov", + dec: "Dec" + } + }, + inputNumber: { + decrease: "decrease number", + increase: "increase number" + }, + select: { + loading: "Loading", + noMatch: "No matching data", + noData: "No data", + placeholder: "Select" + }, + dropdown: { + toggleDropdown: "Toggle Dropdown" + }, + cascader: { + noMatch: "No matching data", + loading: "Loading", + placeholder: "Select", + noData: "No data" + }, + pagination: { + goto: "Go to", + pagesize: "/page", + total: "Total {total}", + pageClassifier: "", + page: "Page", + prev: "Go to previous page", + next: "Go to next page", + currentPage: "page {pager}", + prevPages: "Previous {pager} pages", + nextPages: "Next {pager} pages", + deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details" + }, + dialog: { + close: "Close this dialog" + }, + drawer: { + close: "Close this dialog" + }, + messagebox: { + title: "Message", + confirm: "OK", + cancel: "Cancel", + error: "Illegal input", + close: "Close this dialog" + }, + upload: { + deleteTip: "press delete to remove", + delete: "Delete", + preview: "Preview", + continue: "Continue" + }, + slider: { + defaultLabel: "slider between {min} and {max}", + defaultRangeStartLabel: "pick start value", + defaultRangeEndLabel: "pick end value" + }, + table: { + emptyText: "No Data", + confirmFilter: "Confirm", + resetFilter: "Reset", + clearFilter: "All", + sumText: "Sum" + }, + tree: { + emptyText: "No Data" + }, + transfer: { + noMatch: "No matching data", + noData: "No data", + titles: ["List 1", "List 2"], + filterPlaceholder: "Enter keyword", + noCheckedFormat: "{total} items", + hasCheckedFormat: "{checked}/{total} checked" + }, + image: { + error: "FAILED" + }, + pageHeader: { + title: "Back" + }, + popconfirm: { + confirmButtonText: "Yes", + cancelButtonText: "No" + } + } +}; diff --git a/apps/web/vue3/static/cdn/en.mjs b/apps/web/vue3/static/cdn/en.mjs new file mode 100644 index 000000000..71aff82d0 --- /dev/null +++ b/apps/web/vue3/static/cdn/en.mjs @@ -0,0 +1,165 @@ +/*! Element Plus v2.3.14 */ + +var en = { + name: "en", + el: { + colorpicker: { + confirm: "OK", + clear: "Clear", + defaultLabel: "color picker", + description: "current color is {color}. press enter to select a new color." + }, + datepicker: { + now: "Now", + today: "Today", + cancel: "Cancel", + clear: "Clear", + confirm: "OK", + dateTablePrompt: "Use the arrow keys and enter to select the day of the month", + monthTablePrompt: "Use the arrow keys and enter to select the month", + yearTablePrompt: "Use the arrow keys and enter to select the year", + selectedDate: "Selected date", + selectDate: "Select date", + selectTime: "Select time", + startDate: "Start Date", + startTime: "Start Time", + endDate: "End Date", + endTime: "End Time", + prevYear: "Previous Year", + nextYear: "Next Year", + prevMonth: "Previous Month", + nextMonth: "Next Month", + year: "", + month1: "January", + month2: "February", + month3: "March", + month4: "April", + month5: "May", + month6: "June", + month7: "July", + month8: "August", + month9: "September", + month10: "October", + month11: "November", + month12: "December", + week: "week", + weeks: { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat" + }, + weeksFull: { + sun: "Sunday", + mon: "Monday", + tue: "Tuesday", + wed: "Wednesday", + thu: "Thursday", + fri: "Friday", + sat: "Saturday" + }, + months: { + jan: "Jan", + feb: "Feb", + mar: "Mar", + apr: "Apr", + may: "May", + jun: "Jun", + jul: "Jul", + aug: "Aug", + sep: "Sep", + oct: "Oct", + nov: "Nov", + dec: "Dec" + } + }, + inputNumber: { + decrease: "decrease number", + increase: "increase number" + }, + select: { + loading: "Loading", + noMatch: "No matching data", + noData: "No data", + placeholder: "Select" + }, + dropdown: { + toggleDropdown: "Toggle Dropdown" + }, + cascader: { + noMatch: "No matching data", + loading: "Loading", + placeholder: "Select", + noData: "No data" + }, + pagination: { + goto: "Go to", + pagesize: "/page", + total: "Total {total}", + pageClassifier: "", + page: "Page", + prev: "Go to previous page", + next: "Go to next page", + currentPage: "page {pager}", + prevPages: "Previous {pager} pages", + nextPages: "Next {pager} pages", + deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details" + }, + dialog: { + close: "Close this dialog" + }, + drawer: { + close: "Close this dialog" + }, + messagebox: { + title: "Message", + confirm: "OK", + cancel: "Cancel", + error: "Illegal input", + close: "Close this dialog" + }, + upload: { + deleteTip: "press delete to remove", + delete: "Delete", + preview: "Preview", + continue: "Continue" + }, + slider: { + defaultLabel: "slider between {min} and {max}", + defaultRangeStartLabel: "pick start value", + defaultRangeEndLabel: "pick end value" + }, + table: { + emptyText: "No Data", + confirmFilter: "Confirm", + resetFilter: "Reset", + clearFilter: "All", + sumText: "Sum" + }, + tree: { + emptyText: "No Data" + }, + transfer: { + noMatch: "No matching data", + noData: "No data", + titles: ["List 1", "List 2"], + filterPlaceholder: "Enter keyword", + noCheckedFormat: "{total} items", + hasCheckedFormat: "{checked}/{total} checked" + }, + image: { + error: "FAILED" + }, + pageHeader: { + title: "Back" + }, + popconfirm: { + confirmButtonText: "Yes", + cancelButtonText: "No" + } + } +}; + diff --git a/apps/web/vue3/static/cdn/zh-cn.js b/apps/web/vue3/static/cdn/zh-cn.js new file mode 100644 index 000000000..b86238535 --- /dev/null +++ b/apps/web/vue3/static/cdn/zh-cn.js @@ -0,0 +1,130 @@ +// global wrapper +/*! Element Plus v2.3.14 */ + +window.zhCn = { + name: "zh-cn", + el: { + colorpicker: { + confirm: "\u786E\u5B9A", + clear: "\u6E05\u7A7A" + }, + datepicker: { + now: "\u6B64\u523B", + today: "\u4ECA\u5929", + cancel: "\u53D6\u6D88", + clear: "\u6E05\u7A7A", + confirm: "\u786E\u5B9A", + selectDate: "\u9009\u62E9\u65E5\u671F", + selectTime: "\u9009\u62E9\u65F6\u95F4", + startDate: "\u5F00\u59CB\u65E5\u671F", + startTime: "\u5F00\u59CB\u65F6\u95F4", + endDate: "\u7ED3\u675F\u65E5\u671F", + endTime: "\u7ED3\u675F\u65F6\u95F4", + prevYear: "\u524D\u4E00\u5E74", + nextYear: "\u540E\u4E00\u5E74", + prevMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + year: "\u5E74", + month1: "1 \u6708", + month2: "2 \u6708", + month3: "3 \u6708", + month4: "4 \u6708", + month5: "5 \u6708", + month6: "6 \u6708", + month7: "7 \u6708", + month8: "8 \u6708", + month9: "9 \u6708", + month10: "10 \u6708", + month11: "11 \u6708", + month12: "12 \u6708", + weeks: { + sun: "\u65E5", + mon: "\u4E00", + tue: "\u4E8C", + wed: "\u4E09", + thu: "\u56DB", + fri: "\u4E94", + sat: "\u516D" + }, + months: { + jan: "\u4E00\u6708", + feb: "\u4E8C\u6708", + mar: "\u4E09\u6708", + apr: "\u56DB\u6708", + may: "\u4E94\u6708", + jun: "\u516D\u6708", + jul: "\u4E03\u6708", + aug: "\u516B\u6708", + sep: "\u4E5D\u6708", + oct: "\u5341\u6708", + nov: "\u5341\u4E00\u6708", + dec: "\u5341\u4E8C\u6708" + } + }, + select: { + loading: "\u52A0\u8F7D\u4E2D", + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + placeholder: "\u8BF7\u9009\u62E9" + }, + cascader: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + loading: "\u52A0\u8F7D\u4E2D", + placeholder: "\u8BF7\u9009\u62E9", + noData: "\u6682\u65E0\u6570\u636E" + }, + pagination: { + goto: "\u524D\u5F80", + pagesize: "\u6761/\u9875", + total: "\u5171 {total} \u6761", + pageClassifier: "\u9875", + page: "\u9875", + prev: "\u4E0A\u4E00\u9875", + next: "\u4E0B\u4E00\u9875", + currentPage: "\u7B2C {pager} \u9875", + prevPages: "\u5411\u524D {pager} \u9875", + nextPages: "\u5411\u540E {pager} \u9875", + deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863" + }, + messagebox: { + title: "\u63D0\u793A", + confirm: "\u786E\u5B9A", + cancel: "\u53D6\u6D88", + error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!" + }, + upload: { + deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664", + delete: "\u5220\u9664", + preview: "\u67E5\u770B\u56FE\u7247", + continue: "\u7EE7\u7EED\u4E0A\u4F20" + }, + table: { + emptyText: "\u6682\u65E0\u6570\u636E", + confirmFilter: "\u7B5B\u9009", + resetFilter: "\u91CD\u7F6E", + clearFilter: "\u5168\u90E8", + sumText: "\u5408\u8BA1" + }, + tree: { + emptyText: "\u6682\u65E0\u6570\u636E" + }, + transfer: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + titles: ["\u5217\u8868 1", "\u5217\u8868 2"], + filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9", + noCheckedFormat: "\u5171 {total} \u9879", + hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879" + }, + image: { + error: "\u52A0\u8F7D\u5931\u8D25" + }, + pageHeader: { + title: "\u8FD4\u56DE" + }, + popconfirm: { + confirmButtonText: "\u786E\u5B9A", + cancelButtonText: "\u53D6\u6D88" + } + } +}; diff --git a/apps/web/vue3/static/cdn/zh-cn.mjs b/apps/web/vue3/static/cdn/zh-cn.mjs new file mode 100644 index 000000000..ed050d71c --- /dev/null +++ b/apps/web/vue3/static/cdn/zh-cn.mjs @@ -0,0 +1,130 @@ +/*! Element Plus v2.3.14 */ + +var zhCn = { + name: "zh-cn", + el: { + colorpicker: { + confirm: "\u786E\u5B9A", + clear: "\u6E05\u7A7A" + }, + datepicker: { + now: "\u6B64\u523B", + today: "\u4ECA\u5929", + cancel: "\u53D6\u6D88", + clear: "\u6E05\u7A7A", + confirm: "\u786E\u5B9A", + selectDate: "\u9009\u62E9\u65E5\u671F", + selectTime: "\u9009\u62E9\u65F6\u95F4", + startDate: "\u5F00\u59CB\u65E5\u671F", + startTime: "\u5F00\u59CB\u65F6\u95F4", + endDate: "\u7ED3\u675F\u65E5\u671F", + endTime: "\u7ED3\u675F\u65F6\u95F4", + prevYear: "\u524D\u4E00\u5E74", + nextYear: "\u540E\u4E00\u5E74", + prevMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + year: "\u5E74", + month1: "1 \u6708", + month2: "2 \u6708", + month3: "3 \u6708", + month4: "4 \u6708", + month5: "5 \u6708", + month6: "6 \u6708", + month7: "7 \u6708", + month8: "8 \u6708", + month9: "9 \u6708", + month10: "10 \u6708", + month11: "11 \u6708", + month12: "12 \u6708", + weeks: { + sun: "\u65E5", + mon: "\u4E00", + tue: "\u4E8C", + wed: "\u4E09", + thu: "\u56DB", + fri: "\u4E94", + sat: "\u516D" + }, + months: { + jan: "\u4E00\u6708", + feb: "\u4E8C\u6708", + mar: "\u4E09\u6708", + apr: "\u56DB\u6708", + may: "\u4E94\u6708", + jun: "\u516D\u6708", + jul: "\u4E03\u6708", + aug: "\u516B\u6708", + sep: "\u4E5D\u6708", + oct: "\u5341\u6708", + nov: "\u5341\u4E00\u6708", + dec: "\u5341\u4E8C\u6708" + } + }, + select: { + loading: "\u52A0\u8F7D\u4E2D", + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + placeholder: "\u8BF7\u9009\u62E9" + }, + cascader: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + loading: "\u52A0\u8F7D\u4E2D", + placeholder: "\u8BF7\u9009\u62E9", + noData: "\u6682\u65E0\u6570\u636E" + }, + pagination: { + goto: "\u524D\u5F80", + pagesize: "\u6761/\u9875", + total: "\u5171 {total} \u6761", + pageClassifier: "\u9875", + page: "\u9875", + prev: "\u4E0A\u4E00\u9875", + next: "\u4E0B\u4E00\u9875", + currentPage: "\u7B2C {pager} \u9875", + prevPages: "\u5411\u524D {pager} \u9875", + nextPages: "\u5411\u540E {pager} \u9875", + deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863" + }, + messagebox: { + title: "\u63D0\u793A", + confirm: "\u786E\u5B9A", + cancel: "\u53D6\u6D88", + error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!" + }, + upload: { + deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664", + delete: "\u5220\u9664", + preview: "\u67E5\u770B\u56FE\u7247", + continue: "\u7EE7\u7EED\u4E0A\u4F20" + }, + table: { + emptyText: "\u6682\u65E0\u6570\u636E", + confirmFilter: "\u7B5B\u9009", + resetFilter: "\u91CD\u7F6E", + clearFilter: "\u5168\u90E8", + sumText: "\u5408\u8BA1" + }, + tree: { + emptyText: "\u6682\u65E0\u6570\u636E" + }, + transfer: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + titles: ["\u5217\u8868 1", "\u5217\u8868 2"], + filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9", + noCheckedFormat: "\u5171 {total} \u9879", + hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879" + }, + image: { + error: "\u52A0\u8F7D\u5931\u8D25" + }, + pageHeader: { + title: "\u8FD4\u56DE" + }, + popconfirm: { + confirmButtonText: "\u786E\u5B9A", + cancelButtonText: "\u53D6\u6D88" + } + } +}; + diff --git a/apps/web/vue3/static/conversation-sidebar.js b/apps/web/vue3/static/conversation-sidebar.js index 87a3f8aba..8c020d3db 100755 --- a/apps/web/vue3/static/conversation-sidebar.js +++ b/apps/web/vue3/static/conversation-sidebar.js @@ -1,373 +1,373 @@ /** * Conversation Sidebar Component - * 类似ChatGPT的会话侧边栏组件 + * Conversation Sidebar component */ const ConversationSidebar = { - template: ` -
- - - - - - - -
- -
-
今天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} - {{ conv.message_count }}条消息 -
-
{{ conv.last_message }}
-
-
- - - - -
-
-
- - -
-
昨天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} - {{ conv.message_count }}条 -
-
-
-
- - -
-
过去7天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} -
-
-
-
- - -
- -

暂无会话

-

点击上方"New Chat"开始新的基因家族分析

-
-
- - - -
- `, - - props: { - userId: { - type: String, - default: 'xia-lab' - }, - apiBaseUrl: { - type: String, - default: 'http://localhost:8000/api' - } - }, - - data() { - return { - isCollapsed: false, - searchQuery: '', - conversations: [], - currentConversationId: null, - loading: false, - // 图标 - Plus: ElementPlusIconsVue.Plus, - ArrowLeft: ElementPlusIconsVue.ArrowLeft, - ArrowRight: ElementPlusIconsVue.ArrowRight, - ChatDotRound: ElementPlusIconsVue.ChatDotRound, - ChatLineRound: ElementPlusIconsVue.ChatLineRound, - More: ElementPlusIconsVue.More, - Edit: ElementPlusIconsVue.Edit, - Folder: ElementPlusIconsVue.Folder, - Delete: ElementPlusIconsVue.Delete, - UserFilled: ElementPlusIconsVue.UserFilled, - } - }, - - computed: { - filteredConversations() { - if (!this.searchQuery) return this.conversations; - const query = this.searchQuery.toLowerCase(); - return this.conversations.filter(conv => - conv.title.toLowerCase().includes(query) || - (conv.gene_family && conv.gene_family.toLowerCase().includes(query)) || - conv.last_message.toLowerCase().includes(query) - ); - }, - - todayConversations() { - const today = new Date().toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate === today; - }); - }, - - yesterdayConversations() { - const yesterday = new Date(Date.now() - 86400000).toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate === yesterday; - }); - }, - - olderConversations() { - const yesterday = new Date(Date.now() - 86400000).toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate < yesterday; - }); - } - }, - - mounted() { - this.loadConversations(); - // 定时刷新 - this.refreshInterval = setInterval(() => { - this.loadConversations(); - }, 30000); // 30秒刷新一次 - }, - - beforeUnmount() { - if (this.refreshInterval) { - clearInterval(this.refreshInterval); - } - }, - - methods: { - async loadConversations() { - try { - this.loading = true; - const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`); - if (response.ok) { - const data = await response.json(); - this.conversations = data.conversations || []; - } - } catch (error) { - console.error('Failed to load conversations:', error); - // 使用模拟数据 - this.conversations = this.getMockConversations(); - } finally { - this.loading = false; - } - }, - - getMockConversations() { - // 模拟数据,用于测试 - return [ - { - conversation_id: 'conv_001', - title: 'At.WRKY基因家族分析', - gene_family: 'WRKY', - species: 'At', - message_count: 12, - last_message: '系统发育树已生成完成', - updated_at: new Date().toISOString() - }, - { - conversation_id: 'conv_002', - title: 'Os.TCP进化分析', - gene_family: 'TCP', - species: 'Os', - message_count: 5, - last_message: '正在构建进化树...', - updated_at: new Date(Date.now() - 3600000).toISOString() - }, - { - conversation_id: 'conv_003', - title: 'HMMER参数配置咨询', - message_count: 8, - last_message: 'E-value建议设置为1e-5', - updated_at: new Date(Date.now() - 86400000).toISOString() - } - ]; - }, - - toggleSidebar() { - this.isCollapsed = !this.isCollapsed; - this.$emit('toggle', this.isCollapsed); - }, - - async createNewConversation() { - try { - const response = await fetch(`${this.apiBaseUrl}/conversations/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: this.userId, - title: '新会话 ' + new Date().toLocaleTimeString() - }) - }); - - if (response.ok) { - const data = await response.json(); - this.currentConversationId = data.conversation_id; - this.$emit('new-conversation', data); - await this.loadConversations(); - } - } catch (error) { - console.error('Failed to create conversation:', error); - // 创建本地会话 - const newConv = { - conversation_id: 'conv_' + Date.now(), - title: '新会话', - message_count: 0, - last_message: '', - updated_at: new Date().toISOString() - }; - this.conversations.unshift(newConv); - this.currentConversationId = newConv.conversation_id; - this.$emit('new-conversation', newConv); - } - }, - - selectConversation(conv) { - this.currentConversationId = conv.conversation_id; - this.$emit('select-conversation', conv); - }, - - async renameConversation(conv) { - const newTitle = prompt('请输入新标题:', conv.title); - if (newTitle && newTitle !== conv.title) { - try { - await fetch(`${this.apiBaseUrl}/conversations/update-title`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id, - title: newTitle - }) - }); - conv.title = newTitle; - } catch (error) { - console.error('Failed to rename:', error); - conv.title = newTitle; // 本地更新 - } - } - }, - - async archiveConversation(conv) { - try { - await fetch(`${this.apiBaseUrl}/conversations/archive`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id - }) - }); - await this.loadConversations(); - } catch (error) { - console.error('Failed to archive:', error); - } - }, - - async deleteConversation(conv) { - if (confirm(`确定要删除会话 "${conv.title}" 吗?`)) { - try { - await fetch(`${this.apiBaseUrl}/conversations/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id - }) - }); - await this.loadConversations(); - if (this.currentConversationId === conv.conversation_id) { - this.currentConversationId = null; - this.$emit('select-conversation', null); - } - } catch (error) { - console.error('Failed to delete:', error); - } - } - } - } + template: ` +
+ + + + + + + +
+ +
+
Today
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }} messages +
+
{{ conv.last_message }}
+
+
+ + + + +
+
+
+ + +
+
Yesterday
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }} +
+
+
+
+ + +
+
Past 7 days
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} +
+
+
+
+ + +
+ +

No conversations yet

+

Click "New Chat" to start gene family analysis

+
+
+ + + +
+ `, + + props: { + userId: { + type: String, + default: 'xia-lab' + }, + apiBaseUrl: { + type: String, + default: 'http://localhost:8000/api' + } + }, + + data() { + return { + isCollapsed: false, + searchQuery: '', + conversations: [], + currentConversationId: null, + loading: false, + // Icons + Plus: ElementPlusIconsVue.Plus, + ArrowLeft: ElementPlusIconsVue.ArrowLeft, + ArrowRight: ElementPlusIconsVue.ArrowRight, + ChatDotRound: ElementPlusIconsVue.ChatDotRound, + ChatLineRound: ElementPlusIconsVue.ChatLineRound, + More: ElementPlusIconsVue.More, + Edit: ElementPlusIconsVue.Edit, + Folder: ElementPlusIconsVue.Folder, + Delete: ElementPlusIconsVue.Delete, + UserFilled: ElementPlusIconsVue.UserFilled, + } + }, + + computed: { + filteredConversations() { + if (!this.searchQuery) return this.conversations; + const query = this.searchQuery.toLowerCase(); + return this.conversations.filter(conv => + conv.title.toLowerCase().includes(query) || + (conv.gene_family && conv.gene_family.toLowerCase().includes(query)) || + conv.last_message.toLowerCase().includes(query) + ); + }, + + todayConversations() { + const today = new Date().toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate === today; + }); + }, + + yesterdayConversations() { + const yesterday = new Date(Date.now() - 86400000).toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate === yesterday; + }); + }, + + olderConversations() { + const yesterday = new Date(Date.now() - 86400000).toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate < yesterday; + }); + } + }, + + mounted() { + this.loadConversations(); + // Auto-refresh + this.refreshInterval = setInterval(() => { + this.loadConversations(); + }, 30000); // 30 seconds refresh interval + }, + + beforeUnmount() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + }, + + methods: { + async loadConversations() { + try { + this.loading = true; + const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`); + if (response.ok) { + const data = await response.json(); + this.conversations = data.conversations || []; + } + } catch (error) { + console.error('Failed to load conversations:', error); + // Use mock data + this.conversations = this.getMockConversations(); + } finally { + this.loading = false; + } + }, + + getMockConversations() { + // Mock data for testing + return [ + { + conversation_id: 'conv_001', + title: 'At.WRKY gene family analysis', + gene_family: 'WRKY', + species: 'At', + message_count: 12, + last_message: 'Phylogenetic tree done', + updated_at: new Date().toISOString() + }, + { + conversation_id: 'conv_002', + title: 'Os.TCP evolution analysis', + gene_family: 'TCP', + species: 'Os', + message_count: 5, + last_message: 'Building tree...', + updated_at: new Date(Date.now() - 3600000).toISOString() + }, + { + conversation_id: 'conv_003', + title: 'HMMER config consultation', + message_count: 8, + last_message: 'E-value 1e-5 recommended', + updated_at: new Date(Date.now() - 86400000).toISOString() + } + ]; + }, + + toggleSidebar() { + this.isCollapsed = !this.isCollapsed; + this.$emit('toggle', this.isCollapsed); + }, + + async createNewConversation() { + try { + const response = await fetch(`${this.apiBaseUrl}/conversations/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: this.userId, + title: 'New Chat ' + new Date().toLocaleTimeString() + }) + }); + + if (response.ok) { + const data = await response.json(); + this.currentConversationId = data.conversation_id; + this.$emit('new-conversation', data); + await this.loadConversations(); + } + } catch (error) { + console.error('Failed to create conversation:', error); + // Create local conversation + const newConv = { + conversation_id: 'conv_' + Date.now(), + title: 'New Chat', + message_count: 0, + last_message: '', + updated_at: new Date().toISOString() + }; + this.conversations.unshift(newConv); + this.currentConversationId = newConv.conversation_id; + this.$emit('new-conversation', newConv); + } + }, + + selectConversation(conv) { + this.currentConversationId = conv.conversation_id; + this.$emit('select-conversation', conv); + }, + + async renameConversation(conv) { + const newTitle = prompt('Enter new title:', conv.title); + if (newTitle && newTitle !== conv.title) { + try { + await fetch(`${this.apiBaseUrl}/conversations/update-title`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id, + title: newTitle + }) + }); + conv.title = newTitle; + } catch (error) { + console.error('Failed to rename:', error); + conv.title = newTitle; // local update + } + } + }, + + async archiveConversation(conv) { + try { + await fetch(`${this.apiBaseUrl}/conversations/archive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id + }) + }); + await this.loadConversations(); + } catch (error) { + console.error('Failed to archive:', error); + } + }, + + async deleteConversation(conv) { + if (confirm(`Delete conversation "${conv.title}"?`)) { + try { + await fetch(`${this.apiBaseUrl}/conversations/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id + }) + }); + await this.loadConversations(); + if (this.currentConversationId === conv.conversation_id) { + this.currentConversationId = null; + this.$emit('select-conversation', null); + } + } catch (error) { + console.error('Failed to delete:', error); + } + } + } + } }; -// 导出组件 +// Export component if (typeof module !== 'undefined' && module.exports) { - module.exports = { ConversationSidebar }; + module.exports = { ConversationSidebar }; } diff --git a/apps/web/vue3/static/i18n.js b/apps/web/vue3/static/i18n.js new file mode 100644 index 000000000..770d574fe --- /dev/null +++ b/apps/web/vue3/static/i18n.js @@ -0,0 +1,1911 @@ +// /static/i18n.js — CropAI 中英文翻译对照表 +// 默认中文,由 Vue setup() 中 reactive t() 接管切换 +window.__CROPAI_LANG__ = 'zh'; + +// 全局 t/tp 可能被 Vue reactive 版本覆盖;保留为兼容回退 +function t(key) { + var entry = I18N_MAP[key]; + if (!entry) return '⚠' + key; + var lang = window.__CROPAI_LANG__ || 'zh'; + return entry[lang] || entry['zh'] || key; +} + +// 带参数替换: t('found-node', { name: 'xxx' }) +function tp(key, params) { + var s = t(key); + if (params) { + Object.keys(params).forEach(function(k) { + s = s.replace('{' + k + '}', params[k]); + }); + } + return s; +} + +var I18N_MAP = { + // ========== 通用 ========== + 'send': { zh: '发送', en: 'Send' }, + 'cancel': { zh: '取消', en: 'Cancel' }, + 'confirm': { zh: '确认', en: 'Confirm' }, + 'close': { zh: '关闭', en: 'Close' }, + 'loading': { zh: '加载中...', en: 'Loading...' }, + 'processing': { zh: '处理中...', en: 'Processing...' }, + 'refresh': { zh: '刷新', en: 'Refresh' }, + 'delete': { zh: '删除', en: 'Delete' }, + 'search': { zh: '搜索', en: 'Search' }, + 'download': { zh: '下载', en: 'Download' }, + 'upload': { zh: '上传', en: 'Upload' }, + 'save': { zh: '保存', en: 'Save' }, + 'reset': { zh: '重置', en: 'Reset' }, + 'prefix': { zh: '前缀', en: 'Prefix' }, + 'status': { zh: '状态', en: 'Status' }, + 'copy': { zh: '复制', en: 'Copy' }, + 'new-chat': { zh: 'New Chat', en: 'New Chat' }, + + // ========== agent_status_component ========== + 'agent-assistant': { zh: '🤖 Agent 智能助手', en: '🤖 Agent Assistant' }, + 'standby': { zh: '待机', en: 'Standby' }, + 'enter-question': { zh: '输入您的问题,例如:分析SPL基因家族', en: 'Enter your question, e.g.: Analyze SPL gene family' }, + 'task-list': { zh: '📋 任务列表', en: '📋 Task List' }, + 'user-role': { zh: '👤 用户', en: '👤 User' }, + 'agent-role': { zh: '🤖 Agent', en: '🤖 Agent' }, + 'system-role': { zh: '💬 系统', en: '💬 System' }, + 'error-role': { zh: '❌ 错误', en: '❌ Error' }, + 'execution-role': { zh: '⚙️ 执行', en: '⚙️ Execution' }, + 'planning-role': { zh: '📋 规划', en: '📋 Planning' }, + 'diagnosis-role': { zh: '🔍 诊断', en: '🔍 Diagnosis' }, + 'msg-task-submitted': { zh: '任务已提交: ', en: 'Task submitted: ' }, + 'msg-submit-failed': { zh: '提交失败', en: 'Submission failed' }, + 'msg-request-failed': { zh: '请求失败: ', en: 'Request failed: ' }, + 'msg-task-completed': { zh: '任务完成', en: 'Task completed' }, + 'msg-task-failed': { zh: '任务失败', en: 'Task failed' }, + 'msg-analysis-complete': { zh: '✅ 分析完成! 找到 ', en: '✅ Analysis complete! Found ' }, + 'msg-gene-members': { zh: ' 个基因成员', en: ' gene members' }, + + // ========== index-with-sidebar ========== + 'prefix-placeholder': { zh: '如: Ft.SPL', en: 'e.g. Ft.SPL' }, + 'gf-analysis': { zh: '基因家族分析', en: 'Gene Family Analysis' }, + 'gf-analysis-system': { zh: '🌱 BotanicalBaseAgent - 基因家族分析系统', en: '🌱 BotanicalBaseAgent - Gene Family Analysis System' }, + 'search-conv': { zh: '搜索会话...', en: 'Search conversations...' }, + 'conv-list': { zh: '会话列表', en: 'Conversations' }, + 'msg-count': { zh: '条消息', en: ' messages' }, + 'select-stage': { zh: '选择分析阶段', en: 'Select Stage' }, + 'identify-label': { zh: 'Identify - 基因家族鉴定', en: 'Identify - Gene Family Identification' }, + 'phylogeny-label': { zh: 'Phylogeny - 系统发育分析', en: 'Phylogeny - Phylogenetic Analysis' }, + 'motif-label': { zh: 'Motif - Motif分析', en: 'Motif - Motif Analysis' }, + 'domain-label': { zh: 'Domain - 结构域分析', en: 'Domain - Domain Analysis' }, + 'expression-label': { zh: 'Expression - 表达分析', en: 'Expression - Expression Analysis' }, + 'execute-analysis': { zh: '🚀 执行分析', en: '🚀 Execute Analysis' }, + 'analysis-stage': { zh: '▶ Analysis Stage', en: '▶ Analysis Stage' }, + 'param-settings': { zh: '⚙️ 参数设置', en: '⚙️ Parameters' }, + 'user': { zh: '用户', en: 'User' }, + 'ai-assistant': { zh: 'AI助手', en: 'AI Assistant' }, + 'initial-welcome': { zh: '欢迎来到 "{title}"!我是BotanicalBaseAgent,专注于基因家族分析。请告诉我您想分析哪个基因家族?', en: 'Welcome to "{title}"! I am BotanicalBaseAgent, specialized in gene family analysis. Tell me which gene family you want to analyze?' }, + 'describe-need': { zh: '描述您的需求,如:帮我分析拟南芥SPL基因家族', en: 'Describe your needs, e.g.: Help me analyze the Arabidopsis SPL gene family' }, + 'results-here': { zh: '分析结果将在此显示', en: 'Analysis results will appear here' }, + 'start-new-chat': { zh: '开始一个新的对话', en: 'Start a new conversation' }, + 'or-continue': { zh: '或点击左侧会话继续之前的对话', en: 'Or click a conversation on the left to continue' }, + 'completed': { zh: '已完成', en: 'Completed' }, + 'running': { zh: '运行中', en: 'Running' }, + 'pending': { zh: '等待中', en: 'Pending' }, + 'msg-select-stage-first': { zh: '请先选择分析阶段', en: 'Please select an analysis stage first' }, + 'msg-executing': { zh: '开始执行 ', en: 'Starting execution: ' }, + 'msg-analysis-text': { zh: ' 分析', en: ' analysis' }, + + // ========== kg.html ========== + 'kg-title': { zh: '知识图谱 - V13.1 基因家族分析', en: 'Knowledge Graph - V13.1 Gene Family Analysis' }, + 'kg-visualization': { zh: '🧬 知识图谱可视化', en: '🧬 Knowledge Graph Visualization' }, + 'kg-subtitle': { zh: 'V13.1 基因家族分析系统 - Pecan SPL', en: 'V13.1 Gene Family Analysis System - Pecan SPL' }, + 'back-to-main': { zh: '返回主界面', en: 'Back to Main' }, + 'kg-stats': { zh: '📊 图谱统计', en: '📊 Graph Statistics' }, + 'node-count': { zh: '节点总数', en: 'Total Nodes' }, + 'edge-count': { zh: '关系总数', en: 'Total Edges' }, + 'node-types': { zh: '节点类型', en: 'Node Types' }, + 'edge-types': { zh: '关系类型', en: 'Edge Types' }, + 'node-search': { zh: '🔍 节点搜索', en: '🔍 Node Search' }, + 'enter-gene-name': { zh: '输入基因名或实体...', en: 'Enter gene name or entity...' }, + 'relation-filter-pc': { zh: '🏷️ 关系过滤 (PC语义)', en: '🏷️ Relation Filter (PC Semantic)' }, + 'activation-induction': { zh: '激活/诱导', en: 'Activation/Induction' }, + 'repression-inhibition': { zh: '抑制/下调', en: 'Repression/Inhibition' }, + 'regulation-control': { zh: '调控', en: 'Regulation/Control' }, + 'expression-detection': { zh: '表达', en: 'Expression/Detection' }, + 'association-interaction': { zh: '关联', en: 'Association/Interaction' }, + 'localization': { zh: '定位', en: 'Localization' }, + 'containment-composition': { zh: '包含', en: 'Containment/Composition' }, + 'participation-function': { zh: '参与', en: 'Participation/Function' }, + 'other-rel': { zh: '其他', en: 'Other' }, + 'node-legend-pc': { zh: '🎨 节点图例 (PC语义)', en: '🎨 Node Legend (PC Semantic)' }, + 'edge-legend-pc': { zh: '🔗 关系图例 (PC语义)', en: '🔗 Edge Legend (PC Semantic)' }, + 'fit-view': { zh: '适应视图', en: 'Fit View' }, + 'reset-filters': { zh: '重置过滤', en: 'Reset Filters' }, + 'click-drag-hint': { zh: '💡 点击节点查看详情,拖拽调整布局', en: '💡 Click nodes for details, drag to adjust layout' }, + 'node-detail': { zh: '📋 节点详情', en: '📋 Node Details' }, + 'node-name': { zh: '节点名称', en: 'Node Name' }, + 'node-type-pc': { zh: '节点类型 (PC)', en: 'Node Type (PC)' }, + 'connection-count': { zh: '连接关系数', en: 'Connections' }, + 'click-node-detail': { zh: '点击图谱中的节点查看详情', en: 'Click a node in the graph to view details' }, + 'related-relations': { zh: '🔗 相关关系', en: '🔗 Related Relations' }, + 'more-relations': { zh: '还有 {count} 个关系...', en: '{count} more relations...' }, + 'msg-kg-loaded': { zh: '知识图谱加载完成 (PC语义规范)', en: 'Knowledge graph loaded (PC Semantic)' }, + 'msg-load-data-failed': { zh: '加载数据失败: ', en: 'Data load failed: ' }, + 'msg-node-found': { zh: '找到节点: ', en: 'Node found: ' }, + 'msg-node-not-found': { zh: '未找到匹配的节点', en: 'No matching node found' }, + + // ========== NEW ENTRIES ========== + '1basedinclusivealignmentcolumnendsite': { zh: '1-based inclusive alignment column / 位点终点', en: '1-based inclusive alignment column / End Site' }, + '1basedinclusivealignmentcolumnstartsite': { zh: '1-based inclusive alignment column / 位点起点', en: '1-based inclusive alignment column / Start Site' }, + '11samplesforpipelinesmoketestingonlynotf': { zh: '11 个样本仅用于流程烟雾测试,不用于稳定群体结构推断。', en: '11 samples for pipeline smoke testing only; not for stable population structure inference.' }, + '11samplevcfrequired': { zh: '11 样本 VCF(必填)', en: '11-sample VCF (Required)' }, + '22msareplotregionreplot': { zh: '2.2 MSA Replot / 区间重绘', en: '2.2 MSA Replot / Region Replot' }, + '23treereplotphylogenetictreereplot': { zh: '2.3 Tree Replot / 进化树重绘', en: '2.3 Tree Replot / Phylogenetic Tree Replot' }, + '25subfamilyconfirmation': { zh: '2.5 亚家族确认', en: '2.5 Subfamily Confirmation' }, + 'autoexecutewithcurrentparamsifidle40spau': { zh: '40秒内不操作将使用当前参数自动执行,可随时暂停倒计时后手动确认。', en: 'Auto-execute with current params if idle 40s; pause countdown for manual.' }, + '61promotercustomplot': { zh: '6.1 启动子自定义绘图', en: '6.1 Promoter Custom Plot' }, + '71intraspeciessyntenycircosreplot': { zh: '7.1 种内共线性 Circos 重绘', en: '7.1 Intraspecies Synteny Circos Replot' }, + 'intraspeciessynteny': { zh: '7️⃣ 种内Synteny', en: 'Intraspecies Synteny' }, + 'interspeciessynteny': { zh: '9️⃣ 种间Synteny', en: 'Interspecies Synteny' }, + 'rnaseqanalysis': { zh: '===== RNA-Seq 分析 =====', en: '----- RNA-Seq Analysis -----' }, + 'datapreparation': { zh: '===== 数据准备 =====', en: '----- Data Preparation -----' }, + 'coreanalysis': { zh: '===== 核心分析 =====', en: '----- Core Analysis -----' }, + 'kmaximum': { zh: '>K 最大值K 最小值指定 GWAS K(0=仅报告最佳 K)预览💡 可通过上方', en: 'Tip: via above' }, + 'alignstagedidnotfindupstreamrnaseqqcgene': { zh: 'Align 阶段没有找到上游 RNA-seq QC 生成的 *.clean.fastq.gz。', en: 'Align stage did not find upstream RNA-seq QC generated *.clean.fastq.gz.' }, + 'annotationbackgroundannotationgeneratege': { zh: 'Annotation - 背景注释(生成gene2go/gene2ko', en: 'Annotation - Background Annotation (generate gene2go/gene2ko)' }, + 'applyconfirmfill': { zh: 'Apply 确认填入', en: 'Apply Confirm Fill' }, + 'blastpercentidentitylowerboundfill50when': { zh: 'BLAST percent identity 下限;文献写 identity 50% 时填 50', en: 'BLAST percent identity lower bound; fill 50 when literature reports 50%' }, + 'blastengine': { zh: 'BLAST 引擎', en: 'BLAST Engine' }, + 'blastsignificancethresholdforcandidatesc': { zh: 'BLAST 显著性阈值,用于初步筛选候选基因', en: 'BLAST significance threshold for candidate screening' }, + 'blastreference': { zh: 'BLAST参考', en: 'BLAST Reference' }, + 'bootstrapsize': { zh: 'Bootstrap大小', en: 'Bootstrap Size' }, + 'celeryrestartedqueuecheckcomplete': { zh: 'Celery 已重启并完成队列检查', en: 'Celery Restarted & Queue Check Complete' }, + 'celeryrestartincomplete': { zh: 'Celery 重启未完成', en: 'Celery Restart Incomplete' }, + 'comborevisionparameters': { zh: 'Combo 修订参数', en: 'Combo Revision Parameters' }, + 'comborevisionrequiresatleasttwotypessele': { zh: 'Combo 修订至少选择两个类型。', en: 'Combo revision requires at least two types selected.' }, + 'comboplotrevisionfailed': { zh: 'Combo 图修订失败:', en: 'Combo Plot Revision Failed:' }, + 'comboplotrevisioncompleteswitchedtonewfi': { zh: 'Combo 图修订完成,已切换到新图', en: 'Combo plot revision complete; switched to new figure' }, + 'comboplotrevisionsubmittedtaskid': { zh: 'Combo 图修订已提交:task_id=', en: 'Combo plot revision submitted: task_id=' }, + 'comparativerolespecieseligibleasanalysis': { zh: 'Comparative 角色物种(可作为本次待分析物种)', en: 'Comparative Role Species (eligible as analysis species)' }, + 'demoduleautodetect': { zh: 'DE模块:自动识别', en: 'DE Module: Auto-detect' }, + 'domainparameters': { zh: 'Domain 参数', en: 'Domain Parameters' }, + 'domaincoveragefilterthresholdinpercenteg': { zh: 'Domain 绘图覆盖度过滤阈值,按百分比填写;例如 30 表示 30%', en: 'Domain coverage filter threshold in percent; e.g. 30 means 30%' }, + 'domainuseshmmerhmmscanthisvaluepassedtoe': { zh: 'Domain 阶段使用 HMMER hmmscan 扫描 Pfam;该值传给 --evalue 并用于 i-Evalue 过滤,越小越严格', en: 'Domain uses HMMER hmmscan; this value passed to --evalue for i-Evalue filter (smaller = stricter)' }, + 'ensemblformat': { zh: 'Ensembl 格式', en: 'Ensembl Format' }, + 'expressionusingfpkmdifferentialexpressio': { zh: 'Expression (使用FPKM) - Differential Expression', en: 'Expression (using FPKM) - Differential Expression' }, + 'expressionparametersfpkmtpmmatrix': { zh: 'Expression 参数 (FPKM/TPM矩阵', en: 'Expression Parameters (FPKM/TPM matrix)' }, + 'fastafileleaveemptyforautodiscoverycomma': { zh: 'FASTA文件;可留空自动查找,多个用逗号分隔', en: 'FASTA file; leave empty for auto-discovery; comma-separated for multiples' }, + 'fastqfiles': { zh: 'FASTQ文件', en: 'FASTQ Files' }, + 'fimopvaluethresholddefault1e4': { zh: 'FIMO p-value 阈值,默认 1e-4', en: 'FIMO p-value threshold, default 1e-4' }, + 'fimoqvaluefilterthresholddefault005': { zh: 'FIMO q-value 过滤阈值,默认 0.05', en: 'FIMO q-value filter threshold, default 0.05' }, + 'filescopeunchanged': { zh: 'File_Scope不改', en: 'File_Scope (Unchanged)' }, + 'filetypeunchanged': { zh: 'File_Type不改', en: 'File_Type (Unchanged)' }, + 'gatkexecutionfailed': { zh: 'GATK 执行失败', en: 'GATK Execution Failed' }, + 'geonumber': { zh: 'GEO编号', en: 'GEO Number' }, + 'geonumberinputgeonumbereggse111937autodo': { zh: 'GEO编号 - InputGEO编号(如 GSE111937)自动Download', en: 'GEO Number - Input GEO Number (e.g. GSE111937) Auto-download' }, + 'goenrichment': { zh: 'GO 富集', en: 'GO Enrichment' }, + 'goenrichmentbackgroundmissing': { zh: 'GO 富集背景缺失', en: 'GO Enrichment Background Missing' }, + 'gokeggenrichmentbackgroundmissing': { zh: 'GO/KEGG 富集背景缺失', en: 'GO/KEGG Enrichment Background Missing' }, + 'gokeggenrichmentrequiresgene2goorgene2ko': { zh: 'GO/KEGG 富集需要 gene2go 或 gene2ko 背景映射文件,但当前任务没有找到可用文件。', en: 'GO/KEGG enrichment requires gene2go or gene2ko background mapping files; none found.' }, + 'gwasemmaxplotting': { zh: 'GWAS EMMAX绘图', en: 'GWAS EMMAX Plotting' }, + 'gwasmlmcmlmplotting': { zh: 'GWAS MLM/CMLM绘图', en: 'GWAS MLM/CMLM Plotting' }, + 'gwastasselkinshipmatrix': { zh: 'GWAS TASSEL亲缘关系矩阵', en: 'GWAS TASSEL Kinship Matrix' }, + 'gwasvcfsorthapmapconversion': { zh: 'GWAS VCF排序与HapMap转换', en: 'GWAS VCF Sort & HapMap Conversion' }, + 'gwasspeciesoutput': { zh: 'GWAS 物种与输出', en: 'GWAS Species & Output' }, + 'gwasinputvcf': { zh: 'GWAS 输入 VCF', en: 'GWAS Input VCF' }, + 'gwascovariatepreparation': { zh: 'GWAS协变量准备', en: 'GWAS Covariate Preparation' }, + 'gwasphenotypepreparation': { zh: 'GWAS表型准备', en: 'GWAS Phenotype Preparation' }, + 'genefamilyunchanged': { zh: 'Gene_Family不改', en: 'Gene_Family (Unchanged)' }, + 'genomepreparegenomedatapreprocessing': { zh: 'Genome Prepare - Genome数据预处理', en: 'Genome Prepare - Genome Data Preprocessing' }, + 'genomeprepareparameters': { zh: 'Genome Prepare 参数', en: 'Genome Prepare Parameters' }, + 'hisat2needsgenomefastaorgenomehisat2inde': { zh: 'HISAT2 比对应使用基因组 FASTA 或基因组 HISAT2 index,不能使用 CDS/PEP index。', en: 'HISAT2 needs genome FASTA or genome HISAT2 index, not CDS/PEP index.' }, + 'hmmpfamaudit': { zh: 'HMM/Pfam 审计', en: 'HMM/Pfam Audit' }, + 'hmmerparameterconsultation': { zh: 'HMMER参数配置咨询', en: 'HMMER Parameter Consultation' }, + 'idlistfileegpathtogeneids': { zh: 'ID列表文件,如 /path/to/gene.ids', en: 'ID list file, e.g. /path/to/gene.ids' }, + 'idlistfileegathdzipidsautolookupbyoutput': { zh: 'ID列表文件,如 At.HD-ZIP.ids;留空时按输出前缀自动查找', en: 'ID list file, e.g. At.HD-ZIP.ids; auto-lookup by output prefix' }, + 'iqtreeparamsegmmfpbb1000': { zh: 'IQ-TREE 建树参数,如 -m MFP -bb 1000', en: 'IQ-TREE params, e.g. -m MFP -bb 1000' }, + 'identifyblastreference': { zh: 'Identify BLAST参考', en: 'Identify BLAST Reference' }, + 'identifyblastreferencespeciesegatos': { zh: 'Identify BLAST参考物种,如 At / Os', en: 'Identify BLAST reference species, e.g. At / Os' }, + 'identifyblastreferenceegatos': { zh: 'Identify BLAST参考,例如 At / Os', en: 'Identify BLAST reference, e.g. At / Os' }, + 'identifydomainmemberconfirmation': { zh: 'Identify Domain 成员确认', en: 'Identify Domain Member Confirmation' }, + 'identifyrefinerevisememberidsrebuildprot': { zh: 'Identify Refine - 修订成员ID并重建蛋白', en: 'Identify Refine - Revise Member IDs & Rebuild Proteins' }, + 'identifyparameters': { zh: 'Identify 参数', en: 'Identify Parameters' }, + 'hmmmodelcoveragethresholdforidentify01cl': { zh: 'Identify 阶段 HMM 模型覆盖度阈值,按 0-1 比例填写;对应 CLI --hmm-cov-min', en: 'HMM model coverage threshold for Identify (0-1); CLI --hmm-cov-min' }, + 'hmmerhmmsearchievaluefilterforidentifycl': { zh: 'Identify 阶段使用 HMMER hmmsearch,过滤 domtblout 的 independent domain E-value;对应 CLI --dom-i-evalue-max,越小越严格', en: 'HMMER hmmsearch i-Evalue filter for Identify; CLI --dom-i-evalue-max' }, + 'inputspecieslatinnameegarabidopsisthalia': { zh: 'Input物种拉丁名 (如 Arabidopsis thaliana', en: 'Input Species Latin Name (e.g. Arabidopsis thaliana)' }, + 'inputspecieslatinnameegarabidopsisthalia-2': { zh: 'Input物种拉丁名,如 Arabidopsis thaliana', en: 'Input species Latin name, e.g. Arabidopsis thaliana' }, + 'interspeciessyntenycrossspeciessynteny': { zh: 'Interspecies Synteny - 物种间共线性分析 (跨物种', en: 'Interspecies Synteny - Cross-Species Synteny' }, + 'jgiformat': { zh: 'JGI 格式', en: 'JGI Format' }, + 'jsondataloadedtoconsole': { zh: 'JSON数据已加载到控制台', en: 'JSON data loaded to console' }, + 'kmaximum-2': { zh: 'K 最大值', en: 'K Maximum' }, + 'kminimum-2': { zh: 'K 最小值', en: 'K Minimum' }, + 'keggenrichment': { zh: 'KEGG 富集', en: 'KEGG Enrichment' }, + 'keggenrichmentbackgroundmissing': { zh: 'KEGG 富集背景缺失', en: 'KEGG Enrichment Background Missing' }, + 'kaksanalysis': { zh: 'Ka/Ks分析', en: 'Ka/Ks Analysis' }, + 'kaksparameters': { zh: 'KaKs 参数', en: 'KaKs Parameters' }, + 'knownsplicesitesource': { zh: 'Known splice site 来源', en: 'Known Splice Site Source' }, + 'm1standardgroupcomparison': { zh: 'M1 - 标准组间比较', en: 'M1 - Standard Group Comparison' }, + 'm3multigroupstratifiedcomparison': { zh: 'M3 - 多组合分层比较', en: 'M3 - Multi-group Stratified Comparison' }, + 'm4twowayinteractionanalysis': { zh: 'M4 - 双因素交互分析', en: 'M4 - Two-way Interaction Analysis' }, + 'mafftparamsegautothread1': { zh: 'MAFFT 多序列Alignment参数,如 --auto --thread -1', en: 'MAFFT params, e.g. --auto --thread -1' }, + 'numberofconservedmotifsminedbymeme': { zh: 'MEME 挖掘的保守 Motif 数量', en: 'Number of conserved motifs mined by MEME' }, + 'msaregionplot': { zh: 'MSA Region Plot - MSA区间绘图', en: 'MSA Region Plot' }, + 'msaregionplot-2': { zh: 'MSA区间图 (', en: 'MSA Region Plot (' }, + 'msaregionplotparameters': { zh: 'MSA区间绘图参数', en: 'MSA Region Plot Parameters' }, + 'msaregionreplotcomplete': { zh: 'MSA区间重绘完成', en: 'MSA Region Replot Complete' }, + 'msaregionreplotsubmittedtaskid': { zh: 'MSA区间重绘已提交:task_id=', en: 'MSA region replot submitted: task_id=' }, + 'msasourceautoresolvedfromcurrentimageman': { zh: 'MSA来源:将根据当前图片/manifest自动解析', en: 'MSA source: auto-resolved from current image/manifest' }, + 'minerudocumentparsing': { zh: 'MinerU 文档解析', en: 'MinerU Document Parsing' }, + 'motifparameters': { zh: 'Motif 参数', en: 'Motif Parameters' }, + 'motifmaximumaminoacidlength': { zh: 'Motif 最大氨基酸长度', en: 'Motif Maximum Amino Acid Length' }, + 'motifminimumaminoacidlength': { zh: 'Motif 最小氨基酸长度', en: 'Motif Minimum Amino Acid Length' }, + 'motifanalysis': { zh: 'Motif分析', en: 'Motif Analysis' }, + 'motifcount': { zh: 'Motif数量', en: 'Motif Count' }, + 'ncbitaxideg3702': { zh: 'NCBI TaxID,如 3702', en: 'NCBI TaxID, e.g. 3702' }, + 'ncbiformat': { zh: 'NCBI 格式', en: 'NCBI Format' }, + 'ncbisequencedownload': { zh: 'NCBI序列下载', en: 'NCBI Sequence Download' }, + 'nlruntimegatefailed': { zh: 'NL Runtime Gate 失败', en: 'NL Runtime Gate Failed' }, + 'nlruntimegatepassedstartinguidisplaygate': { zh: 'NL Runtime Gate 已通过,开始 UI Display Gate...', en: 'NL Runtime Gate Passed; Starting UI Display Gate...' }, + 'orgdbspecies': { zh: 'OrgDb物种', en: 'OrgDb Species' }, + 'orthologmapping': { zh: 'Ortholog 映射', en: 'Ortholog Mapping' }, + 'orthologppikakspausedawaitingpepcdsother': { zh: 'Ortholog/PPI/KaKs 已暂停等待 PEP/CDS 和映射;其它可执行阶段将继续提交。', en: 'Ortholog/PPI/KaKs paused awaiting PEP/CDS; other stages continue.' }, + 'ostcpevolutionaryanalysis': { zh: 'Os.TCP进化分析', en: 'Os.TCP Evolutionary Analysis' }, + 'pcacorrelationdistanceheatmapsandvstmatr': { zh: 'PCA、样本相关性热图、样本距离热图和 VST 矩阵通常已经生成;可先查看这些 QC 结果。若要让阶段标记成功,应在代码里对样本数少于 3 的情况跳过聚类树或改成距离热图。', en: 'PCA, correlation/distance heatmaps and VST matrix typically generated; for stage success skip clustering when N<3.' }, + 'ppiresetfailed': { zh: 'PPI Reset 失败', en: 'PPI Reset Failed' }, + 'ppiresetresult': { zh: 'PPI Reset 结果 (', en: 'PPI Reset Result (' }, + 'ppiresetinputgenelist': { zh: 'PPI Reset 输入基因列表', en: 'PPI Reset Input Gene List' }, + 'ppiparameters': { zh: 'PPI 参数', en: 'PPI Parameters' }, + 'ppiparametersproteininteraction': { zh: 'PPI 参数 (Protein Interaction', en: 'PPI Parameters (Protein Interaction)' }, + 'ppireferencespeciesnotconfirmed': { zh: 'PPI 参考物种未确认', en: 'PPI Reference Species Not Confirmed' }, + 'ppitechnicalrefmustcomefromconfirmedstri': { zh: 'PPI 的技术参考必须来自已确认的 STRING 候选;项目参考物种不会写入 PPI owner。', en: 'PPI technical ref must come from confirmed STRING candidates; project species not written to PPI owner.' }, + 'ppinearsourcestringrefnotconfirmedorthol': { zh: 'PPI 近源 STRING 参考物种尚未确认;本次不提交 Ortholog 或 PPI。', en: 'PPI near-source STRING ref not confirmed; Ortholog/PPI not submitted.' }, + 'ppikaksdownloadfailurefallbackconfirmati': { zh: 'PPI/KaKs 下载失败兜底确认', en: 'PPI/KaKs Download Failure Fallback Confirmation' }, + 'ppikaksfallbackconfirmation': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'ppikaksreferencedata': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'ppikaksreferencedataorthologprecheckfail': { zh: 'PPI/KaKs 参考数据/同源映射预检未通过。', en: 'PPI/KaKs reference data/ortholog pre-check failed.' }, + 'ppikaksreferencedataprocessing': { zh: 'PPI/KaKs 参考数据处理', en: 'PPI/KaKs Reference Data Processing' }, + 'ppikakscannotexecuterefspeciesdatanotrea': { zh: 'PPI/KaKs 暂时不能直接执行:参考物种数据还没准备好。', en: 'PPI/KaKs cannot execute: ref species data not ready.' }, + 'ppikaksneedsrefspeciesorthologprocessing': { zh: 'PPI/KaKs 需要先处理参考物种/Ortholog', en: 'PPI/KaKs needs ref species/Ortholog processing first' }, + 'ppikaksrequiresrefspeciespepcdsandorthol': { zh: 'PPI/KaKs 需要参考物种 PEP/CDS 和 Ortholog 映射,已先暂停相关阶段。', en: 'PPI/KaKs requires ref species PEP/CDS and Ortholog mapping; related stages paused.' }, + 'ppikaksprecheckfailed': { zh: 'PPI/KaKs 预检失败', en: 'PPI/KaKs Pre-check Failed' }, + 'ppianalysis': { zh: 'PPI分析', en: 'PPI Analysis' }, + 'phylogenyparameters': { zh: 'Phylogeny 参数', en: 'Phylogeny Parameters' }, + 'qctools': { zh: 'QC 工具', en: 'QC Tools' }, + 'rreturnedanerrorprecedingwarningsmaynotb': { zh: 'R 已返回错误。前面的 warning 不一定是失败原因,真正原因通常在日志最后的“错误于/停止执行”附近。', en: 'R returned an error. Preceding warnings may not be the cause; check near "Error in/Execution halted" at log end.' }, + 'rscriptexecutionfailed': { zh: 'R 脚本执行失败', en: 'R Script Execution Failed' }, + 'ragmethodologyextractionlaunchfailed': { zh: 'RAG 方法学提取启动失败', en: 'RAG Methodology Extraction Launch Failed' }, + 'ragsource': { zh: 'RAG来源', en: 'RAG Source' }, + 'ragstreamresponsereturnednofinalanswer': { zh: 'RAG流式响应未返回最终回答', en: 'RAG Stream Response Returned No Final Answer' }, + 'ragstreamresponseparsefailed': { zh: 'RAG流式响应解析失败', en: 'RAG Stream Response Parse Failed' }, + 'ragstreamrequestfailed': { zh: 'RAG流式请求失败', en: 'RAG Stream Request Failed' }, + 'raginterpretationrefreshfailed': { zh: 'RAG解读刷新失败', en: 'RAG Interpretation Refresh Failed' }, + 'raginterpretationenhanced': { zh: 'RAG解读增强', en: 'RAG Interpretation Enhanced' }, + 'raginterpretationrefreshed': { zh: 'RAG解读已刷新', en: 'RAG Interpretation Refreshed' }, + 'ragrequestfailed': { zh: 'RAG请求失败', en: 'RAG Request Failed' }, + 'rnaseqcandidatefamilycandidatefamilyscre': { zh: 'RNA-Seq Candidate Family - 候选家族筛选', en: 'RNA-Seq Candidate Family - Candidate Family Screening' }, + 'rnaseqdedifferentialexpression': { zh: 'RNA-Seq DE - 差异表达', en: 'RNA-Seq DE - Differential Expression' }, + 'rnaseqgoenrichgoenrichmentanalysis': { zh: 'RNA-Seq GO Enrich - GO富集分析', en: 'RNA-Seq GO Enrich - GO Enrichment Analysis' }, + 'rnaseqkeggenrichkeggenrichmentanalysis': { zh: 'RNA-Seq KEGG Enrich - KEGG富集分析', en: 'RNA-Seq KEGG Enrich - KEGG Enrichment Analysis' }, + 'rnaseqsampleqcsamplequalityassessment': { zh: 'RNA-Seq Sample QC - 样本质量评估', en: 'RNA-Seq Sample QC - Sample Quality Assessment' }, + 'rnaseqwgcnaweightedcoexpressionanalysis': { zh: 'RNA-Seq WGCNA - 加权共表达分析', en: 'RNA-Seq WGCNA - Weighted Co-expression Analysis' }, + 'rnaseqfgsearankedenrichmentanalysis': { zh: 'RNA-Seq fgsea - 排序富集分析', en: 'RNA-Seq fgsea - Ranked Enrichment Analysis' }, + 'rnaseqalignparameters': { zh: 'RNA-seq Align 参数', en: 'RNA-seq Align Parameters' }, + 'rnaseqalignparametersalignmentstep2': { zh: 'RNA-seq Align 参数 (Alignment - Step 2', en: 'RNA-seq Align Parameters (Alignment - Step 2)' }, + 'rnaseqcandidateparameters': { zh: 'RNA-seq Candidate 参数', en: 'RNA-seq Candidate Parameters' }, + 'rnaseqdeparametersstep4': { zh: 'RNA-seq DE 参数 (差异表达 - Step 4', en: 'RNA-seq DE Parameters (Step 4)' }, + 'rnaseqenrichparametersstep5': { zh: 'RNA-seq Enrich 参数 (Enrichment Analysis - Step 5', en: 'RNA-seq Enrich Parameters (Step 5)' }, + 'rnaseqfgseaparameters': { zh: 'RNA-seq FGSEA 参数', en: 'RNA-seq FGSEA Parameters' }, + 'rnaseqqcparametersstep1': { zh: 'RNA-seq QC 参数 (Quality Control - Step 1', en: 'RNA-seq QC Parameters (Step 1)' }, + 'rnaseqqctoolparameters': { zh: 'RNA-seq QC 工具参数', en: 'RNA-seq QC Tool Parameters' }, + 'rnaseqquantparametersstep3': { zh: 'RNA-seq Quant 参数 (Quantification - Step 3', en: 'RNA-seq Quant Parameters (Step 3)' }, + 'rnaseqwgcnaparameters': { zh: 'RNA-seq WGCNA 参数', en: 'RNA-seq WGCNA Parameters' }, + 'rnaseqanalysischainconfirmation': { zh: 'RNA-seq 分析链路确认', en: 'RNA-seq Analysis Chain Confirmation' }, + 'rnaseqparameters': { zh: 'RNA-seq 参数', en: 'RNA-seq Parameters' }, + 'rnaseqquantificationparameters': { zh: 'RNA-seq 定量参数', en: 'RNA-seq Quantification Parameters' }, + 'rnaseqenrichmentbackgroundmissing': { zh: 'RNA-seq 富集背景缺失', en: 'RNA-seq Enrichment Background Missing' }, + 'rnaseqenrichmentadvancedparameters': { zh: 'RNA-seq 富集高级参数', en: 'RNA-seq Enrichment Advanced Parameters' }, + 'rnaseqdifferentialexpressionparameters': { zh: 'RNA-seq 差异分析参数', en: 'RNA-seq Differential Expression Parameters' }, + 'rnaseqalignmentreferencefileincorrect': { zh: 'RNA-seq 比对参考文件不对', en: 'RNA-seq Alignment Reference File Incorrect' }, + 'rnaseqspeciesinfoforkeggfgseaenrichment': { zh: 'RNA-seq 物种信息(KEGG/fgsea/富集用)', en: 'RNA-seq Species Info (for KEGG/fgsea/Enrichment)' }, + 'refunchanged': { zh: 'Ref不改', en: 'Ref (Unchanged)' }, + 'snpdensityanalysis': { zh: 'SNP密度分析', en: 'SNP Density Analysis' }, + 'stringscorethresholddefault200': { zh: 'STRING score阈值,默认200', en: 'STRING score threshold, default 200' }, + 'stringlinkfilecontent': { zh: 'STRING 链接文件内容:', en: 'STRING Link File Content:' }, + 'salmontximporttpmentersexpressionplotsaf': { zh: 'Salmon/tximport TPM 只在 bridge QC 通过后进入 expression plots;tximport counts_from_abundance 不作为默认 DESeq2 输入。', en: 'Salmon/tximport TPM enters expression plots after bridge QC; tximport counts_from_abundance not default DESeq2 input.' }, + 'scorethreshold': { zh: 'Score阈值', en: 'Score Threshold' }, + 'selectgenomedatasourceformatautowillauto': { zh: 'SelectGenome数据来源格式,auto会自动检测', en: 'SelectGenome data source format; auto will auto-detect' }, + 'selectidconversionmethod': { zh: 'SelectID转换方式', en: 'SelectID Conversion Method' }, + 'selectinputmode': { zh: 'SelectInput模式', en: 'SelectInput Mode' }, + 'selectinputmodeuploadmanualrefgenomegtfd': { zh: 'SelectInput模式:上传模式需要手动上传参考Genome和GTF,Download模式可自动检索物种数据', en: 'SelectInput Mode: Upload=manual ref Genome+GTF; Download=auto-retrieve' }, + 'selectsession': { zh: 'Select会话', en: 'Select Session' }, + 'selectexistingworkingdirectory': { zh: 'Select已有Working Directory', en: 'Select Existing Working Directory' }, + 'selectdatabasetype': { zh: 'Select数据库类型', en: 'Select Database Type' }, + 'selectdatasource': { zh: 'Select数据来源', en: 'Select Data Source' }, + 'selectdatasourceuploadfilesorspecifyexis': { zh: 'Select数据来源方式:Upload Files或指定已有数据路径', en: 'Select Data Source: Upload Files or specify existing data path' }, + 'selectstage': { zh: 'Select阶段', en: 'Select Stage' }, + 'subfamilyconfirmconfirmsubfamilylabels': { zh: 'Subfamily Confirm - 确认亚家族标签', en: 'Subfamily Confirm - Confirm Subfamily Labels' }, + 'syntenyintraspeciessyntenymcscanxcircos': { zh: 'Synteny - 种内共线性分析 (MCScanX / Circos', en: 'Synteny - Intraspecies Synteny (MCScanX / Circos)' }, + 'tfbsparameters': { zh: 'TFBS 参数', en: 'TFBS Parameters' }, + 'tfbindingsiteprediction': { zh: 'TF结合位点预测', en: 'TF Binding Site Prediction' }, + 'targetrolespecieseligibleasanalysisspeci': { zh: 'Target 角色物种(可作为本次待分析物种)', en: 'Target Role Species (eligible as analysis species)' }, + 'tipmarkshape': { zh: 'Tip标记形状', en: 'Tip Mark Shape' }, + 'uidisplaygatefailed': { zh: 'UI Display Gate 失败', en: 'UI Display Gate Failed' }, + 'vcfallcontigs': { zh: 'VCF 全部 contig', en: 'VCF All Contigs' }, + 'wgs2gwasgeneticvariationpopulationanalys': { zh: 'WGS2GWAS(遗传变异与群体分析)', en: 'WGS2GWAS (Genetic Variation & Population Analysis)' }, + 'yesconfirmed': { zh: 'Yes,确认无误', en: 'Yes, Confirmed' }, + 'sessioncreatefailed': { zh: '[会话] 创建失败', en: '[Session] Create Failed' }, + 'sessiondeletefailed': { zh: '[会话] 删除失败', en: '[Session] Delete Failed' }, + 'sessionloaded': { zh: '[会话] 加载了', en: '[Session] Loaded' }, + 'sessionloadhistoryfailed': { zh: '[会话] 加载历史消息失败', en: '[Session] Load History Failed' }, + 'sessionloadfailed': { zh: '[会话] 加载失败', en: '[Session] Load Failed' }, + 'sessionstagescleared': { zh: '[会话] 已清空 stages 状态', en: '[Session] Stages Cleared' }, + 'sessionjobbound': { zh: '[会话] 已绑定 job', en: '[Session] Job Bound' }, + 'sessionjobrestored': { zh: '[会话] 恢复 job', en: '[Session] Job Restored' }, + 'sessionmessagepersistfailed': { zh: '[会话] 消息持久化失败', en: '[Session] Message Persist Failed' }, + 'sessionjobinfocleared': { zh: '[会话] 清空 jobInfo', en: '[Session] JobInfo Cleared' }, + 'sessionstagescleared-2': { zh: '[会话] 清空 stages', en: '[Session] Stages Cleared' }, + 'sessionmessagesclearednewsession': { zh: '[会话] 清空消息,新建会话', en: '[Session] Messages Cleared, New Session' }, + 'sessionbindjobfailed': { zh: '[会话] 绑定 job 失败', en: '[Session] Bind Job Failed' }, + 'sessionautocreatedsession': { zh: '[会话] 自动创建会话', en: '[Session] Auto-Created Session' }, + 'sessionautocreatefailedmessagesinpagemem': { zh: '[会话] 自动创建失败,消息仅保存在当前页面内存', en: '[Session] Auto-Create Failed; messages in page memory' }, + 'sessionautorestorerecentmessages': { zh: '[会话] 自动恢复最近会话消息', en: '[Session] Auto-Restore Recent Messages' }, + 'deepinterpretationrequestfailed': { zh: '[深度解读] 请求失败', en: '[Deep Interpretation] Request Failed' }, + 'alignmentcolumn': { zh: 'alignment列', en: 'Alignment Column' }, + 'configautodetectionsampleautodetection': { zh: 'autofillState.type === \'config\' ? \'配置表自动识别\' : \'样本表自动识别', en: 'Config Auto-detection / Sample Auto-detection' }, + 'filtermatchedrowsbytype': { zh: 'autofillState.type === \'samples\' ? \'筛选匹配行:SRR / Con-1 / CK / T0 / rep1 / fastq 前缀\' : \'筛选匹配行:At.SPL / Cpapaya_113_ / Lc / gff / pep', en: 'Filter matched rows by type' }, + 'bbav5parameters': { zh: 'bbav5 参数', en: 'bbav5 Parameters' }, + 'bbav5alignmentaudit': { zh: 'bbav5 对齐与审计', en: 'bbav5 Alignment & Audit' }, + 'bbav5tools': { zh: 'bbav5 工具', en: 'bbav5 Tools' }, + 'comparativespecies': { zh: 'comparative 比较物种', en: 'Comparative Species' }, + 'comparativespeciesforcomparisonnotchange': { zh: 'comparative 比较物种(参与比较/分类,不改为 target)', en: 'Comparative Species (for comparison; not changed to target)' }, + 'conditionunchanged': { zh: 'condition不改', en: 'Condition (Unchanged)' }, + 'confidencehighconfirmablemediumreviewlow': { zh: 'confidence: high 可确认,medium 建议检查,low 必须人工补全;正式写入前会自动备份旧文件。', en: 'Confidence: high=confirmable, medium=review, low=manual completion; auto-backup before write.' }, + 'countmatrixcontainsnonnumericcolumns': { zh: 'count matrix 含非数字列', en: 'Count matrix contains non-numeric columns' }, + 'samplenamesincountmatrixnotfoundinsample': { zh: 'count matrix 里的样本名没有在 sample_table.tsv/samples.yaml 中找到,或样本表缺少 sample/condition 列。', en: 'Sample names in count matrix not found in sample table, or missing sample/condition column.' }, + 'withoutmanualfilteringonlymembershitting': { zh: 'domainReviewSource === \'member_evidence_fallback\' ? \'未找到 InterProScan domain_review.tsv,当前显示 final_members 与 HMM/BLAST 证据;默认保留全部 final_members,确认后才继续方法学下游分析。\' : \'如果不手动筛选,系统默认只保留命中本基因家族目标结构域的成员;你也可以按 domain、e-value/score 和 coverage 调整后重新生成最终成员蛋白。', en: 'Without manual filtering, only members hitting target domain kept; adjust by domain, e-value/score and coverage.' }, + 'failabortondiscoverystrict': { zh: 'fail:发现即终止(严格)', en: 'fail: abort on discovery (strict)' }, + 'fastpexecutable': { zh: 'fastp 可执行文件', en: 'fastp Executable' }, + 'gene2godoesnotmatchdegeneids': { zh: 'gene2go 与 DE 基因 ID 不匹配', en: 'gene2go does not match DE gene IDs' }, + 'gene2gogobackground': { zh: 'gene2go(GO 背景)', en: 'gene2go (GO Background)' }, + 'gene2kokeggbackground': { zh: 'gene2ko(KEGG 背景)', en: 'gene2ko (KEGG Background)' }, + 'identifydonemethodologyneedsgenefamilyme': { zh: 'identify 已完成,但方法学链路需要先确认基因家族成员表', en: 'Identify done; methodology needs gene family member table confirmation' }, + 'includeunchanged': { zh: 'include不改', en: 'Include (Unchanged)' }, + 'gwasstagecheck': { zh: 'isGwasStage(confirmParams.stage) ? \'At / Os / Zm\' : \'留空则自动推断', en: 'GWAS stage check' }, + 'describerevisioncontenthere': { zh: 'lightweightExtensionConversation.pendingRevisionCardId ? \'请在这里说明需要修订的内容,然后发送。\' : (preferenceMode ? \'Ask a question about methods or biology...\' : \'Describe what you want to run (default prefix At).', en: 'Describe revision content here.' }, + 'log2fcthreshold': { zh: 'log2FC| 阈值', en: '|log2FC| Threshold' }, + 'maskexcludesmarkerstasselhapmapcannotrep': { zh: 'mask 会排除 TASSEL/HapMap 无法无损表示的标记;输出仅保留可严格复现的标记子集。', en: 'mask excludes markers TASSEL/HapMap cannot represent losslessly; only reproducible markers retained.' }, + 'maskandcontinuelossy': { zh: 'mask:屏蔽后继续(有损)', en: 'Mask and continue (lossy)' }, + 'planningstagefailedtogenerateexecutablew': { zh: 'n\\n规划阶段未能生成可执行流程,请查看后端日志中的具体原因。', en: 'Planning stage failed to generate executable workflow; check backend logs.' }, + 'padjthreshold': { zh: 'padj 阈值', en: 'padj Threshold' }, + 'pfamid': { zh: 'pfam号', en: 'Pfam ID' }, + 'plantairestartscriptorrestartplantai9000': { zh: 'projectRestartAvailable ? \'提交配置好的项目重启脚本\' : \'未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh', en: 'PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured' }, + 'qrtpcrvalidation': { zh: 'qRT-PCR 验证', en: 'qRT-PCR Validation' }, + 'referencespecies': { zh: 'reference 参考物种', en: 'Reference Species' }, + 'referencespeciespreliminarytechnicalrefd': { zh: 'reference 参考物种(仅作为初步推荐,执行前将确定技术参考物种)', en: 'Reference Species (preliminary; technical ref determined before execution)' }, + 'referencespeciesforlabelmigrationonly': { zh: 'reference 参考物种(仅用于标签迁移)', en: 'Reference Species (for label migration only)' }, + 'replicateid': { zh: 'replicate(重复编号', en: 'Replicate (ID)' }, + 'replicateunchanged': { zh: 'replicate不改', en: 'Replicate (Unchanged)' }, + 'rununchanged': { zh: 'run不改', en: 'Run (Unchanged)' }, + 'sampleidunchanged': { zh: 'sample_id不改', en: 'Sample ID (Unchanged)' }, + 'samplesyamlbulkeditarea': { zh: 'samples.yaml 批量修订区', en: 'samples.yaml Bulk Edit Area' }, + 'splicesiteannotationpath': { zh: 'splice site / 注释路径', en: 'Splice Site / Annotation Path' }, + 'targetspecies': { zh: 'target 目标物种', en: 'Target Species' }, + 'targetcomparativerefinsametreecomboplota': { zh: 'target、comparative 与 reference 放入同一棵树、同一张组合证据图和一张联合亚家族确认表;确认表只包含待分类物种。', en: 'Target, comparative & ref in same tree, combo plot and joint confirmation table.' }, + 'timestagetimeseriesonly': { zh: 'time/stage(仅时间序列', en: 'Time/Stage (time series only)' }, + 'timestageunchanged': { zh: 'time/stage不改', en: 'Time/Stage (Unchanged)' }, + 'wholegenomeorknownfamily': { zh: 'whole_genome 或 known_family', en: 'whole_genome or known_family' }, + 'skip': { zh: '⏭️ 跳过', en: 'Skip' }, + 'recommendedrefdatadownloadmovedtobackgro': { zh: '⏳ 推荐参考数据下载已转入后台:', en: 'Recommended Ref Data Download Moved to Background:' }, + 'downloadfailurefallbackrequiresmanualcon': { zh: '⏸ 下载失败兜底需要手动确认', en: 'Download Failure Fallback Requires Manual Confirmation' }, + 'countdownpaused': { zh: '⏸ 倒计时已暂停', en: 'Countdown Paused' }, + 'countdownpausedpleaseconfirmparametersth': { zh: '⏸ 倒计时已暂停,请确认参数后手动执行', en: 'Countdown Paused; Please Confirm Parameters then Execute Manually' }, + 'fixskippedthisblocksdownstreamsubsequent': { zh: '⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。', en: 'Fix Skipped; This Blocks Downstream; Subsequent Stages Paused.' }, + 'waitingforvalidvcfselection': { zh: '⏸ 等待选择有效 VCF', en: 'Waiting for Valid VCF Selection' }, + 'ppikaksprecheckfailedbatchpausedcomplete': { zh: '⏸️ PPI/KaKs 前置数据检查未通过,已暂停批量执行;请补齐参考注释包或确认参考物种后重新执行。', en: 'PPI/KaKs Pre-check Failed; Batch Paused. Complete Ref Packages then Re-execute.' }, + 'identifydoneconfirmgenefamilymembersfirs': { zh: '⏸️ identify 已完成,但方法学链路需要先确认基因家族成员表;保存成员确认后再继续 phylogeny。', en: 'Identify done; confirm gene family members first, then continue phylogeny.' }, + 'openedidentifymemberscreeningtablesaveto': { zh: '⏸️ 已打开 identify 成员初筛确认表;保存后会生成 curated_members,再继续亚家族证据阶段。', en: 'Opened identify member screening table; save to generate curated_members.' }, + 'foregroundbatchrunningwgsplanpreservedre': { zh: '⏸️ 当前已有前台批次执行中;WGS 规划已保留,请在当前批次结束后重新执行。', en: 'Foreground batch running; WGS plan preserved. Re-execute after batch completes.' }, + 'stopall': { zh: '⏹️ 停止全部', en: 'Stop All' }, + 'backendprecheckfailedbatchstoppedrepullp': { zh: '⏹️ 后端预检失败,当前批次已停止;请重新拉取规划后继续。', en: 'Backend Pre-check Failed; Batch Stopped. Re-pull Plan to Continue.' }, + 'analysisstage': { zh: '▶ 分析阶段 Analysis Stage', en: 'Analysis Stage' }, + 'parameterconfirmationadjustment': { zh: '⚙️ 参数确认与调整', en: 'Parameter Confirmation & Adjustment' }, + 'configuredpermethodologyplan': { zh: '⚙️ 已按方法学规划', en: 'Configured per Methodology Plan' }, + 'backgroundbatchstagesskippedduetodepende': { zh: '⚠️ 后台批次中以下阶段因依赖失败被跳过:', en: 'Background batch: stages skipped due to dependency failure:' }, + 'backgroundbatchcannotcontinue': { zh: '⚠️ 后台批次无法继续:', en: 'Background batch cannot continue:' }, + 'issuedetected': { zh: '⚠️ 检测到问题', en: 'Issue Detected' }, + 'autoannotationnote': { zh: '⚠️ 自动注释说明:', en: 'Auto-Annotation Note:' }, + 'interpretationrisk': { zh: '⚠️ 解释风险', en: 'Interpretation Risk' }, + 'ppikaksprecheckpassed': { zh: '✅ PPI/KaKs 前置数据检查通过:', en: 'PPI/KaKs Pre-check Passed:' }, + 'ppikaksprecheckpassedorthologexistsgener': { zh: '✅ PPI/KaKs 前置数据检查通过:当前批次已有 Ortholog,将先生成同源映射再继续下游分析。', en: 'PPI/KaKs pre-check passed: Ortholog exists; generating mapping then continuing downstream.' }, + 'fixappliedcontinuing': { zh: '✅ 已应用修复方案,继续执行...', en: 'Fix Applied; Continuing...' }, + 'confirmedarabidopsisfallbackforppikakson': { zh: '✅ 已确认使用拟南芥作为 PPI/KaKs 的显式兜底参考;该选择只写入 Ortholog/PPI/KaKs 专用参数,其它常规阶段不受影响。', en: 'Confirmed Arabidopsis fallback for PPI/KaKs; only Ortholog/PPI/KaKs params affected.' }, + 'selectedprojectreferencespecies': { zh: '✅ 已选择项目参考物种', en: 'Selected project reference species:' }, + 'executioncomplete': { zh: '✅ 执行完成', en: 'Execution Complete' }, + 'recommendedrefdatadownloadcomplete': { zh: '✅ 推荐参考数据下载完成:', en: 'Recommended Ref Data Download Complete:' }, + 'confirmexecution': { zh: '✅ 确认执行', en: 'Confirm Execution' }, + 'confirmselection': { zh: '✅ 确认选择', en: 'Confirm Selection' }, + 'autousingpreviousstepoutput': { zh: '✅ 自动使用上一步Output:', en: 'Auto-using Previous Step Output:' }, + 'stage': { zh: '✅ 阶段', en: 'Stage' }, + 'restoredpublished': { zh: '✓ 已恢复并发布。', en: 'Restored & Published.' }, + 'restoredcompleted': { zh: '✓ 已恢复并完成。', en: 'Restored & Completed.' }, + 'restoredcompletedcandidatereviewcardgene': { zh: '✓ 已恢复并完成;候选核对卡已生成。', en: 'Restored & Completed; candidate review card generated.' }, + 'restoredverificationcanbegin': { zh: '✓ 已恢复确认;可以开始验证。', en: 'Restored; verification can begin.' }, + 'resultsreviewrestoredauthorizedtopublish': { zh: '✓ 已恢复结果核对;可以授权发布。', en: 'Results review restored; authorized to publish.' }, + 'executionrestoredresultcardgenerated': { zh: '✓ 已恢复运行;本次结果卡已生成。', en: 'Execution restored; result card generated.' }, + 'verificationrestoredresultreviewcardgene': { zh: '✓ 已恢复验证;结果核对卡已生成。', en: 'Verification restored; result review card generated.' }, + 'explicitlyauthorizedpublished': { zh: '✓ 已显式授权并发布。', en: 'Explicitly authorized & published.' }, + 'runresultsreviewed': { zh: '✓ 已核对运行结果。', en: 'Run results reviewed.' }, + 'candidateconfirmednorevisions': { zh: '✓ 已确认候选|未作修订', en: 'Candidate confirmed; no revisions.' }, + 'inputparamsconfirmedruncompleted': { zh: '✓ 已确认输入与参数;本次运行已完成。', en: 'Input & params confirmed; run completed.' }, + 'executionentryreproducedcandidateinfogen': { zh: '✓ 执行入口已成功复现;候选信息已生成。', en: 'Execution entry reproduced; candidate info generated.' }, + 'semanticdescriptionregeneratedcommandnot': { zh: '✓ 语义说明已重新生成;执行命令未重新运行。', en: 'Semantic description regenerated; command not re-run.' }, + 'verificationcompletedresultreviewcardgen': { zh: '✓ 验证已完成;结果核对卡已生成。', en: 'Verification completed; result review card generated.' }, + 'cancel-2': { zh: '❌ 取消', en: 'Cancel' }, + 'fixapplicationfailed': { zh: '❌ 应用修复失败', en: 'Fix Application Failed' }, + 'batchexecutioncancelled': { zh: '❌ 批量执行已取消', en: 'Batch Execution Cancelled' }, + 'methodologyexecutionplanfailed': { zh: '❌ 方法学执行规划失败:', en: 'Methodology Execution Plan Failed:' }, + 'servicetemporarilyunavailable': { zh: '❌ 服务暂时不可用:', en: 'Service Temporarily Unavailable:' }, + 'networkerrorpleaseretry': { zh: '❌ 网络错误,请重试', en: 'Network Error; Please Retry' }, + 'resetview': { zh: '⟲ 重置视图', en: 'Reset View' }, + 'submittingrecommendedrefannotationdownlo': { zh: '⬇️ 正在提交推荐近源参考物种注释包后台下载;Ortholog/PPI/KaKs 将等待参考数据,其它阶段继续执行。', en: 'Submitting recommended ref annotation download; Ortholog/PPI/KaKs will wait.' }, + '1analysismodulequickcommands': { zh: '一、分析模块快速指令', en: '1. Analysis Module Quick Commands' }, + '3ragquickcommands': { zh: '三、RAG快速指令', en: '3. RAG Quick Commands' }, + 'threeagentcontributionaudit': { zh: '三个 agent 贡献审计', en: 'Three-agent Contribution Audit' }, + 'triangle': { zh: '三角形', en: 'Triangle' }, + 'uploadexecutablecodetestdataandoriginalc': { zh: '上传可执行代码、测试数据和原始命令,核对候选后验证并授权发布;发布后可指定输入和原命令已有参数运行。', en: 'Upload executable code, test data and original commands; verify candidates then publish.' }, + 'uploadfailed': { zh: '上传失败', en: 'Upload Failed' }, + 'uploadbackgroundfileorselectautoannotati': { zh: '上传对应背景文件,或选择自动注释模式先生成背景注释后再继续富集。', en: 'Upload background file or select auto-annotation mode to generate backgrounds then enrich.' }, + 'uploadverifiedcodeandtestdataexecutiontr': { zh: '上传已跑通过的代码和测试数据;执行通过自然语言触发。', en: 'Upload verified code and test data; execution triggered via natural language.' }, + 'uploadoriginalfilenamesautoscannedfromdi': { zh: '上传文件原名,自动从目录扫描,不需要手动逐个输入', en: 'Upload original filenames; auto-scanned from directory, no manual entry' }, + 'uploadmodeuploadreferencegenome': { zh: '上传模式 (upload) - 上传参考Genome', en: 'Upload Mode - Upload Reference Genome' }, + 'uploadmode': { zh: '上传模式:', en: 'Upload Mode:' }, + 'upstreammanifest': { zh: '上游 Manifest', en: 'Upstream Manifest' }, + 'nextstep': { zh: '下一步', en: 'Next Step' }, + 'downloadcompleterecommendedrefdatathenco': { zh: '下载/补齐推荐参考数据后继续', en: 'Download/Complete Recommended Ref Data Then Continue' }, + 'arabidopsisfallbackafterdlfailurerequire': { zh: '下载失败后的拟南芥兜底必须手动确认,不会自动执行。', en: 'Arabidopsis fallback after DL failure requires manual confirmation.' }, + 'downloadreportfailed': { zh: '下载报告失败', en: 'Download Report Failed' }, + 'downloadincompletecannotbeusedforppikaks': { zh: '下载未完成,不能直接用于本次 PPI/KaKs。', en: 'Download incomplete; cannot be used for PPI/KaKs.' }, + 'wontoverwriteoriginalppioutputstofinalpp': { zh: '不会覆盖原始 PPI;提交后输出到 final/ppi_reset/reset_###', en: 'Won\'t overwrite original PPI; outputs to final/ppi_reset/reset_###/' }, + 'unsupportedmarkerhandlingpolicy': { zh: '不受支持标记处理策略', en: 'Unsupported Marker Handling Policy' }, + 'noconversionnone': { zh: '不转换 (none', en: 'No Conversion (none)' }, + 'twocolumnssampleandgroup': { zh: '两列:sample 与 group', en: 'Two columns: sample and group' }, + 'sessions': { zh: '个会话', en: ' sessions' }, + 'stagespleaseconfirmparameters': { zh: '个阶段,请确认参数', en: ' stages; please confirm parameters' }, + 'mediumpriority': { zh: '中优先级', en: 'Medium Priority' }, + 'neutralsubstitutionrate': { zh: '中性替换率', en: 'Neutral Substitution Rate' }, + 'neutralevolutionratedefault708e9': { zh: '中性进化速率,默认7.08e-9', en: 'Neutral evolution rate, default 7.08e-9' }, + 'neutralrate': { zh: '中性速率', en: 'Neutral Rate' }, + 'abundanceengine': { zh: '丰度引擎', en: 'Abundance Engine' }, + 'serial': { zh: '串行', en: 'Serial' }, + 'mainagentstillprocessing': { zh: '主 Agent 仍在处理……', en: 'Main Agent Still Processing...' }, + 'mainagentstreamresponsereturnednofinalre': { zh: '主 Agent 流式响应未返回最终结果', en: 'Main Agent Stream Response Returned No Final Result' }, + 'mainagentstreamrequestfailed': { zh: '主 Agent 流式请求失败', en: 'Main Agent Stream Request Failed' }, + 'primaryreference': { zh: '主参考', en: 'Primary Reference' }, + 'primaryreferencespecies': { zh: '主参考物种', en: 'Primary Reference Species' }, + 'mainresults': { zh: '主要结果', en: 'Main Results' }, + '2articlereproductionsteps': { zh: '二、文章复现步骤', en: '2. Article Reproduction Steps' }, + 'twogroup': { zh: '二分类 two_group', en: 'Two-group' }, + 'subfamily': { zh: '亚家族', en: 'Subfamily' }, + 'subfamilyclassificationconfirmation': { zh: '亚家族分类确认', en: 'Subfamily Classification Confirmation' }, + 'subfamilyclassificationevidencechaincomb': { zh: '亚家族分类证据链: combo', en: 'Subfamily Classification Evidence Chain: combo' }, + 'subfamilyclassificationevidencechaindoma': { zh: '亚家族分类证据链: domain', en: 'Subfamily Classification Evidence Chain: domain' }, + 'subfamilyclassificationpredictionrealtim': { zh: '亚家族分类预测实时解读', en: 'Subfamily Classification Prediction - Real-time Interpretation' }, + 'subfamilyclassificationpredictioninterpr': { zh: '亚家族分类预测解读', en: 'Subfamily Classification Prediction Interpretation' }, + 'subfamilyrefnotinjobconfiglocalplangener': { zh: '亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种', en: 'Subfamily ref not in job config; local plan generated. Verify ref on batch page.' }, + 'subfamilynamesize0auto': { zh: '亚家族名称大小(0=自动)', en: 'Subfamily Name Size (0=auto)' }, + 'subfamilynamedistance': { zh: '亚家族名称距离', en: 'Subfamily Name Distance' }, + 'subfamilyouterarc': { zh: '亚家族外圈弧线', en: 'Subfamily Outer Arc' }, + 'subfamilylabelsnotconfirmed': { zh: '亚家族标签尚未确认', en: 'Subfamily Labels Not Confirmed' }, + 'subfamilylabelsnotconfirmeddownstreampau': { zh: '亚家族标签尚未确认,已暂停后续执行。', en: 'Subfamily labels not confirmed; downstream paused.' }, + 'subfamilylabelsconfirmed': { zh: '亚家族标签已确认', en: 'Subfamily Labels Confirmed' }, + 'subfamilylabelconfirmation': { zh: '亚家族标签确认', en: 'Subfamily Label Confirmation' }, + 'subfamilyconfirmationskippedstagesdepend': { zh: '亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明“不需要亚家族”。', en: 'Subfamily confirmation skipped; stages depending on confirmed_subfamily.tsv stopped.' }, + 'subfamilyconfirmationreferenceclassifica': { zh: '亚家族确认 - Reference Classification', en: 'Subfamily Confirmation - Reference Classification' }, + 'subfamilyconfirmationparameters': { zh: '亚家族确认参数', en: 'Subfamily Confirmation Parameters' }, + 'autocontinueplanningaftersubfamilyconfir': { zh: '亚家族确认后自动续跑规划失败', en: 'Auto-continue planning after subfamily confirmation failed' }, + 'subfamilyconfirmationoutput': { zh: '亚家族确认输出:', en: 'Subfamily Confirmation Output:' }, + 'subfamilyconfirmationoutputspecies': { zh: '亚家族确认输出:Species.', en: 'Subfamily confirmation output: Species.' }, + 'subfamilyoutputjointconfirmationtablesav': { zh: '亚家族确认输出:一张联合确认表;保存后同步生成各物种 Species.', en: 'Subfamily output: joint confirmation table; saves and syncs per-species.' }, + 'subfamilyevidenceneedsidentifymemberscre': { zh: '亚家族证据阶段需要先保存 identify 成员初筛确认表', en: 'Subfamily evidence needs identify member screening table saved first' }, + 'subfamilypredictionoutputtarget': { zh: '亚家族预测输出:Target.', en: 'Subfamily prediction output: Target.' }, + 'intersection': { zh: '交集', en: 'Intersection' }, + 'humanhsa': { zh: '人 Human (hsa', en: 'Human (hsa)' }, + 'circleplotonly': { zh: '仅圈图', en: 'Circle Plot Only' }, + 'diagnosisonlymanualhandlingrequired': { zh: '仅展示诊断,需人工处理', en: 'Diagnosis Only; Manual Handling Required' }, + 'reportonly': { zh: '仅报告', en: 'Report Only' }, + 'comparativespeciesonly': { zh: '仅比较物种', en: 'Comparative Species Only' }, + 'targetonly': { zh: '仅目标', en: 'Target Only' }, + 'targetspeciesonly': { zh: '仅目标物种', en: 'Target Species Only' }, + 'rectangularonly': { zh: '仅矩形图', en: 'Rectangular Only' }, + 'recordonlyawaitingmanualconfirmation': { zh: '仅记录,待人工确认', en: 'Record Only; Awaiting Manual Confirmation' }, + 'downloadexpressiondatafromncbigeoleaveem': { zh: '从NCBI GEODownload表达量数据,留空则使用已Upload Files', en: 'Download expression data from NCBI GEO; leave empty to use uploaded files' }, + 'removethisgenefromcurrentresults': { zh: '从本次确认结果中删除该基因?', en: 'Remove this gene from current results?' }, + 'promoterlengthupstreamfromeachtargetgene': { zh: '从每个目标基因上游提取的启动子长度,默认 2000 bp', en: 'Promoter length upstream from each target gene; default 2000 bp' }, + 'coderevision': { zh: '代码修订', en: 'Code Revision' }, + 'coderevisionsuggestion': { zh: '代码修订建议', en: 'Code Revision Suggestion' }, + 'tasksubmittedrunninginbackground': { zh: '任务已提交,正在后台执行...', en: 'Task submitted, running in background...' }, + 'taskpaused': { zh: '任务已暂停', en: 'Task Paused' }, + 'prioritizereusingexpressionmatrix': { zh: '优先复用表达矩阵', en: 'Prioritize Reusing Expression Matrix' }, + 'sessiondeleted': { zh: '会话已删除', en: 'Session Deleted' }, + 'traditionalanalysisoutput': { zh: '传统分析输出:', en: 'Traditional Analysis Output:' }, + 'traditionalanalysisoutputgeneratedperspe': { zh: '传统分析输出:按物种分别生成 Species.', en: 'Traditional Analysis Output: Generated Per Species.' }, + 'locatedat': { zh: '位于', en: 'Located at' }, + 'positiondistributionmap': { zh: '位置分布图', en: 'Position Distribution Map' }, + 'youselectedgene2gobackgroundfile': { zh: '你选择了 gene2go 背景文件', en: 'You selected gene2go background file' }, + 'retrywithbcftools': { zh: '使用 bcftools 重试', en: 'Retry with bcftools' }, + 'usebuiltinmodelspeciesorgdb': { zh: '使用内置模式物种 OrgDb', en: 'Use Built-in Model Species OrgDb' }, + 'usebuiltinmodelspeciesorgdbgoonly': { zh: '使用内置模式物种 OrgDb(仅 GO)', en: 'Use Built-in Model Species OrgDb (GO only)' }, + 'useuploadedgene2gofile': { zh: '使用已上传 gene2go 文件', en: 'Use Uploaded gene2go File' }, + 'useuploadedgene2gogene2kobackgroundfiles': { zh: '使用已上传 gene2go/gene2ko 背景文件', en: 'Use Uploaded gene2go/gene2ko Background Files' }, + 'usearabidopsisasexplicitfallback': { zh: '使用拟南芥作为显式兜底', en: 'Use Arabidopsis as Explicit Fallback' }, + 'userecommendedrange': { zh: '使用推荐区间', en: 'Use Recommended Range' }, + 'usequeryid': { zh: '使用查询ID', en: 'Use Query ID' }, + 'usedefaultdomainfilter': { zh: '使用默认结构域筛选', en: 'Use Default Domain Filter' }, + 'eg03': { zh: '例如 0.3', en: 'e.g. 0.3' }, + 'eg1e5': { zh: '例如 1e-5', en: 'e.g. 1e-5' }, + 'eghmmerhmmsearch': { zh: '例如 HMMER hmmsearch', en: 'e.g. HMMER hmmsearch' }, + 'egbashrunshinputfa': { zh: '例如 bash run.sh input.fa', en: 'e.g. bash run.sh input.fa' }, + 'sidebarstatus': { zh: '侧边栏状态', en: 'Sidebar Status' }, + 'savemethodologyplan': { zh: '保存方法学方案', en: 'Save Methodology Plan' }, + 'retain': { zh: '保留', en: 'Retain' }, + 'retainoriginalheader': { zh: '保留原header', en: 'Retain Original Header' }, + 'modify': { zh: '修改', en: 'Modify' }, + 'revise': { zh: '修订', en: 'Revise' }, + 'reviseredraw': { zh: '修订 / 重绘', en: 'Revise / Redraw' }, + 'countdownpaused-2': { zh: '倒计时已暂停', en: 'Countdown Paused' }, + 'candidatestage': { zh: '候选阶段', en: 'Candidate Stage' }, + 'failedtostopextensionanalysis': { zh: '停止拓展分析失败', en: 'Failed to Stop Extension Analysis' }, + 'tryfindingmatrixfirstfallbacktorawrnaseq': { zh: '先找矩阵,失败后 raw RNA-seq fallback', en: 'Try finding matrix first; fall back to raw RNA-seq' }, + 'runrnaseqqcfirstverifycleanfastqfilesinf': { zh: '先运行 RNA-seq QC,并确认 final/ 中有每个样本的 clean FASTQ 文件。', en: 'Run RNA-seq QC first; verify clean FASTQ files in final/.' }, + 'firstrunsubfamilyclassificationconfirmat': { zh: '先进行亚家族分类确认(reference_classification/subfamily_confirm', en: 'First run subfamily classification confirmation' }, + 'genomewideforegrounduseglobaldegenes': { zh: '全基因组 foreground:使用全局 DE_genes', en: 'Genome-wide foreground: use global DE_genes' }, + 'genomewideforegroundusealldifferentialex': { zh: '全基因组 foreground:使用所有差异表达基因', en: 'Genome-wide foreground: use all differential expression genes' }, + 'genomewidedifferentialexpressionoutputgl': { zh: '全基因组差异表达:输出全局 DE_genes', en: 'Genome-wide differential expression: output global DE_genes' }, + 'globaldegenes': { zh: '全局 DE_genes', en: 'Global DE_genes' }, + 'fullscreen': { zh: '全屏', en: 'Full Screen' }, + 'fullscreendisplay': { zh: '全屏展示', en: 'Full Screen Display' }, + 'all': { zh: '全部', en: 'All' }, + 'allrows': { zh: '全部行', en: 'All Rows' }, + 'closenofurtheradjustments': { zh: '关闭,不再调整', en: 'Close; No Further Adjustments' }, + 'closeconfirmlater': { zh: '关闭,稍后确认', en: 'Close; Confirm Later' }, + 'compatibilitymode': { zh: '兼容模式', en: 'Compatibility Mode' }, + 'content': { zh: '内容', en: 'Content' }, + 'builtinspeciescode': { zh: '内置物种代码', en: 'Built-in Species Code' }, + 'writecheckpoint': { zh: '写入检查点', en: 'Write Checkpoint' }, + 'analyzing': { zh: '分析中', en: 'Analyzing' }, + 'analysisfamily': { zh: '分析家族', en: 'Analysis Family' }, + 'analysisdata': { zh: '分析数据', en: 'Analysis Data' }, + 'analyzesyntenybetweentargetandreferencec': { zh: '分析目标物种与参考/比较物种之间的共线性', en: 'Analyze synteny between target and reference/comparative species' }, + 'classificationtable': { zh: '分类表', en: 'Classification Table' }, + 'grouprole': { zh: '分组角色', en: 'Group Role' }, + 'grouproleunchanged': { zh: '分组角色不改', en: 'Group Role (Unchanged)' }, + 'switchto': { zh: '切换到', en: 'Switch to' }, + 'switchfailed': { zh: '切换失败', en: 'Switch Failed' }, + 'createsessionfailed': { zh: '创建会话失败', en: 'Create Session Failed' }, + 'createfailed': { zh: '创建失败', en: 'Create Failed' }, + 'deletesession': { zh: '删除会话', en: 'Delete Session' }, + 'deletesessionfailed': { zh: '删除会话失败', en: 'Delete Session Failed' }, + 'refreshresults': { zh: '刷新结果', en: 'Refresh Results' }, + 'foregrounddependencymissing': { zh: '前景依赖缺失', en: 'Foreground Dependency Missing' }, + 'foregroundgenes': { zh: '前景基因', en: 'Foreground Genes' }, + 'function': { zh: '功能', en: 'Function' }, + 'featurecategory': { zh: '功能类别', en: 'Feature Category' }, + 'addtoformallist': { zh: '加入正式列表', en: 'Add to Formal List' }, + 'failedtoloadppiinput': { zh: '加载 PPI 输入失败', en: 'Failed to Load PPI Input' }, + 'failedtoloadrnaseqsamples': { zh: '加载RNA-seq样本失败', en: 'Failed to Load RNA-seq Samples' }, + 'failedtoloadtasks': { zh: '加载任务失败', en: 'Failed to Load Tasks' }, + 'failedtoloadsessionmessages': { zh: '加载会话消息失败', en: 'Failed to Load Session Messages' }, + 'failedtoloadgraph': { zh: '加载图谱失败', en: 'Failed to Load Graph' }, + 'loadfailed': { zh: '加载失败', en: 'Load Failed' }, + 'failedtoloadmethodologyconfig': { zh: '加载方法学配置失败', en: 'Failed to Load Methodology Config' }, + 'failedtoloadknowledgegraph': { zh: '加载知识图谱失败', en: 'Failed to Load Knowledge Graph' }, + 'twornaseqpathsreusematrixorfallbacktoraw': { zh: '包含RNA-seq两条路: 可直接复用矩阵则复用,无法复用时执行raw RNA-seq fallback的质控/比对/定量/样本评估/差异表达', en: 'Two RNA-seq paths: reuse matrix or fallback to raw (QC/Align/Quant/Sample/DE)' }, + 'containskeyword': { zh: '包含关键词', en: 'Contains Keyword' }, + 'includeskaryotypechromosomedistributiona': { zh: '包含核型/染色体分布分析(karyotype', en: 'Includes karyotype/chromosome distribution analysis' }, + 'matchmode': { zh: '匹配方式', en: 'Match Mode' }, + 'matchedrows': { zh: '匹配行', en: 'Matched Rows' }, + 'singlegeneidgenenamelocusleaveemptywhenu': { zh: '单个GeneID/基因名/locus;使用ID文件时可留空', en: 'Single GeneID/gene name/locus; leave empty when using ID file' }, + 'singleormultipleidsegat1g01010at1g01720': { zh: '单个或多个ID,如 At1g01010,At1g01720', en: 'Single or multiple IDs, e.g. At1g01010,At1g01720' }, + 'reason': { zh: '原因', en: 'Reason' }, + 'originalcode': { zh: '原始代码', en: 'Original Code' }, + 'originalpackagereproducibilitycheckinpro': { zh: '原始包可复现检查中…', en: 'Original Package Reproducibility Check In Progress...' }, + 'originalpackagereproducibilitychecksubmi': { zh: '原始包可复现检查提交失败', en: 'Original Package Reproducibility Check Submit Failed' }, + 'originalcommand': { zh: '原始命令', en: 'Original Command' }, + 'originalcommandtestdataandexecutionfacts': { zh: '原始命令、测试数据和执行事实已经保留;这里只重新生成说明,不会重新运行原始命令。', en: 'Original command, test data and execution facts retained; only description regenerated.' }, + 'originalfilename': { zh: '原始文件名', en: 'Original Filename' }, + 'originalsequencingid': { zh: '原始测序编号', en: 'Original Sequencing ID' }, + 'originalevidence': { zh: '原始证据', en: 'Original Evidence' }, + 'originalrecommendedreference': { zh: '原推荐参考', en: 'Original Recommended Reference' }, + 'removet01suffixremovet01': { zh: '去除.T01后缀 (remove_T01', en: 'Remove .T01 Suffix (remove_T01)' }, + 'parameters': { zh: '参数', en: 'Parameters' }, + 'parameterpreviewfailed': { zh: '参数预览失败', en: 'Parameter Preview Failed' }, + 'referencegenome': { zh: '参考Genome', en: 'Reference Genome' }, + 'referencesubfamilyclassification': { zh: '参考亚家族分类', en: 'Reference Subfamily Classification' }, + 'referenceclassification': { zh: '参考分类', en: 'Reference Classification' }, + 'referenceprefix': { zh: '参考前缀', en: 'Reference Prefix' }, + 'referencegene': { zh: '参考基因', en: 'Reference Gene' }, + 'referencegeneid': { zh: '参考基因编号', en: 'Reference Gene ID' }, + 'refdatadownloadfailed': { zh: '参考数据下载失败', en: 'Ref Data Download Failed' }, + 'referencedataready': { zh: '参考数据已就绪', en: 'Reference Data Ready' }, + 'referencesource': { zh: '参考来源', en: 'Reference Source' }, + 'referencespecies-2': { zh: '参考物种', en: 'Reference Species' }, + 'refspeciessubfamilytwocolumntableautofin': { zh: '参考物种亚家族两列表。留空时自动从 input/final 查找 *.subfamily 或 *subfamily*.tsv', en: 'Ref species subfamily two-column table; auto-find from input/final when empty.' }, + 'refspeciesprefixinjointtreetipsegatat1g': { zh: '参考物种在联合树 tip 中的前缀,例如 At|AT1G...', en: 'Ref species prefix in joint tree tips, e.g. At|AT1G...' }, + 'referencespeciesshaperoletree': { zh: '参考物种形状(角色树)', en: 'Reference Species Shape (Role Tree)' }, + 'referencespeciesreferenceonlynotfortarge': { zh: '参考物种(仅参考,不参与目标鉴定)', en: 'Reference species (reference only; not for target identification)' }, + 'referencespeciesuseathforarabidopsis': { zh: '参考物种,拟南芥用ath', en: 'Reference species; use ath for Arabidopsis' }, + 'referenceuseadvanced': { zh: '参考用途(高级', en: 'Reference Use (Advanced)' }, + 'refgroupoptionalcontrolrequiredfortimetw': { zh: '参考组可选;time/two_group 必须在表中标 control,多组组织比较可留空', en: 'Ref group optional; control required for time/two_group; leave empty for multi-group tissue' }, + 'reflecting': { zh: '反思中', en: 'Reflecting...' }, + 'publishing': { zh: '发布中...', en: 'Publishing...' }, + 'prepublishverificationfailed': { zh: '发布前验收失败', en: 'Pre-publish Verification Failed' }, + 'prepublishverificationallpassedreadytopu': { zh: '发布前验收已全部通过,可以发布。', en: 'Pre-publish verification all passed; ready to publish.' }, + 'prepublishverificationpassed': { zh: '发布前验收已通过', en: 'Pre-publish Verification Passed' }, + 'publishfailed': { zh: '发布失败', en: 'Publish Failed' }, + 'agentfound': { zh: '发现 agent', en: 'Agent Found' }, + 'cancelppi': { zh: '取消 PPI', en: 'Cancel PPI' }, + 'cancelpreview': { zh: '取消预览', en: 'Cancel Preview' }, + 'onlycheckedandexecutablestageswillrun': { zh: '只会执行已勾选且可执行的阶段', en: 'Only checked and executable stages will run' }, + 'analyzeonlytargetspeciesinternalduplicat': { zh: '只分析目标物种内部复制关系', en: 'Analyze only target species internal duplication events' }, + 'onlyforidentifyblastreferencefamilyprote': { zh: '只用于 identify BLAST 的参考家族蛋白物种,在上方“Identify BLAST参考”填写,如 At,Os', en: 'Only for identify BLAST reference family protein species; fill under Identify BLAST Ref.' }, + 'onlyforidentifystageblastrefproteinsearc': { zh: '只用于 identify 阶段 BLAST/reference-protein search 的参考家族蛋白来源;可以多选,如 At 和 Os', en: 'Only for identify stage BLAST/ref-protein search; multi-select supported.' }, + 'onlyredrawtreenomafftiqtreererunbothoutp': { zh: '只重绘树图,不重跑 MAFFT/IQ-TREE。both 会同时输出矩形树和圈图。', en: 'Only redraw tree; no MAFFT/IQ-TREE rerun. Both outputs rectangular and circle plots.' }, + 'retrydirectlyorcancelandcheckerrorcause': { zh: '可以直接重试,或取消后检查错误原因。', en: 'Retry directly or cancel and check error cause.' }, + 'manuallyspecifykeggorganismcodeosariceat': { zh: '可手动指定 KEGG organism code;水稻 osa,拟南芥 ath。留空时系统尝试从拉丁名推断,失败则回退 KO 全库植物过滤。', en: 'Manually specify KEGG organism code; osa=rice, ath=Arabidopsis; auto-infer from Latin name otherwise.' }, + 'editable': { zh: '可改', en: 'Editable' }, + 'optionaleglcdlat': { zh: '可选,如 Lc / Dl / At', en: 'Optional, e.g. Lc / Dl / At' }, + 'numberofsamplesprocessedsimultaneously': { zh: '同时处理的样本数', en: 'Number of Samples Processed Simultaneously' }, + 'alsorunfullannotationgenepromoterbackgro': { zh: '同时跑全注释基因 promoter 背景', en: 'Also run full annotation gene promoter background' }, + 'name': { zh: '名称', en: 'Name' }, + 'modelstagedescription': { zh: '描述模型的阶段', en: 'Describe the model stage' }, + 'downstreamprimaryreferencespecies': { zh: '后续主参考物种', en: 'Downstream Primary Reference Species' }, + 'downstreamprimaryreferencespeciesegat': { zh: '后续主参考物种,例如 At', en: 'Downstream primary reference species, e.g. At' }, + 'downstreamprimaryreferencespeciesegat-2': { zh: '后续主参考物种,如 At', en: 'Downstream primary reference species, e.g. At' }, + 'defaultprimaryreffordownstreamusuallyone': { zh: '后续阶段默认主参考物种;通常一个,例如 At。不会自动加入 identify BLAST,除非也在 Identify BLAST参考 中选择', en: 'Default primary ref for downstream; usually one, e.g. At.' }, + 'promoterelements': { zh: '启动子元件', en: 'Promoter Elements' }, + 'promoterelementprediction': { zh: '启动子元件预测', en: 'Promoter Element Prediction' }, + 'promotercustomplot': { zh: '启动子自定义绘图', en: 'Promoter Custom Plot' }, + 'promotercustomplotfailed': { zh: '启动子自定义绘图失败:', en: 'Promoter Custom Plot Failed:' }, + 'promotercustomplotcomplete': { zh: '启动子自定义绘图完成', en: 'Promoter Custom Plot Complete' }, + 'promotercustomplotsubmittedtaskid': { zh: '启动子自定义绘图已提交:task_id=', en: 'Promoter custom plot submitted: task_id=' }, + 'commandtemplatereturnednopreviewablecomm': { zh: '命令模板未返回可预览命令;请先检查 entrypoint.command_template。', en: 'Command template returned no previewable command; check entrypoint.command_template.' }, + 'legendcolor': { zh: '图例颜色', en: 'Legend Color' }, + 'plottype': { zh: '图类型', en: 'Plot Type' }, + 'charttitlelist': { zh: '图表标题清单', en: 'Chart Title List' }, + 'charttitleevidence': { zh: '图表标题证据', en: 'Chart Title Evidence' }, + 'charttitledrivenreproductionplanpreview': { zh: '图表标题驱动复现规划预览', en: 'Chart Title-Driven Reproduction Plan Preview' }, + 'circle': { zh: '圆形', en: 'Circle' }, + 'geneid': { zh: '基因ID', en: 'Gene ID' }, + 'geneidsize': { zh: '基因ID大小', en: 'Gene ID Size' }, + 'geneiddistance': { zh: '基因ID距离', en: 'Gene ID Distance' }, + 'geneidtochromosomeblockdistance': { zh: '基因ID距离染色体色块', en: 'Gene ID to Chromosome Block Distance' }, + 'geneidtrackouterboundary': { zh: '基因ID轨道外边界', en: 'Gene ID Track Outer Boundary' }, + 'geneidconversionremovet01oraddt01suffix': { zh: '基因ID转换方式:去除.T01后缀或添加.T01后缀', en: 'Gene ID conversion: remove .T01 or add .T01 suffix' }, + 'geneid-2': { zh: '基因id', en: 'Gene ID' }, + 'genelist': { zh: '基因list', en: 'Gene List' }, + 'genelist-2': { zh: '基因列表', en: 'Gene List' }, + 'genotype': { zh: '基因型 genotype', en: 'Genotype' }, + 'genefamilyforegroundonlyanalyzemembersin': { zh: '基因家族 foreground:仅分析 final_members.id 中的家族成员', en: 'Gene family foreground: only analyze members in final_members.id' }, + 'genefamilyforegroundusefamilydegenes': { zh: '基因家族 foreground:使用 family_DE_genes', en: 'Gene family foreground: use family_DE_genes' }, + 'genefamilypromoterscanning': { zh: '基因家族 promoter 扫描', en: 'Gene family promoter scanning' }, + 'genefamilyrequiredforknownfamily': { zh: '基因家族(known_family必填', en: 'Gene Family (required for known_family)' }, + 'genefamilydifferentialexpressionoutputfa': { zh: '基因家族差异表达:只输出 family_DE_genes', en: 'Gene family differential expression: output family_DE_genes only' }, + 'genefamilymembers': { zh: '基因家族成员', en: 'Gene Family Members' }, + 'genefamilyidentification': { zh: '基因家族鉴定', en: 'Gene Family Identification' }, + 'geneannotation': { zh: '基因注释', en: 'Gene Annotation' }, + 'genestructureanalysis': { zh: '基因结构分析', en: 'Gene Structure Analysis' }, + 'fill': { zh: '填写', en: 'Fill' }, + 'absolutepathtorawdatadircommaseparatedfo': { zh: '填写Raw Data所在目录的绝对路径,多个路径用逗号分隔,如 /data/SRR1,/data/SRR2', en: 'Absolute path to Raw Data dir; comma-separated for multiples' }, + 'fillgenefamilyegsplwrky': { zh: '填写基因家族,如 SPL / WRKY', en: 'Fill gene family, e.g. SPL / WRKY' }, + 'fillabsolutedatadirpathcommaseparatedfor': { zh: '填写数据目录绝对路径,多个路径用逗号分隔', en: 'Fill absolute data dir path; comma-separated for multiples' }, + 'condition': { zh: '处理/条件 condition', en: 'Condition' }, + 'processing-2': { zh: '处理中', en: 'Processing' }, + 'processingmethod': { zh: '处理方式', en: 'Processing Method' }, + 'copyfailed': { zh: '复制失败', en: 'Copy Failed' }, + 'reuseplantcarefilteredtablefromcompleted': { zh: '复用已完成 promoter 阶段的 PlantCARE filtered 表,不重新提交 PlantCARE。', en: 'Reuse PlantCARE filtered table from completed promoter; do not re-submit.' }, + 'multigroup': { zh: '多分类 multi_group', en: 'Multi-group' }, + 'multiagentplanning': { zh: '多智能体规划', en: 'Multi-agent Planning' }, + 'multiagentmethodologyexecutionsequencepl': { zh: '多智能体规划方法学执行顺序失败', en: 'Multi-agent Methodology Execution Sequence Planning Failed' }, + 'multiagentplanningshowsstagescompletedno': { zh: '多智能体规划结果显示相关阶段已完成,无需重复执行', en: 'Multi-agent planning shows stages completed; no re-execution needed' }, + 'multispeciesprocessing': { zh: '多物种处理方式', en: 'Multi-species Processing' }, + 'multispeciesjointanalysisconfirmationdef': { zh: '多物种联合分析与确认(默认)', en: 'Multi-species Joint Analysis & Confirmation (default)' }, + 'size': { zh: '大小', en: 'Size' }, + 'largefilescanbereferencedoneabsolutepath': { zh: '大文件可直接引用;每行填写一个绝对路径', en: 'Large files can be referenced; one absolute path per line' }, + 'soybeangma': { zh: '大豆 (gma', en: 'Soybean (gma)' }, + 'ratrno': { zh: '大鼠 Rat (rno', en: 'Rat (rno)' }, + 'failed': { zh: '失败', en: 'Failed' }, + 'howtouse': { zh: '如何使用', en: 'How to Use' }, + 'withoutmanualfilteringonlymembershitting-2': { zh: '如果不手动筛选,系统默认只保留命中本基因家族目标结构域的成员;你也可以按 domain、e-value/score 和 coverage 调整后重新生成最终成员蛋白。', en: 'Without manual filtering, only members hitting target domain kept; adjust by domain, e-value/score and coverage.' }, + 'fielddescription': { zh: '字段说明:', en: 'Field Description:' }, + 'complete': { zh: '完成', en: 'Complete' }, + 'quantification': { zh: '定量', en: 'Quantification' }, + 'quantificationmode': { zh: '定量模式', en: 'Quantification Mode' }, + 'realtimecumulativeinterpretation': { zh: '实时累计解读', en: 'Real-time Cumulative Interpretation' }, + 'actualcommand': { zh: '实际命令', en: 'Actual Command' }, + 'experimentaldesign': { zh: '实验设计', en: 'Experimental Design' }, + 'experimentaldesignmodule': { zh: '实验设计模块', en: 'Experimental Design Module' }, + 'reviewnotes': { zh: '审核备注', en: 'Review Notes' }, + 'familymembers': { zh: '家族成员', en: 'Family Members' }, + 'enrichmentanalysismissingbackgroundannot': { zh: '富集分析缺少背景注释', en: 'Enrichment Analysis Missing Background Annotation' }, + 'enrichmenttype': { zh: '富集类型', en: 'Enrichment Type' }, + 'chathistorycleared': { zh: '对话历史已清空', en: 'Chat History Cleared' }, + 'comparisonstobegenerated': { zh: '将生成的比较:', en: 'Comparisons to be Generated:' }, + 'speciestobeidentified': { zh: '将鉴定物种', en: 'Species to be identified' }, + 'mousemmu': { zh: '小鼠 Mouse (mmu', en: 'Mouse (mmu)' }, + 'expand': { zh: '展开', en: 'Expand' }, + 'displaychain': { zh: '展示链:', en: 'Display Chain:' }, + 'deorsampleqcneedspurelynumericrawcountsb': { zh: '差异分析或样本 QC 需要纯数字 raw counts,但矩阵里混入了注释列或文本列。', en: 'DE or sample QC needs purely numeric raw counts, but matrix has annotation/text columns.' }, + 'differentialexpression': { zh: '差异表达', en: 'Differential Expression' }, + 'differentialexpressiondownstreamanalysis': { zh: '差异表达下游分析', en: 'Differential Expression Downstream Analysis' }, + 'differentialexpressionscope': { zh: '差异表达范围', en: 'Differential Expression Scope' }, + 'currentsessioncleared': { zh: '已Clear当前会话', en: 'Current Session Cleared' }, + 'methodologyplansavedbutnotexecuting': { zh: '已保存方法学方案,但暂不执行:', en: 'Methodology plan saved but not executing:' }, + 'writtentoofficialconfigxlsx': { zh: '已写入正式 config.xlsx', en: 'Written to Official config.xlsx' }, + 'writtentoofficialsamplesyaml': { zh: '已写入正式 samples.yaml', en: 'Written to Official samples.yaml' }, + 'registeredstages': { zh: '已写阶段', en: 'Registered Stages' }, + 'switchedtoworkingdirectory': { zh: '已切换到Working Directory', en: 'Switched to Working Directory' }, + 'creatednewworkingdirectory': { zh: '已创建新Working Directory', en: 'Created New Working Directory' }, + 'addedtoformalstagelistincludedinthisexec': { zh: '已加入正式阶段列表,并默认纳入本次执行', en: 'Added to formal stage list; included in this execution' }, + 'publishedreadytoexecute': { zh: '已发布,可执行', en: 'Published; Ready to Execute' }, + 'cancelled': { zh: '已取消', en: 'Cancelled' }, + 'applied': { zh: '已应用', en: 'Applied' }, + 'openedconfigxlsxautodetectiontableeditro': { zh: '已打开 config.xlsx 自动识别表。你可以直接修订单行,或用批量修订区筛选、预览、Apply。', en: 'Opened config.xlsx auto-detection table. Edit rows or use bulk edit.' }, + 'openedsamplesyamlautodetectiontableedits': { zh: '已打开 samples.yaml 自动识别表。你可以直接修订样本、分组、重复和对照组。', en: 'Opened samples.yaml auto-detection table. Edit sample, group, replicate and control.' }, + 'openedcurrentjobconfigxlsxreviewtable': { zh: '已打开当前 job 的 config.xlsx 核对表。', en: 'Opened current job config.xlsx review table.' }, + 'existingorthologmappinginconsistentwithp': { zh: '已有 Ortholog 映射与当前 PPI/KaKs 参考物种不一致', en: 'Existing Ortholog mapping inconsistent with PPI/KaKs ref' }, + 'downstreamstagesmarkedforrerunbasedonfil': { zh: '已标记下游 phylogeny/motif/domain/structure/combo 需要基于筛选后成员重跑。', en: 'Downstream stages marked for re-run based on filtered members.' }, + 'paramsoutputconfirmedpleasecontinueverif': { zh: '已确认参数和输出,请继续验证', en: 'Params & output confirmed; please continue verification' }, + 'boundtoserversidevcf': { zh: '已绑定服务器端 VCF', en: 'Bound to Server-side VCF' }, + 'paramsoutputautoconfirmedstartingverific': { zh: '已自动确认参数和输出,开始验证...', en: 'Params & output auto-confirmed; starting verification...' }, + 'autobound': { zh: '已自动绑定:', en: 'Auto-bound:' }, + 'broughtup': { zh: '已调出', en: 'Brought Up' }, + 'help': { zh: '帮助', en: 'Help' }, + 'parallel': { zh: '并行', en: 'Parallel' }, + 'suggestedfixplan': { zh: '建议修复方案', en: 'Suggested Fix Plan' }, + 'suggestedparameters': { zh: '建议参数', en: 'Suggested Parameters' }, + 'suggestedhandlingplan': { zh: '建议处理方案', en: 'Suggested Handling Plan' }, + 'startingprepublishnlruntimegate': { zh: '开始发布前 NL Runtime Gate...', en: 'Starting Pre-publish NL Runtime Gate...' }, + 'startverification': { zh: '开始验证', en: 'Start Verification' }, + 'arcavoidancedistance': { zh: '弧线避让距离', en: 'Arc Avoidance Distance' }, + 'popout': { zh: '弹出', en: 'Pop Out' }, + 'goenrichmentlacksgene2gosystemwillrunloc': { zh: '当前 GO 富集缺少可用 gene2go 背景。系统将先运行本地 GO 注释生成 gene2go,再继续 GO 富集分析。', en: 'GO enrichment lacks gene2go; system will run local GO annotation first.' }, + 'goandkegglackbackgroundssystemwillrunloc': { zh: '当前 GO 缺少可用 gene2go 背景,KEGG 缺少 gene2ko 背景。系统将先运行本地自动注释生成背景文件,再继续富集分析。', en: 'GO and KEGG lack backgrounds; system will run local auto-annotation first.' }, + 'gobackgrounddoesnotmatchdegeneidssystemw': { zh: '当前 GO 背景与 DE 基因 ID 不匹配。系统将先运行本地 GO 注释生成匹配的 gene2go,再继续 GO 富集分析。', en: 'GO background does not match DE gene IDs; system will run local GO annotation first.' }, + 'keggenrichmentlacksgene2kosystemwillrunl': { zh: '当前 KEGG 富集缺少 gene2ko 背景。系统将先运行本地 KEGG 注释生成 gene2ko,再继续 KEGG 富集分析。', en: 'KEGG enrichment lacks gene2ko; system will run local KEGG annotation first.' }, + 'currentgene2go': { zh: '当前 gene2go', en: 'Current gene2go' }, + 'currentjob': { zh: '当前 job', en: 'Current Job' }, + 'currentjobfinal': { zh: '当前 job final', en: 'Current Job Final' }, + 'noconfigxlsxdraftorofficialconfigfoundin': { zh: '当前 job 中没有找到可打开的 config.xlsx 草稿或正式配置。', en: 'No config.xlsx draft or official config found in current job.' }, + 'filesincurrentjobinputorworkspaceegkey': { zh: '当前 job 的 input/ 或 workspace/ 中的文件,例如', en: 'Files in current job input/ or workspace/, e.g.: {key}' }, + 'currentjobidisemptycannotsavemethodology': { zh: '当前 job_id 为空,不能保存方法学方案', en: 'Current job_id is empty; cannot save methodology plan' }, + 'currentjobidisemptycannotrunprepublishve': { zh: '当前 job_id 为空,不能运行发布前验收。', en: 'Current job_id is empty; cannot run pre-publish verification.' }, + 'currentparameters': { zh: '当前参数', en: 'Current Parameters' }, + 'currentreferencespecies': { zh: '当前参考物种', en: 'Current Reference Species' }, + 'currentdisplaymode': { zh: '当前显示模式', en: 'Current Display Mode' }, + 'nodirectlyusableexpressionmatrixdetected': { zh: '当前未检测到可直接执行的表达矩阵。', en: 'No directly usable expression matrix detected.' }, + 'currentsampleinfoinsufficienttogenerated': { zh: '当前样品信息不足以生成 DE 设计', en: 'Current sample info insufficient to generate DE design' }, + 'currentsamplecounttoolow': { zh: '当前样本数过少', en: 'Current sample count too low' }, + 'nousablejobidavailable': { zh: '当前没有可用 job_id', en: 'No Usable Job ID Available' }, + 'currentbrowserdoesnotsupportstreamingres': { zh: '当前浏览器不支持流式响应', en: 'Current browser does not support streaming response' }, + 'messagenotsavedtosessionhistorymaybelost': { zh: '当前消息未成功写入会话历史,刷新后可能丢失', en: 'Message not saved to session history; may be lost on refresh' }, + 'nomembersafterfilterfinalmembersnotrebui': { zh: '当前筛选没有成员,未重建 final_members。', en: 'No members after filter; final_members not rebuilt.' }, + 'currentlymissing': { zh: '当前缺失:', en: 'Currently Missing:' }, + 'currentselection': { zh: '当前选择', en: 'Current Selection' }, + 'currentstage': { zh: '当前阶段', en: 'Current Stage' }, + 'pending-2': { zh: '待执行', en: 'Pending' }, + 'pendingauthorization': { zh: '待授权', en: 'Pending Authorization' }, + 'pendingreview': { zh: '待核对', en: 'Pending Review' }, + 'pendingconfirmation': { zh: '待确认', en: 'Pending Confirmation' }, + 'awaitingdata': { zh: '待补数据', en: 'Awaiting Data' }, + 'required': { zh: '必填', en: 'Required' }, + 'requiredpfamidsorcommondomainaliasesrequ': { zh: '必需 Pfam 号(PFxxxxx)或常见结构域别名,多个用逗号分隔;Identify 无上传 HMM 时必填', en: 'Required Pfam IDs or common domain aliases; required when no HMM uploaded for Identify' }, + 'requiredpfamproteinfamilyidscommaseparat': { zh: '必需包含的 Pfam 蛋白家族ID,多个用逗号分隔', en: 'Required Pfam protein family IDs, comma-separated' }, + 'mustmatchfilenameininput': { zh: '必须匹配 input 中的文件名', en: 'Must match filename in input' }, + 'howtouse-2': { zh: '怎么用', en: 'How to Use' }, + 'helloiambotanicalbaseagentspecializeding': { zh: '您好!我是BotanicalBaseAgent,专注于基因家族分析。请告诉我您想分析哪个基因家族?', en: 'Hello! I am BotanicalBaseAgent, specialized in gene family analysis.' }, + 'memberscreeningconfirmation': { zh: '成员初筛确认', en: 'Member Screening Confirmation' }, + 'memberconfirmation': { zh: '成员确认', en: 'Member Confirmation' }, + 'memberfilter': { zh: '成员筛选', en: 'Member Filter' }, + 'memberidentificationoutput': { zh: '成员鉴定输出:', en: 'Member Identification Output:' }, + 'memberidentificationoutputperspeciesgene': { zh: '成员鉴定输出:每个鉴定物种分别生成 Species.', en: 'Member identification output: per species generates Species.' }, + 'membersetchangedphylogenyneedsrerun': { zh: '成员集合有变化,需要重跑 phylogeny。', en: 'Member set changed; phylogeny needs re-run.' }, + 'membersetunchangeddownstreamwillreusecon': { zh: '成员集合未变,后续绘图将直接复用确认标签。', en: 'Member set unchanged; downstream will reuse confirmed labels.' }, + 'gotit': { zh: '我知道了', en: 'Got It' }, + 'nousablefilesforconfigxlsxsamplesyamlatp': { zh: '我识别到你是在提供数据路径,但该路径下没有发现可用于 config.xlsx / samples.yaml 的文件。请确认目录是否包含 genome/pep/cds/gff/hmm 或 FASTQ 文件。', en: 'No usable files for config.xlsx/samples.yaml at path; check for genome/pep/cds/gff/hmm or FASTQ.' }, + 'allstagescompleted': { zh: '所有阶段已完成', en: 'All Stages Completed' }, + 'manualrange': { zh: '手动区间', en: 'Manual Range' }, + 'manuallyprovidessfile': { zh: '手动提供 .ss 文件', en: 'Manually Provide .ss File' }, + 'open': { zh: '打开', en: 'Open' }, + 'openselectedextractionresult': { zh: '打开所选提取结果', en: 'Open Selected Extraction Result' }, + 'failedtoopenmethodologyextractionresults': { zh: '打开方法学提取结果失败', en: 'Failed to Open Methodology Extraction Results' }, + 'openlatestextractionresult': { zh: '打开最新提取结果', en: 'Open Latest Extraction Result' }, + 'execute': { zh: '执行', en: 'Execute' }, + 'executing': { zh: '执行中...', en: 'Executing...' }, + 'executionentryraninisolatedenvironmentve': { zh: '执行入口已在隔离环境运行。请核对系统记录的事实与说明;确认后才会验证。', en: 'Execution entry ran in isolated environment. Verify recorded facts before confirming.' }, + 'executegenefamilyanalysis': { zh: '执行基因家族分析', en: 'Execute Gene Family Analysis' }, + 'executionfailed': { zh: '执行失败', en: 'Execution Failed' }, + 'erroroccurredduringexecution': { zh: '执行过程中发生错误', en: 'Error occurred during execution' }, + 'executionissue': { zh: '执行问题', en: 'Execution Issue' }, + 'executestage': { zh: '执行阶段', en: 'Execute Stage' }, + 'extendedanalysis': { zh: '扩展分析', en: 'Extended Analysis' }, + 'scanbothstrands': { zh: '扫描双链', en: 'Scan Both Strands' }, + 'batchexecutionconfirmation': { zh: '批量执行确认', en: 'Batch Execution Confirmation' }, + 'collapse': { zh: '折叠', en: 'Collapse' }, + 'reportdata': { zh: '报告数据', en: 'Report Data' }, + 'sorryididnotunderstandpleasetrytodescrib': { zh: '抱歉,我没有理解你的意思。请尝试更详细地描述。', en: 'Sorry, I did not understand. Please try to describe in more detail.' }, + 'sorryliteraturesearchserviceistemporaril': { zh: '抱歉,文献检索服务暂时不可用。', en: 'Sorry, literature search service is temporarily unavailable.' }, + 'sorryliteraturesearchserviceisunavailabl': { zh: '抱歉,文献检索服务暂时不可用,请稍后重试。', en: 'Sorry, literature search service is unavailable; please try again later.' }, + 'sorryserviceistemporarilyunavailableplea': { zh: '抱歉,服务暂时不可用,请稍后重试。', en: 'Sorry, service is temporarily unavailable; please try again later.' }, + 'latinname': { zh: '拉丁名', en: 'Latin Name' }, + 'latinnamealiasunchanged': { zh: '拉丁名/别名不改', en: 'Latin Name/Alias (Unchanged)' }, + 'extensionanalysis': { zh: '拓展分析', en: 'Extension Analysis' }, + 'extensionanalysiscandidatereview': { zh: '拓展分析候选核对', en: 'Extension Analysis Candidate Review' }, + 'extensionanalysisdescriptiongenerationfa': { zh: '拓展分析说明生成失败', en: 'Extension Analysis Description Generation Failed' }, + 'extensionanalysisrunparameterconfirmatio': { zh: '拓展分析运行参数确认', en: 'Extension Analysis Run Parameter Confirmation' }, + 'extensionanalysisstage': { zh: '拓展分析阶段', en: 'Extension Analysis Stage' }, + 'extensionanalysisstagepublished': { zh: '拓展分析阶段已发布', en: 'Extension Analysis Stage Published' }, + 'extensionanalysisstagegeneratedpublished': { zh: '拓展分析阶段已生成并发布', en: 'Extension Analysis Stage Generated & Published' }, + 'extensionanalysisstageexecutioncomplete': { zh: '拓展分析阶段执行完成', en: 'Extension Analysis Stage Execution Complete' }, + 'extensionanalysisstagefailedcheckstderrl': { zh: '拓展分析阶段执行未成功,请查看 stderr.log', en: 'Extension Analysis Stage Failed; check stderr.log' }, + 'extensionanalysisstagefailedcheckresultl': { zh: '拓展分析阶段执行未成功,请查看结果日志', en: 'Extension Analysis Stage Failed; check result log' }, + 'extensionanalysisstagestatus': { zh: '拓展分析阶段状态', en: 'Extension Analysis Stage Status' }, + 'extensionanalysisstagegenerationfailed': { zh: '拓展分析阶段生成失败', en: 'Extension Analysis Stage Generation Failed' }, + 'extensionanalysisstageconfirmation': { zh: '拓展分析阶段确认', en: 'Extension Analysis Stage Confirmation' }, + 'extensionanalysisstagerunresult': { zh: '拓展分析阶段运行结果', en: 'Extension Analysis Stage Run Result' }, + 'extensionstage': { zh: '拓展阶段', en: 'Extension Stage' }, + 'arabidopsisath': { zh: '拟南芥 (ath', en: 'Arabidopsis (ath)' }, + 'arabidopsisthalianaath': { zh: '拟南芥 Arabidopsis (ath', en: 'Arabidopsis thaliana (ath)' }, + 'arabidopsisfallbackreferenceconfirmed': { zh: '拟南芥兜底参考已确认', en: 'Arabidopsis Fallback Reference Confirmed' }, + 'arabidopsisonlyinorthologppikaksparamsot': { zh: '拟南芥只会写入 Ortholog/PPI/KaKs 专用参考参数,不会把全流程其它分析阶段改成 At。', en: 'Arabidopsis only in Ortholog/PPI/KaKs params; other stages unchanged.' }, + 'specifygtfgffannotation': { zh: '指定 GTF/GFF 注释', en: 'Specify GTF/GFF Annotation' }, + 'specifygwask0reportbestkonly-2': { zh: '指定 GWAS K(0=仅报告最佳 K)', en: 'Specify GWAS K (0=report best K only)' }, + 'splitperspecies': { zh: '按 per_species 拆分', en: 'Split per-species:' }, + 'byelement': { zh: '按元件', en: 'By Element' }, + 'perspeciesseparateanalysisconfirmation': { zh: '按单个物种分别分析与确认', en: 'Per-species Separate Analysis & Confirmation' }, + 'markbyspeciesandgeneratespecieslegend': { zh: '按物种标记并生成Species图例', en: 'Mark by species and generate Species legend' }, + 'markbytargetreferenceroleroletree': { zh: '按目标/参考角色标记(角色树)', en: 'Mark by target/reference role (Role Tree)' }, + 'bycategory': { zh: '按类别', en: 'By Category' }, + 'authorizepublish': { zh: '授权发布', en: 'Authorize Publish' }, + 'queued': { zh: '排队中', en: 'Queued' }, + 'waitinginqueue': { zh: '排队等待', en: 'Waiting in Queue' }, + 'recommendedrefdatadownloadfailed': { zh: '推荐参考数据下载失败', en: 'Recommended Ref Data Download Failed' }, + 'recommendedrefdatadownloadcomplete-2': { zh: '推荐参考数据已下载完成', en: 'Recommended Ref Data Download Complete' }, + 'recommendedrefdatadownloadinginbackgroun': { zh: '推荐参考数据正在后台下载', en: 'Recommended Ref Data Downloading in Background' }, + 'recommendednearsourcestringreferencespec': { zh: '推荐近源 STRING 参考物种:', en: 'Recommended Near-source STRING Reference Species:' }, + 'recommendednearsourceorthologousrefspeci': { zh: '推荐近源/同源参考物种', en: 'Recommended Near-source / Orthologous Ref Species' }, + 'submitrerunppi': { zh: '提交并重新运行 PPI', en: 'Submit & Re-run PPI' }, + 'submitconfiguredprojectrestartscript': { zh: '提交配置好的项目重启脚本', en: 'Submit Configured Project Restart Script' }, + 'submitrevisionsuggestions': { zh: '提出修订意见', en: 'Submit Revision Suggestions' }, + 'extracting': { zh: '提取中', en: 'Extracting...' }, + 'extractioncompletebutconfigreadfailed': { zh: '提取完成但配置读取失败', en: 'Extraction Complete but Config Read Failed' }, + 'tip': { zh: '提示:', en: 'Tip:' }, + 'searchgeneorentity': { zh: '搜索基因或实体...', en: 'Search gene or entity...' }, + 'operation': { zh: '操作', en: 'Operation' }, + 'operationfailed': { zh: '操作失败', en: 'Operation Failed' }, + 'placeat': { zh: '放在', en: 'Place at' }, + 'datapreparation-2': { zh: '数据准备', en: 'Data Preparation' }, + 'nonewexecutablemethodologystagesafterdat': { zh: '数据准备完成后没有新的可执行方法学阶段', en: 'No new executable methodology stages after data preparation' }, + 'dataat': { zh: '数据在', en: 'Data at' }, + 'databasearticleexpressionmatrix': { zh: '数据库/文章表达矩阵', en: 'Database / Article Expression Matrix' }, + 'dataformaterrornodesundefinedornotanarra': { zh: '数据格式错误: nodes 未定义或不是数组', en: 'Data Format Error: nodes undefined or not an array' }, + 'datadirectory': { zh: '数据目录', en: 'Data Directory' }, + 'datarolefromconfigxlsx': { zh: '数据角色(来自 config.xlsx)', en: 'Data Role (from config.xlsx)' }, + 'datawarningedgesundefinedusingemptyarray': { zh: '数据警告: edges 未定义,使用空数组', en: 'Data Warning: edges undefined, using empty array' }, + 'datapath': { zh: '数据路径', en: 'Data Path' }, + 'datagate': { zh: '数据门控', en: 'Data Gate' }, + 'dataneeds': { zh: '数据需求', en: 'Data Needs' }, + 'file': { zh: '文件', en: 'File' }, + 'fileuploadsuccessfulgenerationverificati': { zh: '文件上传成功,已开始生成并验收', en: 'File upload successful; generation & verification started' }, + 'fileat': { zh: '文件在', en: 'File at' }, + 'folder': { zh: '文件夹', en: 'Folder' }, + 'filedirectory': { zh: '文件目录', en: 'File Directory' }, + 'filedirectoryleaveemptytousecurrentjobin': { zh: '文件目录;留空则使用当前 job input 目录', en: 'File directory; leave empty to use current job input directory' }, + 'filetype': { zh: '文件类型', en: 'File Type' }, + 'filescope': { zh: '文件范围', en: 'File Scope' }, + 'documentsearch': { zh: '文档检索', en: 'Document Search' }, + 'literatureparameters': { zh: '文献参数', en: 'Literature Parameters' }, + 'literaturetools': { zh: '文献工具', en: 'Literature Tools' }, + 'literaturesupport': { zh: '文献支撑', en: 'Literature Support' }, + 'literaturesupportclaim': { zh: '文献支撑claim', en: 'Literature Support Claim' }, + 'literaturemethodvsbbav5stageaudit': { zh: '文献方法与 bbav5 阶段核对', en: 'Literature Method vs bbav5 Stage Audit' }, + 'literaturesnippet': { zh: '文献片段', en: 'Literature Snippet' }, + 'literaturerecord': { zh: '文献记录', en: 'Literature Record' }, + 'requiredforarticlereproductionegarabidop': { zh: '文章复现时必填,如 Arabidopsis thaliana', en: 'Required for article reproduction, e.g. Arabidopsis thaliana' }, + 'articlespeciesdownloadentry': { zh: '文章涉及物种与下载入口', en: 'Article Species & Download Entry' }, + 'newsession': { zh: '新会话', en: 'New Session' }, + 'newsessioncreatedpleaseselectorcreateawo': { zh: '新会话已创建,请选择或创建 Working Directory', en: 'New session created; please select or create a Working Directory' }, + 'add': { zh: '新增', en: 'Add' }, + 'newid': { zh: '新增 ID', en: 'New ID' }, + 'method1': { zh: '方式1:', en: 'Method 1:' }, + 'method2': { zh: '方式2:', en: 'Method 2:' }, + 'square': { zh: '方形', en: 'Square' }, + 'methodreference': { zh: '方法参考', en: 'Method Reference' }, + 'methodologysandboxbuilt': { zh: '方法学 sandbox 已构建', en: 'Methodology Sandbox Built' }, + 'methodologysandboxbuildfailed': { zh: '方法学 sandbox 构建失败', en: 'Methodology Sandbox Build Failed' }, + 'methodologysandboxqueryfailed': { zh: '方法学 sandbox 查询失败', en: 'Methodology Sandbox Query Failed' }, + 'methodologysandboxexamplereadfailed': { zh: '方法学 sandbox 示例读取失败', en: 'Methodology Sandbox Example Read Failed' }, + 'methodologyreference': { zh: '方法学参考', en: 'Methodology Reference' }, + 'methodologyexecutionplanningfailed': { zh: '方法学执行规划失败', en: 'Methodology Execution Planning Failed' }, + 'methodologyextraction': { zh: '方法学抽取', en: 'Methodology Extraction' }, + 'methodologyextractionbbav5executionaudit': { zh: '方法学提取与 bbav5 执行核对', en: 'Methodology Extraction & bbav5 Execution Audit' }, + 'methodologyextracting': { zh: '方法学提取中', en: 'Methodology Extracting...' }, + 'methodologyextractionlaunchfailed': { zh: '方法学提取启动失败', en: 'Methodology Extraction Launch Failed' }, + 'methodologyextractionfailed': { zh: '方法学提取失败', en: 'Methodology Extraction Failed' }, + 'methodologyextractionstatusreadfailed': { zh: '方法学提取状态读取失败', en: 'Methodology Extraction Status Read Failed' }, + 'methodologyextractionrecordreadfailed': { zh: '方法学提取记录读取失败', en: 'Methodology Extraction Record Read Failed' }, + 'methodologydatareadinesscheckfailed': { zh: '方法学数据就绪检查失败', en: 'Methodology Data Readiness Check Failed' }, + 'methodologypapers': { zh: '方法学文献', en: 'Methodology Papers' }, + 'methodologyplansavefailed': { zh: '方法学方案保存失败', en: 'Methodology Plan Save Failed' }, + 'methodologyplansavedpreparedatathenexecu': { zh: '方法学方案已保存,可先准备数据,稍后再按该方案执行', en: 'Methodology plan saved; prepare data then execute later.' }, + 'methodologyplanbindingfailed': { zh: '方法学方案绑定失败', en: 'Methodology Plan Binding Failed' }, + 'datapreparationrequiredbymethodologyplan': { zh: '方法学方案要求的数据准备', en: 'Data Preparation Required by Methodology Plan' }, + 'methodologypipeline': { zh: '方法学流水线', en: 'Methodology Pipeline' }, + 'methodologycontinueplanningfailed': { zh: '方法学续跑规划失败', en: 'Methodology Continue Planning Failed' }, + 'methodologyconfigreadfailed': { zh: '方法学配置读取失败', en: 'Methodology Config Read Failed' }, + 'noavailablenearsourcerecommendation': { zh: '无可用近源推荐', en: 'No Available Near-source Recommendation' }, + 'noexternalreference': { zh: '无外部参考', en: 'No External Reference' }, + 'cannotdeterminecombooutputlabelselectaco': { zh: '无法确定 combo 输出标签,请先选择 combo 图。', en: 'Cannot determine combo output label; select a combo plot first.' }, + 'cannotdeterminemsaoutputlabelselectimage': { zh: '无法确定MSA输出标签,请先选择对应图片或填写输出标签。', en: 'Cannot determine MSA output label; select image or fill output label.' }, + 'cannotdeterminepromoteroutputprefix': { zh: '无法确定启动子输出前缀。', en: 'Cannot determine promoter output prefix.' }, + 'cannotdetermineintraspeciessyntenyoutput': { zh: '无法确定种内共线性输出标签,请先选择 Circos 图。', en: 'Cannot determine intraspecies synteny output label; select Circos plot.' }, + 'cannotdeterminetreeoutputlabelselecttree': { zh: '无法确定进化树输出标签,请先选择树图或填写输出标签。', en: 'Cannot determine tree output label; select tree plot or fill output label.' }, + 'cannotreadoperationstatus': { zh: '无法读取操作状态', en: 'Cannot Read Operation Status' }, + 'noneedtouploadfilesagain': { zh: '无需再次Upload Files', en: 'No Need to Upload Files Again' }, + 'log': { zh: '日志', en: 'Log' }, + 'legacyfieldusuallyfalseseedreferencemayb': { zh: '旧兼容字段,通常 FALSE;seed_reference 可为 TRUE', en: 'Legacy field, usually FALSE; seed_reference may be TRUE' }, + 'timeseries': { zh: '时间序列 time_series', en: 'Time Series' }, + 'explicitlyspecify': { zh: '明确指定', en: 'Explicitly Specify' }, + 'show': { zh: '显示', en: 'Show' }, + 'pausecountdown': { zh: '暂停倒计时', en: 'Pause Countdown' }, + 'norecommendationsavailable': { zh: '暂无可用推荐', en: 'No Recommendations Available' }, + 'nouploadedfilesyet': { zh: '暂无已上传文件', en: 'No Uploaded Files Yet' }, + 'noextensionanalysisstageresultsyet': { zh: '暂无拓展分析阶段结果', en: 'No Extension Analysis Stage Results Yet' }, + 'nostatusrecordyet': { zh: '暂无状态记录', en: 'No Status Record Yet' }, + 'maxwidth': { zh: '最大宽度', en: 'Max Width' }, + 'minwidth': { zh: '最小宽度', en: 'Min Width' }, + 'minnongapratio': { zh: '最小非缺口比例', en: 'Min Non-gap Ratio' }, + 'finalcomprehensiveinterpretation': { zh: '最终综合解读', en: 'Final Comprehensive Interpretation' }, + 'closesttogenus': { zh: '最近到属', en: 'Closest to Genus' }, + 'closesttoorder': { zh: '最近到目', en: 'Closest to Order' }, + 'closesttofamily': { zh: '最近到科', en: 'Closest to Family' }, + 'closesttoclass': { zh: '最近到纲', en: 'Closest to Class' }, + 'lossyconversionwarning': { zh: '有损转换警告', en: 'Lossy Conversion Warning' }, + 'stagespausedawaitinguserconfirmation': { zh: '有阶段暂停,等待用户确认', en: 'Stages paused, awaiting user confirmation' }, + 'servertestdatapath': { zh: '服务器测试数据路径', en: 'Server Test Data Path' }, + 'serversidevcforvcfgzpath': { zh: '服务器端 .vcf 或 .vcf.gz 路径', en: 'Server-side .vcf or .vcf.gz Path' }, + 'noadditionalstagesinferredfromcharttitle': { zh: '未从图表标题推断出额外阶段', en: 'No Additional Stages Inferred from Chart Titles' }, + 'nomethodologyplanmatched': { zh: '未匹配到方法学计划', en: 'No Methodology Plan Matched' }, + 'unnamedsample': { zh: '未命名样本', en: 'Unnamed Sample' }, + 'nooutputfilesscanned': { zh: '未扫描到输出文件', en: 'No output files scanned' }, + 'nointerproscandomainreviewtsvfoundshowin': { zh: '未找到 InterProScan domain_review.tsv,当前显示 final_members 与 HMM/BLAST 证据;默认保留全部 final_members,确认后才继续方法学下游分析。', en: 'No InterProScan domain_review.tsv found; showing final_members with HMM/BLAST evidence; keeping all for downstream.' }, + 'referencespeciesnotfound': { zh: '未找到 reference 物种', en: 'Reference Species Not Found' }, + 'targetspeciesnotfound': { zh: '未找到 target 物种', en: 'Target Species Not Found' }, + 'nodirectlydownloadablestringnearsourcere': { zh: '未找到可直接下载的 STRING 近源参考物种。', en: 'No directly downloadable STRING near-source ref species found.' }, + 'noexistingtablefound': { zh: '未找到已有表格', en: 'No Existing Table Found' }, + 'novalidvcffound': { zh: '未找到有效 VCF', en: 'No Valid VCF Found' }, + 'norelatedinformationfound': { zh: '未找到相关信息', en: 'No Related Information Found' }, + 'nooutputfilesfound': { zh: '未找到输出文件', en: 'No Output Files Found' }, + 'nocharttitleextracted': { zh: '未提取到图表标题', en: 'No Chart Title Extracted' }, + 'notsupported': { zh: '未支持', en: 'Not Supported' }, + 'nopairablefastqfilesdetectedcannotgenera': { zh: '未检测到可配对的 FASTQ 样本文件,不能生成 samples.yaml 草稿。', en: 'No pairable FASTQ files detected; cannot generate samples.yaml draft.' }, + 'nopairablefastqfilesuseconfigxlsxautofil': { zh: '未检测到可配对的 FASTQ 样本文件;如果这是基因组/注释/蛋白/CDS目录,请使用 config.xlsx 自动填充。', en: 'No pairable FASTQ files; use config.xlsx auto-fill for genome/annotation/protein/CDS dirs.' }, + 'notgenerated': { zh: '未生成', en: 'Not Generated' }, + 'noexecutablestagesgeneratedcheckstagenam': { zh: '未生成可执行阶段。未检测到当前 scope 的完成结果时,不再把空计划解释为已完成;请检查阶段名称或前置依赖。', en: 'No executable stages generated; check stage names or upstream dependencies.' }, + 'noadvanceddesigngenerated': { zh: '未生成高级设计', en: 'No Advanced Design Generated' }, + 'unknown': { zh: '未知', en: 'Unknown' }, + 'unknownspecies': { zh: '未知物种', en: 'Unknown Species' }, + 'notbound': { zh: '未绑定', en: 'Not Bound' }, + 'notrecorded': { zh: '未记录', en: 'Not Recorded' }, + 'notset': { zh: '未设置', en: 'Not Set' }, + 'notrecognized': { zh: '未识别', en: 'Not Recognized' }, + 'nobuiltindllinkidentified': { zh: '未识别到内置下载链接', en: 'No Built-in DL Link Identified' }, + 'noparametersidentified': { zh: '未识别到参数', en: 'No Parameters Identified' }, + 'noliteraturetoolidentified': { zh: '未识别到文献工具', en: 'No Literature Tool Identified' }, + 'nospeciesinfodetected': { zh: '未识别到物种信息', en: 'No Species Info Detected' }, + 'notselected': { zh: '未选择', en: 'Not Selected' }, + 'plantairestartscriptorrestartplantai9000-2': { zh: '未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh', en: 'PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured' }, + 'noprojectrestartscriptconfigured': { zh: '未配置项目重启脚本', en: 'No Project Restart Script Configured' }, + 'thisupload': { zh: '本次上传', en: 'This Upload' }, + 'currentsubfamilyclassificationtargetstar': { zh: '本次亚家族分类对象(target', en: 'Current Subfamily Classification Targets (target)' }, + 'skipppikaksthistime': { zh: '本次先跳过 PPI/KaKs', en: 'Skip PPI/KaKs This Time' }, + 'allstagesofthiscompletegwasusethisprefix': { zh: '本次完整 GWAS 的所有阶段统一使用该前缀。', en: 'All stages of this complete GWAS use this prefix uniformly.' }, + 'currentmemberidentificationspecies': { zh: '本次成员鉴定物种', en: 'Current Member Identification Species' }, + 'executionscopeforthisstage': { zh: '本阶段执行范围', en: 'Execution Scope for This Stage' }, + 'historicalmessages': { zh: '条历史消息', en: ' historical messages' }, + 'hints': { zh: '条提示', en: ' hints' }, + 'source': { zh: '来源:', en: 'Source:' }, + 'nearsourcerefrecommendationfromstringspe': { zh: '来自 STRING 物种列表的近源参考推荐', en: 'Near-source ref recommendation from STRING species list' }, + 'buildfailed': { zh: '构建失败', en: 'Build Failed' }, + 'buildapididnotreturnextensionidcannotpro': { zh: '构建接口未返回 extension_id,不能继续试运行。', en: 'Build API did not return extension_id; cannot proceed with trial.' }, + 'fruitflydme': { zh: '果蝇 Fly (dme', en: 'Fruit Fly (dme)' }, + 'chromosomeannotationposition': { zh: '染色体标注位置', en: 'Chromosome Annotation Position' }, + 'chromosomeannotationsize': { zh: '染色体标注大小', en: 'Chromosome Annotation Size' }, + 'chromosomestrategy': { zh: '染色体策略', en: 'Chromosome Strategy' }, + 'chromosomeblockwidth': { zh: '染色体色块粗细', en: 'Chromosome Block Width' }, + 'chromosomescommaseparated': { zh: '染色体(逗号分隔)', en: 'Chromosomes (comma-separated)' }, + 'viewselectedextractionstatus': { zh: '查看所选提取状态', en: 'View Selected Extraction Status' }, + 'viewlatestextractionstatus': { zh: '查看最新提取状态', en: 'View Latest Extraction Status' }, + 'queryanalysis': { zh: '查询分析', en: 'Query Analysis' }, + 'standardoutput': { zh: '标准输出', en: 'Standard Output' }, + 'standardoutputdirectdisplay': { zh: '标准输出(直接展示)', en: 'Standard Output (Direct Display)' }, + 'labeltable': { zh: '标签表', en: 'Label Table' }, + 'marksize': { zh: '标记大小', en: 'Mark Size' }, + 'markmode': { zh: '标记模式', en: 'Mark Mode' }, + 'candidatestagesinferredfromtitles': { zh: '标题推断的候选阶段', en: 'Candidate Stages Inferred from Titles' }, + 'charttitleruleinference': { zh: '标题规则推断', en: 'Chart Title Rule Inference' }, + 'treefileautoresolvedfromcurrentimagetotr': { zh: '树文件:将根据当前图片自动解析对应 .tree.nwk', en: 'Tree file: auto-resolved from current image to .tree.nwk' }, + 'branchwidth': { zh: '树枝粗细', en: 'Branch Width' }, + 'branchcolor': { zh: '树枝颜色', en: 'Branch Color' }, + 'sample': { zh: '样本', en: 'Sample' }, + 'sampleqcrequiresatleast2samplesforpcacor': { zh: '样本 QC 至少需要 2 个样本才能计算 PCA、相关性或距离。', en: 'Sample QC requires at least 2 samples for PCA, correlation or distance.' }, + 'sampleinfotablesampleid': { zh: '样本信息表 sample_id', en: 'Sample Info Table sample_id' }, + 'sampleinfotablepathoptionalsamplesinfoxl': { zh: '样本信息表路径,可选:samples_info.xlsx/csv/tsv', en: 'Sample info table path, optional: samples_info.xlsx/csv/tsv' }, + 'samplegrouptableoptionalforpcald': { zh: '样本分组表(PCA/LD 可选)', en: 'Sample Group Table (optional for PCA/LD)' }, + 'sampleparallelcount': { zh: '样本并行数量', en: 'Sample Parallel Count' }, + 'insufficientsamplecount': { zh: '样本数量不足', en: 'Insufficient sample count' }, + 'samplefiles': { zh: '样本文件', en: 'Sample Files' }, + 'sampleclusteringtreeplotfailed': { zh: '样本聚类树绘图失败', en: 'Sample Clustering Tree Plot Failed' }, + 'sampletable': { zh: '样本表', en: 'Sample Table' }, + 'sampletableandexpressionmatrixmismatch': { zh: '样本表和表达矩阵不匹配', en: 'Sample table and expression matrix mismatch' }, + 'sampletableautodetection': { zh: '样本表自动识别', en: 'Sample Table Auto-detection' }, + 'sampleevaluation': { zh: '样本评估', en: 'Sample Evaluation' }, + 'sampleconfiguration': { zh: '样本配置', en: 'Sample Configuration' }, + 'sampleconfiguration-2': { zh: '样本配置:', en: 'Sample Configuration:' }, + 'karyotypechromosomedistributionanalysis': { zh: '核型/染色体分布分析', en: 'Karyotype / Chromosome Distribution Analysis' }, + 'checksamplenamesrunidsinsamplesyamlagain': { zh: '核对 samples.yaml 的样本名、run ID 和 count matrix 表头,确保名称一致。', en: 'Check sample names, run IDs in samples.yaml against count matrix headers.' }, + 'check': { zh: '检查', en: 'Check' }, + 'checkcountmatrixkeeponlygeneidsamplecoun': { zh: '检查 count matrix,只保留 gene_id', en: 'Check count matrix; keep only gene_id + sample count columns; remove Chr/Start/End/Strand/Length.' }, + 'checkifsamplesyamlhasonlyonesampleorqcqu': { zh: '检查 samples.yaml 是否只保留了一个样本,或上游 QC/quant 是否只产出一个样本的 count。', en: 'Check if samples.yaml has only one sample, or QC/quant produced single-sample count.' }, + 'checktaskstatusfailed': { zh: '检查任务状态失败', en: 'Check Task Status Failed' }, + 'multipletaxadetectedforclassification': { zh: '检测到多个待分类物种:', en: 'Multiple taxa detected for classification:' }, + 'multiplevalidvcfsdetectedpleaseselect': { zh: '检测到多个有效 VCF,请选择', en: 'Multiple valid VCFs detected; please select' }, + 'downloadmodeautoretrievespeciesdata': { zh: '检索Download模式 (download) - 自动检索物种数据', en: 'Download Mode - Auto-retrieve Species Data' }, + 'downloadmode': { zh: '检索Download模式:', en: 'Download Mode:' }, + 'searching': { zh: '检索中', en: 'Searching...' }, + 'searchresultssufficient': { zh: '检索结果充分', en: 'Search Results Sufficient' }, + 'searchtablecontent': { zh: '检索表格内容', en: 'Search Table Content' }, + 'regexmatch': { zh: '正则匹配', en: 'Regex Match' }, + 'loadinginterspeciesspecieslist': { zh: '正在加载种间物种列表...', en: 'Loading Interspecies Species List...' }, + 'processingrequest': { zh: '正在处理请求...', en: 'Processing request...' }, + 'searchingknowledgebaseandgeneratinganswe': { zh: '正在检索知识库并生成回答...', en: 'Searching knowledge base and generating answer...' }, + 'understandingyourquestionandidentifyings': { zh: '正在理解你的问题并识别检索目标……', en: 'Understanding your question and identifying search targets...' }, + 'generatingexecutionplanandcheckinginputf': { zh: '正在生成执行计划并检查输入文件...', en: 'Generating execution plan and checking input files...' }, + 'searchingknowledgebaseforrelevantcontent': { zh: '正在知识库中查找相关内容……', en: 'Searching knowledge base for relevant content...' }, + 'recognizingyouranalysisintent': { zh: '正在识别你的分析意图...', en: 'Recognizing your analysis intent...' }, + 'recognizingintentandanalysistype': { zh: '正在识别意图和分析类型...', en: 'Recognizing intent and analysis type...' }, + 'invokingmultiagentmethodologyexecutionse': { zh: '正在调用多智能体规划方法学执行顺序...', en: 'Invoking multi-agent methodology execution sequence planning...' }, + 'thisextensionstagehasnoconfigurableparam': { zh: '此拓展阶段没有可配置参数', en: 'This extension stage has no configurable parameters' }, + 'eachtaxongeneratesindependenttreecombopl': { zh: '每个待分类物种分别与 reference 生成独立树、组合图、亚家族分类结果和确认表。', en: 'Each taxon generates independent tree, combo plot, subfamily results with reference.' }, + 'alignment': { zh: '比对', en: 'Alignment' }, + 'comparisonaxis': { zh: '比较主轴:', en: 'Comparison Axis:' }, + 'comparisonaxisautodetect': { zh: '比较主轴:自动判断', en: 'Comparison Axis: Auto-detect' }, + 'comparativespeciescheckasneeded': { zh: '比较物种(按需勾选)', en: 'Comparative Species (check as needed)' }, + 'riceosa': { zh: '水稻 (osa', en: 'Rice (osa)' }, + 'ricechromosomes112': { zh: '水稻 1–12 号染色体', en: 'Rice Chromosomes 1-12' }, + 'aggregatebatchoutput': { zh: '汇总批次输出', en: 'Aggregate Batch Output' }, + 'nomatchingdraftrows': { zh: '没有匹配的草稿行', en: 'No matching draft rows' }, + 'nodownloadablerecommendednearsourcerefsp': { zh: '没有可下载的推荐近源参考物种', en: 'No downloadable recommended near-source ref species' }, + 'noresultsfoundtodisplay': { zh: '没有找到可展示的结果。', en: 'No results found to display.' }, + 'noextensionanalysisstagedetectedforexecu': { zh: '没有识别到要执行的拓展分析阶段', en: 'No extension analysis stage detected for execution' }, + 'uselegacypipelinecompatibilitydesign': { zh: '沿用原流程兼容设计', en: 'Use legacy pipeline compatibility design' }, + 'testdata': { zh: '测试数据', en: 'Test Data' }, + 'browse': { zh: '浏览', en: 'Browse' }, + 'addt01suffixaddt01': { zh: '添加.T01后缀 (add_T01', en: 'Add .T01 Suffix (add_T01)' }, + 'addrow': { zh: '添加行', en: 'Add Row' }, + 'gray': { zh: '灰色', en: 'Gray' }, + 'species': { zh: '物种', en: 'Species' }, + 'species1cdsegatcdsfasta': { zh: '物种1 CDS序列 (如 At.cds.fasta', en: '- Species 1 CDS (e.g. At.cds.fasta)' }, + 'species2cdsegoscdsfasta': { zh: '物种2 CDS序列 (如 Os.cds.fasta', en: '- Species 2 CDS (e.g. Os.cds.fasta)' }, + 'speciescode': { zh: '物种代码', en: 'Species Code' }, + 'speciescodeunchanged': { zh: '物种代码不改', en: 'Species Code (Unchanged)' }, + 'speciesname': { zh: '物种名', en: 'Species Name' }, + 'specieslatinnameegarabidopsisthaliana': { zh: '物种拉丁名,如 Arabidopsis thaliana', en: 'Species Latin name, e.g. Arabidopsis thaliana' }, + 'speciesabbreviationegosatzm': { zh: '物种简称,如 Os / At / Zm', en: 'Species abbreviation, e.g. Os / At / Zm' }, + 'speciesrole': { zh: '物种角色', en: 'Species Role' }, + 'speciesroleunchanged': { zh: '物种角色不改', en: 'Species Role (Unchanged)' }, + 'maizezma': { zh: '玉米 (zma', en: 'Maize (zma)' }, + 'environment': { zh: '环境', en: 'Environment' }, + 'generate': { zh: '生成', en: 'Generate' }, + 'generating': { zh: '生成中', en: 'Generating...' }, + 'generateanswer': { zh: '生成回答', en: 'Generate Answer' }, + 'generatedraft': { zh: '生成草稿', en: 'Generate Draft' }, + 'draftgenerationfailed': { zh: '生成草稿失败', en: 'Draft Generation Failed' }, + 'generateinterpretationfailed': { zh: '生成解读失败', en: 'Generate Interpretation Failed' }, + 'biologicalcontext': { zh: '生物学背景', en: 'Biological Context' }, + 'usedtoinferkeggorganismcodeegoryzasativa': { zh: '用于推断 KEGG organism code;例如 Oryza sativa 会优先检索 osa 物种级 pathway gene set', en: 'Used to infer KEGG organism code; e.g. Oryza sativa retrieves osa species-level pathway gene set' }, + 'usermanuallyadded': { zh: '用户手动添加', en: 'User Manually Added' }, + 'leaveemptytousegtfgffannotationfrominput': { zh: '留空则使用 input 中的 GTF/GFF 注释', en: 'Leave empty to use GTF/GFF annotation from input' }, + 'leaveemptytouseoutput': { zh: '留空则使用 output', en: 'Leave empty to use output' }, + 'leaveemptyforautouseoffeaturecountscount': { zh: '留空则自动使用 featureCounts count matrix', en: 'Leave empty for auto-use of featureCounts count matrix' }, + 'leaveemptyforautouseofnormalizedvsttpmma': { zh: '留空则自动使用 normalized/vst/TPM 表达矩阵', en: 'Leave empty for auto-use of normalized/vst/TPM matrix' }, + 'leaveemptyforautouseofsamplesyamlsamplet': { zh: '留空则自动使用 samples.yaml / sample table', en: 'Leave empty for auto-use of samples.yaml / sample table' }, + 'leaveemptyforautodiscovery': { zh: '留空则自动发现', en: 'Leave empty for auto-discovery' }, + 'leaveemptyforautoinference': { zh: '留空则自动推断', en: 'Leave empty for auto-inference' }, + 'leaveemptyforautogenerationdiscovery': { zh: '留空则自动生成/发现', en: 'Leave empty for auto-generation/discovery' }, + 'leaveemptyforautoselectionofdecomparison': { zh: '留空则自动选择 DE comparison', en: 'Leave empty for auto-selection of DE comparison' }, + 'leaveemptytousefastpbinorpath': { zh: '留空时使用 FASTP_BIN 或 PATH', en: 'Leave empty to use FASTP_BIN or PATH' }, + 'tomatosly': { zh: '番茄 (sly', en: 'Tomato (sly)' }, + 'directory': { zh: '目录', en: 'Directory' }, + 'target': { zh: '目标', en: 'Target' }, + 'targetprefix': { zh: '目标前缀', en: 'Target Prefix' }, + 'targetspecies-2': { zh: '目标物种', en: 'Target Species' }, + 'targetspeciesnotinstringsnapshotconfirmu': { zh: '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:', en: 'Target species not in STRING snapshot. Confirm using top-ranked near-source species:' }, + 'targetspeciesnotusableforstringppikaks': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs', en: 'Target species not usable for STRING PPI/KaKs.' }, + 'targetspeciesnotusableforstringppikaks-2': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs。', en: 'Target species not usable for STRING PPI/KaKs.' }, + 'directuploadexpressionmatrixfpkmtxttpmtx': { zh: '直接上传 - 上传表达量矩阵(*.fpkm.txt/*.tpm.txt)', en: 'Direct Upload: expression matrix (*.fpkm.txt/*.tpm.txt)+geneID+sample.txt' }, + 'knowledgegraph': { zh: '知识图谱', en: 'Knowledge Graph' }, + 'knowledgegraphloaded': { zh: '知识图谱加载完成', en: 'Knowledge Graph Loaded' }, + 'knowledgebasesearch': { zh: '知识库检索', en: 'Knowledge Base Search' }, + 'knowledgebasesearchtimedout300s': { zh: '知识库检索超时(300秒未返回)。', en: 'Knowledge base search timed out (300s).' }, + 'gotit-2': { zh: '知道了', en: 'Got It' }, + 'rectangularplot': { zh: '矩形图', en: 'Rectangular Plot' }, + 'rectangulartree': { zh: '矩形树', en: 'Rectangular Tree' }, + 'confirm-2': { zh: '确定', en: 'Confirm' }, + 'confirmdeanalysisdesign': { zh: '确认 DE 分析设计', en: 'Confirm DE Analysis Design' }, + 'confirmppistringnearsourcereferencespeci': { zh: '确认 PPI 的 STRING 近源参考物种', en: 'Confirm PPI STRING Near-source Reference Species' }, + 'verifygenomefilesindexininputdonotusecds': { zh: '确认 input 目录中有类似 Os.genome.fasta/fa/fna,或 Os.genome.1.ht2 等基因组索引;不要把 Os.cds 或 Os.pep 当作比对参考。', en: 'Verify genome files/index in input; do not use CDS/PEP as alignment reference.' }, + 'confirmsubfamilylabels': { zh: '确认亚家族标签', en: 'Confirm Subfamily Labels' }, + 'confirmsave': { zh: '确认保存', en: 'Confirm Save' }, + 'confirmkeepallmembers': { zh: '确认保留全部成员', en: 'Confirm Keep All Members' }, + 'nonewexecutablestagesafterconfirmation': { zh: '确认后常规分析没有新的可执行阶段', en: 'No new executable stages after confirmation' }, + 'nonewexecutablestagesafterconfirmation-2': { zh: '确认后常规分析没有新的可执行阶段。', en: 'No new executable stages after confirmation.' }, + 'refpepdownloadorthologgenerationppiexecu': { zh: '确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。', en: 'Ref PEP download, Ortholog generation, PPI execution only after confirmation.' }, + 'onlypublishafterconfirmationwillnotautop': { zh: '确认后才会发布;不会自动发布。', en: 'Only publish after confirmation; will not auto-publish.' }, + 'confirmfailed': { zh: '确认失败', en: 'Confirm Failed' }, + 'confirmedsaved': { zh: '确认已保存', en: 'Confirmed & Saved' }, + 'confirmwritetoofficialconfig': { zh: '确认并写入正式配置', en: 'Confirm & Write to Official Config' }, + 'confirmexecute': { zh: '确认并执行', en: 'Confirm & Execute' }, + 'confirmexecution-2': { zh: '确认执行', en: 'Confirm Execution' }, + 'confirmfirstcandidate': { zh: '确认第一候选', en: 'Confirm First Candidate' }, + 'confirmautoannotation': { zh: '确认自动注释', en: 'Confirm Auto-Annotation' }, + 'confirmationtable': { zh: '确认表', en: 'Confirmation Table' }, + 'confirmdesignwrite': { zh: '确认设计并写入', en: 'Confirm Design & Write' }, + 'confirmrestart': { zh: '确认重启', en: 'Confirm Restart' }, + 'exampledownloadfunctioncurrentlyunavaila': { zh: '示例下载功能当前不可用', en: 'Example Download Function Currently Unavailable' }, + 'disablekeggnetwork': { zh: '禁用 KEGG 网络', en: 'Disable KEGG Network' }, + 'intraspeciessyntenycircosrevisionparamet': { zh: '种内共线性 Circos 修订参数', en: 'Intraspecies Synteny Circos Revision Parameters' }, + 'intraspeciessyntenycircosreplotfailed': { zh: '种内共线性 Circos 重绘失败:', en: 'Intraspecies Synteny Circos Replot Failed:' }, + 'intraspeciessyntenycircosreplotcompletes': { zh: '种内共线性 Circos 重绘完成,已切换到新图', en: 'Intraspecies synteny Circos replot complete; switched to new figure' }, + 'intraspeciessyntenycircosreplotsubmitted': { zh: '种内共线性 Circos 重绘已提交:task_id=', en: 'Intraspecies synteny Circos replot submitted: task_id=' }, + 'intraspeciessyntenyanalysis': { zh: '种内共线性分析', en: 'Intraspecies Synteny Analysis' }, + 'interspeciessyntenyanalysis': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'interspeciessyntenycomparativespecies': { zh: '种间共线性比较物种', en: 'Interspecies Synteny Comparative Species' }, + 'interspeciessyntenyplotsettings': { zh: '种间共线性绘图设置', en: 'Interspecies Synteny Plot Settings' }, + 'remove': { zh: '移除', en: 'Remove' }, + 'windowlength': { zh: '窗口长度', en: 'Window Length' }, + 'filter': { zh: '筛选', en: 'Filter' }, + 'simplifytooriginalrecordid': { zh: '简化为原record ID', en: 'Simplify to Original Record ID' }, + 'type': { zh: '类型', en: 'Type' }, + 'exactmatch': { zh: '精确匹配', en: 'Exact Match' }, + 'system': { zh: '系统', en: 'System' }, + 'systemwillpreparerefpepcdsgenerateorthol': { zh: '系统会先准备参考 PEP/CDS,再由 Ortholog 生成映射,之后执行 PPI/KaKs。', en: 'System will prepare ref PEP/CDS, generate Ortholog mapping, then execute PPI/KaKs.' }, + 'systemwillautosearchfilesattheselocation': { zh: '系统会自动搜索以下位置的文件:', en: 'System Will Auto-Search Files at These Locations:' }, + 'phylogenetics': { zh: '系统发育', en: 'Phylogenetics' }, + 'phylogeneticanalysis': { zh: '系统发育分析', en: 'Phylogenetic Analysis' }, + 'systemdeterminingexecutionentryrunningup': { zh: '系统正在确定执行入口,并运行上传的代码和测试数据,提取不可修改的执行事实。', en: 'System determining execution entry; running uploaded code and test data.' }, + 'red': { zh: '红色', en: 'Red' }, + 'comboplot': { zh: '组合图', en: 'Combo Plot' }, + 'tissue': { zh: '组织 tissue', en: 'Tissue' }, + 'endalignmentcolumn': { zh: '终止 alignment 列', en: 'End Alignment Column' }, + 'endsite': { zh: '终止位点', en: 'End Site' }, + 'endresidue': { zh: '终止残基', en: 'End Residue' }, + 'domainanalysis': { zh: '结构域分析', en: 'Domain Analysis' }, + 'numberoftopmotifsinresultplot': { zh: '结果图展示的 Top motif 数量', en: 'Number of Top Motifs in result plot' }, + 'resultcorrectcontinue': { zh: '结果无误,继续', en: 'Result Correct; Continue' }, + 'resultfailedmanifestvalidation': { zh: '结果未通过 manifest 校验', en: 'Result failed manifest validation' }, + 'resultchecklistvalidationfailed': { zh: '结果清单校验未通过', en: 'Result checklist validation failed' }, + 'resulttable': { zh: '结果表', en: 'Result Table' }, + 'resultassessment': { zh: '结果评估', en: 'Result Assessment' }, + 'plotdatareusegffmcscanxcollinearityfamil': { zh: '绘图数据:复用 synteny 阶段的 GFF、MCScanX collinearity、family gene ID 和 genome。', en: 'Plot data: reuse GFF, MCScanX collinearity, family gene ID and genome from synteny stage.' }, + 'plotmode': { zh: '绘图模式', en: 'Plot Mode' }, + 'unifiedtipmark': { zh: '统一Tip标记', en: 'Unified Tip Mark' }, + 'statisticschain': { zh: '统计链:', en: 'Statistics Chain:' }, + 'green': { zh: '绿色', en: 'Green' }, + 'edit': { zh: '编辑', en: 'Edit' }, + 'missingcleanreadsafterqc': { zh: '缺少 QC 后的 clean reads', en: 'Missing clean reads after QC' }, + 'missinggene2kobackground': { zh: '缺少 gene2ko 背景', en: 'Missing gene2ko Background' }, + 'missinggene2kobackgroundfile': { zh: '缺少 gene2ko 背景文件', en: 'Missing gene2ko Background File' }, + 'missingusablegene2gobackground': { zh: '缺少可用 gene2go 背景', en: 'Missing Usable gene2go Background' }, + 'missingusableorthologmapping': { zh: '缺少可用同源映射', en: 'Missing Usable Ortholog Mapping' }, + 'missingbackgroundannotationfile': { zh: '缺少背景注释文件', en: 'Missing Background Annotation File' }, + 'missinginputdata': { zh: '缺少输入数据', en: 'Missing Input Data' }, + 'confidence': { zh: '置信度', en: 'Confidence' }, + 'confidencethreshold': { zh: '置信度阈值', en: 'Confidence Threshold' }, + 'populationlddecayanalysis': { zh: '群体LD衰减分析', en: 'Population LD Decay Analysis' }, + 'populationpcaanalysis': { zh: '群体PCA分析', en: 'Population PCA Analysis' }, + 'populationphylogenetictreeanalysis': { zh: '群体系统树分析', en: 'Population Phylogenetic Tree Analysis' }, + 'populationstructureanalysis': { zh: '群体结构分析', en: 'Population Structure Analysis' }, + 'populationgeneticinput': { zh: '群体遗传输入', en: 'Population Genetic Input' }, + 'backgroundidexample': { zh: '背景ID示例', en: 'Background ID Example' }, + 'backgroundreferenceonlynotindataprep': { zh: '背景提及,不进入数据准备', en: 'Background reference only; not in data prep' }, + 'backgroundsource': { zh: '背景来源', en: 'Background Source' }, + 'backgroundannotation': { zh: '背景注释', en: 'Background Annotation' }, + 'auto': { zh: '自动', en: 'Auto' }, + 'autoinferfromconfigxlsxscope': { zh: '自动从 config.xlsx / scope 推断', en: 'Auto-infer from config.xlsx / scope' }, + 'autoextractfromannotation': { zh: '自动从注释提取', en: 'Auto-extract from Annotation' }, + 'autousesamplesyamlfrominputfinal': { zh: '自动使用 input/final 中的 samples.yaml', en: 'Auto-use samples.yaml from input/final' }, + 'autoconservedwindow': { zh: '自动保守窗口', en: 'Auto Conserved Window' }, + 'autofix': { zh: '自动修复', en: 'Auto Fix' }, + 'autofixresults': { zh: '自动修复结果', en: 'Auto Fix Results' }, + 'autorecommendedprioritizefamilydegenesfa': { zh: '自动判定(推荐):优先 family_DE_genes,缺失时回退 DE_genes', en: 'Auto (recommended): prioritize family_DE_genes, fallback to global DE_genes' }, + 'autoprioritizefamilydegenesfallbacktoglo': { zh: '自动判定:优先 family_DE_genes,缺失时回退全局 DE_genes', en: 'Auto: prioritize family_DE_genes, fallback to global DE_genes' }, + 'autogenefamilywhenfinalmembersidexistsot': { zh: '自动判定:有 final_members.id 时做基因家族,否则做全基因组', en: 'Auto: gene family when final_members.id exists, otherwise genome-wide' }, + 'autodetect': { zh: '自动判断', en: 'Auto-detect' }, + 'autodetectrecommended': { zh: '自动判断(推荐)', en: 'Auto-detect (Recommended)' }, + 'autoreview': { zh: '自动审核', en: 'Auto Review' }, + 'autodetectpeadapter': { zh: '自动检测 PE adapter', en: 'Auto-detect PE Adapter' }, + 'autoannotatecontinue': { zh: '自动注释并继续', en: 'Auto-annotate & Continue' }, + 'autoannotationislongtaskautoconfirmin40s': { zh: '自动注释是长任务。40秒内不操作将自动确认;可暂停倒计时后手动决定。', en: 'Auto-annotation is long task. Auto-confirm in 40s if no action; pause countdown to decide.' }, + 'autodetectrecommended-2': { zh: '自动识别(推荐)', en: 'Auto-detect (Recommended)' }, + 'autoselectplanttfdbjaspar': { zh: '自动选择 PlantTFDB/JASPAR', en: 'Auto-select PlantTFDB/JASPAR' }, + 'autoselectgene2gofirstthenbuiltinorgdbbi': { zh: '自动选择:优先 gene2go,其次内置OrgDb/可用Bioconductor', en: 'Auto-select: gene2go first, then built-in OrgDb/Bioconductor' }, + 'autoselectuploadedbackgroundfirstthenbui': { zh: '自动选择:优先已上传背景,其次内置模式物种/可用Bioconductor', en: 'Auto-select: uploaded background first, then built-in model species/Bioconductor' }, + 'autoperspeciesformultiunifiedtipforsingl': { zh: '自动:多物种按物种,单物种统一Tip', en: 'Auto: per-species for multi; unified Tip for single' }, + 'nltriggerwordscommaseparatedegblastpsequ': { zh: '自然语言触发词,逗号分隔,例如 blastp, 序列比对, SPL blast', en: 'NL trigger words, comma-separated, e.g. blastp, sequence alignment' }, + 'nldescriptiontargetspeciesfamilyreferenc': { zh: '自然语言说明:目标物种、家族、参考物种、比较物种等', en: 'NL description: target species, family, reference, comparative, etc.' }, + 'naturallanguageschedulinginprogress': { zh: '自然语言调度执行中...', en: 'Natural language scheduling in progress...' }, + 'atleasttwosamples': { zh: '至少两个样本', en: 'At least two samples' }, + 'keepatleastonegeneid': { zh: '至少保留一个 gene ID', en: 'Keep at least one gene ID' }, + 'diamond': { zh: '菱形', en: 'Diamond' }, + 'blue': { zh: '蓝色', en: 'Blue' }, + 'protein': { zh: '蛋白', en: 'Protein' }, + 'proteinpep': { zh: '蛋白/PEP', en: 'Protein/PEP' }, + 'proteinfiles': { zh: '蛋白文件', en: 'Protein Files' }, + 'proteinphysicochemicalpropertiessubcellu': { zh: '蛋白理化性质与亚细胞定位', en: 'Protein Physicochemical Properties & Subcellular Localization' }, + 'tablepreview': { zh: '表格预览', en: 'Table Preview' }, + 'expressionmatrix': { zh: '表达矩阵', en: 'Expression Matrix' }, + 'expressionmatrixanalysis': { zh: '表达矩阵分析', en: 'Expression Matrix Analysis' }, + 'seeexecutionlog': { zh: '见执行日志', en: 'See Execution Log' }, + 'plan': { zh: '规划', en: 'Plan' }, + 'planning': { zh: '规划中', en: 'Planning...' }, + 'planningrevisionsuggestions': { zh: '规划修订建议', en: 'Planning Revision Suggestions' }, + 'planningfailed': { zh: '规划失败', en: 'Planning Failed' }, + 'planningrequestfailed': { zh: '规划请求失败', en: 'Planning Request Failed' }, + 'normalizeobservation': { zh: '规范化 observation', en: 'Normalize Observation' }, + 'viewreset': { zh: '视图已重置', en: 'View Reset' }, + 'interpretationcontentcopied': { zh: '解读内容已复制', en: 'Interpretation Content Copied' }, + 'designreviewfailed': { zh: '设计审查失败', en: 'Design Review Failed' }, + 'designreviewneedsadditionalinformation': { zh: '设计审查需要补充信息', en: 'Design Review Needs Additional Information' }, + 'designreview': { zh: '设计审查:', en: 'Design Review:' }, + 'diagnosis': { zh: '诊断', en: 'Diagnosis' }, + 'trialrunning': { zh: '试运行中...', en: 'Trial Running...' }, + 'trialrunfailed': { zh: '试运行失败', en: 'Trial Run Failed' }, + 'trialrunfailedpleasechecklog': { zh: '试运行失败,请查看日志', en: 'Trial run failed; please check log' }, + 'trialrunsuccessful': { zh: '试运行成功', en: 'Trial Run Successful' }, + 'trialrunnotpassed': { zh: '试运行未通过', en: 'Trial Run Not Passed' }, + 'thisidalreadyexists': { zh: '该 ID 已存在', en: 'This ID Already Exists' }, + 'stagealreadyinformallist': { zh: '该阶段已在正式列表中', en: 'Stage Already in Formal List' }, + 'description': { zh: '说明', en: 'Description' }, + 'noteppineedsorthologmappingoftargetstost': { zh: '说明:PPI 需要先把目标基因通过 Ortholog 映射到 STRING 支持物种;Ka/Ks 还需要参考 CDS。你可以先准备/下载参考数据,也可以本次先跳过 PPI/KaKs,继续其它不依赖该数据的分析。', en: 'Note: PPI needs Ortholog mapping of targets to STRING species; Ka/Ks needs ref CDS. Prepare/download ref data or skip PPI/KaKs this time.' }, + 'pleaseuploadvcffirstorfixauthoritativema': { zh: '请先上传 VCF,或修复当前 job 的权威 Manifest。', en: 'Please upload VCF first, or fix authoritative Manifest of current job.' }, + 'pleasebuildtemporarydbfirst': { zh: '请先构建临时库', en: 'Please Build Temporary DB First' }, + 'pleasereviewconfigxlsxdraftfirst': { zh: '请先核对 config.xlsx 草稿。', en: 'Please review config.xlsx draft first.' }, + 'pleasegenerateorloadadraftfirst': { zh: '请先生成或加载草稿', en: 'Please generate or load a draft first' }, + 'pleasegenerateextensionanalysisstagefirs': { zh: '请先生成拓展分析阶段。', en: 'Please generate extension analysis stage first.' }, + 'pleasetrialrunfirsttorecordactualoutput': { zh: '请先试运行以记录真实输出', en: 'Please trial-run first to record actual output' }, + 'pleaseselectpdforminerucontentlistjsonli': { zh: '请先选择 PDF 或 MinerU content_list JSON 文献文件', en: 'Please select PDF or MinerU content_list JSON literature file' }, + 'pleaseselectanimagefirst': { zh: '请先选择一张图片', en: 'Please select an image first' }, + 'pleaseselectanactualrunjobfirst': { zh: '请先选择实际运行 Job。', en: 'Please select an actual run Job first.' }, + 'pleaseselectorcreateaworkingdirectory': { zh: '请先选择或创建 Working Directory', en: 'Please select or create a Working Directory' }, + 'pleaseselectorentermethodologydirfirst': { zh: '请先选择或输入 methodology_dir', en: 'Please select or enter methodology_dir first' }, + 'pleaseselectavalidvcffirst': { zh: '请先选择有效 VCF', en: 'Please Select a Valid VCF First' }, + 'pleasepreviewchangestoapplyfirst': { zh: '请先预览要应用的修改', en: 'Please preview changes to apply first' }, + 'pleasewriterevisionsuggestions': { zh: '请写明修订意见。', en: 'Please write revision suggestions.' }, + 'pleasedescriberevisioncontentherethensen': { zh: '请在这里说明需要修订的内容,然后发送。', en: 'Please describe revision content here, then send.' }, + 'describerevisioninmainconversationinputb': { zh: '请在页面下方的主对话输入框说明需要修订的内容,然后发送。命令和真实执行事实不会被修改。', en: 'Describe revision in main conversation input below; commands and facts won\'t be modified.' }, + 'pleasefillgwasspeciesabbreviationforresu': { zh: '请填写 GWAS 物种简称(用于结果前缀)', en: 'Please fill GWAS species abbreviation (for result prefix)' }, + 'pleasefillmsaalignmentstartendcolumnsors': { zh: '请填写MSA alignment 起始列和终止列,或选择其他模式。', en: 'Please fill MSA alignment start/end columns or select another mode.' }, + 'pleasefillreferencegeneanditsstartendres': { zh: '请填写参考基因及其起止残基编号。', en: 'Please fill reference gene and its start/end residue numbers.' }, + 'fillnameandprovidetestdataorserverpathat': { zh: '请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。', en: 'Fill name and provide test data or server path; at least original command or code.' }, + 'fillnameanduploadoriginalcode': { zh: '请填写名称,并上传原始代码。', en: 'Fill name and upload original code.' }, + 'pleasecheckverificationlog': { zh: '请查看验证日志', en: 'Please Check Verification Log' }, + 'pleasechecktestdataandcommandtemplate': { zh: '请检查测试数据和命令模板', en: 'Please check test data and command template' }, + 'request': { zh: '请求', en: 'Request' }, + 'requestfailed': { zh: '请求失败', en: 'Request Failed' }, + 'requestfailedpleasetryagainlater': { zh: '请求失败,请稍后重试。', en: 'Request failed; please try again later.' }, + 'interpretationservicerequestfailed': { zh: '请求解读服务失败', en: 'Interpretation Service Request Failed' }, + 'requesttimedoutnoanswerwithintimelimitpl': { zh: '请求超时,未能在规定时间内获得回答,请稍后重试。', en: 'Request timed out; no answer within time limit; please try again later.' }, + 'pleasefillatleastonefieldtopreviewchange': { zh: '请至少填写一个要预览修改的字段', en: 'Please fill at least one field to preview changes' }, + 'pleaseselectatleastoneexecutablebbav5sta': { zh: '请至少选择一个可执行的 bbav5 阶段', en: 'Please select at least one executable bbav5 stage' }, + 'pleaseselectatleastonepromoterelementorc': { zh: '请至少选择一个启动子元件或类别。', en: 'Please select at least one promoter element or category.' }, + 'pleaseselectatleastoneplottype': { zh: '请至少选择一种图类型。', en: 'Please select at least one plot type.' }, + 'pleasedetailyourrequirements': { zh: '请补充你的需求。', en: 'Please detail your requirements.' }, + 'pleaseenterfilepath': { zh: '请输入文件路径', en: 'Please Enter File Path' }, + 'pleaseentermethodologyquestion': { zh: '请输入方法学问题', en: 'Please Enter Methodology Question' }, + 'pleaseselectsyntenyanalysistype': { zh: '请选择共线性分析类型', en: 'Please select synteny analysis type' }, + 'pleaseselectthisgwasinput': { zh: '请选择本次 GWAS 输入', en: 'Please select this GWAS input' }, + 'failedtoreadpromoterelements': { zh: '读取启动子元件失败:', en: 'Failed to Read Promoter Elements:' }, + 'failedtoreadextensionanalysisstatus': { zh: '读取拓展分析状态失败', en: 'Failed to Read Extension Analysis Status' }, + 'failedtoreadextensionanalysisdescription': { zh: '读取拓展分析说明失败', en: 'Failed to Read Extension Analysis Description' }, + 'readconfirmationinfofailed': { zh: '读取确认信息失败', en: 'Read Confirmation Info Failed' }, + 'failedtoreadlightweightextensionanalysis': { zh: '读取轻量拓展分析列表失败', en: 'Failed to Read Lightweight Extension Analysis List' }, + 'failedtoreadrunparameters': { zh: '读取运行参数失败', en: 'Failed to Read Run Parameters' }, + 'failedtoreadrundescription': { zh: '读取运行说明失败', en: 'Failed to Read Run Description' }, + 'failedtoreadverificationresults': { zh: '读取验证结果失败', en: 'Failed to Read Verification Results' }, + 'bringup': { zh: '调出', en: 'Bring Up' }, + 'pedigreeinputinvalid': { zh: '谱系图输入无效', en: 'Pedigree Input Invalid' }, + 'qc': { zh: '质控', en: 'QC' }, + 'startalignmentcolumn': { zh: '起始 alignment 列', en: 'Start Alignment Column' }, + 'startsite': { zh: '起始位点', en: 'Start Site' }, + 'startresidue': { zh: '起始残基', en: 'Start Residue' }, + 'path': { zh: '路径', en: 'Path' }, + 'pathcopied': { zh: '路径已复制', en: 'Path Copied' }, + 'skippathview': { zh: '跳过 pathview', en: 'Skip pathview' }, + 'transcriptome': { zh: '转录组', en: 'Transcriptome' }, + 'polltaskstatusfailed': { zh: '轮询任务状态失败', en: 'Poll Task Status Failed' }, + 'loadparseddocument': { zh: '载入解析文档', en: 'Load Parsed Document' }, + 'input': { zh: '输入', en: 'Input' }, + 'entergeneidtosearchandlocate': { zh: '输入 Gene ID 搜索定位', en: 'Enter Gene ID to Search and Locate' }, + 'inputfiles': { zh: '输入文件', en: 'Input Files' }, + 'inputfilesparameters': { zh: '输入文件和参数', en: 'Input Files & Parameters' }, + 'output': { zh: '输出', en: 'Output' }, + 'outputprefix': { zh: '输出前缀', en: 'Output Prefix' }, + 'outputnaming': { zh: '输出命名:', en: 'Output Naming:' }, + 'outputnamingautofromconfigxlsxfilespecie': { zh: '输出命名:按 config.xlsx / 文件物种代码自动生成,不需要填写输出标签', en: 'Output naming: auto from config.xlsx / file species codes' }, + 'outputnamingautoinferredwhenempty': { zh: '输出命名:留空时自动推断', en: 'Output naming: auto-inferred when empty' }, + 'outputfiles': { zh: '输出文件', en: 'Output Files' }, + 'outputlabel': { zh: '输出标签', en: 'Output Label' }, + 'outputtagprefix': { zh: '输出标签 / Prefix', en: 'Output Tag / Prefix' }, + 'runvalue': { zh: '运行值', en: 'Run Value' }, + 'runresults': { zh: '运行结果', en: 'Run Results' }, + 'runresultreview': { zh: '运行结果核对', en: 'Run Result Review' }, + 'returntomodify': { zh: '返回修改', en: 'Return to Modify' }, + 'whatisthemethodologyworkflowofthispaper': { zh: '这个文献的方法学流程是什么?', en: 'What is the methodology workflow of this paper?' }, + 'thiswillstopandrestartceleryworkerbacken': { zh: '这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。', en: 'This will stop and restart Celery worker; backend rejects if tasks running.' }, + 'thiswillsubmitprojectrestartscriptpagema': { zh: '这会提交项目重启脚本,当前页面可能短暂断开。', en: 'This will submit project restart script; page may briefly disconnect.' }, + 'sampleqcfinalplotboundaryissueupstreamal': { zh: '这是 sample QC 最后的绘图边界问题,不代表前面的比对、定量或 VST 计算全部失败。', en: 'Sample QC final plot boundary issue; upstream alignment/quantification/VST not all failed.' }, + 'phylogenetictreerevisionparameters': { zh: '进化树图修订参数', en: 'Phylogenetic Tree Revision Parameters' }, + 'phylogenetictreereplotfailed': { zh: '进化树图重绘失败:', en: 'Phylogenetic Tree Replot Failed:' }, + 'phylogenetictreereplotcompleteswitchedto': { zh: '进化树图重绘完成,已切换到新图', en: 'Phylogenetic tree replot complete; switched to new figure' }, + 'phylogenetictreereplotsubmittedtaskid': { zh: '进化树图重绘已提交:task_id=', en: 'Phylogenetic tree replot submitted: task_id=' }, + 'treeoutputlayoutbothoutputsrectangularan': { zh: '进化树输出布局。both 会同时输出矩形树和圈图。', en: 'Tree output layout; both outputs rectangular and circle plots.' }, + 'selectabredreltrmbsetc': { zh: '选择 ABRE / DRE / LTR / MBS 等', en: 'Select ABRE / DRE / LTR / MBS etc.' }, + 'afterselectingdlrecommendedrefdatasystem': { zh: '选择“下载推荐参考数据”后,系统会先下载参考 PEP/CDS,再重新检查并由 Ortholog 生成映射。', en: 'After selecting DL recommended ref data, system downloads ref PEP/CDS, re-checks, generates Ortholog mapping.' }, + 'whenselectingskipthistimeppikaksmarkedpa': { zh: '选择“本次先跳过”时,PPI/KaKs 会标记为暂停,其它阶段继续执行。', en: 'When selecting skip this time, PPI/KaKs marked paused; other stages continue.' }, + 'selectotherextractionresult': { zh: '选择其他提取结果', en: 'Select Other Extraction Result' }, + 'selectanalysisstageselectstage': { zh: '选择分析阶段 / Select stage', en: 'Select Analysis Stage / Select stage' }, + 'selecttool': { zh: '选择工具', en: 'Select Tool' }, + 'selectcompletedextensionstage': { zh: '选择已完成撰写的拓展阶段', en: 'Select Completed Extension Stage' }, + 'selectorentergeneid': { zh: '选择或输入基因ID', en: 'Select or Enter Gene ID' }, + 'selectclassificationidentificationtarget': { zh: '选择本次分类/鉴定对象', en: 'Select classification/identification targets for this run' }, + 'selectanalysistypestoshowincomboplotuses': { zh: '选择要在 combo 图中展示的分析类型;单类型图请使用对应阶段已有结果。', en: 'Select analysis types to show in combo plot; use stage results for single-type plots.' }, + 'perrowfieldleaveemptyforgenomewidefillfo': { zh: '逐行字段;全基因组资源留空,已知家族 seed 文件才填写,如 NAC/WRKY', en: 'Per-row field; leave empty for genome-wide; fill for known family seed files' }, + 'usuallyleaveempty': { zh: '通常留空', en: 'Usually Leave Empty' }, + 'configbulkeditarea': { zh: '配置批量修订区', en: 'Config Bulk Edit Area' }, + 'configfile': { zh: '配置文件', en: 'Config File' }, + 'configtable': { zh: '配置表', en: 'Config Table' }, + 'configtableautodetection': { zh: '配置表自动识别', en: 'Config Table Auto-detection' }, + 'yeastsce': { zh: '酵母 Yeast (sce', en: 'Yeast (sce)' }, + 'restartcelery': { zh: '重启 Celery', en: 'Restart Celery' }, + 'restartproject': { zh: '重启项目', en: 'Restart Project' }, + 'redrawingfeatureunderdevelopment': { zh: '重新绘图功能开发中...', en: 'Re-drawing feature under development...' }, + 'redetect': { zh: '重新识别', en: 'Re-detect' }, + 'resetview-2': { zh: '重置视图', en: 'Reset View' }, + 'link': { zh: '链接', en: 'Link' }, + 'librarystrandedness': { zh: '链特异性', en: 'Library Strandedness' }, + 'librarystrandednessunchangededitable': { zh: '链特异性不改/可输入', en: 'Library Strandedness (Unchanged/Editable)' }, + 'error': { zh: '错误', en: 'Error' }, + 'stage-2': { zh: '阶段', en: 'Stage' }, + 'stagepausedawaitinguserconfirmation': { zh: '阶段暂停,等待用户确认', en: 'Stage paused, awaiting user confirmation' }, + 'hide': { zh: '隐藏', en: 'Hide' }, + 'needsconfirmation': { zh: '需确认', en: 'Needs Confirmation' }, + 'preparenearsourcehomologousrefdatafirstt': { zh: '需要先准备近源/同源参考物种数据,再由 Ortholog 生成目标到参考物种映射。', en: 'Prepare near-source/homologous ref data first, then generate target-to-ref mapping via Ortholog.' }, + 'needtorunidentifystagefirst': { zh: '需要先运行 Identify 阶段', en: 'Need to run Identify stage first' }, + 'refdataandorthologmappingneeded': { zh: '需要准备参考数据和 Ortholog 映射', en: 'Ref data and Ortholog mapping needed' }, + 'autoannotationneededtogenerateenrichment': { zh: '需要自动注释生成富集背景', en: 'Auto-annotation needed to generate enrichment background' }, + 'needtosupplement': { zh: '需要补充', en: 'Need to Supplement' }, + 'needs': { zh: '需要:', en: 'Needs:' }, + 'leaveemptyfornontimeexperiments': { zh: '非时间实验留空', en: 'Leave empty for non-time experiments' }, + 'project': { zh: '项目', en: 'Project' }, + 'projectrestartnotsubmitted': { zh: '项目重启未提交', en: 'Project Restart Not Submitted' }, + 'projectrestartscriptsubmitted': { zh: '项目重启脚本已提交', en: 'Project Restart Script Submitted' }, + 'preview-2': { zh: '预览', en: 'Preview' }, + 'previewtotable': { zh: '预览到表格', en: 'Preview to Table' }, + 'previewcommands': { zh: '预览命令', en: 'Preview Commands' }, + 'verifying': { zh: '验证中...', en: 'Verifying...' }, + 'verificationfailed': { zh: '验证失败', en: 'Verification Failed' }, + 'verificationfailedpleasecheckbackendlog': { zh: '验证失败,请查看后端日志', en: 'Verification failed; please check backend log' }, + 'verificationsuccessful': { zh: '验证成功', en: 'Verification Successful' }, + 'verificationsuccessfulreadytopublish': { zh: '验证成功,可发布', en: 'Verification Successful; Ready to Publish' }, + 'verificationsuccessfulstartingpublish': { zh: '验证成功,开始发布...', en: 'Verification successful; starting publish...' }, + 'verificationnotpassed': { zh: '验证未通过', en: 'Verification Not Passed' }, + 'highpriority': { zh: '高优先级', en: 'High Priority' }, + 'black': { zh: '黑色', en: 'Black' }, + 'defaultvalue': { zh: '默认值', en: 'Default Value' }, + 'bydefaultgeneratesallspeciesoverviewandp': { zh: '默认生成所有物种总图和两两共线性图。', en: 'By default generates all-species overview and pairwise synteny plots.' }, + 'tabdelimited': { zh: '(制表符分隔)', en: '(Tab-delimited)' }, + 'nofileoutput': { zh: '(无文件输出)', en: '(No File Output)' }, + 'noproteinfastadetected': { zh: '(未检测到蛋白FASTA)', en: '(No Protein FASTA Detected)' }, + 'prerequisitegate': { zh: ';前置/门控', en: '; Pre-requisite / Gate' }, + 'parallelizable': { zh: ';可并行', en: '; Parallelizable' }, + 'hasresultsorhandledbydependency': { zh: ';已有结果或由依赖链处理', en: '; Has Results or Handled by Dependency' }, + 'searchresults': { zh: '📄 检索结果', en: 'Search Results' }, + 'filterallresults': { zh: '📄 过滤全库页', en: 'Filter All Results' }, + 'cumulativeprogress': { zh: '📈 当前累计进展', en: 'Cumulative Progress' }, + 'expressiondatasourceselectone': { zh: '📊 表达量数据来源(Select一种):', en: 'Expression Data Source (Select One):' }, + 'nextstepsuggestions': { zh: '📋 下一步建议', en: 'Next Step Suggestions' }, + 'requiredfiles': { zh: '📋 所需文件:', en: 'Required Files:' }, + 'uploadfilestoinputdirectory': { zh: '📤 Upload Files到input目录', en: 'Upload Files to Input Directory' }, + 'needtouploadfiles': { zh: '📤 需要Upload Files:', en: 'Need to Upload Files:' }, + 'inputfilerequirementsgzcompressionsuppor': { zh: '📦 Input文件要求(支持.gz压缩):', en: 'Input File Requirements (.gz Compression Supported):' }, + 'subfamilylabelsconfirmedpullingpostconfi': { zh: '🔁 亚家族标签已确认,正在拉取确认后常规分析规划...', en: 'Subfamily labels confirmed; pulling post-confirmation analysis plan...' }, + 'subfamilylabelsconfirmedreplanningpostco': { zh: '🔁 亚家族标签已确认,正在重新规划确认后常规基因家族分析...', en: 'Subfamily labels confirmed; re-planning post-confirmation gene family analysis...' }, + 'reload': { zh: '🔄 重新加载', en: 'Reload' }, + 'scientificdiscoverycandidatehypothesis': { zh: '🔎 科学发现 / 候选假设', en: 'Scientific Discovery / Candidate Hypothesis' }, + 'useexistingdatapathautocreatesymlink': { zh: '🔗 使用已有数据路径(自动创建软链接', en: 'Use Existing Data Path (Auto-create Symlink)' }, + 'rnaseqdatasource': { zh: '🔬 RNA-seq 数据来源:', en: 'RNA-seq Data Source:' }, + 'biologicalstory': { zh: '🧬 生物学故事', en: 'Biological Story' }, + 'evidencecard': { zh: '🧷 证据卡', en: 'Evidence Card' }, + 'err-data-format': { zh: '数据格式错误: nodes 未定义或不是数组', en: 'Data format error: nodes is undefined or not an array' }, + 'warn-edges-undefined': { zh: '数据警告: edges 未定义,使用空数组', en: 'Data warning: edges undefined, using empty array' }, + + // ========== agent_status_component additions ========== + 'msg-load-task-failed': { zh: '加载任务失败:', en: 'Failed to load tasks:' }, + 'msg-check-task-status-failed': { zh: '检查任务状态失败:', en: 'Failed to check task status:' }, + + // ========== Auto-generated from scan ========== + 'label-gobioconductorinterproscankegg': { zh: '按需补背景 + 富集(GO 优先Bioconductor,回退InterProScan;KEGG 用KofamScan)', en: 'Add background + enrichment (GO via Bioconductor, fallback InterProScan; KEGG via KofamScan)' }, + 'label-gobioconductorkeggkofamscan': { zh: '按需补背景 + 继续富集(GO优先Bioconductor,KEGG用KofamScan)', en: 'Add background + continue enrichment (GO via Bioconductor, KEGG via KofamScan)' }, + 'ph-athhsaosa': { zh: '如 ath / hsa / osa', en: 'e.g. ath / hsa / osa' }, + 'ph-atos': { zh: '如 At / Os', en: 'e.g. At / Os' }, + 'ph-ftosprefix': { zh: '如 Ft / Os,留空取 prefix 第一段', en: 'e.g. Ft / Os, leave empty for first segment of prefix' }, + 'ph-geneid': { zh: '检索 gene ID', en: 'Search gene ID' }, + 'ph-oryzasativa': { zh: '如 Oryza sativa', en: 'e.g. Oryza sativa' }, + 'ph-osaathzma': { zh: '如 osa / ath / zma', en: 'e.g. osa / ath / zma' }, + 'txt-': { zh: '📚 参考文献', en: '📚 References' }, + 'txt--': { zh: '- 同源基因对', en: '- Orthologous gene pairs' }, + 'txt-0claim': { zh: '0">🧬 Claim级结果解读', en: '🧬 Claim-Level Result Interpretation' }, + 'txt-agent': { zh: '结果 agent', en: 'Result agent' }, + 'txt-id': { zh: '按ID提取序列', en: 'Extract sequence by ID' }, + 'txt-rag': { zh: '🧪 证据质量 / RAG解读审计', en: '🧪 Evidence Quality / RAG Audit' }, + 'txt-self-rag': { zh: '📝 Self-RAG反思', en: '📝 Self-RAG Reflection' }, + 'txt-tip': { zh: '💡 使用小Tip', en: '💡 Quick Tips' }, + + + + // === Fix empty t() === + 'extract-methodology': { zh: '提取方法学', en: 'Extract' }, + 'run-published': { zh: '运行已发布', en: 'Run Published' }, + 'more-conversations': { zh: '更多会话', en: 'More Conversations' }, + 'new-session': { zh: '新建会话', en: 'New Session' }, + 'new-session-title': { zh: '新会话', en: 'New Session' }, + 'generate-and-publish': { zh: '生成并发布', en: 'Generate & Publish' }, + 'stop-waiting': { zh: '停止等待', en: 'Stop Waiting' }, + + 'current-display-mode-analysis-or-rag': { zh: '当前显示模式: analysis or rag', en: 'Current display mode: analysis or rag' }, + + // === Bulk additions === + 'refresh-records': { zh: '刷新记录', en: 'Refresh Records' }, + 'more-sessions': { zh: '更多会话', en: 'More Sessions' }, + 'new': { zh: '新建', en: 'New' }, + 'restart-celery': { zh: '重启 Celery', en: 'Restart Celery' }, + 'restart-project': { zh: '重启项目', en: 'Restart Project' }, + 'current-session': { zh: '当前会话', en: 'Current Session' }, + 'no-parameters-identified': { zh: '未识别到参数', en: 'No parameters identified' }, + 'please-trial-run-to-record-output': { zh: '请先试运行以记录真实输出', en: 'Please run a trial first to record actual output' }, + 'no-configurable-parameters': { zh: '此拓展阶段没有可配置参数', en: 'No configurable parameters for this extension stage' }, + 'current-job': { zh: '当前 job', en: 'Current job' }, + 'latin-name-alias': { zh: '拉丁名/别名(文章复现必填)', en: 'Latin name/alias (required for reproduction)' }, + 'revision': { zh: '修订', en: 'Revision' }, + 'search-failed': { zh: '检索失败', en: 'Search failed' }, + 'matched': { zh: '匹配', en: 'Matched' }, + 'above': { zh: '上方', en: 'Above' }, + 'primary-reference-species': { zh: '主参考物种', en: 'Primary reference species' }, + 'reference-config-main-entry': { zh: '参考配置主入口', en: 'Reference config main entry' }, + 'will-be-written-to': { zh: '会分别写入', en: 'Will be written to' }, + 'row-level-override-only': { zh: '仅作为行级高级覆盖或旧配置兼容', en: 'Row-level override / legacy config only' }, + 'usually-leave-empty': { zh: '通常留空', en: 'Usually leave empty' }, + 'species-code-short': { zh: '物种简称', en: 'Species code (short)' }, + 'required-for-article-reproduction': { zh: '文章复现时为必填项', en: 'Required for article reproduction' }, + 'required-only-for-known-family-ref': { zh: '只在已知家族参考序列行必填', en: 'Required only for known family reference rows' }, + 'sequencing-run-srr': { zh: '测序运行号/SRR号', en: 'Sequencing run / SRR ID' }, + 'used-to-match': { zh: '用于匹配', en: 'Used to match' }, + 'sample-name-in-info-table': { zh: '样本信息表中的样本名', en: 'Sample name in info table' }, + 'sample-key': { zh: '样本键', en: 'Sample key' }, + 'paired-end-sequencing-file': { zh: '双端测序文件', en: 'Paired-end sequencing file' }, + 'indicates-treatment-tissue-group': { zh: '表示处理/组织/分组', en: 'Indicates treatment / tissue / group' }, + 'biological-replicate-number': { zh: '生物学重复编号', en: 'Biological replicate number' }, + 'only-for-time-series': { zh: '只在时间序列或明确发育时期实验中填写', en: 'Only for time series or developmental stage experiments' }, + 'leave-empty-for-tissue-comparison': { zh: '普通组织比较请留空', en: 'Leave empty for tissue comparison' }, + 'must-fill-per-library-instructions': { zh: '必须按建库说明填写', en: 'Must fill per library instructions' }, + 'group-role-for-marking': { zh: '分组角色用于标记', en: 'Group role for marking' }, + 'de-generates-comparisons': { zh: 'DE 会按设计生成对照比较或多组两两比较', en: 'DE generates control comparisons or pairwise group comparisons' }, + 'view-knowledge-retrieval-process': { zh: '查看本次知识检索过程', en: 'View knowledge retrieval process' }, + 'searching-knowledge-base': { zh: '正在知识库中查找相关内容', en: 'Searching knowledge base for related content' }, + 'default-thread-description': { zh: 'Default 是新提交任务使用的线程数', en: 'Default — thread count for new tasks' }, + 'max-stage-limit': { zh: 'Max 是该阶段上限', en: 'Max — upper limit for this stage' }, + 'increase-default-syncs-max': { zh: '直接提高 Default 时会同步提高 Max', en: 'Increasing Default will also raise Max' }, + 'sample-parallelism-affects-qc-align': { zh: '样本并行数只影响 QC/Align 的样本级并发', en: 'Sample parallelism only affects QC/Align sample concurrency' }, + 'original-filename-must-match': { zh: '原文件名必须匹配上传文件名', en: 'Original filename must match uploaded filename' }, + + // === R2 === + + 'stopped-waiting-no-answer': { zh: '已停止等待,本次未继续获取回答', en: 'Stopped waiting, no response received.' }, + + + 'text': { zh: '文本', en: 'Text' }, + 'background-knowledge': { zh: '背景知识', en: 'Background knowledge' }, + 'none': { zh: '无', en: 'None' }, + 'current-cumulative-findings': { zh: '当前累计发现', en: 'Current cumulative findings' }, + 'num-connections': { zh: ' 个', en: ' ' }, + 'or': { zh: '或', en: 'or' }, + + // === index_ml.html 模板文本(第5批) === + 'building-extension-stage': { zh: '生成并发布', en: 'Build & Publish' }, + 'running-published': { zh: '运行已发布', en: 'Run Published' }, + 'no-results-yet': { zh: '暂无结果', en: 'No Results' }, + 'unidentified-params': { zh: '未识别到参数', en: 'No Parameters Identified' }, + 'trial-first-for-output': { zh: '请先试运行以记录真实输出', en: 'Please Run a Trial First to Record Actual Output' }, + 'no-config-params': { zh: '此拓展阶段没有可配置参数', en: 'No Configurable Parameters' }, + 'searching-knowledge-base': { zh: '正在知识库中查找相关内容……', en: 'Searching Knowledge Base...' }, + 'processing-request': { zh: '正在处理请求...', en: 'Processing Request...' }, + 'uploaded-files': { zh: '已上传文件', en: 'Uploaded Files' }, + 'not-selected': { zh: '未选择', en: 'Not Selected' }, + 'not-loaded-gene-id': { zh: '未加载到 gene ID;请确认 PPI 已生成输入基因列表', en: 'Gene ID not loaded; please confirm PPI has generated input gene list' }, + 'searching-knowledge-base-full': { zh: '正在知识库中查找相关内容……', en: 'Searching Knowledge Base for Related Content...' }, + 'selectfile': { zh: '选择文件', en: 'Select File' }, + 'upload-files': { zh: '上传文件', en: 'Upload Files' }, + + // === tip popup steps === + 'tip-analysis-step1': { zh: '1.选择输入框上方"Analysis"', en: '1. Select "Analysis" above the input box' }, + 'tip-analysis-step2': { zh: '2.粘贴数据位置', en: '2. Paste data location' }, + 'tip-analysis-step3': { zh: '3.核对配置文件', en: '3. Verify configuration file' }, + 'tip-analysis-step4': { zh: '4.执行数据准备', en: '4. Execute data preparation' }, + 'tip-analysis-step5': { zh: '5.执行XX物种的亚家族分类', en: '5. Execute subfamily classification for XX species' }, + 'tip-analysis-step6': { zh: '6.执行XX物种的基因家族分析', en: '6. Execute gene family analysis for XX species' }, + 'tip-analysis-step7': { zh: '7.执行rnaseq分析', en: '7. Execute RNA-seq analysis' }, + 'tip-repro-step1': { zh: '1.点击"浏览"上传文献', en: '1. Click "Browse" to upload literature' }, + 'tip-repro-step2': { zh: '2.点击提取方法学', en: '2. Click "Extract Methodology"' }, + 'tip-repro-step3': { zh: '3.核对分析步骤', en: '3. Verify analysis steps' }, + 'tip-repro-step4': { zh: '4.点击保存方案', en: '4. Click "Save Plan"' }, + 'tip-repro-step5': { zh: '5.上传数据', en: '5. Upload data' }, + 'tip-repro-step6': { zh: '6.发送"按已保存的方法学方案继续执行"', en: '6. Send "Continue with saved methodology plan"' }, + 'tip-rag-step1': { zh: '1.选择输入框上方"RAG"', en: '1. Select "RAG" above the input box' }, + 'tip-rag-step2': { zh: '2.输入问题', en: '2. Enter your question' }, + 'tip-rag-step3': { zh: '3.点击"知识图谱"查看关系', en: '3. Click "Knowledge Graph" to view relationships' }, + + // === Additional ElMessage keys === + 'msg-create-failed': { zh: '创建失败', en: 'Creation failed' }, + 'msg-create-wd-success': { zh: '已创建新Working Directory', en: 'New Working Directory created' }, + 'msg-found-node': { zh: '找到节点', en: 'Found node' }, + 'msg-members-rebuilt': { zh: '已按 {count} 个成员重建 final_members。{rerun}', en: 'Rebuilt final_members with {count} members. {rerun}' }, + 'msg-ppi-reset-done': { zh: 'PPI Reset 已完成', en: 'PPI Reset completed' }, + 'msg-selected-code-files': { zh: '已选择 {count} 个原始代码文件', en: 'Selected {count} original code files' }, + 'msg-selected-data-files': { zh: '已选择 {count} 个测试数据文件', en: 'Selected {count} test data files' }, + 'msg-subfamily-confirmed': { zh: '已确认 {count} 个亚家族标签。{msg}', en: 'Confirmed {count} subfamily labels. {msg}' }, + 'msg-switch-failed': { zh: '切换失败', en: 'Switch failed' }, + 'msg-switch-wd-success': { zh: '已切换到Working Directory', en: 'Switched to Working Directory' }, + 'msg-switched-to': { zh: '切换到', en: 'Switched to' }, + 'msg-task-info': { zh: '任务', en: 'Task' }, + 'msgbox-ppi-tech-ref': { zh: 'PPI 的技术参考必须来自已确认的 STRING 候选;项目参考物种不会写入 PPI owner。', en: 'PPI technical reference must come from confirmed STRING candidates; project reference species will not be written to PPI owner.' }, + 'msgbox-restart-celery': { zh: '这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。', en: 'This will stop the Celery worker for the current profile and restart it. If tasks are running, the backend will refuse by default.' }, + 'msgbox-restart-project': { zh: '这会提交项目重启脚本,当前页面可能短暂断开。', en: 'This will submit the project restart script. The current page may briefly disconnect.' }, + + // === ElMessage toast messages === + 'msg-batch-already-running': { zh: '当前已有批量任务在执行,请等待完成后再提交方法学流程', en: 'A batch task is already running; wait for it to finish before submitting methodology pipeline' }, + 'msg-build-extension-first': { zh: '请先生成拓展分析阶段。', en: 'Please build the extension stage first.' }, + 'msg-build-temp-library-first': { zh: '请先构建临时库', en: 'Please build temporary library first' }, + 'msg-celery-restarted': { zh: 'Celery 已重启并完成队列检查', en: 'Celery restarted and queue check completed' }, + 'msg-clear-current-session': { zh: '已Clear当前会话', en: 'Current session cleared' }, + 'msg-combo-min-two-types': { zh: 'Combo 修订至少选择两个类型。', en: 'Combo revision requires at least two types selected.' }, + 'msg-combo-output-label-error': { zh: '无法确定 combo 输出标签,请先选择 combo 图。', en: 'Cannot determine combo output label; select a combo plot first.' }, + 'msg-combo-replot-done': { zh: 'Combo 图修订完成,已切换到新图', en: 'Combo revision completed, switched to new image' }, + 'msg-combo-replot-error': { zh: 'Combo 图修订失败', en: 'Combo revision failed' }, + 'msg-confirm-saved': { zh: '确认已保存', en: 'Confirmation saved' }, + 'msg-content-copied': { zh: '解读内容已复制', en: 'Content copied' }, + 'msg-copy-failed': { zh: '复制失败', en: 'Copy failed' }, + 'msg-create-session-failed': { zh: '创建会话失败', en: 'Failed to create session' }, + 'msg-create-wd-error': { zh: '获取Working Directory列表失败', en: 'Failed to get Working Directory list' }, + 'msg-delete-session-failed': { zh: '删除会话失败', en: 'Failed to delete session' }, + 'msg-design-hint': { zh: '设计提示', en: 'Design hint' }, + 'msg-diagnosis-closed': { zh: '已关闭诊断提示。', en: 'Diagnosis hint closed.' }, + 'msg-domain-confirm-error': { zh: 'Domain确认失败', en: 'Domain confirmation failed' }, + 'msg-download-demo-unavailable': { zh: '示例下载功能当前不可用', en: 'Download demo feature is currently unavailable' }, + 'msg-enter-file-path': { zh: '请输入文件路径', en: 'Please enter a file path' }, + 'msg-enter-methodology-question': { zh: '请输入方法学问题', en: 'Please enter methodology question' }, + 'msg-error-log-cleared': { zh: '错误日志已清空', en: 'Error log cleared' }, + 'msg-evidence-supplement-error': { zh: '补充证据失败', en: 'Evidence supplement failed' }, + 'msg-extension-built': { zh: '拓展分析阶段已生成并发布', en: 'Extension analysis stage built and published' }, + 'msg-extension-executed': { zh: '拓展分析阶段执行完成', en: 'Extension analysis stage execution completed' }, + 'msg-extension-execution-warn': { zh: '拓展分析阶段执行未成功,请查看结果日志', en: 'Extension analysis execution unsuccessful; check result logs' }, + 'msg-extension-published': { zh: '拓展分析阶段已发布', en: 'Extension stage published' }, + 'msg-fill-at-least-one-field': { zh: '请至少填写一个要预览修改的字段', en: 'Please fill at least one field to preview the change' }, + 'msg-fill-each-input-file': { zh: '请填写每个输入文件。', en: 'Please fill in each input file.' }, + 'msg-fill-gwas-species': { zh: '请填写 GWAS 物种简称(用于结果前缀)', en: 'Please fill in GWAS species code (used for result prefix)' }, + 'msg-fill-name-and-code': { zh: '请填写名称,并上传原始代码。', en: 'Please fill name and upload original code.' }, + 'msg-fill-name-and-code-or-data': { zh: '请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。', en: 'Please fill name and provide test data file or server path; original command and code — at least one required.' }, + 'msg-fill-revision-comments': { zh: '请写明修订意见。', en: 'Please specify revision comments.' }, + 'msg-fix-applied': { zh: '✅ 已应用修复方案,继续执行...', en: '✅ Fix applied, continuing...' }, + 'msg-fix-apply-failed': { zh: '❌ 应用修复失败', en: '❌ Failed to apply fix' }, + 'msg-fix-skipped': { zh: '⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。', en: '⏸ Fix skipped; downstream blocked, later stages paused.' }, + 'msg-generate-or-load-draft-first': { zh: '请先生成或加载草稿', en: 'Please generate or load a draft first' }, + 'msg-history-cleared': { zh: '对话历史已清空', en: 'Chat history cleared' }, + 'msg-id-already-exists': { zh: '该 ID 已存在', en: 'This ID already exists' }, + 'msg-json-loaded-to-console': { zh: 'JSON数据已加载到控制台', en: 'JSON data loaded to console' }, + 'msg-keep-at-least-one-gene': { zh: '至少保留一个 gene ID', en: 'Keep at least one gene ID' }, + 'msg-kg-load-error': { zh: '加载图谱失败', en: 'Failed to load graph' }, + 'msg-kg-loaded': { zh: '知识图谱加载完成', en: 'Knowledge graph loaded' }, + 'msg-load-subgraph-first': { zh: '请先通过问答加载子图,或点击「重新加载」', en: 'Please load a subgraph via Q&A first, or click "Reload"' }, + 'msg-member-table-load-error': { zh: '成员确认表加载失败', en: 'Member confirmation table load failed' }, + 'msg-methodology-plan-saved': { zh: '方法学方案已保存,可先准备数据,稍后再按该方案执行', en: 'Methodology plan saved; prepare data and execute per the plan later' }, + 'msg-msa-fill-alignment': { zh: '请填写MSA alignment 起始列和终止列,或选择其他模式。', en: 'Please fill MSA alignment start/end columns, or select another mode.' }, + 'msg-msa-fill-ref-gene': { zh: '请填写参考基因及其起止残基编号。', en: 'Please fill reference gene and residue start/end numbers.' }, + 'msg-msa-output-label-error': { zh: '无法确定MSA输出标签,请先选择对应图片或填写输出标签。', en: 'Cannot determine MSA output label; select a corresponding image or fill output label first.' }, + 'msg-msa-replot-done': { zh: 'MSA区间重绘完成', en: 'MSA region replot completed' }, + 'msg-msa-replot-error': { zh: 'MSA区间重绘失败', en: 'MSA region replot failed' }, + 'msg-msg-not-saved': { zh: '当前消息未成功写入会话历史,刷新后可能丢失', en: 'Message not saved to session history, may be lost on refresh' }, + 'msg-network-error-retry': { zh: '❌ 网络错误,请重试', en: '❌ Network error, please retry' }, + 'msg-new-session-created': { zh: '新会话已创建,请选择或创建 Working Directory', en: 'New session created, please select or create a Working Directory' }, + 'msg-no-job-id': { zh: '当前没有可用 job_id', en: 'No available job_id' }, + 'msg-no-matching-draft-rows': { zh: '没有匹配的草稿行', en: 'No matching draft rows' }, + 'msg-no-members-filtered': { zh: '当前筛选没有成员,未重建 final_members。', en: 'No members match current filter; final_members not rebuilt.' }, + 'msg-no-subfamily-labels': { zh: '没有可确认的亚家族标签。', en: 'No subfamily labels to confirm.' }, + 'msg-node-not-found': { zh: '未找到匹配的节点', en: 'No matching node found' }, + 'msg-not-advanced-design': { zh: '未生成高级设计', en: 'No advanced design generated' }, + 'msg-path-copied': { zh: '路径已复制', en: 'Path copied' }, + 'msg-plan-request-error': { zh: '规划请求失败', en: 'Planning request failed' }, + 'msg-please-input-content': { zh: '请Input内容', en: 'Please input content' }, + 'msg-pre-publish-validation-passed': { zh: '发布前验收已通过', en: 'Pre-publish validation passed' }, + 'msg-preview-first': { zh: '请先预览要应用的修改', en: 'Please preview the changes first' }, + 'msg-project-restart-not-configured': { zh: '未配置项目重启脚本', en: 'Project restart script not configured' }, + 'msg-project-restart-submitted': { zh: '项目重启脚本已提交', en: 'Project restart script submitted' }, + 'msg-promoter-plot-done': { zh: '启动子自定义绘图完成', en: 'Custom promoter plot completed' }, + 'msg-promoter-plot-error': { zh: '启动子自定义绘图失败', en: 'Custom promoter plot failed' }, + 'msg-promoter-prefix-error': { zh: '无法确定启动子输出前缀。', en: 'Cannot determine promoter output prefix.' }, + 'msg-promoter-read-error': { zh: '读取启动子元件失败', en: 'Failed to read promoter elements' }, + 'msg-promoter-select-element': { zh: '请至少选择一个启动子元件或类别。', en: 'Please select at least one promoter element or category.' }, + 'msg-rag-interpretation-refreshed': { zh: 'RAG解读已刷新', en: 'RAG interpretation refreshed' }, + 'msg-rag-request-error': { zh: 'RAG请求失败', en: 'RAG request failed' }, + 'msg-read-file-error': { zh: '读取文件失败', en: 'Failed to read file' }, + 'msg-replot-in-development': { zh: '重新绘图功能开发中...', en: 'Replot feature in development...' }, + 'msg-request-failed': { zh: '请求失败', en: 'Request failed' }, + 'msg-sandbox-built': { zh: '方法学 sandbox 已构建', en: 'Methodology sandbox built' }, + 'msg-select-actual-job': { zh: '请先选择实际运行 Job。', en: 'Please select an actual running job first.' }, + 'msg-select-executable-stage': { zh: '请至少选择一个可执行的 bbav5 阶段', en: 'Please select at least one executable bbav5 stage' }, + 'msg-select-image-first': { zh: '请先选择一张图片', en: 'Please select an image first' }, + 'msg-select-methodology-dir': { zh: '请先选择或输入 methodology_dir', en: 'Please select or enter methodology_dir' }, + 'msg-select-or-create-wd': { zh: '请先选择或创建 Working Directory', en: 'Please select or create a Working Directory' }, + 'msg-select-pdf-or-mineru': { zh: '请先选择 PDF 或 MinerU content_list JSON 文献文件', en: 'Please select a PDF or MinerU content_list JSON file first' }, + 'msg-select-plot-type': { zh: '请至少选择一种图类型。', en: 'Please select at least one plot type.' }, + 'msg-select-stage-first': { zh: '请先SelectAnalysis Stage', en: 'Please select an analysis stage first' }, + 'msg-select-valid-vcf': { zh: '请先选择有效 VCF', en: 'Please select a valid VCF first' }, + 'msg-session-deleted': { zh: '会话已删除', en: 'Session deleted' }, + 'msg-split-species-no-results': { zh: '当前选择为按物种分别分析,但后端没有返回完整的分物种亚家族结果;将尝试打开可用的联合结果。', en: 'Split-species mode selected but backend returned no per-species subfamily results; trying available combined results.' }, + 'msg-stage-added-to-list': { zh: '已加入正式阶段列表,并默认纳入本次执行', en: 'Added to formal stage list and included in this execution by default' }, + 'msg-stage-already-in-list': { zh: '该阶段已在正式列表中', en: 'This stage is already in the formal list' }, + 'msg-stop-extension-error': { zh: '停止拓展分析失败', en: 'Failed to stop extension analysis' }, + 'msg-stop-extension-requested': { zh: '已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。', en: 'Stop requested for current extension; status will keep polling until process exits.' }, + 'msg-string-link-content': { zh: 'STRING 链接文件内容', en: 'STRING link file content' }, + 'msg-subfamily-confirm-data-error': { zh: '亚家族确认数据加载失败', en: 'Subfamily confirmation data load failed' }, + 'msg-subfamily-confirm-error': { zh: '亚家族确认失败', en: 'Subfamily confirmation failed' }, + 'msg-subfamily-not-auto-open': { zh: '亚家族预测已完成,但未能自动打开确认表;请发送"调出亚家族确认表"手动打开。', en: 'Subfamily prediction completed but confirmation table could not auto-open; send "调出亚家族确认表" to open manually.' }, + 'msg-subfamily-ref-not-written': { zh: '亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种', en: 'Subfamily reference species not written to current job config; methodology local plan generated; verify reference species on batch confirmation page' }, + 'msg-synteny-output-label-error': { zh: '无法确定种内共线性输出标签,请先选择 Circos 图。', en: 'Cannot determine synteny output label; select a Circos plot first.' }, + 'msg-synteny-replot-done': { zh: '种内共线性 Circos 重绘完成,已切换到新图', en: 'Intraspecific synteny Circos replot completed, switched to new image' }, + 'msg-synteny-replot-error': { zh: '种内共线性 Circos 重绘失败', en: 'Intraspecific synteny Circos replot failed' }, + 'msg-table-read-error': { zh: '读取表格失败', en: 'Failed to read table' }, + 'msg-tree-output-label-error': { zh: '无法确定进化树输出标签,请先选择树图或填写输出标签。', en: 'Cannot determine tree output label; select a tree plot or fill output label first.' }, + 'msg-tree-replot-done': { zh: '进化树图重绘完成,已切换到新图', en: 'Tree replot completed, switched to new image' }, + 'msg-tree-replot-error': { zh: '进化树图重绘失败', en: 'Tree replot failed' }, + 'msg-trial-failed': { zh: '试运行未通过', en: 'Trial run failed' }, + 'msg-trial-succeeded': { zh: '试运行成功', en: 'Trial run succeeded' }, + 'msg-upload-error': { zh: '上传失败', en: 'Upload failed' }, + 'msg-uploaded-build-and-validate': { zh: '文件上传成功,已开始生成并验收', en: 'File uploaded, starting build and validation' }, + 'msg-validation-failed': { zh: '验证未通过', en: 'Validation failed' }, + 'msg-validation-succeeded': { zh: '验证成功', en: 'Validation succeeded' }, + 'msg-view-reset': { zh: '视图已重置', en: 'View reset' }, + 'msg-will-keep-compatible': { zh: '将保留原流程的兼容处理', en: 'Will keep original pipeline compatibility handling' }, + + // === Template string fragments === + 'msg-labels-count': { zh: '个亚家族标签', en: 'subfamily labels' }, + 'msg-loaded-files': { zh: '已加载', en: 'Loaded' }, + 'msg-members-count': { zh: '个成员', en: 'members' }, + 'msg-progress': { zh: '进度', en: 'Progress' }, + 'msg-samples-not-filled': { zh: '个样本未填写', en: 'samples not filled' }, + 'msg-status': { zh: '状态', en: 'Status' }, + 'msg-strand-specificity-required': { zh: '链特异性为必填', en: 'Strand specificity is required' }, + + // === Dialog button text === + 'btn-auto-annotate-continue': { zh: '自动注释并继续', en: 'Auto-annotate & Continue' }, + 'btn-back-to-edit': { zh: '返回修改', en: 'Back to Edit' }, + 'btn-cancel': { zh: '取消', en: 'Cancel' }, + 'btn-cancel-ppi': { zh: '取消 PPI', en: 'Cancel PPI' }, + 'btn-confirm-design-write': { zh: '确认设计并写入', en: 'Confirm Design & Write' }, + 'btn-confirm-first-candidate': { zh: '确认第一候选', en: 'Confirm First Candidate' }, + 'btn-confirm-restart': { zh: '确认重启', en: 'Confirm Restart' }, + 'btn-delete': { zh: '删除', en: 'Delete' }, + 'btn-got-it': { zh: '知道了', en: 'Got it' }, + 'btn-retry-bcftools': { zh: '使用 bcftools 重试', en: 'Retry with bcftools' }, + 'btn-upload-bg-first': { zh: '我先去上传背景文件', en: 'Upload Background Files First' }, + + // === Template labels === + 'label-added': { zh: '新增', en: 'Added' }, + 'label-conserved-msa': { zh: '自动保守区 MSA 图', en: 'Auto Conserved MSA Plot' }, + + // === Category & node labels === + 'category-gene-family': { zh: '基因家族', en: 'Gene Family' }, + 'category-wgs-population-gwas': { zh: 'WGS2GWAS(遗传变异与群体分析)', en: 'WGS2GWAS (Genetic Variation & Population)' }, + 'label-current-identification': { zh: '当前鉴定', en: 'Current Identification' }, + 'label-current-image': { zh: '当前图片', en: 'Current Image' }, + 'label-degree': { zh: '连接数', en: 'Degree' }, + 'label-deleted': { zh: '删除', en: 'Deleted' }, + 'label-edges': { zh: '关系', en: 'Edges' }, + 'label-input': { zh: '输入', en: 'Input' }, + 'label-msa-region': { zh: 'MSA区间图', en: 'MSA Region Plot' }, + 'label-msa-source': { zh: 'MSA来源', en: 'MSA Source' }, + 'label-nodes': { zh: '节点', en: 'Nodes' }, + 'label-ppi-reset': { zh: 'PPI Reset 结果', en: 'PPI Reset Results' }, + 'label-recommended-conserved': { zh: '推荐保守区', en: 'Recommended Conserved Region' }, + 'label-types': { zh: '类型', en: 'Types' }, + + // === Stage labels === + 'stage-combo': { zh: '组合图', en: 'Combined Plot' }, + 'stage-domain': { zh: '结构域分析', en: 'Domain Analysis' }, + 'stage-expression': { zh: '表达矩阵分析', en: 'Expression Matrix Analysis' }, + 'stage-gwas-cov': { zh: 'GWAS协变量准备', en: 'GWAS Covariate Preparation' }, + 'stage-gwas-emmax': { zh: 'GWAS EMMAX', en: 'GWAS EMMAX' }, + 'stage-gwas-emplot': { zh: 'GWAS EMMAX绘图', en: 'GWAS EMMAX Plotting' }, + 'stage-gwas-fmt': { zh: 'GWAS VCF排序与HapMap转换', en: 'GWAS VCF Sort & HapMap Conversion' }, + 'stage-gwas-glm': { zh: 'GWAS TASSEL GLM', en: 'GWAS TASSEL GLM' }, + 'stage-gwas-kinship': { zh: 'GWAS TASSEL亲缘关系矩阵', en: 'GWAS TASSEL Kinship Matrix' }, + 'stage-gwas-mlm': { zh: 'GWAS TASSEL MLM/CMLM', en: 'GWAS TASSEL MLM/CMLM' }, + 'stage-gwas-plot': { zh: 'GWAS MLM/CMLM绘图', en: 'GWAS MLM/CMLM Plotting' }, + 'stage-gwas-trait': { zh: 'GWAS表型准备', en: 'GWAS Trait Preparation' }, + 'stage-identify': { zh: '基因家族鉴定', en: 'Gene Family Identification' }, + 'stage-interspecies-synteny': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'stage-kaks': { zh: 'Ka/Ks分析', en: 'Ka/Ks Analysis' }, + 'stage-karyotype': { zh: '核型/染色体分布分析', en: 'Karyotype Analysis' }, + 'stage-motif': { zh: 'Motif分析', en: 'Motif Analysis' }, + 'stage-pep': { zh: '蛋白理化性质与亚细胞定位', en: 'Protein Properties & Subcellular Localization' }, + 'stage-phylogeny': { zh: '系统发育分析', en: 'Phylogenetic Analysis' }, + 'stage-pop-ld-decay': { zh: '群体LD衰减分析', en: 'Population LD Decay Analysis' }, + 'stage-pop-pca': { zh: '群体PCA分析', en: 'Population PCA Analysis' }, + 'stage-pop-phylogeny': { zh: '群体系统树分析', en: 'Population Phylogenetic Tree' }, + 'stage-pop-snp-density': { zh: 'SNP密度分析', en: 'SNP Density Analysis' }, + 'stage-pop-structure': { zh: '群体结构分析', en: 'Population Structure Analysis' }, + 'stage-ppi': { zh: 'PPI分析', en: 'PPI Analysis' }, + 'stage-ppi-kaks-download-fallback': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'stage-ppi-kaks-reference-data': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'stage-promoter': { zh: '启动子元件预测', en: 'Promoter Element Prediction' }, + 'stage-reference-classification': { zh: '参考亚家族分类', en: 'Reference Subfamily Classification' }, + 'stage-structure': { zh: '基因结构分析', en: 'Gene Structure Analysis' }, + 'stage-subfamily-confirm': { zh: '亚家族标签确认', en: 'Subfamily Label Confirmation' }, + 'stage-synteny': { zh: '种内共线性分析', en: 'Intraspecific Synteny Analysis' }, + 'stage-tfbs': { zh: 'TF结合位点预测', en: 'TFBS Prediction' }, + + // === Short stage labels === + 'stage-interspecies-synteny-short': { zh: '9️⃣ 种间Synteny', en: '9️⃣ Interspecies Synteny' }, + 'stage-msa-region-plot': { zh: '2.2 MSA Replot / 区间重绘', en: '2.2 MSA Replot / Region Plot' }, + 'stage-promoter-plot-revise': { zh: '6.1 启动子自定义绘图', en: '6.1 Custom Promoter Plot' }, + 'stage-reference-classification-short': { zh: '2.5 亚家族确认', en: '2.5 Subfamily Confirmation' }, + 'stage-synteny-plot-revise': { zh: '7.1 种内共线性 Circos 重绘', en: '7.1 Intraspecies Synteny Circos Replot' }, + 'stage-synteny-short': { zh: '7️⃣ 种内Synteny', en: '7️⃣ Intraspecies Synteny' }, + 'stage-tree-plot-revise': { zh: '2.3 Tree Replot / 进化树重绘', en: '2.3 Tree Replot' }, + + // === Synteny choice labels === + 'choice-synteny': { zh: '种内共线性分析', en: 'Intraspecific Synteny Analysis' }, + 'choice-synteny-desc': { zh: '只分析目标物种内部复制关系', en: 'Analyze duplication within target species only' }, + 'choice-interspecies-synteny': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'choice-interspecies-synteny-desc': { zh: '分析目标物种与参考/比较物种之间的共线性', en: 'Analyze synteny between target and reference/comparative species' }, + + // === Sidebar status labels === + 'status-pending-confirm': { zh: '待确认', en: 'Pending Confirm' }, + 'status-running': { zh: '运行中', en: 'Running' }, + 'status-pending-exec': { zh: '待执行', en: 'Pending' }, + 'status-pending-review': { zh: '待核对', en: 'Pending Review' }, + 'status-pending-auth': { zh: '待授权', en: 'Pending Auth' }, + 'status-completed': { zh: '已完成', en: 'Completed' }, + + // === Lightweight card titles === + 'card-title-extension-candidate': { zh: '拓展分析候选核对', en: 'Extension Candidate Review' }, + 'card-title-generating-extension': { zh: '正在生成拓展分析', en: 'Generating Extension' }, + 'card-title-start-validation': { zh: '开始验证', en: 'Start Validation' }, + 'card-title-result-review': { zh: '运行结果核对', en: 'Result Review' }, + 'card-title-auth-publish': { zh: '授权发布', en: 'Authorize Publish' }, + 'card-title-input-files': { zh: '输入文件和参数', en: 'Input Files & Parameters' }, + 'card-title-run-result': { zh: '运行结果', en: 'Run Result' }, + 'card-title-restoring-extension': { zh: '正在恢复拓展分析状态', en: 'Restoring Extension State' }, + + // === More status labels === + 'card-title-extension-gen-failed': { zh: '拓展分析说明生成失败', en: 'Extension Description Generation Failed' }, + 'status-cancelled': { zh: '已取消', en: 'Cancelled' }, + 'status-failed': { zh: '失败', en: 'Failed' }, + + // === Species label === + 'label-multi-species-analysis': { zh: '多物种联合分析与确认(默认)', en: 'Multi-Species Joint Analysis (default)' }, + 'label-per-species-analysis': { zh: '按单个物种分别分析与确认', en: 'Per-Species Analysis & Confirmation' }, + 'label-target-only': { zh: '仅目标物种', en: 'Target Species Only' }, + + // === More visible labels === + 'desc-smart-extract': { zh: '如果没有蛋白文件,但有 genome.fa + GFF,系统会自动用 gffread 提取蛋白序列', en: 'If no protein file is available but genome.fa + GFF exist, the system will automatically extract protein sequences using gffread' }, + 'label-batch': { zh: '批次', en: 'Batch' }, + 'label-smart-extract': { zh: '智能提取', en: 'Smart Extract' }, + 'label-view-phase-report': { zh: '查看阶段性综合报告', en: 'View Phase Summary Report' }, + + + // === Target species === + 'label-target-species': { zh: '目标物种', en: 'Target Species' }, + 'msg-target-cannot-string': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs。', en: 'Target species cannot be used directly for STRING PPI/KaKs.' }, + 'msg-target-not-in-string': { zh: '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:', en: 'Target species not in STRING fixed snapshot. Confirm using the closest related species:' }, + 'msg-methodology-plan-saved-detail': { zh: '已保存方法学方案到当前 job。下一步请上传/填写目标物种和参考物种数据,并确认 config.xlsx 后再执行方法学流程。', en: 'Methodology plan saved to current job. Next: upload/fill target and reference species data, verify config.xlsx, then execute the methodology pipeline.' }, + + // === json list tables === + 'json-list-tables': { zh: 'json list tables', en: 'JSON List Tables' }, + + // === Methodology labels === + 'label-auto-review': { zh: '自动审核', en: 'Auto Review' }, + 'label-missing': { zh: '缺少', en: 'Missing' }, + 'label-missing-fig': { zh: '缺失 Fig', en: 'Missing Fig' }, + 'label-missing-table': { zh: '缺失 Table', en: 'Missing Table' }, + 'label-stage-count': { zh: '阶段', en: 'Stages' }, + 'label-title-count': { zh: '标题', en: 'Titles' }, + 'unit-figure-titles': { zh: '条图表标题', en: 'figure titles' }, + 'unit-preview-stages': { zh: '个预览阶段', en: 'preview stages' }, + 'unit-species': { zh: '个物种', en: 'species' }, + 'unit-stages': { zh: '个方法阶段', en: 'methodology stages' }, + + // === getStageI18nKey mappings === + '0-genome-prepare': { zh: '0️⃣ Genome Prepare', en: '0️⃣ Genome Prepare' }, + '1-identify': { zh: '1️⃣ Identify', en: '1️⃣ Identify' }, + '1.1-identify-refine': { zh: '1.1 Identify Refine', en: '1.1 Identify Refine' }, + '10-ppi': { zh: '🔟 PPI', en: '🔟 PPI' }, + '11-kaks': { zh: '1️⃣1️⃣ KaKs', en: '1️⃣1️⃣ KaKs' }, + '12-pep': { zh: '1️⃣2️⃣ PEP', en: '1️⃣2️⃣ PEP' }, + '13-expression': { zh: '1️⃣3️⃣ Expression (FPKM)', en: '1️⃣3️⃣ Expression (FPKM)' }, + '2-phylogeny-tree': { zh: '2️⃣ Phylogeny Tree', en: '2️⃣ Phylogeny Tree' }, + '2.1-msa-alignment': { zh: '2.1 MSA Alignment Pages', en: '2.1 MSA Alignment Pages' }, + '20-Annotation': { zh: '2️⃣0️⃣ Annotation', en: '2️⃣0️⃣ Annotation' }, + '3-motif': { zh: '3️⃣ Motif', en: '3️⃣ Motif' }, + '4-domain': { zh: '4️⃣ Domain', en: '4️⃣ Domain' }, + '5-structure': { zh: '5️⃣ Structure', en: '5️⃣ Structure' }, + '6-combo': { zh: '6️⃣ Combo', en: '6️⃣ Combo' }, + '6-promoter': { zh: '6️⃣ Promoter', en: '6️⃣ Promoter' }, + '6.2-tfbs': { zh: '6.2 TFBS', en: '6.2 TFBS' }, + '8-karyotype': { zh: '8️⃣ Karyotype', en: '8️⃣ Karyotype' }, + 'GWAS-Cov': { zh: 'GWAS Covariate Preparation', en: 'GWAS Covariate Preparation' }, + 'GWAS-EMMAX': { zh: 'GWAS EMMAX', en: 'GWAS EMMAX' }, + 'GWAS-EMPlot': { zh: 'GWAS EMMAX Plot', en: 'GWAS EMMAX Plot' }, + 'GWAS-Format': { zh: 'GWAS Format (TASSEL)', en: 'GWAS Format (TASSEL)' }, + 'GWAS-GLM': { zh: 'GWAS TASSEL GLM', en: 'GWAS TASSEL GLM' }, + 'GWAS-Kinship': { zh: 'GWAS Kinship (TASSEL)', en: 'GWAS Kinship (TASSEL)' }, + 'GWAS-MLM': { zh: 'GWAS TASSEL MLM/CMLM', en: 'GWAS TASSEL MLM/CMLM' }, + 'GWAS-Plot': { zh: 'GWAS MLM/CMLM Plot', en: 'GWAS MLM/CMLM Plot' }, + 'GWAS-Trait': { zh: 'GWAS Trait Preparation', en: 'GWAS Trait Preparation' }, + 'PPI-KaKs-Fallback': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'PPI-KaKs-Ref': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'RNA-seq-Align': { zh: '1️⃣6️⃣ RNA-seq Align', en: '1️⃣6️⃣ RNA-seq Align' }, + 'RNA-seq-Candidate': { zh: '2️⃣5️⃣ RNA-seq Candidate Family', en: '2️⃣5️⃣ RNA-seq Candidate Family' }, + 'RNA-seq-DE': { zh: '1️⃣9️⃣ RNA-seq DE + Expression', en: '1️⃣9️⃣ RNA-seq DE + Expression' }, + 'RNA-seq-GO': { zh: '2️⃣1️⃣ RNA-seq GO Enrich', en: '2️⃣1️⃣ RNA-seq GO Enrich' }, + 'RNA-seq-KEGG': { zh: '2️⃣2️⃣ RNA-seq KEGG Enrich', en: '2️⃣2️⃣ RNA-seq KEGG Enrich' }, + 'RNA-seq-QC': { zh: '1️⃣5️⃣ RNA-seq QC', en: '1️⃣5️⃣ RNA-seq QC' }, + 'RNA-seq-Quant': { zh: '1️⃣7️⃣ RNA-seq Quant', en: '1️⃣7️⃣ RNA-seq Quant' }, + 'RNA-seq-Sample-QC': { zh: '1️⃣8️⃣ RNA-seq Sample QC', en: '1️⃣8️⃣ RNA-seq Sample QC' }, + 'RNA-seq-WGCNA': { zh: '2️⃣4️⃣ RNA-seq WGCNA', en: '2️⃣4️⃣ RNA-seq WGCNA' }, + 'RNA-seq-fgsea': { zh: '2️⃣3️⃣ RNA-seq fgsea', en: '2️⃣3️⃣ RNA-seq fgsea' }, + 'WGS-Align': { zh: 'WGS Alignment', en: 'WGS Alignment' }, + 'WGS-BAM-QC': { zh: 'WGS BAM QC', en: 'WGS BAM QC' }, + 'WGS-GVCF': { zh: 'WGS GVCF Combine', en: 'WGS GVCF Combine' }, + 'WGS-Haplotype': { zh: 'WGS Haplotype Calling', en: 'WGS Haplotype Calling' }, + 'WGS-Impute': { zh: 'WGS Imputation', en: 'WGS Imputation' }, + 'WGS-Index': { zh: 'WGS Reference Index', en: 'WGS Reference Index' }, + 'WGS-Joint-Geno': { zh: 'WGS Joint Genotyping', en: 'WGS Joint Genotyping' }, + 'WGS-MAF': { zh: 'WGS MAF / Missing Filter', en: 'WGS MAF / Missing Filter' }, + 'WGS-MarkDup': { zh: 'WGS Mark Duplicates', en: 'WGS Mark Duplicates' }, + 'WGS-QC': { zh: 'WGS QC', en: 'WGS QC' }, + 'WGS-Var-Filter': { zh: 'WGS Variant Filter', en: 'WGS Variant Filter' }, + 'WGS-Var-Select': { zh: 'WGS Variant Select', en: 'WGS Variant Select' }, + 'pop-ld-decay': { zh: 'Population LD Decay', en: 'Population LD Decay' }, + 'pop-pca': { zh: 'Population PCA', en: 'Population PCA' }, + 'pop-phylogeny': { zh: 'Population Phylogeny', en: 'Population Phylogeny' }, + 'pop-snp-density': { zh: 'SNP Density', en: 'SNP Density' }, + 'pop-structure': { zh: 'Population Structure', en: 'Population Structure' }, + + // === Countdown labels === + 'countdown-paused': { zh: '倒计时已暂停', en: 'Countdown paused' }, + 'seconds-auto-annotate': { zh: '秒后自动注释', en: 's to auto-annotate' }, + 'seconds-auto-domain-review': { zh: '秒后自动使用默认结构域筛选', en: 's to auto-use default domain filter' }, + 'seconds-auto-execute': { zh: '秒后自动执行', en: 's to auto-execute' }, + 'seconds-auto-stage-confirm': { zh: '秒后自动执行', en: 's to auto-execute' }, + 'seconds-auto-subfamily-review': { zh: '秒后自动确认当前亚家族标签', en: 's to auto-confirm subfamily labels' }, +}; diff --git a/apps/web/vue3/static/i18n_en.js b/apps/web/vue3/static/i18n_en.js new file mode 100644 index 000000000..416f57655 --- /dev/null +++ b/apps/web/vue3/static/i18n_en.js @@ -0,0 +1,380 @@ +window.__I18N_EN__ = { + "Agent状态": "AgentStatus", + "Self-RAG反思": "Self-RAG Reflection", + "三角形": "Triangle", + "下一步": "Next Step", + "下载": "Download", + "不受支持标记处理策略": "Unsupported Marker Handling Policy", + "中性替换率": "Neutral Substitution Rate", + "中性速率": "Neutral Rate", + "中文": "Chinese", + "丰度引擎": "Abundance Engine", + "主参考": "Primary Reference", + "主参考物种": "Primary reference species", + "主要结果": "Main Results", + "亚家族分类确认": "Subfamily Classification Confirmation", + "亚家族名称距离": "Subfamily Name Distance", + "亚家族外圈弧线": "Subfamily Outer Arc", + "亚家族确认参数": "Subfamily Confirmation Parameters", + "交集": "Intersection", + "仅圈图": "Circle Plot Only", + "仅比较物种": "Comparative Species Only", + "仅目标": "Target Only", + "仅目标物种": "Target Species Only", + "仅矩形图": "Rectangular Only", + "任务列表": "Task List", + "位置分布图": "Position Distribution Map", + "使用拟南芥作为显式兜底": "Use Arabidopsis as Explicit Fallback", + "使用推荐区间": "Use Recommended Range", + "保存方法学方案": "Save Methodology Plan", + "保留": "Retain", + "修订": "Revision", + "倒计时已暂停": "Countdown paused", + "全屏": "Full Screen", + "全屏展示": "Full Screen Display", + "全部": "All", + "全部行": "All Rows", + "关系总数": "Total Edges", + "关闭": "Close", + "内容": "Content", + "内置物种代码": "Built-in Species Code", + "分析家族": "Analysis Family", + "分组角色": "Group Role", + "分组角色不改": "Group Role (Unchanged)", + "删除": "Deleted", + "删除会话": "Delete Session", + "刷新": "Refresh", + "刷新结果": "Refresh Results", + "前景基因": "Foreground Genes", + "功能类别": "Feature Category", + "加入正式列表": "Add to Formal List", + "包含关键词": "Contains Keyword", + "匹配方式": "Match Mode", + "匹配行": "Matched Rows", + "原因": "Reason", + "原始代码": "Original Code", + "原始命令": "Original Command", + "原始文件名": "Original Filename", + "原始证据": "Original Evidence", + "原推荐参考": "Original Recommended Reference", + "参数": "Parameters", + "参考分类": "Reference Classification", + "参考前缀": "Reference Prefix", + "参考基因": "Reference Gene", + "参考基因编号": "Reference Gene ID", + "参考文献": "References", + "参考物种": "Reference Species", + "发送": "Send", + "取消": "Cancel", + "取消预览": "Cancel Preview", + "只会执行已勾选且可执行的阶段": "Only checked and executable stages will run", + "可改": "Editable", + "同时处理的样本数": "Number of Samples Processed Simultaneously", + "名称": "Name", + "后续主参考物种": "Downstream Primary Reference Species", + "启动子元件": "Promoter Elements", + "启动子自定义绘图": "Promoter Custom Plot", + "图例颜色": "Legend Color", + "图类型": "Plot Type", + "图表标题清单": "Chart Title List", + "图表标题驱动复现规划预览": "Chart Title-Driven Reproduction Plan Preview", + "圆形": "Circle", + "基因注释": "Gene Annotation", + "基本参数": "Basic Parameters", + "基础参数": "Basic Parameters", + "处理中...": "Processing...", + "处理方式": "Processing Method", + "多物种处理方式": "Multi-species Processing", + "大小": "Size", + "定量模式": "Quantification Mode", + "实验设计": "Experimental Design", + "实验设计模块": "Experimental Design Module", + "审核备注": "Review Notes", + "富集类型": "Enrichment Type", + "差异表达范围": "Differential Expression Scope", + "已写阶段": "Registered Stages", + "已完成": "Completed", + "已完成阶段": "Completed Stages", + "已应用": "Applied", + "建议参数": "Suggested Parameters", + "开始验证": "Start Validation", + "弧线避让距离": "Arc Avoidance Distance", + "当前参数": "Current Parameters", + "当前参考物种": "Current Reference Species", + "当前选择": "Current Selection", + "待补数据": "Awaiting Data", + "必填": "Required", + "我知道了": "Got It", + "手动区间": "Manual Range", + "执行": "Execute", + "扫描双链": "Scan Both Strands", + "批量执行确认": "Batch Execution Confirmation", + "拉丁名": "Latin Name", + "拓展分析运行参数确认": "Extension Analysis Run Parameter Confirmation", + "拓展分析阶段": "Extension Analysis Stage", + "拓展分析阶段状态": "Extension Analysis Stage Status", + "拓展分析阶段确认": "Extension Analysis Stage Confirmation", + "拓展分析阶段运行结果": "Extension Analysis Stage Run Result", + "按元件": "By Element", + "按单个物种分别分析与确认": "Per-Species Analysis & Confirmation", + "按当前筛选重建成员蛋白": "Rebuild Member Proteins by Current Filter", + "按类别": "By Category", + "授权发布": "Authorize Publish", + "推荐参考数据下载失败": "Recommended Ref Data Download Failed", + "提出修订意见": "Submit Revision Suggestions", + "操作": "Operation", + "数据准备": "Data Preparation", + "数据路径": "Data Path", + "数据需求": "Data Needs", + "文件": "File", + "文件类型": "File Type", + "文件范围": "File Scope", + "文献参数": "Literature Parameters", + "文献工具": "Literature Tools", + "文章涉及物种与下载入口": "Article Species & Download Entry", + "新增": "Added", + "方形": "Square", + "方法参考": "Method Reference", + "方法学文献": "Methodology Papers", + "无": "None", + "明确指定": "Explicitly Specify", + "显示": "Show", + "暂停倒计时": "Pause Countdown", + "暂无已上传文件": "No Uploaded Files Yet", + "暂无拓展分析阶段结果": "No Extension Analysis Stage Results Yet", + "暂无状态记录": "No Status Record Yet", + "最大宽度": "Max Width", + "最小宽度": "Min Width", + "最小非缺口比例": "Min Non-gap Ratio", + "最近到属": "Closest to Genus", + "最近到目": "Closest to Order", + "最近到科": "Closest to Family", + "最近到纲": "Closest to Class", + "有损转换警告": "Lossy Conversion Warning", + "服务器测试数据路径": "Server Test Data Path", + "未从图表标题推断出额外阶段": "No Additional Stages Inferred from Chart Titles", + "未提取到图表标题": "No Chart Title Extracted", + "未识别到内置下载链接": "No Built-in DL Link Identified", + "未识别到参数": "No Parameters Identified", + "未识别到文献工具": "No Literature Tool Identified", + "本次成员鉴定物种": "Current Member Identification Species", + "来源": "Sources", + "染色体标注位置": "Chromosome Annotation Position", + "染色体标注大小": "Chromosome Annotation Size", + "染色体策略": "Chromosome Strategy", + "染色体色块粗细": "Chromosome Block Width", + "查看本次知识检索过程": "View knowledge retrieval process", + "标记大小": "Mark Size", + "标记模式": "Mark Mode", + "标题推断的候选阶段": "Candidate Stages Inferred from Titles", + "树枝粗细": "Branch Width", + "树枝颜色": "Branch Color", + "样本并行数量": "Sample Parallel Count", + "检索结果": "Retrieval Results", + "检索表格内容": "Search Table Content", + "正则匹配": "Regex Match", + "此拓展阶段没有可配置参数": "No Configurable Parameters", + "测试数据": "Test Data", + "涉及阶段": "Involved Stages", + "添加行": "Add Row", + "灰色": "Gray", + "物种": "Species", + "物种代码": "Species Code", + "物种代码不改": "Species Code (Unchanged)", + "物种名": "Species Name", + "物种角色": "Species Role", + "物种角色不改": "Species Role (Unchanged)", + "环境": "Environment", + "留空则自动发现": "Leave empty for auto-discovery", + "目标前缀": "Target Prefix", + "目标物种": "Target Species", + "知识图谱": "Knowledge Graph", + "确定": "Confirm", + "确认亚家族标签": "Confirm Subfamily Labels", + "确认保存": "Confirm Save", + "确认并写入正式配置": "Confirm & Write to Official Config", + "确认并执行": "Confirm & Execute", + "确认执行": "Confirm Execution", + "确认自动注释": "Confirm Auto-Annotation", + "确认重绘": "Confirm Replot", + "种间共线性比较物种": "Interspecies Synteny Comparative Species", + "种间共线性绘图设置": "Interspecies Synteny Plot Settings", + "移除": "Remove", + "窗口长度": "Window Length", + "类型": "Types", + "精确匹配": "Exact Match", + "红色": "Red", + "终止位点": "End Site", + "终止残基": "End Residue", + "绘图模式": "Plot Mode", + "绿色": "Green", + "置信度": "Confidence", + "置信度阈值": "Confidence Threshold", + "群体遗传输入": "Population Genetic Input", + "背景来源": "Background Source", + "背景知识": "Background knowledge", + "自动": "Auto", + "自动从注释提取": "Auto-extract from Annotation", + "自动保守窗口": "Auto Conserved Window", + "自动判断": "Auto-detect", + "节点名称": "Node Name", + "节点总数": "Total Nodes", + "节点类型": "Node Types", + "菱形": "Diamond", + "蓝色": "Blue", + "蛋白": "Protein", + "蛋白文件": "Protein Files", + "表达矩阵": "Expression Matrix", + "诊断动作": "Diagnostic Actions", + "说明": "Description", + "请先试运行以记录真实输出": "Please Run a Trial First to Record Actual Output", + "请选择共线性分析类型": "Please select synteny analysis type", + "请选择模式物种": "Select Model Species", + "起始位点": "Start Site", + "起始残基": "Start Residue", + "跳数": "Hops", + "输出": "Output", + "输出前缀": "Output Prefix", + "输出标签": "Output Label", + "运行值": "Run Value", + "运行结果": "Run Result", + "进化树图修订参数": "Phylogenetic Tree Revision Parameters", + "连接关系数": "Connections", + "迭代": "Iteration", + "选择其他提取结果": "Select Other Extraction Result", + "选择工具": "Select Tool", + "选择已完成撰写的拓展阶段": "Select Completed Extension Stage", + "通常留空": "Usually leave empty", + "重写查询": "Rewrite Query", + "重新生成说明": "Regenerate Description", + "重置": "Reset", + "重置视图": "Reset View", + "链接": "Link", + "链特异性": "Library Strandedness", + "隐藏": "Hide", + "需检查": "Needs Review", + "需要补充": "Need to Supplement", + "非时间实验留空": "Leave empty for non-time experiments", + "项目": "Project", + "预览": "Preview", + "预览到表格": "Preview to Table", + "预览命令": "Preview Commands", + "黑色": "Black", + "默认值": "Default Value", + "提取方法学": "Extract", + "刷新记录": "Refresh", + "打开最新提取结果": "Open Latest Extraction", + "查看最新提取状态": "View Latest Status", + "打开所选提取结果": "Open Selected Extraction", + "查看所选提取状态": "View Selected Status", + "生成并发布": "Generate & Publish", + "运行已发布": "Run Published", + "当前会话": "Current Session", + "更多会话": "More Sessions", + "新建": "New", + "新建会话": "New Session", + "使用小Tip": "Tips", + "一、分析模块快速指令": "1. Analysis Module Quick Commands", + "选择输入框上方\"Analysis\"": "Select \"Analysis\" above input box", + "粘贴数据位置": "Paste data location", + "核对配置文件": "Review config files", + "执行数据准备": "Run data preparation", + "执行XX物种的亚家族分类": "Run subfamily classification for XX species", + "执行XX物种的基因家族分析": "Run gene family analysis for XX species", + "执行rnaseq分析": "Run RNA-seq analysis", + "二、文章复现步骤": "2. Article Reproduction Steps", + "点击\"浏览\"上传文献": "Click \"Browse\" to upload paper", + "点击提取方法学": "Click Extract Methodology", + "核对分析步骤": "Review analysis steps", + "点击保存方案": "Click Save Plan", + "上传数据": "Upload data", + "发送\"按已保存的方法学方案继续执行\"": "Send \"Continue with saved methodology plan\"", + "三、RAG快速指令": "3. RAG Quick Commands", + "选择输入框上方\"RAG\"": "Select \"RAG\" above input box", + "输入问题": "Enter question", + "点击\"知识图谱\"查看关系": "Click \"Knowledge Graph\" to view relations", + "知识图谱": "Knowledge Graph", + "重新加载": "Reload", + "重置视图": "Reset View", + "2.2 MSA Replot / 区间重绘": "2.2 MSA Replot", + "2.3 Tree Replot / 进化树重绘": "2.3 Tree Replot", + "2.5 亚家族确认": "2.5 Subfamily Confirmation", + "6.1 启动子自定义绘图": "6.1 Promoter Custom Plot", + "7️⃣ 种内Synteny": "7️⃣ Intraspecies Synteny", + "7.1 种内共线性 Circos 重绘": "7.1 Intraspecies Synteny Circos Replot", + "8️⃣ Karyotype": "8️⃣ Karyotype", + "9️⃣ 种间Synteny": "9️⃣ Interspecies Synteny", + "基因家族": "Gene Family", + "WGS2GWAS(遗传变异与群体分析)": "WGS2GWAS", + "基因家族 promoter 扫描": "Gene family promoter scanning", + "新会话": "New Session", + "新会话已创建,请选择或创建 Working Directory": "New session created. Please select or create a Working Directory", + "创建会话失败": "Failed to create session", + "会话已删除": "Session deleted", + "删除会话失败": "Failed to delete session", + "对话历史已清空": "Chat history cleared", + "错误日志已清空": "Error logs cleared", + "重启 Celery": "Restart Celery", + "重启项目": "Restart Project", + "提交配置好的项目重启脚本": "Submit configured project restart script", + "未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh": "PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured", + "未配置项目重启脚本": "Project restart script not configured", + "这会提交项目重启脚本,当前页面可能短暂断开。": "This will submit the project restart script. The page may briefly disconnect.", + "确认重启": "Confirm Restart", + "项目重启脚本已提交": "Project restart script submitted", + "项目重启未提交": "Project restart not submitted", + "基因家族鉴定": "Gene Family Identification", + "系统发育分析": "Phylogenetic Analysis", + "Motif分析": "Motif Analysis", + "结构域分析": "Domain Analysis", + "基因结构分析": "Gene Structure Analysis", + "启动子元件预测": "Promoter Element Prediction", + "TF结合位点预测": "TF Binding Site Prediction", + "种内共线性分析": "Intraspecies Synteny Analysis", + "种间共线性分析": "Interspecies Synteny Analysis", + "核型/染色体分布分析": "Karyotype/Chromosome Distribution", + "PPI分析": "PPI Analysis", + "Ka/Ks分析": "Ka/Ks Analysis", + "蛋白理化性质与亚细胞定位": "Protein Properties & Subcellular Localization", + "表达矩阵分析": "Expression Matrix Analysis", + "RNA-seq QC": "RNA-seq QC", + "RNA-seq Align": "RNA-seq Align", + "RNA-seq Quant": "RNA-seq Quant", + "RNA-seq Sample QC": "RNA-seq Sample QC", + "GWAS VCF排序与HapMap转换": "GWAS VCF Sort & HapMap Conversion", + "GWAS TASSEL亲缘关系矩阵": "GWAS TASSEL Kinship Matrix", + "GWAS表型准备": "GWAS Phenotype Preparation", + "GWAS协变量准备": "GWAS Covariate Preparation", + "GWAS TASSEL MLM/CMLM": "GWAS TASSEL MLM/CMLM", + "GWAS MLM/CMLM绘图": "GWAS MLM/CMLM Plotting", + "GWAS EMMAX": "GWAS EMMAX", + "GWAS EMMAX绘图": "GWAS EMMAX Plotting", + "GWAS TASSEL GLM": "GWAS TASSEL GLM", + "群体PCA分析": "Population PCA Analysis", + "群体系统树分析": "Population Phylogenetic Tree", + "群体结构分析": "Population Structure Analysis", + "群体LD衰减分析": "Population LD Decay Analysis", + "SNP密度分析": "SNP Density Analysis", + "RNA-seq DE": "RNA-seq DE", + "组合图": "Combo Plot", + "参考亚家族分类": "Reference Subfamily Classification", + "亚家族标签确认": "Subfamily Label Confirmation", + "PPI/KaKs 参考数据": "PPI/KaKs Reference Data", + "PPI/KaKs 兜底确认": "PPI/KaKs Fallback Confirmation", + "亚家族确认输出": "Subfamily Confirmation Output", + "MSA区间重绘": "MSA Region Replot", + "MSA区间重绘已提交": "MSA Replot submitted", + "MSA区间重绘完成": "MSA Replot completed", + "MSA区间重绘失败": "MSA Replot failed", + "启动子自定义绘图已提交": "Promoter custom plot submitted", + "启动子自定义绘图完成": "Promoter custom plot completed", + "启动子自定义绘图失败": "Promoter custom plot failed", + "种内共线性 Circos 重绘已提交": "Intraspecies Circos replot submitted", + "种内共线性 Circos 重绘完成,已切换到新图": "Intraspecies Circos replot completed, switched to new figure", + "种内共线性 Circos 重绘失败": "Intraspecies Circos replot failed", + "亚家族确认 - Reference Classification": "Subfamily Confirmation - Reference Classification", + "这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。": "This will stop and restart the Celery worker for the current profile. Running tasks will be rejected by default.", + "Celery 已重启并完成队列检查": "Celery restarted and queue check complete", + "Celery 重启未完成": "Celery restart incomplete" +}; + diff --git a/gfa_core/extension_stage_lightweight/api.py b/gfa_core/extension_stage_lightweight/api.py index 09e5927c4..54927cdce 100644 --- a/gfa_core/extension_stage_lightweight/api.py +++ b/gfa_core/extension_stage_lightweight/api.py @@ -361,6 +361,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]: settings = LightweightApiSettings.from_environment() action = str(payload["action"]) + runtime_env = str(payload.get("runtime_env", "test4py311")) extension_id = _safe_component(str(payload["extension_id"]), name="extension_id") token = _safe_component(str(payload["operation_token"]), name="operation_token") _set_receipt(settings, token, state="running", status="running") @@ -373,6 +374,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]: raw_code=stage / "raw_code", raw_data=stage / "raw_test_data", original_command=str(payload["original_command"]), + runtime_env=runtime_env, operation_token=token, ) elif action == "rebuild": @@ -479,6 +481,7 @@ def create_lightweight_extension_router( raw_code_files: list[UploadFile] | None = File(None), raw_data_files: list[UploadFile] | None = File(None), raw_data_paths: str | None = Form(None), + runtime_env: str = Form("test4py311"), ) -> dict[str, Any]: temporary_stage: Path | None = None try: @@ -555,6 +558,7 @@ def create_lightweight_extension_router( "action": "upload", "operation_token": token, "original_command": normalized_command, + "runtime_env": runtime_env, } ) except BaseException as exc: diff --git a/gfa_core/extension_stage_lightweight/service.py b/gfa_core/extension_stage_lightweight/service.py index 8c457dae5..903a4eef4 100644 --- a/gfa_core/extension_stage_lightweight/service.py +++ b/gfa_core/extension_stage_lightweight/service.py @@ -109,6 +109,18 @@ class LightweightExtensionService: def get(self, extension_id: str) -> dict[str, Any]: return self._read_record(extension_id) + def _make_env(self, runtime_env: str) -> dict[str, str]: + """Build a process env that activates the selected conda environment.""" + + conda_prefix = f"/home/xzq/miniconda3/envs/{runtime_env}" + conda_bin = f"{conda_prefix}/bin" + env = dict(self.environment or {}) + existing_path = env.get("PATH", os.environ.get("PATH", "")) + env["PATH"] = f"{conda_bin}:{existing_path}" if existing_path else conda_bin + env["CONDA_PREFIX"] = conda_prefix + env["CONDA_DEFAULT_ENV"] = runtime_env + return env + def _deepseek_candidate(self, facts: dict[str, Any], raw_code: Path) -> tuple[dict[str, Any], dict[str, Any]]: return generate_semantic_candidate( execution_facts=facts, raw_code=raw_code, environ=self.environment @@ -324,12 +336,13 @@ class LightweightExtensionService: build: dict[str, Any] | None = None failure: tuple[str, BaseException | str] | None = None try: + runtime_env = str(record.get("runtime_env") or "test4py311") evidence = run_raw_baseline( command=record["original_command"], raw_code=root / "raw_code", raw_data=root / "raw_test_data", run_dir=root / "baseline" / run_id, - env=self.environment, + env=self._make_env(runtime_env), on_process_start=lambda identity: self._mark_process( extension_id, token=token, phase="baseline", identity=identity ), @@ -365,6 +378,7 @@ class LightweightExtensionService: raw_code: Path, raw_data: Path, original_command: str, + runtime_env: str = "test4py311", operation_token: str, ) -> dict[str, Any]: raw_code, raw_data = Path(raw_code), Path(raw_data) @@ -394,6 +408,7 @@ class LightweightExtensionService: "schema_version": "lightweight-extension-service/v1", "extension_id": extension_id, "original_command": original_command, + "runtime_env": runtime_env, "status": "baseline_running", "operations": { operation_token: { @@ -605,9 +620,10 @@ class LightweightExtensionService: rendered = render_runtime_package( record["runtime_binding_contract"], root / "raw_code", resolved, run_dir ) + runtime_env = str(record.get("runtime_env") or "test4py311") result = replay_runtime_binding( rendered, - env=self.environment, + env=self._make_env(runtime_env), on_process_start=lambda identity: self._mark_process( record["extension_id"], token=operation_token, phase=phase, identity=identity ), From e9f006351db3073038e83b0da3ea82aa5b898219 Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 05:57:49 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80+=E6=8B=93?= =?UTF-8?q?=E5=B1=95=E5=8C=85=E6=8B=96=E6=8B=BD=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index.html | 14 ++++++++------ apps/web/vue3/index_ml.html | 8 ++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index 7b228b2cc..48c906b6f 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -1363,7 +1363,13 @@ -
+<<<<<<< HEAD +
+======= +
+>>>>>>> 272d13b41 (多语言+拓展包拖拽上传)

拓展分析阶段

@@ -1422,10 +1428,6 @@
-
- 拖入拓展包到此自动填充 -
拖入固定结构拓展包
diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index c9b076946..8936729a6 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -2267,7 +2267,7 @@ :file-list="uploadedFiles" :show-file-list="false"> - Upload Files + {{ uploadBtnText }} ({{ uploadedFiles.length }}) @@ -7574,6 +7574,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 if (!elLocale.value) { elLocale.value = (typeof zhCn !== "undefined") ? zhCn : (typeof en !== "undefined" ? en : null); } + var uploadBtnText = Vue.computed(function() { + return currentLang.value === "en" ? "Select Files" : "选择文件"; + }); var I18N_EN = window.__I18N_EN__ || {}; function t(key) { if (!key) return ""; @@ -7588,7 +7591,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 } function switchLang(lang) { currentLang.value = lang; - elLocale.value = lang === "zh" ? zhCn : en; + elLocale.value = (lang === "zh" ? (typeof zhCn !== "undefined" ? zhCn : elLocale.value) : (typeof en !== "undefined" ? en : elLocale.value)); window.__CROPAI_LANG__ = lang; window.t = t; window.tp = tp; @@ -23720,6 +23723,7 @@ compare: [[flesh, peel]]`; currentLang, elLocale, switchLang, + uploadBtnText, t, tp }; From cd3e9a5f2256a923aa5eff6ecf94c66d4a7004dd Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 05:57:49 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80+=E6=8B=93?= =?UTF-8?q?=E5=B1=95=E5=8C=85=E6=8B=96=E6=8B=BD=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index.html | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index 48c906b6f..f2c7b5618 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -995,7 +995,7 @@ } .execution-log-item.assistant .log-content.structured-content { - /* 结构化内容使用rag样式 */ + font-size: 13px; } .execution-log-item.assistant .log-content.short-content { @@ -1363,13 +1363,8 @@ -<<<<<<< HEAD
-======= -
->>>>>>> 272d13b41 (多语言+拓展包拖拽上传)

拓展分析阶段

From 774490b47f3a92d5101a5ca6834d85edbba903ef Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 06:38:53 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E9=A6=96?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index_ml.html | 105 +++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index 8936729a6..ef98e8dcd 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -999,6 +999,10 @@ padding: 10px; } + .execution-log-item.assistant .log-content.structured-content { + font-size: 13px; + } + .execution-log-item.assistant .log-content.short-content { font-size: 13px; } @@ -1366,11 +1370,13 @@ -
+

{{ t('拓展分析阶段') }}

+ {{ t('运行结果') }}
{{ t('已写阶段') }} @@ -1431,7 +1437,6 @@ {{ t('运行已发布') }} - {{ t('运行结果') }}
@@ -1780,6 +1785,20 @@ 📂 Select
+ + +
@@ -7825,6 +7844,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 }); const activeRnaseqScopeId = ref(''); const activeGenotypeScopeId = ref(''); + const lightweightExtensionTestStatus = ref('拖入固定结构文件夹,系统将自动创建 Job、解析环境并开始 upload。'); // 对话消息 const messages = ref([]); @@ -19304,6 +19324,10 @@ compare: [[flesh, peel]]`; }; const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`; + const extensionUploadPath = (file) => { + const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, ''); + return relative || String(file?.name || '').trim(); + }; const appendExtensionFiles = (target, files) => { const current = Array.isArray(target) ? target : []; const merged = [...current]; @@ -19329,6 +19353,53 @@ compare: [[flesh, peel]]`; if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`); event.target.value = ''; }; + const handleLightweightExtensionFolderDrop = (event) => { + const droppedFiles = Array.from(event?.dataTransfer?.files || []); + if (!droppedFiles.length) { + ElMessage.warning('未检测到可用文件'); + return; + } + + const codeExtPattern = /\.(py|r|sh|bash|pl|js|ts|java|c|cc|cpp|go|rs|jl|rb|m|smk|nf|ipynb)$/i; + const dataExtPattern = /\.(fastq|fq|gz|fa|fasta|fna|faa|pep|cds|gff|gff3|gtf|csv|tsv|txt|xlsx|xls|vcf|sam|bam)$/i; + + const normalized = droppedFiles.map((file) => { + const relativePath = file.webkitRelativePath || file.name; + if (!file.lightweightRelativePath) { + Object.defineProperty(file, 'lightweightRelativePath', { + value: relativePath, + configurable: true + }); + } + return file; + }); + + const codeFiles = []; + const dataFiles = []; + normalized.forEach((file) => { + const filePath = extensionUploadPath(file).toLowerCase(); + if (codeExtPattern.test(filePath)) { + codeFiles.push(file); + } else if (dataExtPattern.test(filePath)) { + dataFiles.push(file); + } else { + codeFiles.push(file); + } + }); + + extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, codeFiles); + extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, dataFiles); + + if (!extensionBuild.displayName) { + const firstPath = normalized[0]?.webkitRelativePath || normalized[0]?.name || ''; + const folder = firstPath.includes('/') ? firstPath.split('/')[0] : ''; + if (folder) { + extensionBuild.displayName = folder.replace(/[^a-zA-Z0-9_-]+/g, '_'); + } + } + + ElMessage.success(`已拖入 ${normalized.length} 个文件,已自动填充拓展包`); + }; const removeExtensionFile = (kind, file) => { const key = extensionFileKey(file); if (kind === 'code') { @@ -19363,7 +19434,7 @@ compare: [[flesh, peel]]`; const value = String(path || ''); if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs')) return value; const host = window.location.hostname || '127.0.0.1'; - return `${window.location.protocol}//${host}:7880${value}`; + return `${window.location.protocol}//${host}:${window.location.port}${value}`; }; const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options); const pushExtensionStatus = (message, type = 'info', showDialog = false) => { @@ -19724,8 +19795,8 @@ compare: [[flesh, peel]]`; form.append('extension_id', extensionId); if (hasCommand) form.append('original_command', extensionBuild.command); form.append('operation_token', token); - extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file)); - extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file)); + extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file, extensionUploadPath(file))); + extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file))); if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths); const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form }); if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败')); @@ -22289,7 +22360,7 @@ compare: [[flesh, peel]]`; addMessage('user', userInput); ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() }); - // 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址(例如 7880) + // 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址 const data = await fetchJsonWithTimeout('/api/agent/rag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -23523,6 +23594,25 @@ compare: [[flesh, peel]]`; runExtensionStageFromIntent, extensionOutputIcon, openExtensionOutput, + // lightWeightExtensionTest + lightweightExtensionTestStatus, + handleLightweightExtensionFolderDrop: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handleDrop({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(), methodologyPaperId, methodologyFamily, methodologyBatchDir, @@ -23761,6 +23851,9 @@ compare: [[flesh, peel]]`; document.getElementById('app').style.display = 'block'; console.log('App mounted successfully'); + + + + +
{body}
+ +""" + + def _model_claim_catalog(evidence: dict[str, Any]) -> list[dict[str, str]]: catalog: list[dict[str, str]] = [] @@ -1113,11 +1621,12 @@ def _model_enhancement(evidence: dict[str, Any], enabled: bool) -> tuple[str, di def _artifact(workspace: Path, path: Path, kind: str) -> dict[str, Any]: + artifact_type = {".md": "markdown", ".html": "html", ".json": "json"}.get(path.suffix.lower(), "file") return { "artifact": kind, "path": str(path.relative_to(workspace)), "full_path": str(path.absolute()), - "type": "markdown" if path.suffix == ".md" else "json", + "type": artifact_type, "size": path.stat().st_size, "sha256": _sha256(path), } @@ -1156,13 +1665,14 @@ def generate_rnaseq_report( source_records = _source_records(all_sources) source_fingerprint, request_fingerprint = _fingerprint(source_records, expected, use_model) report_path = final_dir / f"{prefix}.rnaseq_report.md" + html_path = final_dir / f"{prefix}.rnaseq_report.html" evidence_path = final_dir / f"{prefix}.rnaseq_report.evidence.json" model_path = final_dir / f"{prefix}.rnaseq_report.model.json" manifest_path = final_dir / f"{prefix}.rnaseq_report.manifest.json" stage_statuses = {stage: str(all_sources.get(stage, {}).get("status") or "missing") for stage in expected} coverage_status = "complete" if expected and all(stage_statuses[s] == "success" for s in expected) else "partial" - if action == "open" and all(path.is_file() for path in (report_path, evidence_path, model_path, manifest_path)): + if action == "open" and all(path.is_file() for path in (report_path, html_path, evidence_path, model_path, manifest_path)): previous = _read_json(manifest_path) metrics = previous.get("metrics") or {} if ( @@ -1248,6 +1758,8 @@ def generate_rnaseq_report( _write_text_atomic(evidence_path, json.dumps(evidence, ensure_ascii=False, indent=2) + "\n") _write_text_atomic(model_path, json.dumps(model_metadata, ensure_ascii=False, indent=2) + "\n") _write_text_atomic(report_path, markdown) + html_report = _markdown_to_html(markdown, report_path, f"{prefix} RNA-seq 结果解读报告") + _write_text_atomic(html_path, html_report) manifest = { "manifest_version": "1.0", @@ -1288,6 +1800,7 @@ def generate_rnaseq_report( "provenance": {"source_manifests": source_records}, "artifacts": { "rnaseq_report": _artifact(workspace, report_path, "rnaseq_report"), + "rnaseq_report_html": _artifact(workspace, html_path, "rnaseq_report_html"), "rnaseq_report_evidence": _artifact(workspace, evidence_path, "rnaseq_report_evidence"), "rnaseq_report_model": _artifact(workspace, model_path, "rnaseq_report_model"), }, From 0e5cece447576bfa30296c027d22fe371629671f Mon Sep 17 00:00:00 2001 From: wtx Date: Sun, 9 Aug 2026 14:26:34 +0800 Subject: [PATCH 12/18] feat: refine gene family result report --- apps/web/vue3/main.py | 4 + gfa_core/gene_family_report.py | 52 ++++++++- gfa_core/gene_family_report_data.py | 8 +- gfa_core/gene_family_report_html.py | 122 ++++++++++++++++++++ gfa_core/templates/gene_family_report_v1.md | 45 +++----- 5 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 gfa_core/gene_family_report_html.py diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 11cd8410e..77e5596a2 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -15354,6 +15354,10 @@ async def get_results( if group_stage not in visible_stage_names: result_groups.pop(group_name, None) + gene_family_report_group = result_groups.get("gene_family_report") + if isinstance(gene_family_report_group, dict): + stages["gene_family_report"] = gene_family_report_group + stages["_result_groups"] = result_groups stages["_scope"] = { "rnaseq_scope_id": active_rnaseq_scope_id, diff --git a/gfa_core/gene_family_report.py b/gfa_core/gene_family_report.py index 5fd145909..becc12d30 100644 --- a/gfa_core/gene_family_report.py +++ b/gfa_core/gene_family_report.py @@ -14,6 +14,12 @@ from gfa_core.gene_family_report_data import ( ReportSection, build_report_data, ) +from gfa_core.gene_family_report_html import ( + REPORT_RENDERER_VERSION, + collect_stage_figures, + markdown_to_html, + render_stage_figures, +) from gfa_core.mas.agents.interpretation_evidence import load_stage_manifests @@ -40,6 +46,7 @@ class GeneFamilyReportResult: generation_mode: str included_stages: tuple[str, ...] reused: bool = False + html_path: Path | None = None def _normalized_stages(stages: Iterable[str] | None) -> list[str]: @@ -185,12 +192,32 @@ def _arranged_content(section: ReportSection, arranged: Mapping[str, Any]) -> st return "\n\n".join(paragraphs) +def _render_completion_table(report_data: ReportData) -> str: + supported_stages = {section.stage for section in report_data.sections if section.stage} + included = set(report_data.included) + status_labels = {"success": "成功", "partial_success": "部分完成", "failed": "失败", "missing": "未完成"} + lines = [ + f"本次计划 {len(report_data.planned)} 个阶段,纳入解读 {len(report_data.included)} 个,未纳入 {len(report_data.planned) - len(report_data.included)} 个。", + "", + "| 分析阶段 | 运行状态 | 模板支持 | 纳入报告 |", + "|---|---|---|---|", + ] + for stage in report_data.planned: + status = str(report_data.statuses.get(stage, "missing")) + lines.append( + f"| `{stage}` | {status_labels.get(status, status)} | " + f"{'是' if stage in supported_stages else '否'} | {'是' if stage in included else '否'} |" + ) + return "\n".join(lines) + + def _render_markdown( report_data: ReportData, *, job_id: str, generation_mode: str, arrangement: Mapping[str, Any], + figures_by_stage: Mapping[str, list[dict[str, str]]], ) -> str: section_data = {section.section_id: section for section in report_data.sections} arranged = {section["section_id"]: section for section in arrangement["sections"]} @@ -198,7 +225,15 @@ def _render_markdown( for section_id, block in _template_blocks(): if section_id not in section_data: continue - rendered.append(block.replace("{{SECTION_CONTENT}}", _arranged_content(section_data[section_id], arranged[section_id]))) + content = ( + _render_completion_table(report_data) + if section_id == "common.completeness" + else _arranged_content(section_data[section_id], arranged[section_id]) + ) + stage = section_id.removeprefix("stage.") if section_id.startswith("stage.") else "" + if stage and figures_by_stage.get(stage): + content += render_stage_figures(stage, figures_by_stage[stage]) + rendered.append(block.replace("{{SECTION_CONTENT}}", content)) markdown = "\n\n".join(rendered) fallback_notice = ( "> 生成模式:候补脚本。模型 API 不可用或输出越过语言编排边界;报告事实仍来自确定性结果数据。" @@ -237,9 +272,14 @@ def generate_gene_family_report( raise ValueError("action must be 'generate' or 'open'") final_dir = Path(workspace).expanduser().resolve() / "final" report_path = final_dir / f"{prefix}.gene_family_report.md" + html_path = final_dir / f"{prefix}.gene_family_report.html" manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json" if action == "open" and report_path.is_file() and manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if not html_path.is_file(): + html_path.write_text(markdown_to_html(report_path.read_text(encoding="utf-8"), report_path, f"{prefix} 基因家族分析结果解读报告"), encoding="utf-8") + manifest.setdefault("artifacts", {})["gene_family_report_html"] = {"path": f"final/{html_path.name}", "full_path": str(html_path), "type": "html"} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") metrics = manifest.get("metrics") or {} return GeneFamilyReportResult( report_path, @@ -248,6 +288,7 @@ def generate_gene_family_report( str(metrics.get("generation_mode") or "fallback_script"), tuple(metrics.get("included_stages") or []), reused=True, + html_path=html_path, ) planned = _normalized_stages(expected_stages) @@ -283,14 +324,17 @@ def generate_gene_family_report( "reason": "api_unavailable_or_boundary_violation", } + figures_by_stage = collect_stage_figures(final_dir, report_data.included, manifests) markdown = _render_markdown( report_data, job_id=job_id, generation_mode=generation_mode, arrangement=arrangement, + figures_by_stage=figures_by_stage, ) final_dir.mkdir(parents=True, exist_ok=True) report_path.write_text(markdown, encoding="utf-8") + html_path.write_text(markdown_to_html(markdown, report_path, f"{prefix} 基因家族分析结果解读报告"), encoding="utf-8") manifest = { "manifest_version": "1.0", "stage": REPORT_STAGE, @@ -308,6 +352,8 @@ def generate_gene_family_report( "included_stages": list(report_data.included), "unsupported_stages": list(report_data.unsupported_stages), "failed_or_missing_stages": [stage for stage in planned if stage not in report_data.included], + "report_renderer_version": REPORT_RENDERER_VERSION, + "embedded_figure_count": sum(len(items) for items in figures_by_stage.values()), }, "model": model_metadata, "artifacts": { @@ -316,6 +362,9 @@ def generate_gene_family_report( "full_path": str(report_path), "type": "markdown", }, + "gene_family_report_html": { + "path": f"final/{html_path.name}", "full_path": str(html_path), "type": "html", + }, "gene_family_report_manifest": { "path": f"final/{manifest_path.name}", "full_path": str(manifest_path), @@ -330,4 +379,5 @@ def generate_gene_family_report( report_data.report_status, generation_mode, report_data.included, + html_path=html_path, ) diff --git a/gfa_core/gene_family_report_data.py b/gfa_core/gene_family_report_data.py index 968765ac8..108a94ae9 100644 --- a/gfa_core/gene_family_report_data.py +++ b/gfa_core/gene_family_report_data.py @@ -719,13 +719,7 @@ def build_report_data( ) for index, sentence in enumerate(integrated_observations, start=1) ), - interpretations=( - _sentence( - "common.integrated.boundary", - "综合结论只并列上述已提取事实;当前源结果不足以无偏地指定候选基因,也不据此建立功能因果。", - "interpretation", - ), - ), + interpretations=(), ), ReportSection( "common.limitations", diff --git a/gfa_core/gene_family_report_html.py b/gfa_core/gene_family_report_html.py new file mode 100644 index 000000000..e4cfc712b --- /dev/null +++ b/gfa_core/gene_family_report_html.py @@ -0,0 +1,122 @@ +# HTML rendering and representative-figure selection for gene-family reports. +from __future__ import annotations +import base64 +import html +import mimetypes +from pathlib import Path +import re +from typing import Any, Iterable, Mapping + +REPORT_RENDERER_VERSION = "gene_family_report_html_v4" +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"} +STAGE_FIGURE_TEXT = { + "phylogeny": ("系统发育图展示目标成员与参考成员之间的聚类关系。", "结合分支拓扑、节点支持率和成员标签阅读,不把邻近关系直接等同于功能一致。", "目标与参考序列的聚类关系见图,具体分支以图中节点和标签为准。"), + "reference_classification": ("参考分类图展示目标成员与已知参考亚家族的相对位置。", "结合参考成员标签、目标成员所在分支及支持率判断分类线索。", "目标成员的参考辅助分类位置见图,分类结论以本节确定性结果为准。"), + "motif": ("Motif图展示各成员短保守片段的组成、顺序和相对位置。", "按成员逐行比较Motif颜色、编号、数量和排列,不把Motif相似直接写成功能相同。", "本次成员的Motif组成与排列见图,数量统计以本节结果数据为准。"), + "domain": ("Domain图展示蛋白主要功能区域的类型和位置。", "结合结构域名称、覆盖区间和成员间差异阅读。", "各成员的结构域组成见图,覆盖数量和比例以本节结果数据为准。"), + "structure": ("基因结构图展示外显子、内含子和基因长度的相对关系。", "结合图例比较外显子数量、结构排列和异常长基因。", "目标成员的基因结构差异见图,具体统计以本节结果数据为准。"), + "combo": ("综合组合图并列展示系统发育、Motif、Domain和基因结构信息。", "沿同一成员横向比较多个证据面板,关注一致模式与明显差异。", "多类结构证据的对应关系见图,不据此单独确认功能改变。"), + "promoter": ("启动子图展示顺式作用元件的类型、数量或位置分布。", "结合元件图例、位置坐标和成员标签比较,不把预测位点写成已验证调控。", "目标成员启动子元件的分布见图,数量和类别统计以本节结果数据为准。"), + "synteny": ("共线性图展示染色体区段和基因对之间的连线关系。", "结合染色体标签、连线和家族成员标记阅读,区分全基因组背景与家族证据。", "本次共线性背景见图,家族专属关系以本节可追溯结果为准。"), + "karyotype": ("染色体分布图展示家族成员在染色体上的物理位置。", "结合染色体编号、位置刻度和基因标签查看分布范围。", "目标家族成员的染色体位置见图,成员数和染色体数以本节结果数据为准。"), + "interspecies_synteny": ("种间共线性图展示目标物种与参考物种之间的保守区块和基因连线。", "结合物种、染色体、标记连线和成员标签阅读,不直接移植参考物种功能。", "跨物种共线性关系见图,目标与参考家族配对数以本节结果数据为准。"), + "pep": ("蛋白特征图展示理化性质、跨膜区域或亚细胞定位预测结果。", "根据具体图例比较成员分布,区分不同预测子任务。", "本次蛋白预测结果见图,具体数量与分类以本节结果数据为准。"), + "tfbs": ("Motif统计图展示不同基因的位点计数、全部Motif计数和高频Motif。", "结合基因标签、颜色图例、计数刻度和Motif名称比较分布。", "本次gene、all和top motif统计结果见图,具体计数以图例和源结果为准。"), +} + +def _artifact_image(final_dir: Path, artifact: Any) -> Path | None: + if not isinstance(artifact, Mapping): return None + raw = str(artifact.get("full_path") or artifact.get("path") or "").strip() + target = final_dir / Path(raw).name if raw else None + return target if target and target.is_file() and target.suffix.lower() in IMAGE_SUFFIXES else None + +def _kind(role: str, filename: str) -> str: + value = f"{role} {filename}".lower() + groups = (("gene_motif_count", ("gene_motif_heatmap",)), ("all_motif_count", ("all_motif_count_heatmap",)), ("top_motif", ("top_motifs_barplot",)), ("tree", ("tree",)), ("msa", ("msa_plot", "conserved_region")), ("synteny", ("synteny", "circos")), ("motif", ("motif",)), ("domain", ("domain",)), ("structure", ("structure",)), ("combo", ("combo",)), ("promoter_combined", ("promoter_v6_combined",)), ("promoter_position", ("promoter_position_distribution",)), ("promoter", ("promoter",)), ("karyotype", ("karyotype",)), ("tmhmm", ("tmhmm",)), ("subcellular", ("subcellular",)), ("properties", ("properties",))) + return next((kind for kind, tokens in groups if any(token in value for token in tokens)), role.lower()) + +def _rank(role: str, path: Path) -> tuple[int, int, str]: + value = f"{role} {path.name}".lower() + role_lower = role.lower() + preferred = -5 if role_lower == "synteny_marked_png" else -4 if "circular" in value else -2 if "marked" in value else -1 if "summary" in value else 0 + return (preferred, 0, path.name) + +def collect_stage_figures(final_dir: Path, stages: Iterable[str], manifests: Mapping[str, Mapping[str, Any]]) -> dict[str, list[dict[str, str]]]: + result = {} + for stage in stages: + selected = {}; seen = set() + for role, artifact in ((manifests.get(stage) or {}).get("artifacts") or {}).items(): + target = _artifact_image(final_dir, artifact) + if target is None or target.name in seen: continue + seen.add(target.name); kind = _kind(str(role), target.name) + candidate = (_rank(str(role), target), {"role": str(role), "filename": target.name}) + if kind not in selected or candidate[0] < selected[kind][0]: selected[kind] = candidate + if stage == "tfbs": + for role, pattern in ( + ("gene_motif_heatmap", "*gene_motif_heatmap.png"), + ("all_motif_count_heatmap", "*all_motif_count_heatmap.png"), + ("top_motifs_barplot", "*top_motifs_barplot.png"), + ): + matches = sorted(final_dir.glob(pattern)) + if not matches: + continue + target = matches[0] + kind = _kind(role, target.name) + selected[kind] = (_rank(role, target), {"role": role, "filename": target.name}) + figures = [entry[1] for _, entry in sorted(selected.items(), key=lambda item: (item[1][0], item[0]))][:4] + if figures: result[stage] = figures + return result + +def render_stage_figures(stage: str, figures: list[dict[str, str]]) -> str: + meaning, reading, result = STAGE_FIGURE_TEXT.get(stage, ("结果图展示该阶段的主要计算产物。", "结合图题、坐标轴、图例和成员标签阅读。", "本阶段代表结果见图,具体事实以本节确定性数据为准。")) + lines = ["", "### 结果图"] + for index, figure in enumerate(figures, 1): + title = figure["role"] + lines.extend(["", f"#### 图 {index}. {title}", "", f"![{title}]({figure['filename']})", "", f"图形含义:{meaning}", "", f"阅读方法:{reading}", "", f"本次结果:{result}"]) + return "\n".join(lines) + +def _inline(value: str, report_path: Path) -> str: + tokens = [] + def stash(content): + token = f"@@GFAHTML{len(tokens)}@@"; tokens.append(content); return token + def image(match): + alt = match.group(1).strip() or "基因家族结果图"; target = report_path.parent / Path(match.group(2).strip().replace("\\", "/")).name + source = target.name; mime = mimetypes.guess_type(target.name)[0] or "" + if target.is_file() and mime.startswith("image/"): source = f"data:{mime};base64,{base64.b64encode(target.read_bytes()).decode('ascii')}" + return stash(f'
{html.escape(alt, quote=True)}
{html.escape(alt)}
') + def link(match): + label = html.escape(match.group(1).strip()); target = Path(match.group(2).strip().replace("\\", "/")).name + return stash(f'{label}') + value = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, value); value = re.sub(r"(?{html.escape(m.group(1))}"), value); value = html.escape(value); value = re.sub(r"\*\*([^*]+)\*\*", r"\1", value) + for index, content in enumerate(tokens): value = value.replace(f"@@GFAHTML{index}@@", content) + return value + +def markdown_to_html(markdown: str, report_path: Path, title: str) -> str: + output = []; list_tag = ""; table_rows = [] + def close_list(): + nonlocal list_tag + if list_tag: output.append(f""); list_tag = "" + def flush_table(): + nonlocal table_rows + if not table_rows: return + rows, table_rows = table_rows, [] + if len(rows) >= 2 and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in rows[1]): + output.append('
' + ''.join(f"" for c in rows[0]) + ''); output.extend('' + ''.join(f"" for c in row) + '' for row in rows[2:]); output.append('
{_inline(c, report_path)}
{_inline(c, report_path)}
') + else: output.extend(f"

{_inline(' | '.join(row), report_path)}

" for row in rows) + for raw in markdown.splitlines(): + line = raw.strip() + if line.startswith("|") and line.endswith("|"): close_list(); table_rows.append([c.strip() for c in line.strip("|").split("|")]); continue + flush_table() + if not line: close_list(); continue + heading = re.match(r"^(#{1,6})\s+(.+)$", line) + if heading: close_list(); level=len(heading.group(1)); output.append(f"{_inline(heading.group(2), report_path)}"); continue + if line.startswith("> "): close_list(); output.append(f"
{_inline(line[2:], report_path)}
"); continue + item = re.match(r"^([-*]|\d+\.)\s+(.+)$", line) + if item: + tag = "ol" if item.group(1)[0].isdigit() else "ul" + if list_tag != tag: close_list(); list_tag=tag; output.append(f"<{tag}>") + output.append(f"
  • {_inline(item.group(2), report_path)}
  • "); continue + close_list(); rendered=_inline(line, report_path); output.append(rendered if line.startswith("![") else f"

    {rendered}

    ") + close_list(); flush_table(); body="\n".join(output); safe_title=html.escape(title) + return f'''{safe_title}
    {body}
    ''' diff --git a/gfa_core/templates/gene_family_report_v1.md b/gfa_core/templates/gene_family_report_v1.md index effa63973..5a673c580 100644 --- a/gfa_core/templates/gene_family_report_v1.md +++ b/gfa_core/templates/gene_family_report_v1.md @@ -3,10 +3,8 @@ - 任务:{{JOB_ID}} - 分析前缀:{{PREFIX}} -- 生成模式:{{GENERATION_MODE}} - 生成时间:{{GENERATED_AT}} -{{FALLBACK_NOTICE}} ## 一、报告首页 @@ -51,91 +49,91 @@ 报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。 -## 四、基因家族鉴定(Identify) (`identify`) +## 四、基因家族鉴定(Identify) ### 4.3 最终成员数量与命名 {{SECTION_CONTENT}} -## 五、亲缘关系分析(Phylogeny) (`phylogeny`) +## 五、亲缘关系分析(Phylogeny) ### 5.5 亲缘关系阶段小结 {{SECTION_CONTENT}} -## 六、参考物种辅助的小组分类(Reference Classification) (`reference_classification`) +## 六、参考物种辅助的小组分类(Reference Classification) ### 6.2 分类可信度表述 {{SECTION_CONTENT}} -## 七、短保守片段分析(Motif) (`motif`) +## 七、短保守片段分析(Motif) ### 7.2 Motif 数量与出现频率 {{SECTION_CONTENT}} -## 八、主要功能区域分析(Domain) (`domain`) +## 八、主要功能区域分析(Domain) ### 8.1 Domain 整体统计 {{SECTION_CONTENT}} -## 九、基因内部结构分析(Structure) (`structure`) +## 九、基因内部结构分析(Structure) ### 9.1 结构覆盖与异常结构 {{SECTION_CONTENT}} -## 十、综合组合图(Combo) (`combo`) +## 十、综合组合图(Combo) ### 10.1 树 + Motif + Domain + Structure 组合图 {{SECTION_CONTENT}} -## 十一、启动子元件分析(Promoter) (`promoter`) +## 十一、启动子元件分析(Promoter) ### 11.1 元件总量、类别与高级统计 {{SECTION_CONTENT}} -## 十二、转录因子结合位点预测(TFBS) (`tfbs`) +## 十二、转录因子结合位点预测(TFBS) ### 12.1 高频结合片段与统计边界 {{SECTION_CONTENT}} -## 十三、种内共线性分析(Synteny) (`synteny`) +## 十三、种内共线性分析(Synteny) ### 13.1 全基因组共线性背景与家族证据缺口 {{SECTION_CONTENT}} -## 十四、染色体分布(Karyotype) (`karyotype`) +## 十四、染色体分布(Karyotype) ### 14.1 染色体位置概述 {{SECTION_CONTENT}} -## 十五、种间共线性与跨物种关系(Interspecies Synteny) (`interspecies_synteny`) +## 十五、种间共线性与跨物种关系(Interspecies Synteny) ### 15.6 Interspecies Synteny 阶段小结 {{SECTION_CONTENT}} -## 十六、蛋白基本特征分析(PEP) (`pep`) +## 十六、蛋白基本特征分析(PEP) ### 16.1 蛋白理化性质分布图 @@ -151,20 +149,3 @@ ### 17.5 综合结论 {{SECTION_CONTENT}} - - -## 十八、主要限制 - -{{SECTION_CONTENT}} - - -## 十九、后续验证建议 - -{{SECTION_CONTENT}} - - -## 二十、报告交付前检查清单 - -### 20.1 可追溯性与语言检查 - -{{SECTION_CONTENT}} From b8dcb63fa150c648a577d3ad62db754e02eceba7 Mon Sep 17 00:00:00 2001 From: wtx Date: Sun, 9 Aug 2026 16:03:13 +0800 Subject: [PATCH 13/18] fix: preserve raw intent across language switching --- apps/web/vue3/main.py | 5 --- gfa_core/mas/agents/router.py | 1 + gfa_core/mas/agents/router_runtime.py | 9 +++++ tests/unit/test_agent_chat_actions.py | 35 +++++++++++++++++++ .../test_router_analysis_handoff_boundary.py | 20 +++++++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index a8b89e68d..afb151ee4 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -17141,11 +17141,6 @@ async def _run_agent_chat( executes analysis stages here. """ session_id = request.session_id or f"session_{int(time.time())}" - # Inject language instruction for non-Chinese languages - if request.language and request.language != DEFAULT_AGENT_LANG: - lang_map = {"en": "Please respond in English."} - lang_instruction = lang_map.get(request.language, f"Please respond in {request.language}.") - request.message = f"{lang_instruction}\n\n{request.message}" completed_stages: list[str] = [] if request.job_id: final_dir = PROJECT_ROOT / "workspace" / request.job_id / "final" diff --git a/gfa_core/mas/agents/router.py b/gfa_core/mas/agents/router.py index 36875fdfa..2bc665706 100755 --- a/gfa_core/mas/agents/router.py +++ b/gfa_core/mas/agents/router.py @@ -54,6 +54,7 @@ class RouterAgent(BaseAgent): "history": history, "session_id": str(payload.get("session_id") or state.session_id), "job_id": str(payload.get("job_id") or state.job_id), + "language": str(payload.get("language") or "zh"), } if "goal_ledger" in payload: runtime_context["goal_ledger"] = payload.get("goal_ledger") diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py index f0412fee0..99ce44aee 100644 --- a/gfa_core/mas/agents/router_runtime.py +++ b/gfa_core/mas/agents/router_runtime.py @@ -1174,6 +1174,7 @@ class RouterRuntime: session_id: str, job_id: str, goal_ledger: GoalLedger | None = None, + language: str = "zh", ) -> RouterOutcome: ui_action = self._config_dialog_ui_action(query) if ui_action is not None: @@ -1399,6 +1400,7 @@ class RouterRuntime: job_id, decision, goal_ledger, + language, ) definitions = self._definitions_for_route( decision, @@ -2127,6 +2129,7 @@ class RouterRuntime: job_id: str, decision: RouteDecision, goal_ledger: GoalLedger | None, + language: str = "zh", ) -> list[LLMMessage]: normalized = [] for item in history: @@ -2150,6 +2153,12 @@ class RouterRuntime: f"\nTrusted locked route context: {route_context}." f"\nCurrent session_id={session_id!r}; current job_id={job_id!r}." ) + if str(language or "zh").strip().lower() == "en": + context += ( + "\nRespond to the user in English. Treat the language preference as " + "presentation metadata; do not rewrite the user query or pass the " + "language instruction to intent-recognition tools." + ) if goal_ledger is not None: ledger_context = json.dumps( goal_ledger.model_dump(mode="json", round_trip=True), diff --git a/tests/unit/test_agent_chat_actions.py b/tests/unit/test_agent_chat_actions.py index 18fd49984..962c9063c 100644 --- a/tests/unit/test_agent_chat_actions.py +++ b/tests/unit/test_agent_chat_actions.py @@ -92,6 +92,7 @@ async def test_agent_chat_maps_router_actions_and_passes_context( "history": history, "session_id": "session-7", "job_id": "job-7", + "language": "zh", } ] assert result["success"] is True @@ -108,6 +109,40 @@ async def test_agent_chat_maps_router_actions_and_passes_context( assert result["intent"]["stages"] == [] +@pytest.mark.asyncio +async def test_agent_chat_keeps_english_language_separate_from_business_query( + monkeypatch, + web_main, +): + captured = [] + _install_router( + monkeypatch, + { + "status": "ok", + "action": "reply", + "message": "I can help with that.", + "intent": None, + "sources": [], + "tool_trace": [], + }, + captured, + ) + request = web_main.AgentChatRequest( + message="执行接下来的分析", + language="en", + session_id="english-business-query", + job_id="job-english-business-query", + history=[], + ) + + result = await web_main.agent_chat(request) + + assert result["intent"]["message"] == "I can help with that." + assert request.message == "执行接下来的分析" + assert captured[0]["query"] == "执行接下来的分析" + assert captured[0]["language"] == "en" + + @pytest.mark.asyncio async def test_agent_chat_persists_router_proposal_without_analysis_intent( monkeypatch, diff --git a/tests/unit/test_router_analysis_handoff_boundary.py b/tests/unit/test_router_analysis_handoff_boundary.py index 36fef7f98..c92c34069 100644 --- a/tests/unit/test_router_analysis_handoff_boundary.py +++ b/tests/unit/test_router_analysis_handoff_boundary.py @@ -143,3 +143,23 @@ def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() assert normalized.workflow_id is None assert normalized.clarification_question is None assert normalized.analysis_request_kind == "execution" + + +def test_router_response_language_does_not_rewrite_business_query() -> None: + query = "执行接下来的分析" + messages = RouterRuntime._initial_messages( + query=query, + history=[], + session_id="english-router-session", + job_id="job-english-router", + decision=RouteDecision( + goal="general", + needs=[], + subject=query, + ), + goal_ledger=None, + language="en", + ) + + assert messages[-1].content == query + assert "Respond to the user in English." in messages[0].content From 9dbd87696bfad757c9f3371452c8b2f4e8134701 Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 17:31:22 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=95=A3?= =?UTF-8?q?=E8=90=BD=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gfa_core/mas/agents/router_runtime.py | 176 ++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 24 deletions(-) diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py index 99ce44aee..2c71aec51 100644 --- a/gfa_core/mas/agents/router_runtime.py +++ b/gfa_core/mas/agents/router_runtime.py @@ -1181,7 +1181,11 @@ class RouterRuntime: return RouterOutcome( status="ok", action="reply", - message="已打开当前 job 的 config.xlsx 核对表。", + message=( + "Opened the config.xlsx review sheet for the current job." + if language == "en" + else "已打开当前 job 的 config.xlsx 核对表。" + ), ui_action=ui_action, ) @@ -1373,8 +1377,17 @@ class RouterRuntime: status="ok", action="prepare_inputs", message=( - "已识别当前 job 的数据路径。请先核对自动生成的 config.xlsx 草稿," - "确认物种角色后再进入分析规划。" + ( + "Data paths identified for the current job. " + "Please review the auto-generated config.xlsx draft " + "and confirm species roles before proceeding to " + "analysis planning." + ) + if language == "en" + else ( + "已识别当前 job 的数据路径。请先核对自动生成的 config.xlsx 草稿," + "确认物种角色后再进入分析规划。" + ) ), input_paths=authorized_input_paths, interaction_kind=decision.interaction_kind, @@ -1393,6 +1406,52 @@ class RouterRuntime: workflow_id=decision.workflow_id, guidance=decision.analysis_request_kind == "guidance", ) + if decision.analysis_request_kind == "guidance": + proposal_query = ( + f"{effective_query}\n" + + ( + ( + "Request semantics: the user is asking about a single " + "next business action of the current workflow; this " + "request does not include subsequent business actions. " + "Please only identify the current next business action. " + "Specific stages are still determined by the existing " + "IntentAgent and PlanningAgent." + ) + if language == "en" + else ( + "当前请求语义:用户是在询问当前工作流的单一下一业务动作," + "该请求不包含后续业务动作;请仅识别当前下一业务动作,具体阶段仍由" + "现有 IntentAgent 和 PlanningAgent 判断。" + ) + ) + ) + proposal = self._new_analysis_proposal( + session_id=session_id, + job_id=job_id, + query=proposal_query, + subject=decision.subject, + workflow_id=decision.workflow_id, + ) + return RouterOutcome( + status="ok", + action="clarify", + message=( + ( + f"I understand your request as: {proposal['request']}. " + "After confirmation, I'll hand this to the existing " + "analysis system for intent recognition and planning. " + "Continue?" + ) + if language == "en" + else ( + f"我理解你的当前请求是:{proposal['request']}。" + "确认后我会把这一需求交给现有分析系统识别和规划。是否继续?" + ) + ), + proposal=proposal, + interaction_kind=decision.interaction_kind, + ) messages = self._initial_messages( effective_query, history, @@ -1695,6 +1754,7 @@ class RouterRuntime: sources=derived_sources, tool_trace=tool_trace, interaction_kind=decision.interaction_kind, + language=language, ) post_tool_instruction = self._post_tool_instruction( @@ -1821,9 +1881,33 @@ class RouterRuntime: and guidance_workflow_id == "gene_family" and self._claims_gene_family_complete_at_readiness_boundary(final.message) ): - semantic_reason = ( - "semantic_error: field=message; " - "type=unsupported_workflow_completion_claim" + return RouterOutcome( + status="ok", + action=expected_action, + message=( + ( + "The current job has completed gene family member " + "identification and subfamily confirmation. This " + "only means the confirmed member and subfamily " + "classification table can serve as input for " + "downstream analysis; regular gene family analysis " + "is not yet complete. To continue, I'll hand the " + "request to the existing analysis system for intent " + "recognition and planning. Specific stages are still " + "determined by PlanningAgent." + ) + if language == "en" + else ( + "当前 job 已完成基因家族成员鉴定和亚家族确认;这只表示已确认的" + "家族成员及亚家族分类表可以作为后续分析输入,常规基因家族分析" + "尚未因此完成。若要继续,我会把继续常规基因家族分析的需求交给" + "现有分析系统识别和规划,具体阶段仍由 PlanningAgent 决定。" + ) + ), + sources=self._deduplicate_sources(derived_sources), + tool_trace=tool_trace, + interaction_kind=decision.interaction_kind, + ) ) if ( semantic_reason is None @@ -1997,6 +2081,7 @@ class RouterRuntime: sources=derived_sources, tool_trace=tool_trace, interaction_kind=decision.interaction_kind, + language=language, ) if decision.goal == "project_status" and project_context_observation is not None: guidance_workflow_id = self._effective_workflow_id( @@ -2068,24 +2153,57 @@ class RouterRuntime: sources: list[dict[str, Any]], tool_trace: list[dict[str, Any]], interaction_kind: InteractionKind, + language: str = "zh", ) -> RouterOutcome: if observation.get("success") is not True: - message = { - "file_not_found": "当前 job 中没有找到该文件,请核对文件名或路径。", - "path_not_allowed": "该路径不属于当前 job 的只读授权范围。", - "ambiguous_path": "当前 job 中有多个同名文件,请提供带 input/、final/、logs/ 或 workspace/ 的路径。", - "binary_file": "该文件不是可直接预览的文本文件。", - }.get(str(observation.get("error_code") or "")) or str( - observation.get("message") - or "当前 job 中没有找到或无法读取该文件。" - ).strip() + message = ( + { + "file_not_found": ( + "File not found in the current job. Please check " + "the filename or path." + if language == "en" + else "当前 job 中没有找到该文件,请核对文件名或路径。" + ), + "path_not_allowed": ( + "This path is outside the authorized read-only " + "scope of the current job." + if language == "en" + else "该路径不属于当前 job 的只读授权范围。" + ), + "ambiguous_path": ( + "Multiple files with the same name exist in the " + "current job. Please provide a path with " + "input/, final/, logs/ or workspace/." + if language == "en" + else "当前 job 中有多个同名文件,请提供带 input/、final/、logs/ 或 workspace/ 的路径。" + ), + "binary_file": ( + "This file is not a directly previewable text file." + if language == "en" + else "该文件不是可直接预览的文本文件。" + ), + }.get(str(observation.get("error_code") or "")) or str( + observation.get("message") + or ( + "File not found or not readable in the current job." + if language == "en" + else "当前 job 中没有找到或无法读取该文件。" + ) + ).strip() + ) elif isinstance(observation.get("files"), list): files = [item for item in observation["files"] if isinstance(item, dict)] total = observation.get("total") - message = f"当前 job 文件(本页 {len(files)} 个" - if isinstance(total, int): - message += f",共 {total} 个" - message += "):" + if language == "en": + message = f"Current job files (this page: {len(files)}" + if isinstance(total, int): + message += f", total: {total}" + message += "):" + else: + message = f"当前 job 文件(本页 {len(files)} 个" + if isinstance(total, int): + message += f",共 {total} 个" + message += "):" lines: list[str] = [] for item in files: handle = str(item.get("file_handle") or "").strip() @@ -2100,16 +2218,26 @@ class RouterRuntime: if lines: message += "\n" + "\n".join(lines) if observation.get("truncated"): - message += "\n列表已分页,可继续查看后续文件。" + message += ( + "\nList is paginated; you can continue viewing subsequent files." + if language == "en" + else "\n列表已分页,可继续查看后续文件。" + ) else: file_handle = str(observation.get("file_handle") or "requested file") preview = str(observation.get("preview") or "").strip() returned = observation.get("returned_lines") line_count = observation.get("line_count") - message = f"已读取 {file_handle}" - if isinstance(returned, int) and isinstance(line_count, int): - message += f"(返回 {returned}/{line_count} 行)" - message += "。" + if language == "en": + message = f"Read {file_handle}" + if isinstance(returned, int) and isinstance(line_count, int): + message += f" (returned {returned}/{line_count} lines)" + message += "." + else: + message = f"已读取 {file_handle}" + if isinstance(returned, int) and isinstance(line_count, int): + message += f"(返回 {returned}/{line_count} 行)" + message += "。" if preview: message += f"\n\n{preview}" return RouterOutcome( From 963e4ddc732779b2f6a513fcf0e84ff8d91153c0 Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 18:03:28 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=95=A3?= =?UTF-8?q?=E8=90=BD=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index_ml.html | 2 +- gfa_core/mas/agents/conversation_tools.py | 122 +++++++++++++++++----- 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index 0f5967946..adc74f6de 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -2158,7 +2158,7 @@ {{ ragProgress.current || '正在知识库中查找相关内容……' }} - 停止等待 + {{ t('stop-waiting') }}
    diff --git a/gfa_core/mas/agents/conversation_tools.py b/gfa_core/mas/agents/conversation_tools.py index 6e4bcc4b4..7c253712e 100644 --- a/gfa_core/mas/agents/conversation_tools.py +++ b/gfa_core/mas/agents/conversation_tools.py @@ -264,6 +264,7 @@ class RecognizeAnalysisIntentArgs(BaseModel): query: str job_id: str = "" prefix: str = "" + language: str = "zh" _job_id_is_local = field_validator("job_id")(_validate_job_id) _prefix_is_literal = field_validator("prefix")(_validate_prefix) @@ -1604,21 +1605,29 @@ def build_plantai_conversation_tools( *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: """Adapt MainAgent memory to the original IntentAgent text contract.""" text = str(query or "").strip() + en = language == "en" + if ( + text.startswith("执行数据准备(genome_prepare)") + and "当前业务动作已由数据准备契约唯一确定" in text + ): + return text if goal_ledger is None: return text terms = goal_ledger.user_terms context: list[str] = [] family = str(terms.get("gene_family") or "").strip() if family: - context.append(f"基因家族 {family}") - for key, label in ( - ("target_species", "目标物种"), - ("reference_species", "参考物种"), - ("comparative_species", "比较物种"), + context.append(f"Gene family: {family}" if en else f"基因家族 {family}") + for key, label_zh, label_en in ( + ("target_species", "目标物种", "Target species"), + ("reference_species", "参考物种", "Reference species"), + ("comparative_species", "比较物种", "Comparative species"), ): + label = label_en if en else label_zh values = terms.get(key) or () if isinstance(values, (list, tuple)): cleaned = [ @@ -1638,21 +1647,39 @@ def build_plantai_conversation_tools( if mode == "whole_genome": enabled_designs = "、".join(business_context["enabled_designs"]) disabled_designs = "、".join(business_context["disabled_designs"]) - context.append( - f"RNA-seq业务模式 全基因组;{experiment_design} 是实验设计权威输入;" - f"可解释设计为 {enabled_designs},{disabled_designs} 本轮禁用;" - "设计或 batch 降级不得静默发生" - ) + if en: + context.append( + f"RNA-seq mode: whole genome; {experiment_design} is the authoritative experimental design input; " + f"interpretable designs: {enabled_designs}, {disabled_designs} disabled this round; " + "design or batch downgrades must not happen silently" + ) + else: + context.append( + f"RNA-seq业务模式 全基因组;{experiment_design} 是实验设计权威输入;" + f"可解释设计为 {enabled_designs},{disabled_designs} 本轮禁用;" + "设计或 batch 降级不得静默发生" + ) else: - context.append( - "RNA-seq业务模式 基因家族成员;使用已成功的全基因组差异表达结果" - "和精确确认的基因家族成员进行投影;不得自动选择首个成员文件," - "不得隐式重跑差异表达" - ) + if en: + context.append( + "RNA-seq mode: gene family member; use successful whole-genome differential expression results " + "and precisely confirmed gene family members for projection; must not auto-select the first member file, " + "must not implicitly re-run differential expression" + ) + else: + context.append( + "RNA-seq业务模式 基因家族成员;使用已成功的全基因组差异表达结果" + "和精确确认的基因家族成员进行投影;不得自动选择首个成员文件," + "不得隐式重跑差异表达" + ) elif rna_route == "whole_genome": - context.append("RNA-seq模式 全基因组") + context.append( + "RNA-seq mode: whole genome" if en else "RNA-seq模式 全基因组" + ) elif rna_route == "family_member": - context.append("RNA-seq模式 基因家族成员") + context.append( + "RNA-seq mode: gene family member" if en else "RNA-seq模式 基因家族成员" + ) job_id = bound_job_id or str(terms.get("active_job_id") or "").strip() formal_config_exists = bool( job_id and (project_root / "input" / job_id / "config.xlsx").is_file() @@ -1686,15 +1713,62 @@ def build_plantai_conversation_tools( and family_matches ): subfamily_confirm_observed = True - if config_confirmed: - context.append("正式 config.xlsx 已确认") + genome_prepare_observed = False + canonical_request = None + if config_confirmed and not genome_prepare_observed: + if en: + context.append( + "Formal config.xlsx confirmed; this indicates configuration has been verified, " + "not that data preparation is complete. No data preparation completion evidence in current job; " + "specific analysis intent and stages still determined by the existing analysis system" + ) + else: + context.append( + "正式 config.xlsx 已确认;这只表示配置已核对,不等于数据准备已经完成," + "当前 job 尚无数据准备完成证据;具体分析意图和阶段仍由现有分析系统判断" + ) + if guidance and workflow_id in { + "data_preparation", + "gene_family", + "rnaseq_whole_genome", + "rnaseq_family_member", + }: + contract = load_canonical_workflow_contract("data_preparation") + transition = next( + item + for item in contract.transitions + if item.from_state_id == "config_confirmed" + ) + actions = {item.id: item for item in contract.actions} + target_stage = actions[transition.action_id].target_stage + if en: + canonical_request = ( + f"Execute data preparation ({target_stage})\n" + "Business action uniquely determined by data preparation contract" + ) + else: + canonical_request = ( + f"执行数据准备({target_stage})\n" + "当前业务动作已由数据准备契约唯一确定" + ) if family and subfamily_confirm_observed: - context.append( - "当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认," - "这是当前项目状态事实;具体分析意图和阶段仍由现有分析系统判断" - ) + if en: + context.append( + "Current job's subfamily_confirm completed successfully; " + "family members and subfamily classification table confirmed. " + "This is a current project state fact; specific analysis intent and stages still determined by the existing analysis system" + ) + else: + context.append( + "当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认," + "这是当前项目状态事实;具体分析意图和阶段仍由现有分析系统判断" + ) + if canonical_request: + return canonical_request if not context: return text + if en: + return f"{text}\nConfirmed context: {'; '.join(context)}." return f"{text}\n已确认上下文:{';'.join(context)}。" registry.set_analysis_handoff_resolver(intent_query_with_confirmed_context) @@ -1711,7 +1785,7 @@ def build_plantai_conversation_tools( receiver="intent_v2", message_type="recognize_intent", payload={ - "query": intent_query_with_confirmed_context(args.query), + "query": intent_query_with_confirmed_context(args.query, language=args.language), "job_id": job_id, "prefix": args.prefix, "workspace": str(project_root / "workspace" / job_id) if job_id else "", From 87c158efe5e351a34d2acf24d31880c299f6dbb9 Mon Sep 17 00:00:00 2001 From: potaser Date: Sun, 9 Aug 2026 22:58:26 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=95=A3?= =?UTF-8?q?=E8=90=BD=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gfa_core/mas/agents/conversation_tools.py | 2 ++ gfa_core/mas/agents/router_runtime.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/gfa_core/mas/agents/conversation_tools.py b/gfa_core/mas/agents/conversation_tools.py index 7c253712e..42bd2c9b5 100644 --- a/gfa_core/mas/agents/conversation_tools.py +++ b/gfa_core/mas/agents/conversation_tools.py @@ -53,6 +53,7 @@ class ConversationToolRegistry: *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: text = str(query or "").strip() if self._analysis_handoff_resolver is None: @@ -62,6 +63,7 @@ class ConversationToolRegistry: text, workflow_id=workflow_id, guidance=guidance, + language=language, ) or "" ).strip() diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py index 2c71aec51..80cccdbf3 100644 --- a/gfa_core/mas/agents/router_runtime.py +++ b/gfa_core/mas/agents/router_runtime.py @@ -1405,6 +1405,7 @@ class RouterRuntime: effective_query, workflow_id=decision.workflow_id, guidance=decision.analysis_request_kind == "guidance", + language=language, ) if decision.analysis_request_kind == "guidance": proposal_query = ( @@ -1908,7 +1909,7 @@ class RouterRuntime: tool_trace=tool_trace, interaction_kind=decision.interaction_kind, ) - ) + if ( semantic_reason is None and decision.goal == "project_status" From fe5f7b9d6ee32e3b1dab8fda5e0dcc1b01e68641 Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 10 Aug 2026 00:31:32 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=95=A3?= =?UTF-8?q?=E8=90=BD=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index_ml.html | 286 +++++++++++++++--------------- apps/web/vue3/main.py | 2 + apps/web/vue3/static/i18n_en.js | 176 +++++++++++++++++- gfa_core/mas/agents/diagnosis.py | 44 +++-- gfa_core/mas/core/orchestrator.py | 102 +++++++++-- 5 files changed, 434 insertions(+), 176 deletions(-) diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index adc74f6de..8f2cd4ec7 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -7724,7 +7724,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 if (!conv) return; currentConversationId.value = conv.conversation_id; currentConversation.value = conv; - ElementPlus.ElMessage.info('切换到: ' + conv.title); + ElementPlus.ElMessage.info(tp('switched-to', { title: conv.title })); // 恢复关联的 job_id if (conv.job_id) { @@ -7789,9 +7789,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 if (!conv || !conv.conversation_id) return; try { await ElementPlus.ElMessageBox.confirm( - `确定删除会话「${conv.title || conv.conversation_id}」吗?该操作会从列表中移除,但保留后台记录。`, - '删除会话', - { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' } + tp('delete-conversation-msg', { title: conv.title || conv.conversation_id }), + t('delete-conversation-title'), + { confirmButtonText: t('delete'), cancelButtonText: t('cancel'), type: 'warning' } ); } catch (e) { return; @@ -8080,7 +8080,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 logContent.value = ''; ElMessage.success(t('错误日志已清空')); }; - const handleDownloadExample = () => ElMessage.info('示例下载功能当前不可用'); + const handleDownloadExample = () => ElMessage.info(t('example-download-unavailable')); const autofillRowMatchesBulkFilter = (row) => { const query = String(autofillState.bulkFilter || '').trim().toLowerCase(); if (!query) return true; @@ -8272,12 +8272,12 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 const edits = currentAutofillBulkEdits(); const editedFields = Object.entries(edits).filter(([, value]) => value !== '').map(([key]) => key); if (!editedFields.length) { - ElMessage.warning('请至少填写一个要预览修改的字段'); + ElMessage.warning(t('autofill-preview-required')); return; } const rows = selectedAutofillBulkRows(); if (!rows.length) { - ElMessage.warning('没有匹配的草稿行'); + ElMessage.warning(t('autofill-no-matching-rows')); return; } (autofillState.rows || []).forEach(row => { delete row.__bulkEdited; }); @@ -8289,12 +8289,12 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 pushAutofillChangedRow(row); }); autofillState.bulkPreviewActive = true; - autofillState.bulkLastSummary = `预览 ${rows.length} 行:${editedFields.join(', ')}。确认无误后点击 Apply。`; + autofillState.bulkLastSummary = t('autofill-bulk-rows-preview', { rows: rows.length, fields: editedFields.join(', ') }); ElMessage.success(autofillState.bulkLastSummary); }; const applyAutofillBulkPreview = () => { if (!autofillState.bulkPreviewActive) { - ElMessage.warning('请先预览要应用的修改'); + ElMessage.warning(t('autofill-preview-first')); return; } let count = 0; @@ -8307,14 +8307,14 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 delete row.__bulkPreviewed; }); autofillState.bulkPreviewActive = false; - autofillState.bulkLastSummary = `已应用 ${count} 行修改。`; + autofillState.bulkLastSummary = t('autofill-bulk-rows-applied', { rows: count }); ElMessage.success(autofillState.bulkLastSummary); }; const cancelAutofillBulkPreview = () => { if (!autofillState.bulkPreviewActive) return; restoreAutofillBulkPreview(); autofillState.bulkChangedRows = []; - autofillState.bulkLastSummary = '已取消预览,表格已恢复到预览前。'; + autofillState.bulkLastSummary = t('autofill-bulk-preview-cancelled'); ElMessage.info(autofillState.bulkLastSummary); }; const applyAutofillBulkEdit = previewAutofillBulkEdit; @@ -8557,7 +8557,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 }; const confirmAutofillDraft = async () => { if (!autofillState.draftPath) { - ElMessage.warning('请先生成或加载草稿'); + ElMessage.warning(t('autofill-generate-draft-first')); return; } if (autofillState.bulkPreviewActive) { @@ -8567,20 +8567,20 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 const missing = (autofillState.rows || []).filter(row => !String(row.library_strandness || '').trim()); if (missing.length) { const examples = missing.slice(0, 5).map(row => row.sample_id || row.run || '未命名样本').join(', '); - ElMessage.warning(`链特异性为必填:还有 ${missing.length} 个样本未填写(${examples})`); + ElMessage.warning(tp('autofill-strandness-required', { count: missing.length, examples: examples })); return; } const review = await requestSamplesDesignReview({ quiet: true }); if (review?.status !== 'ready') { - ElMessage.warning(`设计提示:${review?.error || '未生成高级设计'};将保留原流程的兼容处理。`); + ElMessage.warning(tp('autofill-design-tip', { error: review?.error || t('autofill-design-not-generated') })); } const comparisonText = (review?.comparisons || []).map(item => item.display).join(';') || '沿用原流程兼容设计'; const interactionText = review?.interaction?.enabled ? `;${review.interaction.display}(LRT + Wald)` : ''; try { await ElMessageBox.confirm( `${review?.module || 'M1'} ${review?.module_name || '兼容模式'}\n${comparisonText}${interactionText}`, - '确认 DE 分析设计', - { confirmButtonText: '确认设计并写入', cancelButtonText: '返回修改', type: 'info' } + t('confirm-de-design-title'), + { confirmButtonText: t('confirm-design-write'), cancelButtonText: t('return-modify'), type: 'info' } ); } catch (_err) { return; @@ -8614,10 +8614,10 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 }); const data = await response.json(); if (!data.success) { - ElMessage.error(data.detail || data.message || '确认失败'); + ElMessage.error(data.detail || data.message || t('confirm-failed')); return; } - ElMessage.success(autofillState.type === 'config' ? '已写入正式 config.xlsx' : '已写入正式 samples.yaml'); + ElMessage.success(autofillState.type === 'config' ? t('autofill-config-written') : t('autofill-samples-written')); autofillDialogVisible.value = false; if (autofillState.type === 'config' && data.resume_goal) { await addAgentMessage('assistant', t('config.xlsx 已确认,继续当前目标:') + data.resume_goal); @@ -8625,7 +8625,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 }; const loadFromPath = async (options = {}) => { if (!inputPath.value) { - ElMessage.warning('请输入文件路径'); + ElMessage.warning(t('enter-file-path')); return { success: false }; } const response = await fetch('/api/load_from_path', { @@ -8642,7 +8642,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 const data = await response.json(); const hasDraftRows = (draft) => Array.isArray(draft?.rows) && draft.rows.length > 0; if (data.success) { - ElMessage.success(`已加载 ${data.count || 0} files`); + ElMessage.success(tp('files-loaded', { count: data.count || 0 })); const hasSamplesDraft = !!data.samples_draft_created && hasDraftRows(data.samples_draft); const hasConfigDraft = !!data.config_draft_created && hasDraftRows(data.config_draft); if (!options.suppressAutofill && hasSamplesDraft) { @@ -8662,7 +8662,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 } inputPath.value = ''; } else { - ElMessage.error(data.message || '加载失败'); + ElMessage.error(data.message || t('load-failed')); } return data; }; @@ -9328,7 +9328,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 sessionId.value = 'session_' + Date.now(); localStorage.setItem('bbav_session_id', sessionId.value); ragHistory.value = []; - ElMessage.success('已Clear当前会话'); + ElMessage.success(t('cleared-current-session')); }; // 参数确认对话框 @@ -9560,7 +9560,7 @@ compare: [[flesh, peel]]`; currentInterpretStage.value = { id: stageId, name: stageName, identity }; } catch (err) { console.error('[深度解读] 请求失败:', err); - ElMessage.error(err.message || '请求解读服务失败'); + ElMessage.error(err.message || t('interpret-request-failed')); throw err; } }; @@ -9591,9 +9591,9 @@ compare: [[flesh, peel]]`; useRagSupport: true, refreshRag: true }); - ElMessage.success('RAG解读已刷新'); + ElMessage.success(t('rag-interpretation-refreshed')); } catch (err) { - ElMessage.error(err.message || 'RAG解读刷新失败'); + ElMessage.error(err.message || t('rag-interpretation-refresh-failed')); } finally { interpretationRagRefreshing.value = false; } @@ -9715,14 +9715,14 @@ compare: [[flesh, peel]]`; const res = await fetch(`/api/agent/report/${session}/download?format=${format}&stage=${stage.id || ''}`); if (format === 'json') { const data = await res.json(); - if (!res.ok || data.success === false) throw new Error(data.message || '下载报告失败'); + if (!res.ok || data.success === false) throw new Error(data.message || t('download-report-failed')); console.log('报告数据:', data); - ElMessage.success('JSON数据已加载到控制台'); + ElMessage.success(t('json-loaded-to-console')); return; } if (!res.ok) { const errData = await res.json().catch(() => ({})); - throw new Error(errData.message || '下载报告失败'); + throw new Error(errData.message || t('download-report-failed')); } const blob = await res.blob(); const url = window.URL.createObjectURL(blob); @@ -9744,8 +9744,8 @@ compare: [[flesh, peel]]`; if (!interpretationReport.value) return; const content = interpretationReport.value.biological_story || JSON.stringify(interpretationReport.value, null, 2); navigator.clipboard.writeText(content).then(() => { - ElMessage.success('解读内容已复制'); - }).catch(() => ElMessage.error('复制失败')); + ElMessage.success(t('interpretation-copied')); + }).catch(() => ElMessage.error(t('copy-failed'))); }; // 依赖驱动的并行执行架构 @@ -10479,9 +10479,9 @@ compare: [[flesh, peel]]`; nodeTypes: Object.keys(nodeColors).length }; initKgChart(); - ElMessage.success('知识图谱加载完成'); + ElMessage.success(t('kg-loaded')); } catch (error) { - ElMessage.error('加载图谱失败: ' + error.message); + ElMessage.error(t('kg-load-failed', { message: error.message })); } finally { kgLoading.value = false; } @@ -10519,9 +10519,9 @@ compare: [[flesh, peel]]`; if (node) { kgChart.value.dispatchAction({ type: 'highlight', seriesIndex: 0, name: node.id }); kgSelectedNode.value = node; - ElMessage.success(`找到节点: ${node.name}`); + ElMessage.success(tp('kg-node-found', { name: node.name })); } else { - ElMessage.warning('未找到匹配的节点'); + ElMessage.warning(t('kg-node-not-found')); } }; @@ -10534,7 +10534,7 @@ compare: [[flesh, peel]]`; if (kgChart.value) kgChart.value.resize(); }); }); - ElMessage.success('视图已重置'); + ElMessage.success(t('kg-view-reset')); }; const reloadKnowledgeGraph = () => { @@ -10548,7 +10548,7 @@ compare: [[flesh, peel]]`; const openKgFullscreen = () => { if (kgData.value.nodes.length === 0) { - ElMessage.warning('请先通过问答加载子图,或点击「重新加载」'); + ElMessage.warning(t('kg-load-subgraph-first')); return; } kgFullscreenVisible.value = true; @@ -10582,9 +10582,9 @@ compare: [[flesh, peel]]`; if (node) { kgFullscreenChart.value.dispatchAction({ type: 'highlight', seriesIndex: 0, name: node.id }); kgSelectedNode.value = node; - ElMessage.success(`找到节点: ${node.name}`); + ElMessage.success(tp('kg-node-found', { name: node.name })); } else { - ElMessage.warning('未找到匹配的节点'); + ElMessage.warning(t('kg-node-not-found')); } }; @@ -10731,7 +10731,7 @@ compare: [[flesh, peel]]`; } catch (err) { tablePreviewRows.value = []; tablePreviewColumns.value = []; - ElMessage.error('读取表格失败: ' + (err.message || err)); + ElMessage.error(t('table-read-failed', { reason: err.message || err })); } finally { tablePreviewLoading.value = false; } @@ -10765,7 +10765,7 @@ compare: [[flesh, peel]]`; const openPpiResetDialog = async (stage = null) => { if (!jobInfo.job_id) { - ElMessage.warning('当前没有可用 job_id'); + ElMessage.warning(t('no-job-id-available')); return; } ppiResetDialogVisible.value = true; @@ -10795,7 +10795,7 @@ compare: [[flesh, peel]]`; const geneId = String(ppiResetNewId.value || '').trim(); if (!geneId) return; if (ppiResetRows.value.some(row => row.gene_id === geneId)) { - ElMessage.warning('该 ID 已存在'); + ElMessage.warning(t('ppi-id-exists')); return; } ppiResetRows.value.push({ gene_id: geneId, keep: true, source: 'manual_added' }); @@ -10809,7 +10809,7 @@ compare: [[flesh, peel]]`; .map(row => String(row.gene_id || '').trim()) .filter(Boolean); if (!keptIds.length) { - ElMessage.warning('至少保留一个 gene ID'); + ElMessage.warning(t('ppi-keep-at-least-one')); return; } ppiResetSubmitting.value = true; @@ -10830,7 +10830,7 @@ compare: [[flesh, peel]]`; }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.detail || data.message || 'PPI Reset 失败'); - ElMessage.success(`PPI Reset 已完成: ${data.reset_id}`); + ElMessage.success(tp('ppi-reset-done', { reset_id: data.reset_id })); ppiResetDialogVisible.value = false; await refreshResults(); } catch (err) { @@ -10931,10 +10931,10 @@ compare: [[flesh, peel]]`; } } // 如果没找到 URL,显示文件内容 - ElMessage.info('STRING 链接文件内容:' + text.substring(0, 200)); + ElMessage.info(tp('string-link-content', { preview: text.substring(0, 200) })); }) .catch(err => { - ElMessage.error('读取文件失败: ' + err.message); + ElMessage.error(tp('file-read-failed', { message: err.message })); }); return; } @@ -11227,7 +11227,7 @@ compare: [[flesh, peel]]`; const openSelectedImageReplot = async () => { if (!selectedImageUrl.value) { - ElMessage.warning('请先选择一张图片'); + ElMessage.warning(t('select-image-first')); return; } if (isComboImageName(selectedImage.value)) { @@ -11384,7 +11384,7 @@ compare: [[flesh, peel]]`; // 显示任务详情 const showTaskDetail = (task) => { - ElMessage.info(`任务: ${task.message}\n状态: ${task.status}\n进度: ${task.progress}%`); + ElMessage.info(tp('task-detail', { message: task.message, status: task.status, progress: task.progress })); }; // 定时RefreshAgent Tasks @@ -11509,7 +11509,7 @@ compare: [[flesh, peel]]`; } } catch (e) { console.error('[会话] 消息持久化失败:', e); - ElMessage.warning('当前消息未成功写入会话历史,刷新后可能丢失'); + ElMessage.warning(t('message-persist-failed')); } } }; @@ -11528,9 +11528,9 @@ compare: [[flesh, peel]]`; const copyPath = async (path) => { try { await navigator.clipboard.writeText(path); - ElMessage.success('路径已复制'); + ElMessage.success(t('path-copied')); } catch (err) { - ElMessage.error('复制失败'); + ElMessage.error(t('copy-failed')); } }; @@ -11588,10 +11588,10 @@ compare: [[flesh, peel]]`; } } - ElMessage.success('已创建新Working Directory: ' + data.job_id); + ElMessage.success(tp('wd-created', { job_id: data.job_id })); } } catch (err) { - ElMessage.error('创建失败: ' + err.message); + ElMessage.error(tp('wd-create-failed', { message: err.message })); } }; @@ -11610,7 +11610,7 @@ compare: [[flesh, peel]]`; jobSelectorVisible.value = true; } } catch (err) { - ElMessage.error('获取Working Directory列表失败: ' + err.message); + ElMessage.error(tp('wd-list-failed', { message: err.message })); } }; @@ -11657,14 +11657,14 @@ compare: [[flesh, peel]]`; } } - ElMessage.success('已切换到Working Directory: ' + data.job_id); + ElMessage.success(tp('wd-switched', { job_id: data.job_id })); // Refresh结果展示 await refreshResults(); } else { - ElMessage.error(data.error || '切换失败'); + ElMessage.error(data.error || t('wd-switch-failed')); } } catch (err) { - ElMessage.error('切换失败: ' + err.message); + ElMessage.error(tp('wd-switch-failed-reason', { message: err.message })); } }; @@ -12958,7 +12958,7 @@ compare: [[flesh, peel]]`; const showConfirm = async () => { console.log('[DEBUG] showConfirm called, selectedStage:', selectedStage.value); if (!selectedStage.value) { - ElMessage.warning('请先SelectAnalysis Stage'); + ElMessage.warning(t('select-analysis-stage')); console.log('[DEBUG] showConfirm: no stage selected, returning'); return; } @@ -13065,13 +13065,13 @@ compare: [[flesh, peel]]`; const autoAnnotationBackgroundTitle = (payload = {}) => { const required = Array.isArray(payload.required_backgrounds) ? payload.required_backgrounds : []; const enrichType = String(payload.enrich_type || '').toLowerCase(); - if (required.includes('gene2go') && required.includes('gene2ko')) return 'GO/KEGG 富集背景缺失'; - if (required.includes('gene2ko') || enrichType === 'kegg') return 'KEGG 富集背景缺失'; - if (required.includes('gene2go') || enrichType === 'go') return 'GO 富集背景缺失'; - return 'RNA-seq 富集背景缺失'; + if (required.includes('gene2go') && required.includes('gene2ko')) return t('go-kegg-enrich-bg-missing'); + if (required.includes('gene2ko') || enrichType === 'kegg') return t('kegg-enrich-bg-missing'); + if (required.includes('gene2go') || enrichType === 'go') return t('go-enrich-bg-missing'); + return t('rnaseq-enrich-bg-missing'); }; - const autoAnnotationDialogTitle = (payload = {}) => `${autoAnnotationBackgroundTitle(payload)},需要自动注释`; + const autoAnnotationDialogTitle = (payload = {}) => t('enrich-bg-missing-auto-annotation', { title: autoAnnotationBackgroundTitle(payload) }); const autoAnnotationDialogMessage = (payload = {}) => { const required = Array.isArray(payload.required_backgrounds) ? payload.required_backgrounds : []; @@ -13145,7 +13145,7 @@ compare: [[flesh, peel]]`; : (issues.length ? issues[0] : {}); const firstIssue = primaryIssue; if (!Object.keys(firstIssue).length && !fixData.force_show) { - ElMessage.info(fixData.reason || '诊断未发现明确问题,请查看阶段错误日志。'); + ElMessage.info(fixData.reason || t('diagnosis-no-issue-found')); return; } const firstIssueCode = firstIssue.code || ''; @@ -13175,7 +13175,7 @@ compare: [[flesh, peel]]`; const explicitAction = fixData.fix_action || fixData.action || ''; const hasExecutableFix = safeCandidates.length > 0 || ['prepare_job_patch', 'execute_job_patch'].includes(explicitAction); if (!fixData.force_show && !hasExecutableFix && firstIssue.severity !== 'blocking' && firstIssue.severity !== 'error') { - ElMessage.info(firstIssue.recommendation || fixData.reason || '诊断仅提示信息,无需确认修复。'); + ElMessage.info(firstIssue.recommendation || fixData.reason || t('diagnosis-info-only')); return; } const hasConfirmActions = repairActions.some(action => action.policy === 'confirm'); @@ -13261,13 +13261,13 @@ compare: [[flesh, peel]]`; }); if (response.ok) { - ElMessage.success('✅ 已应用修复方案,继续执行...'); + ElMessage.success(t('fix-applied')); } else { - ElMessage.error('❌ 应用修复失败'); + ElMessage.error(t('fix-apply-failed')); } } catch (error) { console.error('[Diagnosis Confirm] Error:', error); - ElMessage.error('❌ 网络错误,请重试'); + ElMessage.error(t('network-error-retry')); } }; @@ -13289,9 +13289,9 @@ compare: [[flesh, peel]]`; const shouldPause = diagnosisFixData.value.blocksDownstream === true; if (shouldPause) { shouldStopBatchExecute.value = true; - ElMessage.warning('⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。'); + ElMessage.warning(t('fix-skipped-paused')); } else { - ElMessage.info('已关闭诊断提示。'); + ElMessage.info(t('diagnosis-closed')); } } catch (error) { console.error('[Diagnosis Reject] Error:', error); @@ -13354,11 +13354,11 @@ compare: [[flesh, peel]]`; executeFn, confirmFn = (message) => ElMessageBox.confirm( message, - 'GATK 执行失败', + t('gatk-failed-title'), { type: 'warning', - confirmButtonText: '使用 bcftools 重试', - cancelButtonText: '取消', + confirmButtonText: t('retry-bcftools'), + cancelButtonText: t('cancel'), } ) ) => { @@ -13615,14 +13615,14 @@ compare: [[flesh, peel]]`; try { await ElMessageBox.confirm( [ - '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:', + t('string-close-species-line1'), '', ...candidateLines, '', - '确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。' + t('string-close-species-line2') ].join('\n'), - '确认 PPI 的 STRING 近源参考物种', - { type: 'warning', confirmButtonText: '确认第一候选', cancelButtonText: '取消 PPI' } + t('string-close-species-title'), + { type: 'warning', confirmButtonText: t('string-confirm-first-candidate'), cancelButtonText: t('string-cancel-ppi') } ); } catch (_err) { return null; @@ -15036,7 +15036,7 @@ compare: [[flesh, peel]]`; if (gwasPrefixMissingFor({ stage: 'gwas_fmt', prefix })) { clearBatchExecuteTimer(); batchExecuteCountdown.value = 0; - ElMessage.warning('请填写 GWAS 物种简称(用于结果前缀)'); + ElMessage.warning(t('gwas-prefix-required')); return; } lockGwasBatchPrefix(prefix); @@ -16022,15 +16022,15 @@ compare: [[flesh, peel]]`; const categories = Array.isArray(confirmParams.promoterCategories) ? confirmParams.promoterCategories : []; const plotModes = Array.isArray(confirmParams.promoterPlotModes) ? confirmParams.promoterPlotModes : []; if (!prefixValue) { - ElMessage.error('无法确定启动子输出前缀。'); + ElMessage.error(t('promoter-prefix-unknown')); return null; } if (!elements.length && !categories.length) { - ElMessage.error('请至少选择一个启动子元件或类别。'); + ElMessage.error(t('promoter-select-element')); return null; } if (!plotModes.length) { - ElMessage.error('请至少选择一种图类型。'); + ElMessage.error(t('promoter-select-plot-type')); return null; } const requestBody = { @@ -16086,7 +16086,7 @@ compare: [[flesh, peel]]`; const submitSelectedTreeReplot = async () => { const prefixValue = confirmParams.prefix || inferTreePrefixFromImageName(selectedImage.value); if (!prefixValue) { - ElMessage.error('无法确定进化树输出标签,请先选择树图或填写输出标签。'); + ElMessage.error(t('tree-output-label-unknown')); return null; } const requestBody = { @@ -16144,12 +16144,12 @@ compare: [[flesh, peel]]`; : outputStem + '.png'; const outputPath = '/workspace/' + jobInfo.job_id + '/final/' + outputName; selectImageFile(displayStage, outputPath); - ElMessage.success('进化树图重绘完成,已切换到新图'); + ElMessage.success(t('tree-replot-done')); return data; } catch (err) { console.error('[Tree Replot] submit failed:', err); const msg = err && err.message ? err.message : String(err); - ElMessage.error('进化树图重绘失败:' + msg); + ElMessage.error(tp('tree-replot-failed-reason', { message: msg })); addAgentMessage('assistant', t('进化树图重绘失败:') + msg); return null; } finally { @@ -16160,7 +16160,7 @@ compare: [[flesh, peel]]`; const submitSelectedSyntenyReplot = async () => { const prefixValue = confirmParams.prefix || inferSyntenyPrefixFromImageName(selectedImage.value); if (!prefixValue) { - ElMessage.error('无法确定种内共线性输出标签,请先选择 Circos 图。'); + ElMessage.error(t('synteny-output-label-unknown')); return null; } const outputLabel = confirmParams.syntenyOutputLabel || 'synteny_replot_circos'; @@ -16219,12 +16219,12 @@ compare: [[flesh, peel]]`; const submitSelectedComboReplot = async () => { const prefixValue = confirmParams.prefix || inferComboPrefixFromImageName(selectedImage.value); if (!prefixValue) { - ElMessage.error('无法确定 combo 输出标签,请先选择 combo 图。'); + ElMessage.error(t('combo-output-label-unknown')); return null; } const panels = Array.isArray(confirmParams.comboPanels) ? [...new Set(confirmParams.comboPanels)] : []; if (panels.length < 2) { - ElMessage.error('Combo 修订至少选择两个类型。'); + ElMessage.error(t('combo-needs-two-types')); return null; } const comboOutputLabel = makeComboOutputLabel(panels); @@ -16268,12 +16268,12 @@ compare: [[flesh, peel]]`; selectImageFile('combo', outputPath); activeStages.value = 'combo'; rightPanelTab.value = 'results'; - ElMessage.success('Combo 图修订完成,已切换到新图'); + ElMessage.success(t('combo-replot-done')); return data; } catch (err) { console.error('[Combo Replot] submit failed:', err); const msg = err && err.message ? err.message : String(err); - ElMessage.error('Combo 图修订失败:' + msg); + ElMessage.error(tp('combo-replot-failed-reason', { message: msg })); addAgentMessage('assistant', t('Combo 图修订失败:') + msg); return null; } finally { @@ -16285,7 +16285,7 @@ compare: [[flesh, peel]]`; const inferredPrefix = inferPrefixFromImageName(selectedImage.value); const prefixValue = inferredPrefix || confirmParams.prefix; if (!prefixValue) { - ElMessage.error('无法确定MSA输出标签,请先选择对应图片或填写输出标签。'); + ElMessage.error(t('msa-output-label-unknown')); return null; } const requestBody = { @@ -16306,11 +16306,11 @@ compare: [[flesh, peel]]`; msaSourceImage: confirmParams.msaSourceImage || selectedImageContext.path || stripUrlQuery(selectedImageUrl.value) || '' }; if (confirmParams.msaRegionMode === 'manual' && (!requestBody.msaStartCol || !requestBody.msaEndCol)) { - ElMessage.error('请填写MSA alignment 起始列和终止列,或选择其他模式。'); + ElMessage.error(t('msa-alignment-required')); return null; } if (confirmParams.msaRegionMode === 'reference' && (!requestBody.msaReferenceId || !requestBody.msaReferenceStartResidue || !requestBody.msaReferenceEndResidue)) { - ElMessage.error('请填写参考基因及其起止残基编号。'); + ElMessage.error(t('msa-reference-required')); return null; } try { @@ -16367,7 +16367,7 @@ compare: [[flesh, peel]]`; const confirmStageExecute = async () => { if (gwasPrefixMissingFor(confirmParams)) { - ElMessage.warning('请填写 GWAS 物种简称(用于结果前缀)'); + ElMessage.warning(t('gwas-prefix-required')); return; } if (isGwasStage(confirmParams.stage)) { @@ -16379,7 +16379,7 @@ compare: [[flesh, peel]]`; } } if (gwasConfirmationBlockedFor(confirmParams)) { - ElMessage.warning('请先选择有效 VCF'); + ElMessage.warning(t('select-valid-vcf')); return; } clearStageConfirmTimer(); @@ -17587,11 +17587,11 @@ compare: [[flesh, peel]]`; ? ['gene2ko'] : ['gene2go']; await ElMessageBox.confirm( - `${text}\n\n是否切换为“自动注释 + 继续富集”?`, + `${text}\n\n${t('auto-annotation-switch-msg')}`, autoAnnotationBackgroundTitle({ required_backgrounds: fallbackRequiredBackgrounds }), { - confirmButtonText: '自动注释并继续', - cancelButtonText: '我先去上传背景文件', + confirmButtonText: t('auto-annotate-continue'), + cancelButtonText: t('upload-bg-first'), type: 'warning', distinguishCancelAndClose: true } @@ -18285,7 +18285,7 @@ compare: [[flesh, peel]]`; const applyDomainReviewIds = async (ids, source) => { clearDomainReviewTimer(); if (!ids || !ids.length) { - ElMessage.warning('当前筛选没有成员,未重建 final_members。'); + ElMessage.warning(t('domain-review-no-members')); return; } domainReviewApplying.value = true; @@ -18397,14 +18397,14 @@ compare: [[flesh, peel]]`; return openedAny; } if (mode === 'per_species' && expectedSpecies.length && (!Array.isArray(perSpeciesResults) || perSpeciesResults.length < expectedSpecies.length)) { - ElMessage.warning('当前选择为按物种分别分析,但后端没有返回完整的分物种亚家族结果;将尝试打开可用的联合结果。'); + ElMessage.warning(t('subfamily-per-species-incomplete')); } const prefixes = collectSubfamilyReviewPrefixes(result, stageParams); for (let idx = 0; idx < prefixes.length; idx += 1) { const opened = await maybeShowSubfamilyReview('reference_classification', idx === prefixes.length - 1, prefixes[idx], false); if (opened) return true; } - ElMessage.warning('亚家族预测已完成,但未能自动打开确认表;请发送“调出亚家族确认表”手动打开。'); + ElMessage.warning(t('subfamily-auto-open-failed')); return false; }; @@ -18510,7 +18510,7 @@ compare: [[flesh, peel]]`; const applySubfamilyReview = async () => { clearSubfamilyReviewTimer(); if (!subfamilyReviewRows.value.length) { - ElMessage.warning('没有可确认的亚家族标签。'); + ElMessage.warning(t('subfamily-no-labels')); return; } subfamilyReviewApplying.value = true; @@ -18863,9 +18863,9 @@ compare: [[flesh, peel]]`; isStopping.value = true; try { await stopLightweightOperation(); - ElMessage.success('已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。'); + ElMessage.success(t('extension-stop-requested')); } catch (err) { - ElMessage.error(`停止拓展分析失败:${err.message || err}`); + ElMessage.error(tp('extension-stop-failed', { message: err.message || err })); } finally { isStopping.value = false; } @@ -18894,7 +18894,7 @@ compare: [[flesh, peel]]`; if (idSet.size === 0) { if (!stoppedBatch) { - ElMessage.warning('No running task to stop'); + ElMessage.warning(t('no-running-task-to-stop')); } return; } @@ -18914,7 +18914,7 @@ compare: [[flesh, peel]]`; } } - ElMessage.success('Task(s) stopped (backend + Celery when available)'); + ElMessage.success(t('tasks-stopped')); addMessage('assistant', '⏹ Task(s) stopped'); if (pollTimer) { @@ -19005,7 +19005,7 @@ compare: [[flesh, peel]]`; console.log('[DEBUG] doExecute called, closing dialog'); if (gwasPrefixMissingFor(confirmParams)) { clearAutoExecuteTimer(); - ElMessage.warning('请填写 GWAS 物种简称(用于结果前缀)'); + ElMessage.warning(t('gwas-prefix-required')); return; } if (isGwasStage(confirmParams.stage)) { @@ -19309,9 +19309,9 @@ compare: [[flesh, peel]]`; const handleUploadSuccess = async (response) => { if (response && response.config_normalized) { const warningText = (response.warnings || []).join(' '); - ElMessage.success(`Upload complete. Latest Excel is now used as config.xlsx.${warningText ? ' ' + warningText : ''}`); + ElMessage.success(tp('upload-config-applied', { warningText: warningText ? ' ' + warningText : '' })); } else { - ElMessage.success('File uploaded successfully'); + ElMessage.success(t('file-uploaded-success')); } refreshInfo(); const opened = await queueAutofillFromPayload(response, 'upload'); @@ -19346,19 +19346,19 @@ compare: [[flesh, peel]]`; const onExtensionCodeFilesChange = (event) => { const selected = Array.from(event.target.files || []); extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, selected); - if (selected.length) ElMessage.success(`已选择 ${selected.length} 个原始代码文件`); + if (selected.length) ElMessage.success(tp('code-files-selected', { count: selected.length })); event.target.value = ''; }; const onExtensionDataFilesChange = (event) => { const selected = Array.from(event.target.files || []); extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, selected); - if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`); + if (selected.length) ElMessage.success(tp('data-files-selected', { count: selected.length })); event.target.value = ''; }; const handleLightweightExtensionFolderDrop = (event) => { const droppedFiles = Array.from(event?.dataTransfer?.files || []); if (!droppedFiles.length) { - ElMessage.warning('未检测到可用文件'); + ElMessage.warning(t('no-usable-files-detected')); return; } @@ -19400,7 +19400,7 @@ compare: [[flesh, peel]]`; } } - ElMessage.success(`已拖入 ${normalized.length} 个文件,已自动填充拓展包`); + ElMessage.success(tp('files-dropped-extension', { count: normalized.length })); }; const removeExtensionFile = (kind, file) => { const key = extensionFileKey(file); @@ -19495,7 +19495,7 @@ compare: [[flesh, peel]]`; }; const runExtensionPrePublishGates = async () => { if (!extensionBuild.extensionId) { - ElMessage.warning('请先生成拓展分析阶段。'); + ElMessage.warning(t('extension-generate-first')); return; } if (!jobInfo.job_id) { @@ -19556,7 +19556,7 @@ compare: [[flesh, peel]]`; extensionBuild.prePublishReady = true; await loadExtensionStages(true); pushExtensionStatus('发布前验收已全部通过,可以发布。', 'gate', true); - ElMessage.success('发布前验收已通过'); + ElMessage.success(t('pre-publish-passed')); } catch (err) { extensionBuild.prePublishReady = false; extensionBuild.prePublishError = err.message || '发布前验收失败'; @@ -20061,7 +20061,7 @@ compare: [[flesh, peel]]`; }; const buildExtensionStageLegacy = async () => { if (!extensionBuild.displayName || !extensionBuild.codeFiles.length) { - ElMessage.warning('请填写名称,并上传原始代码。'); + ElMessage.warning(t('extension-name-code-required')); return; } extensionBuild.confirmDialogVisible = false; @@ -20133,7 +20133,7 @@ compare: [[flesh, peel]]`; if (!publishResponse.ok) throw new Error(await extensionApiError(publishResponse, '发布失败')); await loadExtensionStages(); await extensionProgressMessage(`已发布 ${builtExtensionId},现在可以直接在对话框中用自然语言执行。`, 'publish'); - ElMessage.success('拓展分析阶段已生成并发布'); + ElMessage.success(t('extension-built-published')); } catch (err) { const message = err.message || '拓展分析阶段生成失败'; pushExtensionStatus(message, 'error', false); @@ -20222,7 +20222,7 @@ compare: [[flesh, peel]]`; if (!response.ok) throw new Error(await extensionApiError(response, '确认失败')); pushExtensionStatus('已确认参数和输出,请继续验证', 'confirm'); extensionBuild.confirmDialogVisible = false; - ElMessage.success('确认已保存'); + ElMessage.success(t('confirm-saved')); await loadExtensionStages(true); } catch (err) { ElMessage.error(err.message || '确认失败'); @@ -20239,7 +20239,7 @@ compare: [[flesh, peel]]`; if (!response.ok) throw new Error(await extensionApiError(response, '验证失败')); const data = await response.json(); pushExtensionStatus(data.success ? '验证成功,可发布' : '验证失败,请查看后端日志', data.success ? 'validate' : 'error', !data.success); - data.success ? ElMessage.success('验证成功') : ElMessage.warning('验证未通过'); + data.success ? ElMessage.success(t('validation-success')) : ElMessage.warning(t('validation-not-passed')); await loadExtensionStages(true); } catch (err) { pushExtensionStatus(err.message || '验证失败', 'error', true); @@ -20256,7 +20256,7 @@ compare: [[flesh, peel]]`; const response = await extensionApiFetch(`/api/extension-stages/${encodeURIComponent(extensionBuild.extensionId)}/publish`, { method: 'POST' }); if (!response.ok) throw new Error(await extensionApiError(response, '发布失败')); pushExtensionStatus('已发布,可执行', 'publish'); - ElMessage.success('拓展分析阶段已发布'); + ElMessage.success(t('extension-published')); await loadExtensionStages(); } catch (err) { pushExtensionStatus(err.message || '发布失败', 'error', true); @@ -20385,8 +20385,8 @@ compare: [[flesh, peel]]`; const data = await response.json(); const run = data.run || {}; extensionRun.statusText = `执行完成: ${run.status || 'unknown'}`; - if (run.status === 'success') ElMessage.success('拓展分析阶段执行完成'); - else ElMessage.warning('拓展分析阶段执行未成功,请查看结果日志'); + if (run.status === 'success') ElMessage.success(t('extension-run-success')); + else ElMessage.warning(t('extension-run-incomplete')); await loadExtensionRuns(); extensionRun.activeRun = run.run_id || extensionRun.activeRun; if (fromIntent) { @@ -20568,7 +20568,7 @@ compare: [[flesh, peel]]`; return stageContext === candidateContext && stageRoute === candidateRoute; }); if (exists) { - ElMessage.info('该阶段已在正式列表中'); + ElMessage.info(t('stage-already-in-list')); return; } const stage = { @@ -20580,7 +20580,7 @@ compare: [[flesh, peel]]`; }; methodologyConfig.value.stages = [...(methodologyConfig.value.stages || []), stage]; methodologyConfig.value.figure_only_candidate_stages = methodologyFigureCandidates(methodologyConfig.value).filter(item => item.observation_id !== candidate.observation_id); - ElMessage.success('已加入正式阶段列表,并默认纳入本次执行'); + ElMessage.success(t('stage-added-to-list')); }; const methodologyStageRecordKey = (stage) => { @@ -21209,7 +21209,7 @@ compare: [[flesh, peel]]`; const triggerMethodologyExtraction = async () => { if (!methodologyPaperFile.value) { - ElMessage.warning('请先选择 PDF 或 MinerU content_list JSON 文献文件'); + ElMessage.warning(t('methodology-select-pdf-first')); return; } methodologyLoading.value = true; @@ -21232,7 +21232,7 @@ compare: [[flesh, peel]]`; await pollMethodologyExtraction(); } catch (err) { methodologyLoading.value = false; - ElMessage.error(err.message || '方法学提取启动失败'); + ElMessage.error(err.message || t('methodology-extract-failed')); } }; @@ -21253,7 +21253,7 @@ compare: [[flesh, peel]]`; methodologySandboxRoot.value = data.sandbox_root; } } catch (err) { - ElMessage.error(err.message || '方法学 sandbox 示例读取失败'); + ElMessage.error(err.message || t('methodology-sandbox-examples-failed')); } finally { methodologySandboxExamplesLoading.value = false; } @@ -21261,7 +21261,7 @@ compare: [[flesh, peel]]`; const buildMethodologySandbox = async () => { if (!methodologySandboxDir.value) { - ElMessage.warning('请先选择或输入 methodology_dir'); + ElMessage.warning(t('methodology-select-dir-first')); return; } methodologySandboxLoading.value = true; @@ -21288,7 +21288,7 @@ compare: [[flesh, peel]]`; methodologySandboxRegistryPath.value = report.registry_path || ''; methodologySandboxDirBuilt.value = registry.sandbox_dir || report.sandbox_dir || ''; methodologyStatusText.value = `Sandbox 已构建:阶段 ${report.stage_card_count || 0},图表 ${report.figure_table_stage_card_count || 0},关系 ${report.relation_count || 0}`; - ElMessage.success('方法学 sandbox 已构建'); + ElMessage.success(t('methodology-sandbox-built')); } catch (err) { ElMessage.error(err.message || '方法学 sandbox 构建失败'); } finally { @@ -21298,11 +21298,11 @@ compare: [[flesh, peel]]`; const askMethodologySandbox = async () => { if (!methodologySandboxRegistryPath.value && !methodologySandboxDirBuilt.value) { - ElMessage.warning('请先构建临时库'); + ElMessage.warning(t('methodology-build-first')); return; } if (!methodologySandboxQuestion.value) { - ElMessage.warning('请输入方法学问题'); + ElMessage.warning(t('methodology-enter-question')); return; } methodologySandboxAnswerLoading.value = true; @@ -21326,7 +21326,7 @@ compare: [[flesh, peel]]`; methodologySandboxAnswer.value += `\n\n来源 ${sourceCount} 条`; } } catch (err) { - ElMessage.error(err.message || '方法学 sandbox 查询失败'); + ElMessage.error(err.message || t('methodology-sandbox-query-failed')); } finally { methodologySandboxAnswerLoading.value = false; } @@ -21387,13 +21387,13 @@ compare: [[flesh, peel]]`; } methodologyStatusText.value = `方法学方案已保存到当前 job:${bindData.plan_path || 'methodology_plan.json'}`; if (!silent) { - ElMessage.success('方法学方案已保存,可先准备数据,稍后再按该方案执行'); + ElMessage.success(t('methodology-plan-saved')); await addAgentMessage('assistant', t('已保存方法学方案到当前 job。下一步请上传/填写目标物种和参考物种数据,并确认 config.xlsx 后再执行方法学流程。')); } return bindData; } catch (err) { if (silent) throw err; - ElMessage.error(err.message || '方法学方案保存失败'); + ElMessage.error(err.message || t('methodology-plan-save-failed')); methodologyStatusText.value = err.message || '方法学方案保存失败'; return null; } finally { @@ -21423,7 +21423,7 @@ compare: [[flesh, peel]]`; try { if (isBatchExecuting.value) { methodologyExecuting.value = false; - ElMessage.warning('当前已有批量任务在执行,请等待完成后再提交方法学流程'); + ElMessage.warning(t('methodology-batch-running')); return; } const readiness = await methodologyBindAndCheckReadiness(); @@ -21441,7 +21441,7 @@ compare: [[flesh, peel]]`; const selectedStages = methodologyExecutionSeedStages(methodologyConfig.value); if (!selectedStages.length) { methodologyExecuting.value = false; - ElMessage.warning('请至少选择一个可执行的 bbav5 阶段'); + ElMessage.warning(t('methodology-select-stage')); return; } @@ -21531,7 +21531,7 @@ compare: [[flesh, peel]]`; }, completed_stages: [] }; - ElMessage.warning('亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种'); + ElMessage.warning(t('methodology-subfamily-species-not-saved')); } if (!planData.success || !planData.plan) { throw new Error(planData.message || '多智能体规划方法学执行顺序失败'); @@ -21622,7 +21622,7 @@ compare: [[flesh, peel]]`; startBatchExecuteTimer(); } catch (err) { methodologyExecuting.value = false; - ElMessage.error(err.message || '方法学执行规划失败'); + ElMessage.error(err.message || t('methodology-plan-failed')); addAgentMessage('assistant', t('❌ 方法学执行规划失败:') + (err.message || 'Unknown error'), { taskType: 'error' }); @@ -21651,7 +21651,7 @@ compare: [[flesh, peel]]`; await runPlanForIntent(syntenyChoiceQuery.value || option.label, selectedIntent); } catch (err) { console.error('[DEBUG] Synteny choice plan error:', err); - ElMessage.error(`❌ ${err.message || '规划请求失败'}`); + ElMessage.error(tp('plan-request-failed-reason', { message: err.message || t('plan-request-failed') })); addAgentMessage('assistant', t('❌ 服务暂时不可用:') + (err.message || 'Planning request failed') + '\n\n' + t('请稍后重试。'), { taskType: 'error' }); @@ -22295,7 +22295,7 @@ compare: [[flesh, peel]]`; // 处理自然语言Input const handleNlInput = async () => { if (!nlInput.value.trim()) { - ElMessage.warning('请Input内容'); + ElMessage.warning(t('input-required')); return; } @@ -22333,7 +22333,7 @@ compare: [[flesh, peel]]`; if (wantsIdentifyMemberReviewEarly) { try { if (!jobInfo.job_id || jobInfo.job_id === 'default_job') { - ElMessage.warning('请先选择或创建 Working Directory'); + ElMessage.warning(t('select-or-create-wd')); nlLoading.value = false; return; } @@ -22407,7 +22407,7 @@ compare: [[flesh, peel]]`; try { // 检查是否有绑定的 job if (!jobInfo.job_id || jobInfo.job_id === 'default_job') { - ElMessage.warning('请先选择或创建 Working Directory'); + ElMessage.warning(t('select-or-create-wd')); nlLoading.value = false; return; } @@ -22771,7 +22771,7 @@ compare: [[flesh, peel]]`; // 规划失败(如PreFlight验证失败),显示错误并停止 const errorMsg = planData.message || "规划失败"; console.log('[DEBUG] Plan API failed:', errorMsg); - ElMessage.error(`❌ ${errorMsg}`); + ElMessage.error(tp('plan-request-failed-reason', { message: errorMsg })); let planHint = /缺少|输入文件|upload|上传|file/i.test(errorMsg) ? '\n\n请检查必要输入文件是否已上传。' : '\n\n规划阶段未能生成可执行流程,请查看后端日志中的具体原因。'; @@ -22809,7 +22809,7 @@ compare: [[flesh, peel]]`; if (err?.code === 'user_cancelled') { addAgentMessage('assistant', t('已停止等待,本次未继续获取回答。')); } else { - ElMessage.error('请求失败: ' + err.message); + ElMessage.error(tp('request-failed-reason', { message: err.message })); addAgentMessage('assistant', t('抱歉,服务暂时不可用,请稍后重试。')); } } finally { @@ -23195,7 +23195,7 @@ compare: [[flesh, peel]]`; // 重新绘图 const redrawImage = () => { - ElMessage.info('重新绘图功能开发中...'); + ElMessage.info(t('replot-in-development')); }; // 组件卸载时清理定时器 diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index bf6513b73..3af4b63ae 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -22947,6 +22947,7 @@ class AgentRunRequest(BaseModel): job_id: str = "" intent: dict = {} # 前端可传入已有的intent stages: list = [] # 要执行的阶段列表 + language: str = DEFAULT_AGENT_LANG # "zh" or "en" @app.post("/api/agent/run") @@ -22980,6 +22981,7 @@ async def agent_run(request: AgentRunRequest): ) # 添加额外的运行时字段 + initial_state["language"] = request.language initial_state["status"] = "running" initial_state["intent_result"] = request.intent or {} # 传入已有的 intent initial_state["completed_stages"] = [] diff --git a/apps/web/vue3/static/i18n_en.js b/apps/web/vue3/static/i18n_en.js index 41947fdea..cc294239a 100644 --- a/apps/web/vue3/static/i18n_en.js +++ b/apps/web/vue3/static/i18n_en.js @@ -712,6 +712,180 @@ window.__I18N_EN__ = { " 已暂停依赖 confirmed_subfamily.tsv": " has paused stages depending on confirmed_subfamily.tsv", ";": ";", " 个分析阶段": " analysis stages", - " 个阶段": " stages" + " 个阶段": " stages", + + // ===== 阶段 7:ElMessage/ElMessageBox i18n keys ===== + // 注意:key 为中文原文(zh 模式直接返回 key),value 为 English + // tp() 类 key 需要同时注册变量 placeholder 版本和 static 文案 + + // ElMessageBox + "删除会话": "Delete Session", + "取消 PPI": "Cancel PPI", + "自动注释并继续": "Auto-annotate & Continue", + "我先去上传背景文件": "Upload Background Files First", + "确认设计并写入": "Confirm Design & Write", + "返回修改": "Return to Modify", + "确认第一候选": "Confirm First Candidate", + "使用 bcftools 重试": "Retry with bcftools", + "GATK 执行失败": "GATK Execution Failed", + "确认 DE 分析设计": "Confirm DE Analysis Design", + "确认 PPI 的 STRING 近源参考物种": "Confirm STRING Close Reference Species for PPI", + + // autoAnnotation 标题 + "GO/KEGG 富集背景缺失": "GO/KEGG Enrichment Background Missing", + "KEGG 富集背景缺失": "KEGG Enrichment Background Missing", + "GO 富集背景缺失": "GO Enrichment Background Missing", + "RNA-seq 富集背景缺失": "RNA-seq Enrichment Background Missing", + + // 会话管理 + "示例下载功能当前不可用": "Example download is currently unavailable", + "已Clear当前会话": "Current session cleared", + + // autoFill + "请至少填写一个要预览修改的字段": "Please fill at least one field to preview modifications", + "没有匹配的草稿行": "No matching draft rows", + "请先预览要应用的修改": "Please preview modifications before applying", + "请先生成或加载草稿": "Please generate or load a draft first", + "生成草稿失败": "Failed to generate draft", + "确认失败": "Confirmation failed", + "已写入正式 config.xlsx": "Formal config.xlsx written", + "已写入正式 samples.yaml": "Formal samples.yaml written", + "请输入文件路径": "Please enter file path", + "加载失败": "Load failed", + + // RAG 解读 + "请求解读服务失败": "Interpretation service request failed", + "RAG解读已刷新": "RAG interpretation refreshed", + "RAG解读刷新失败": "RAG interpretation refresh failed", + "下载报告失败": "Failed to download report", + "JSON数据已加载到控制台": "JSON data loaded to console", + "解读内容已复制": "Interpretation content copied", + "复制失败": "Copy failed", + + // 知识图谱 + "知识图谱加载完成": "Knowledge graph loaded", + "未找到匹配的节点": "No matching node found", + "视图已重置": "View reset", + "请先通过问答加载子图,或点击「重新加载」": "Please load subgraph via Q&A first, or click Reload", + + // PPI/STRING + "当前没有可用 job_id": "No job_id currently available", + "该 ID 已存在": "This ID already exists", + "至少保留一个 gene ID": "Keep at least one gene ID", + + // Working Directory + "切换失败": "Switch failed", + + // 图片/可视化 + "请先选择一张图片": "Please select an image first", + "无法确定启动子输出前缀。": "Cannot determine promoter output prefix.", + "请至少选择一个启动子元件或类别。": "Please select at least one promoter element or category.", + "请至少选择一种图类型。": "Please select at least one plot type.", + "无法确定进化树输出标签,请先选择树图或填写输出标签。": "Cannot determine phylogeny tree output label. Please select a tree image or fill in the output label first.", + "进化树图重绘完成,已切换到新图": "Phylogeny tree replot complete; switched to new image", + "无法确定种内共线性输出标签,请先选择 Circos 图。": "Cannot determine intraspecies synteny output label. Please select a Circos image first.", + "无法确定 combo 输出标签,请先选择 combo 图。": "Cannot determine combo output label. Please select a combo image first.", + "Combo 修订至少选择两个类型。": "Combo revision requires at least two panel types.", + "Combo 图修订完成,已切换到新图": "Combo plot revision complete; switched to new image", + "无法确定MSA输出标签,请先选择对应图片或填写输出标签。": "Cannot determine MSA output label. Please select corresponding image or fill in output label.", + "请填写MSA alignment 起始列和终止列,或选择其他模式。": "Please fill in MSA alignment start/end columns or choose another mode.", + "请填写参考基因及其起止残基编号。": "Please fill in reference gene and its start/end residue numbers.", + + // GWAS/诊断 + "请填写 GWAS 物种简称(用于结果前缀)": "Please fill in GWAS species abbreviation (for result prefix)", + "请先选择有效 VCF": "Please select a valid VCF first", + "请先SelectAnalysis Stage": "Please select an analysis stage first", + "诊断未发现明确问题,请查看阶段错误日志。": "Diagnosis found no clear issues. Please check stage error logs.", + "诊断仅提示信息,无需确认修复。": "Diagnosis is informational only; no fix confirmation needed.", + "✅ 已应用修复方案,继续执行...": "✅ Fix applied; continuing execution...", + "❌ 应用修复失败": "❌ Fix application failed", + "❌ 网络错误,请重试": "❌ Network error; please retry", + "⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。": "⏸ Fix skipped; issue blocks downstream; subsequent stages paused.", + "已关闭诊断提示。": "Diagnosis prompt closed.", + + // 任务/消息 + "当前消息未成功写入会话历史,刷新后可能丢失": "Message not persisted to session history; may be lost on refresh", + "路径已复制": "Path copied", + + // 拓展分析 + "当前筛选没有成员,未重建 final_members。": "No members matching current filter; final_members not rebuilt.", + "当前选择为按物种分别分析,但后端没有返回完整的分物种亚家族结果;将尝试打开可用的联合结果。": "Per-species analysis selected but backend returned incomplete results; attempting to open available joint results.", + "亚家族预测已完成,但未能自动打开确认表;请发送\"调出亚家族确认表\"手动打开。": "Subfamily prediction completed but confirmation table could not open automatically; please send \"show subfamily review table\" manually.", + "没有可确认的亚家族标签。": "No subfamily labels to confirm.", + "已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。": "Stop requested for current extension; polling will continue until process exits.", + "No running task to stop": "No running task to stop", + "Task(s) stopped (backend + Celery when available)": "Task(s) stopped (backend + Celery when available)", + "File uploaded successfully": "File uploaded successfully", + "未检测到可用文件": "No usable files detected", + "请先生成拓展分析阶段。": "Please generate the extension stage first.", + "发布前验收已通过": "Pre-publish validation passed", + "请填写名称,并上传原始代码。": "Please provide name and upload source code.", + "拓展分析阶段已生成并发布": "Extension stage generated and published", + "确认已保存": "Confirmation saved", + "验证成功": "Validation successful", + "验证未通过": "Validation not passed", + "拓展分析阶段已发布": "Extension stage published", + "拓展分析阶段执行完成": "Extension stage execution complete", + "拓展分析阶段执行未成功,请查看结果日志": "Extension stage execution incomplete; please check result logs", + "该阶段已在正式列表中": "This stage is already in the formal list", + "已加入正式阶段列表,并默认纳入本次执行": "Added to formal stage list and included in this execution", + + // 方法学 + "请先选择 PDF 或 MinerU content_list JSON 文献文件": "Please select a PDF or MinerU content_list JSON literature file first", + "方法学提取启动失败": "Methodology extraction failed to start", + "方法学 sandbox 示例读取失败": "Failed to read methodology sandbox examples", + "请先选择或输入 methodology_dir": "Please select or enter methodology_dir first", + "方法学 sandbox 已构建": "Methodology sandbox built", + "请先构建临时库": "Please build the sandbox first", + "请输入方法学问题": "Please enter methodology question", + "方法学 sandbox 查询失败": "Methodology sandbox query failed", + "方法学方案已保存,可先准备数据,稍后再按该方案执行": "Methodology plan saved; you may prepare data first and execute later", + "方法学方案保存失败": "Failed to save methodology plan", + "当前已有批量任务在执行,请等待完成后再提交方法学流程": "A batch task is currently running; please wait for completion before submitting methodology workflow", + "请至少选择一个可执行的 bbav5 阶段": "Please select at least one executable bbav5 stage", + "亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种": "Subfamily reference species not written to current job config; local methodology plan generated. Please verify reference species on batch confirmation page.", + "方法学执行规划失败": "Methodology execution planning failed", + + // Other + "请Input内容": "Please enter content", + "请先选择或创建 Working Directory": "Please select or create a Working Directory first", + "规划请求失败": "Plan request failed", + "重新绘图功能开发中...": "Replot feature is under development...", + + // ===== tp() keys — 使用完整中文模板作为 key ===== + "确定删除会话「{title}」吗?该操作会从列表中移除,但保留后台记录。": "Delete session \"{title}\"? This removes it from the list but keeps backend records.", + "切换到: {title}": "Switched to: {title}", + "预览 {rows} 行:{fields}。确认无误后点击 Apply。": "Preview {rows} rows: {fields}. Click Apply once confirmed.", + "已应用 {rows} 行修改。": "Applied {rows} row modifications.", + "已取消预览,表格已恢复到预览前。": "Preview cancelled, table restored.", + "链特异性为必填:还有 {count} 个样本未填写({examples})": "Library strandness required: {count} sample(s) unfilled ({examples})", + "设计提示:{error};将保留原流程的兼容处理。": "Design tip: {error}; keeping original compatible handling.", + "已加载 {count} files": "Loaded {count} files", + "加载图谱失败: {message}": "Graph load failed: {message}", + "找到节点: {name}": "Node found: {name}", + "读取表格失败: {reason}": "Table read failed: {reason}", + "PPI Reset 已完成: {reset_id}": "PPI Reset complete: {reset_id}", + "STRING 链接文件内容:{preview}": "STRING link file content: {preview}", + "读取文件失败: {message}": "File read failed: {message}", + "已创建新Working Directory: {job_id}": "New Working Directory created: {job_id}", + "创建失败: {message}": "Creation failed: {message}", + "获取Working Directory列表失败: {message}": "Failed to get Working Directory list: {message}", + "已切换到Working Directory: {job_id}": "Switched to Working Directory: {job_id}", + "切换失败: {message}": "Switch failed: {message}", + "进化树图重绘失败:{message}": "Phylogeny tree replot failed: {message}", + "Combo 图修订失败:{message}": "Combo plot revision failed: {message}", + "任务: {message}\n状态: {status}\n进度: {progress}%": "Task: {message}\nStatus: {status}\nProgress: {progress}%", + "停止拓展分析失败:{message}": "Failed to stop extension: {message}", + "已选择 {count} 个原始代码文件": "{count} code file(s) selected", + "已选择 {count} 个测试数据文件": "{count} test data file(s) selected", + "已拖入 {count} 个文件,已自动填充拓展包": "{count} file(s) dropped; extension package auto-filled", + "❌ {message}": "❌ {message}", + "Upload complete. Latest Excel is now used as config.xlsx.{warningText}": "Upload complete. Latest Excel is now used as config.xlsx.{warningText}", + "{title},需要自动注释": "{title}, Auto-annotation Required", + + // String close species (tp with multi-line) + "目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:": "Target species not in STRING fixed snapshot. Confirm using the top-ranked close species:", + "确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。": "Reference PEP will be downloaded, Ortholog generated, and PPI executed only after confirmation.", + "是否切换为\"自动注释 + 继续富集\"?": "Switch to \"Auto-annotation + Continue Enrichment\"?" }; diff --git a/gfa_core/mas/agents/diagnosis.py b/gfa_core/mas/agents/diagnosis.py index 98575181d..0376de93e 100755 --- a/gfa_core/mas/agents/diagnosis.py +++ b/gfa_core/mas/agents/diagnosis.py @@ -390,48 +390,57 @@ class DiagnosisAgent(BaseAgent): ERROR_PATTERNS = { "missing_input_file": { "patterns": [r"文件不存在", r"FileNotFoundError", r"No such file", r"找不到文件"], - "suggestion": "检查输入文件路径是否正确,确保文件已上传到input目录", + "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": "请上传HMM模型文件或指定--required-pfams参数", + "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": "Identify BLAST 固定用目标物种全蛋白 FASTA 建库;请优先检查目标全蛋白 FASTA 是否存在、非空且为有效蛋白FASTA格式,再检查参考家族蛋白 query 文件。", + "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": "减少--cpu参数值或分批处理,建议使用--cpu 4", + "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": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples..library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。", + "suggestion_zh": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples..library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。", + "suggestion_en": "Please specify non_stranded / forward / reverse in samples.yaml rnaseq.library_strandness or each samples..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": "增加超时时间或检查输入数据大小", + "suggestion_zh": "增加超时时间或检查输入数据大小", + "suggestion_en": "Increase timeout or check input data size", "auto_fix": False }, "conda_env_error": { "patterns": [r"conda", r"Environment", r"找不到命令"], - "suggestion": "检查conda环境是否正确激活,确保工具已安装", + "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": "检查文件权限,确保有读写权限", + "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": "检查参数名称是否正确,注意使用短横线而非下划线", + "suggestion_zh": "检查参数名称是否正确,注意使用短横线而非下划线", + "suggestion_en": "Check parameter name; use hyphens instead of underscores", "auto_fix": True, "fix_action": "fix_parameter_format" }, @@ -448,7 +457,8 @@ class DiagnosisAgent(BaseAgent): r"RuntimeError: MAFFT失败.*No such file", r"RuntimeError:.*失败.*No such file or directory" ], - "suggestion": "生物信息学工具未安装或未添加到PATH。建议:1) 激活conda环境: conda activate test4py311 2) 安装缺失工具: conda install -c bioconda mafft blast iqtree hmmer", + "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" }, @@ -461,7 +471,8 @@ class DiagnosisAgent(BaseAgent): r"requires.*stage.*not completed", r"dependency.*not satisfied" ], - "suggestion": "前置分析阶段未完成,系统将自动先执行依赖阶段", + "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" @@ -601,6 +612,7 @@ class DiagnosisAgent(BaseAgent): prepare_patch_workspace: bool = False, scope_id: str = "", params: Optional[Dict[str, Any]] = None, + language: str = "zh", ) -> Dict[str, Any]: """只读审计某个 job/stage 的产物、manifest、日志和输入配置。 @@ -930,6 +942,7 @@ class DiagnosisAgent(BaseAgent): 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. @@ -4490,10 +4503,10 @@ class DiagnosisAgent(BaseAgent): except Exception as exc: print(f"[DiagnosisAgent] Failed to remember LLM diagnosis case: {exc}") - def _match_error_pattern(self, error_text: str) -> Optional[Dict]: + 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", @@ -4507,15 +4520,16 @@ class DiagnosisAgent(BaseAgent): "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["suggestion"], + "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") diff --git a/gfa_core/mas/core/orchestrator.py b/gfa_core/mas/core/orchestrator.py index 680fd7ded..3361df9d3 100755 --- a/gfa_core/mas/core/orchestrator.py +++ b/gfa_core/mas/core/orchestrator.py @@ -29,6 +29,74 @@ WORKSPACE_DIR = WORKSPACE_ROOT class Orchestrator: + # 双语消息映射表:中文 → English(用于用户可见消息) + _MSG = { + # analysis_node — response 消息 + "✅ 所有分析阶段已完成!": "✅ All analysis stages completed!", + "📋 自动规划剩余分析阶段": "📋 Auto-planned remaining analysis stages", + "**已完成**": "**Completed**", + "无": "None", + "**待执行**": "**Pending**", + "个阶段)": "stage(s))", + "请确认是否按顺序执行以上分析阶段?": "Confirm to execute the above analysis stages in order?", + "我将帮您执行RNA-seq": "I will help you execute RNA-seq", + "分析。请确认参数后开始。": "analysis. Please confirm parameters to start.", + "RNA-seq分析将通过前端异步执行。": "RNA-seq analysis will be executed asynchronously via frontend.", + " 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。": " analysis. PlanningAgentV2 has generated a complete execution plan based on current state.", + "我将帮您执行": "I will help you execute", + "分析。": "analysis.", + "请提供基因家族蛋白序列文件(.fa/.fasta),用于MEME保守基序预测。": "Please provide gene family protein sequence file (.fa/.fasta) for MEME conserved motif prediction.", + "请提供config.xlsx及基因组/GFF/CDS/PEP等原始文件,用于完整数据准备。": "Please provide config.xlsx and genome/GFF/CDS/PEP files for complete data preparation.", + "请提供基因家族蛋白序列文件,用于Pfam/InterPro结构域注释。": "Please provide gene family protein sequence file for Pfam/InterPro domain annotation.", + "请提供基因家族的GFF注释文件和基因组序列。": "Please provide gene family GFF annotation file and genome sequence.", + "请提供基因家族成员的启动子序列(上游2000bp)或全基因组序列+GFF。": "Please provide gene family promoter sequences (upstream 2000bp) or whole genome + GFF.", + "请提供物种基因组序列(.fasta)和GFF注释文件。": "Please provide species genome sequence (.fasta) and GFF annotation file.", + "请提供多个物种的基因组序列和GFF注释文件,以及物种间的同源关系。": "Please provide genome sequences and GFF annotation files for multiple species, along with inter-species orthology.", + "请提供染色体长度信息和基因位置信息。": "Please provide chromosome length and gene position information.", + "请提供拟南芥同源基因映射文件(ortholog_pairs.txt,格式:拟南芥ID\t苦荞ID),用于STRING数据库互作预测。如未提供,请先运行同源基因识别分析。": "Please provide Arabidopsis ortholog mapping file (ortholog_pairs.txt, format: AtID\tTargetID) for STRING interaction prediction. If unavailable, run ortholog identification first.", + "请提供目标物种和参考物种(如拟南芥At)的蛋白序列文件,用于同源基因识别。": "Please provide target and reference species (e.g., Arabidopsis At) protein FASTA for ortholog identification.", + "请提供物种间同源基因对(ortholog pairs,格式:基因A\t基因B)或共线性分析结果。": "Please provide inter-species ortholog pairs (format: GeneA\tGeneB) or synteny analysis results.", + "请提供RNA-seq表达矩阵(FPKM/TPM/Count)或原始测序数据。": "Please provide RNA-seq expression matrix (FPKM/TPM/Count) or raw sequencing data.", + "请提供差异表达基因列表(DEGs)或目标基因集。": "Please provide differentially expressed gene list (DEGs) or target gene set.", + "请提供基因家族蛋白序列文件,用于理化性质分析。": "Please provide gene family protein sequence file for physicochemical property analysis.", + "请提供基因家族HMM文件和蛋白序列文件,将自动执行标准11阶段分析。": "Please provide gene family HMM file and protein FASTA; standard 11-stage analysis will be executed automatically.", + "请提供RNA-seq原始测序数据(FASTQ格式)。": "Please provide RNA-seq raw sequencing data (FASTQ format).", + "请提供质控后的clean reads(FASTQ格式)和参考基因组。": "Please provide QC-filtered clean reads (FASTQ format) and reference genome.", + "请提供比对后的BAM文件或参考基因组注释GFF。": "Please provide aligned BAM file or reference genome annotation GFF.", + "请提供featureCounts计数矩阵和样本分组表,用于VST、PCA、样本相关性和聚类。": "Please provide featureCounts count matrix and sample grouping table for VST, PCA, sample correlation and clustering.", + "请提供多时相表达矩阵(如不同发育阶段/处理时间点)。": "Please provide multi-temporal expression matrix (e.g., different developmental stages/treatment time points).", + "请提供VST/log2表达矩阵或count矩阵和样本性状表,用于共表达网络构建。": "Please provide VST/log2 expression matrix or count matrix and sample trait table for co-expression network construction.", + "请提供差异表达结果和gene2go背景文件,或先执行背景注释。": "Please provide differential expression results and gene2go background file, or run background annotation first.", + "请提供差异表达结果和gene2ko背景文件,或先执行背景注释。": "Please provide differential expression results and gene2ko background file, or run background annotation first.", + "请提供差异表达排序统计量和gene2ko/KEGG背景,用于fgsea排序富集。": "Please provide differential expression ranking statistics and gene2ko/KEGG background for fgsea ranked enrichment.", + "请提供差异表达结果和目标物种蛋白FASTA;若已有InterProScan TSV会优先复用。": "Please provide differential expression results and target species protein FASTA; existing InterProScan TSV will be reused if available.", + "请提供表达矩阵(样本数≥6),用于共表达网络构建。": "Please provide expression matrix (samples ≥ 6) for co-expression network construction.", + # knowledge_node — system prompt / response 消息 + "你是BotanicalBaseAgent基因家族分析系统的知识助手。基于提供的文献资料,生成专业、详细的回答。": "You are a knowledge assistant for the BotanicalBaseAgent gene family analysis system. Generate professional, detailed answers based on provided literature.", + "📚 基于知识库的回答:": "📚 Knowledge base answer:", + "**参考文献**:": "**References**:", + "(来源: 本地LLM回答 + RAG辅助检索)": "(Source: local LLM answer + RAG-assisted retrieval)", + "⚠️ (本地LLM回答,未使用RAG文献检索)": "⚠️ (local LLM answer, RAG retrieval not used)", + "找到": "Found", + "条相关文献,正在生成本地LLM回答...": " related references; generating local LLM answer...", + "未找到相关文献,生成一般回答": "No related references found; generating general answer", + "你是一个专业的基因家族分析意图识别器。": "You are a professional gene family analysis intent recognizer.", + "你是一个基因家族分析专家。用户想要进行基因家族分析。": "You are a gene family analysis expert. The user wants to perform gene family analysis.", + } + + @classmethod + def _l(cls, zh_text: str, language: str = "zh") -> str: + """Return language-appropriate text from the bilingual map.""" + if language == "zh": + return zh_text + en = cls._MSG.get(zh_text) + if en is not None: + return en + # fallback: try partial match for template strings + for zh_key, en_val in cls._MSG.items(): + if zh_key in zh_text: + return zh_text.replace(zh_key, en_val) + return zh_text def __init__( self, llm_manager: LLMManager, @@ -313,6 +381,7 @@ class Orchestrator: async def _router_node(self, state: GraphState) -> dict: query = state["query"] + language = state.get("language", "zh") # 使用 RouterAgent 进行意图路由 print(f"[RouterNode] Using RouterAgent to classify query: {query[:50]}...") @@ -378,6 +447,7 @@ class Orchestrator: async def _knowledge_node(self, state: GraphState) -> dict: query = state["query"] + language = state.get("language", "zh") try: # 优先调用当前 Dual-KB QA 服务 @@ -578,7 +648,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"✅ 所有分析阶段已完成!\n\n已完成: {', '.join(completed_stages)}", + "response": self._l("✅ 所有分析阶段已完成!", language) + "\n\n" + self._l("**已完成**", language) + ": " + ", ".join(completed_stages), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -595,14 +665,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"""📋 自动规划剩余分析阶段 - -**已完成**: {', '.join(completed_stages) if completed_stages else '无'} - -**待执行** ({len(remaining_stages)}个阶段): -{stages_text} - -请确认是否按顺序执行以上分析阶段?""", + "response": f"{self._l('📋 自动规划剩余分析阶段', language)}\n\n{self._l('**已完成**', language)}: {', '.join(completed_stages) if completed_stages else self._l('无', language)}\n\n{self._l('**待执行**', language)} ({len(remaining_stages)}{self._l('个阶段)', language)}:\n{stages_text}\n\n{self._l('请确认是否按顺序执行以上分析阶段?', language)}", "analysis_plan": { "gene_family": gene_family, "species": species, @@ -630,6 +693,7 @@ class Orchestrator: async def _analysis_node(self, state: GraphState) -> dict: query = state["query"] files = state.get("files", []) + language = state.get("language", "zh") print(f"[_analysis_node] Entered with query: {query[:50]}...", flush=True) try: @@ -821,7 +885,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type}分析。请确认参数后开始。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l("分析。请确认参数后开始。", language), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -831,7 +895,7 @@ class Orchestrator: "prefix": f"{species}.{gene_family}", "cpu": 8 }, - "guidance": "RNA-seq分析将通过前端异步执行。" + "guidance": self._l("RNA-seq分析将通过前端异步执行。", language) } } } @@ -878,7 +942,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type} 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l(" 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。", language), "analysis_plan": plan_result.get("final_output", {}).get("analysis_plan", {}) } } @@ -889,7 +953,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type} 分析。请确认参数后开始。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l(" 分析。请确认参数后开始。", language), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -900,21 +964,25 @@ class Orchestrator: "prefix": f"{species}.{gene_family}", "cpu": 8 }, - "guidance": f"RNA-seq {analysis_type} 分析将通过前端异步执行。" + "guidance": self._l("RNA-seq " + analysis_type + " 分析将通过前端异步执行。", language) } } } # Ka/Ks分析:走通用规划流程(返回analysis_plan让前端执行) # 注意:不再直接调用_execute_kaks,而是通过PlanningAgentV2规划完整流程 - guidance = guidance_map.get(analysis_type, f"请上传进行{analysis_type}分析所需的文件,或确认使用系统内置数据。") - + guidance = guidance_map.get(analysis_type) + if guidance: + guidance = self._l(guidance, language) + else: + guidance = self._l("请上传进行", language) + analysis_type + self._l("分析所需的文件,或确认使用系统内置数据。", language) + return { "current_agent": "analysis", "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行{analysis_type}分析。", + "response": self._l("我将帮您执行", language) + analysis_type + self._l("分析。", language), "analysis_plan": { "gene_family": gene_family, "species": species, From 7228b586c8ca8c8490a57509d8e8f3657db36f03 Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 10 Aug 2026 10:57:49 +0800 Subject: [PATCH 18/18] fix(runtime): standardize 9000 startup and isolate project services --- manage_plantai.sh | 77 ++++++++++++++----- .../legacy_startup/restart_with_prompts.sh | 0 .../legacy_startup/start-webui.sh | 0 .../legacy_startup/start_celery.sh | 0 .../legacy_startup/start_celery_7880.sh | 0 .../legacy_startup/start_celery_port9000.sh | 0 start_celery_9000.sh | 8 +- start_plantai_9000.sh | 21 ++--- 8 files changed, 73 insertions(+), 33 deletions(-) rename restart_with_prompts.sh => scripts/legacy_startup/restart_with_prompts.sh (100%) rename start-webui.sh => scripts/legacy_startup/start-webui.sh (100%) rename start_celery.sh => scripts/legacy_startup/start_celery.sh (100%) rename start_celery_7880.sh => scripts/legacy_startup/start_celery_7880.sh (100%) rename start_celery_port9000.sh => scripts/legacy_startup/start_celery_port9000.sh (100%) diff --git a/manage_plantai.sh b/manage_plantai.sh index 52b893ae5..c14e1c8dd 100755 --- a/manage_plantai.sh +++ b/manage_plantai.sh @@ -2,15 +2,19 @@ # PlantAI-dev 9000 服务管理脚本 # 用法: bash manage_plantai.sh {start|stop|restart|status} [--port PORT] # 默认端口: 9000 -# -# 项目: /home/xzq/5090/teamWorkspace/zh/PlantAI-dev -# 端口: 9000 (web), Redis DB 14 (queue prefix: plantai_dev_9002) set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + . "$SCRIPT_DIR/.env" + set +a +fi + # 解析命令行参数:第一个参数是命令,后面可选 --port / -p CMD="" -WEB_PORT="9000" +WEB_PORT="${PLANTAI_WEB_PORT:-9000}" for arg in "$@"; do case "$arg" in start|stop|restart|status) @@ -33,12 +37,15 @@ done CMD="${CMD:-status}" # Conda 环境:启动脚本内部也依赖这个环境 -CONDA_ENV="${CONDA_ENV:-/home/xzq/miniconda3/envs/test4py311}" +CONDA_BASE="${BBAV5_CONDA_BASE:-/home/xia-lab/miniconda3}" +DEFAULT_ENV="${BBAV5_DEFAULT_ENV:-test4py311}" +CONDA_ENV="${CONDA_ENV:-$CONDA_BASE/envs/$DEFAULT_ENV}" export PATH="$CONDA_ENV/bin:$PATH" # 项目配置(与 .env 和启动脚本保持一致) -PROJECT_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-dev" -QUEUE_PREFIX="plantai_dev_9002" +PROJECT_DIR="$SCRIPT_DIR" +QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" +REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" WEB_LOG="/tmp/plantai_${WEB_PORT}.log" CELERY_LOG="/tmp/celery_${WEB_PORT}.log" @@ -46,28 +53,49 @@ red() { echo -e "\033[31m$*\033[0m"; } green(){ echo -e "\033[32m$*\033[0m"; } cyan() { echo -e "\033[36m$*\033[0m"; } +web_pid_for_port() { + ss -tlnp 2>/dev/null \ + | grep ":$WEB_PORT " \ + | grep -oP 'pid=\K\d+' \ + | head -n 1 \ + || true +} + +web_process_cwd() { + readlink -f "/proc/$1/cwd" 2>/dev/null || true +} + +web_pid_belongs_to_project() { + [ "$(web_process_cwd "$1")" = "$PROJECT_DIR" ] +} + status() { echo "" cyan "=== PlantAI-dev ${WEB_PORT} 服务状态 ===" echo "" # Web 服务 - WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + WEB_PID="$(web_pid_for_port)" if [ -n "${WEB_PID:-}" ]; then - green "[✓] Web 服务运行中 (端口 $WEB_PORT, PID $WEB_PID)" + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if web_pid_belongs_to_project "$WEB_PID"; then + green "[✓] Web 服务运行中 (端口 $WEB_PORT, PID $WEB_PID)" + else + red "[!] 端口 $WEB_PORT 被其他项目占用 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + fi else red "[✗] Web 服务未启动 (端口 $WEB_PORT)" fi # Redis - if redis-cli ping > /dev/null 2>&1; then - green "[✓] Redis 运行中 (localhost:6379)" + if redis-cli -u "$REDIS_URL" ping > /dev/null 2>&1; then + green "[✓] Redis 运行中 ($REDIS_URL)" else red "[✗] Redis 未启动" fi # Celery workers - CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + CELERY_COUNT="$(pgrep -fc "$QUEUE_PREFIX" || true)" if [ "$CELERY_COUNT" -gt 0 ]; then green "[✓] Celery workers 运行中 (${CELERY_COUNT} 进程, 队列前缀: $QUEUE_PREFIX)" echo " Worker 列表:" @@ -86,13 +114,18 @@ stop() { echo "" # 停止 Web 服务 - WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + WEB_PID="$(web_pid_for_port)" if [ -n "${WEB_PID:-}" ]; then + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if ! web_pid_belongs_to_project "$WEB_PID"; then + red "[!] 拒绝停止:端口 $WEB_PORT 属于其他项目 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + return 1 + fi echo "正在停止 Web 服务 (PID $WEB_PID)..." kill "$WEB_PID" 2>/dev/null || true sleep 1 - # 如果还没死,强制 kill - if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then + # 仅强制停止仍属于本项目的原进程,避免 PID 复用造成误杀。 + if kill -0 "$WEB_PID" 2>/dev/null && web_pid_belongs_to_project "$WEB_PID"; then kill -9 "$WEB_PID" 2>/dev/null || true fi green "[✓] Web 服务已停止" @@ -125,12 +158,18 @@ start() { echo "" # 检查是否已在运行 - if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then - red "[!] 端口 $WEB_PORT 已被占用,请先停止服务" + WEB_PID="$(web_pid_for_port)" + if [ -n "${WEB_PID:-}" ]; then + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if web_pid_belongs_to_project "$WEB_PID"; then + red "[!] 本项目 Web 已在端口 $WEB_PORT 运行 (PID $WEB_PID)" + else + red "[!] 端口 $WEB_PORT 被其他项目占用 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + fi exit 1 fi - if ! redis-cli ping > /dev/null 2>&1; then + if ! redis-cli -u "$REDIS_URL" ping > /dev/null 2>&1; then red "[!] Redis 未运行,请先启动 Redis" exit 1 fi @@ -141,7 +180,7 @@ start() { echo "正在启动 Celery workers..." nohup bash start_celery_9000.sh > "$CELERY_LOG" 2>&1 & sleep 3 - CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + CELERY_COUNT="$(pgrep -fc "$QUEUE_PREFIX" || true)" if [ "$CELERY_COUNT" -gt 0 ]; then green " [✓] Celery workers 已启动 (${CELERY_COUNT} 进程)" else diff --git a/restart_with_prompts.sh b/scripts/legacy_startup/restart_with_prompts.sh similarity index 100% rename from restart_with_prompts.sh rename to scripts/legacy_startup/restart_with_prompts.sh diff --git a/start-webui.sh b/scripts/legacy_startup/start-webui.sh similarity index 100% rename from start-webui.sh rename to scripts/legacy_startup/start-webui.sh diff --git a/start_celery.sh b/scripts/legacy_startup/start_celery.sh similarity index 100% rename from start_celery.sh rename to scripts/legacy_startup/start_celery.sh diff --git a/start_celery_7880.sh b/scripts/legacy_startup/start_celery_7880.sh similarity index 100% rename from start_celery_7880.sh rename to scripts/legacy_startup/start_celery_7880.sh diff --git a/start_celery_port9000.sh b/scripts/legacy_startup/start_celery_port9000.sh similarity index 100% rename from start_celery_port9000.sh rename to scripts/legacy_startup/start_celery_port9000.sh diff --git a/start_celery_9000.sh b/start_celery_9000.sh index 2496254f8..2c341bbff 100755 --- a/start_celery_9000.sh +++ b/start_celery_9000.sh @@ -26,13 +26,13 @@ if [ -n "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then BBAV5_WORKER_NAME_PREFIX="$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" fi if [ -z "${BBAV5_QUEUE_PREFIX-}" ]; then - BBAV5_QUEUE_PREFIX="plantai_dev_9000" + BBAV5_QUEUE_PREFIX="plantai_4090_9000" fi if [ -z "${BBAV5_REDIS_URL-}" ]; then BBAV5_REDIS_URL="redis://localhost:6379/10" fi if [ -z "${BBAV5_CELERY_LOG_DIR-}" ]; then - BBAV5_CELERY_LOG_DIR="logs/celery_plantai_dev_9000" + BBAV5_CELERY_LOG_DIR="logs/celery_plantai_4090_9000" fi if [ -z "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then unset BBAV5_WORKER_NAME_PREFIX @@ -51,11 +51,11 @@ export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$PROJECT_ROOT/.env}" export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$PROJECT_ROOT/.runtime/lightweight-extension/store}" export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$PROJECT_ROOT/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$PROJECT_ROOT/workspace}" -export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" +export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" WORKER_NAME_PREFIX="${BBAV5_WORKER_NAME_PREFIX:-${BBAV5_QUEUE_PREFIX//[^A-Za-z0-9_]/_}}" -LOG_DIR="${BBAV5_CELERY_LOG_DIR:-logs/celery_plantai_dev_9000}" +LOG_DIR="${BBAV5_CELERY_LOG_DIR:-logs/celery_plantai_4090_9000}" mkdir -p "$LOG_DIR" CELERY="$CONDA_ENV/bin/celery" diff --git a/start_plantai_9000.sh b/start_plantai_9000.sh index 93050a096..7ad851daf 100755 --- a/start_plantai_9000.sh +++ b/start_plantai_9000.sh @@ -24,6 +24,12 @@ done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + . "$SCRIPT_DIR/.env" + set +a +fi + CONDA_BASE="${BBAV5_CONDA_BASE:-/home/xia-lab/miniconda3}" DEFAULT_ENV="${BBAV5_DEFAULT_ENV:-test4py311}" CONDA_ENV="$CONDA_BASE/envs/$DEFAULT_ENV" @@ -33,23 +39,18 @@ export PATH="$CONDA_ENV/bin:$PATH" # the caller's shell; GWAS/other sandboxes have dedicated restart scripts. export BBAV5_PROJECT_ROOT="$SCRIPT_DIR" export PLANTAI_PROJECT_ROOT="$SCRIPT_DIR" -if [ -f "$SCRIPT_DIR/.env" ]; then - set -a - . "$SCRIPT_DIR/.env" - set +a -fi export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$SCRIPT_DIR/.env}" export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$SCRIPT_DIR/.runtime/lightweight-extension/store}" export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$SCRIPT_DIR/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$SCRIPT_DIR/workspace}" -export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" +export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" export PLANTAI_WEB_PORT="$PORT" export PLANTAI_RESTART_SCRIPT="$SCRIPT_DIR/restart_plantai_9000.sh" -export GENE_FAMILY_MAS_API="http://127.0.0.1:7926" -export DUAL_KB_QA_BASE_URL="http://127.0.0.1:7926" -export RAG_API_URL="http://127.0.0.1:7926/api/v1/qa-v1-1-73/chat" -export DUAL_KB_QA_ROUTE="qa-v1-1-73" +export GENE_FAMILY_MAS_API="${GENE_FAMILY_MAS_API:-${DUAL_KB_QA_BASE_URL:-http://127.0.0.1:7920}}" +export DUAL_KB_QA_BASE_URL="${DUAL_KB_QA_BASE_URL:-$GENE_FAMILY_MAS_API}" +export RAG_API_URL="${RAG_API_URL:-$GENE_FAMILY_MAS_API}" +export DUAL_KB_QA_ROUTE="${DUAL_KB_QA_ROUTE:-qa-v1-1-73}" export PLANTAI_AGENT_STATE_DB="$SCRIPT_DIR/.runtime/agent-state/agent-state.sqlite3" PORT="$PLANTAI_WEB_PORT"