Files
PlantAI-dev/tasks.py
T

1256 lines
55 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
"""
Celery任务定义
"""
import sys
import os
from pathlib import Path
from datetime import datetime
# 添加项目路径
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from celery_app import app
@app.task(
bind=True,
name="tasks.execute_lightweight_extension_operation",
time_limit=None,
soft_time_limit=None,
)
def execute_lightweight_extension_operation(self, payload: dict) -> dict:
"""Run one lightweight-extension operation without a Celery deadline."""
from gfa_core.extension_stage_lightweight.api import execute_queued_operation
return execute_queued_operation(payload)
2026-08-03 18:42:47 +08:00
def normalize_stage_params(stage: str, params: dict | None) -> tuple[dict, dict]:
"""Clamp per-stage CPU at the worker boundary and return resource metadata."""
params = dict(params or {})
try:
from gfa_core.mas.stage_catalog import clamp_stage_cpu, get_stage_resource_profile
requested_cpu = params.get("requested_cpu", params.get("cpu"))
effective_cpu = clamp_stage_cpu(stage, params.get("cpu"))
profile = get_stage_resource_profile(stage)
if stage in {"rnaseq_qc", "rnaseq_align"}:
from gfa_core.mas.resource_governor import recommend_rnaseq_parallelism, rnaseq_project_cpu_limit
explicit_cpu = requested_cpu not in {None, "", 0, "0"}
project_limit = rnaseq_project_cpu_limit(requested_cpu if explicit_cpu else None)
explicit_threads = params.get("threads_per_sample") or params.get("threadsPerSample")
2026-08-03 18:42:47 +08:00
plan = recommend_rnaseq_parallelism(
total_cpu=project_limit,
sample_parallel_jobs=params.get("sample_parallel_jobs") or params.get("sampleParallelJobs"),
stage=stage,
)
effective_cpu = clamp_stage_cpu(stage, project_limit)
2026-08-03 18:42:47 +08:00
params["sample_parallel_jobs"] = plan["sample_parallel_jobs"]
if explicit_threads:
params["threads_per_sample"] = max(1, int(explicit_threads))
else:
params.pop("threads_per_sample", None)
params.pop("threadsPerSample", None)
2026-08-03 18:42:47 +08:00
params["rnaseq_project_max_cpu"] = plan["project_cpu_limit"]
params["resource_decision"] = {
"stage": stage,
"requested_cpu": requested_cpu,
"effective_cpu": effective_cpu,
"project_cpu_limit": plan["project_cpu_limit"],
"sample_parallel_jobs": plan["sample_parallel_jobs"],
"threads_per_sample": max(1, int(explicit_threads)) if explicit_threads else None,
"reason": (
f"{stage} preserves the selected total CPU budget; per-sample threads "
"are derived after the real sample count is known"
),
2026-08-03 18:42:47 +08:00
}
except Exception as exc:
requested_cpu = params.get("requested_cpu", params.get("cpu"))
try:
effective_cpu = max(1, min(int(requested_cpu), 4))
except (TypeError, ValueError):
effective_cpu = 4
profile = {
"queue": "medium",
"resource_class": "medium",
"default_cpu": effective_cpu,
"max_cpu": effective_cpu,
"profile_error": str(exc),
}
params["requested_cpu"] = requested_cpu
params["cpu"] = effective_cpu
_sync_member_set_id_with_scope(stage, params)
return params, profile
def _sync_member_set_id_with_scope(stage: str, params: dict) -> None:
"""Keep a scope-derived member_set_id consistent with the execute-time scope_id.
The execute layer can hand down a comp-polluted scope file together with a
plan-time (no-comp) member_set_id, producing a self-contradictory manifest
that postflight rejects as stage_identity_contract_mismatch. Re-derive the
scope token from the actual scope file so the CLI, the manifest and the
postflight check all agree. User-defined subsets (subfamily-xxx) are NOT
scope-derived and are left untouched.
"""
member_set_id = str(params.get("memberSetId") or params.get("member_set_id") or "").strip()
scope_file = str(params.get("scopeFile") or params.get("scope_file") or "").strip()
if not member_set_id or not scope_file:
return
if "subfamily-" in member_set_id:
return
parts = member_set_id.split(":")
if len(parts) != 3 or parts[2] not in {"all_members", "domain_refined"}:
return
try:
from gfa_core.gene_family_scope import read_scope, sanitize_token
scope_path = Path(scope_file)
if not scope_path.is_absolute():
scope_path = PROJECT_ROOT / scope_path
if not scope_path.exists():
return
scope_id = str(read_scope(scope_path).get("scope_id") or "").strip()
if not scope_id:
return
expected_token = sanitize_token(scope_id)
analysis_route = str(params.get("analysisRoute") or params.get("analysis_route") or parts[1] or "").strip()
if analysis_route == "unscoped":
analysis_route = "single_species" if stage == "identify" else ""
expected_route = analysis_route or parts[1]
expected_selector = "domain_refined" if expected_route == "subfamily_evidence" else parts[2]
if parts[0] == expected_token and parts[1] == expected_route and parts[2] == expected_selector:
return
new_member_set_id = f"{expected_token}:{expected_route}:{expected_selector}"
print(f"[MEMBER_SET SYNC] scope_id={scope_id} member_set_id {member_set_id} -> {new_member_set_id}")
if params.get("memberSetId"):
params["memberSetId"] = new_member_set_id
if params.get("member_set_id"):
params["member_set_id"] = new_member_set_id
if not params.get("memberSetId") and not params.get("member_set_id"):
params["memberSetId"] = new_member_set_id
except Exception as exc:
print(f"[MEMBER_SET SYNC] skipped: {exc}")
def _nonempty_prefix(value) -> str:
text = str(value or "").strip()
return text if text and text.lower() not in {"none", "null", "undefined", "nan", "-"} else ""
def _merge_manifest_identity_params(params: dict | None, manifest: dict | None) -> dict:
"""Prefer the identity actually written by the stage for postflight checks."""
from gfa_core.mas.tools.stage_identity import merge_manifest_identity_params
return merge_manifest_identity_params(params, manifest)
def _gene_family_from_config(config_path: Path):
if not config_path.exists():
return None
try:
import pandas as pd
sheets = pd.read_excel(config_path, sheet_name=None)
for sheet_name, df in sheets.items():
if str(sheet_name).lower() in {"analysis", "meta", "metadata"}:
for _, row in df.fillna("").iterrows():
cells = [str(v).strip() for v in row.tolist()]
for idx, cell in enumerate(cells[:-1]):
if cell.lower() in {"family", "gene_family", "analysis_family", "project_family"}:
value = _nonempty_prefix(cells[idx + 1])
if value:
return value
if "Gene_Family" in df.columns:
for value in df["Gene_Family"].tolist():
value = _nonempty_prefix(value)
if value:
return value
except Exception as exc:
print(f"[Celery] Failed to infer gene family from {config_path}: {exc}")
return None
def _rnaseq_prefix_from_final(final_dir: Path) -> str:
if not final_dir.exists():
return ""
preferred = [
"*.rnaseq_wgcna.manifest.json",
"*.rnaseq_fgsea.manifest.json",
"*.rnaseq_candidate_family.manifest.json",
"*.rnaseq_sample_qc.manifest.json",
"*.rnaseq_de.manifest.json",
"*.rnaseq_quant.manifest.json",
"*.rnaseq_align.manifest.json",
"*.rnaseq_qc.manifest.json",
"*.annotation.manifest.json",
"*.genome_prepare.manifest.json",
]
for pattern in preferred:
matches = sorted(final_dir.glob(pattern))
if len(matches) == 1:
suffix = pattern[1:]
return matches[0].name[: -len(suffix)]
return ""
def resolve_effective_prefix(job_id: str, prefix: str = "", stage: str = "") -> str:
provided = _nonempty_prefix(prefix)
gene_family_stages = {
"identify", "phylogeny", "motif", "domain", "structure", "promoter", "tfbs",
"synteny", "interspecies_synteny", "karyotype", "combo", "ppi",
"pep", "summary", "reference_classification", "tree_plot",
}
def normalize_gene_family_prefix(value: str) -> str:
value = _nonempty_prefix(value)
if stage in gene_family_stages and value.startswith("Multi."):
family = value.split(".", 1)[1].strip()
if family:
return f"Target.{family}"
return value
final_dir = PROJECT_ROOT / "workspace" / job_id / "final"
if stage in {"rnaseq_enrich", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_sample_qc", "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family"}:
rnaseq_prefix = _rnaseq_prefix_from_final(final_dir)
if rnaseq_prefix and rnaseq_prefix != provided:
print(f"[Celery] Overriding RNA-seq prefix {provided or '<empty>'} -> {rnaseq_prefix}")
return rnaseq_prefix
if provided:
return normalize_gene_family_prefix(provided)
input_dir = PROJECT_ROOT / "input" / job_id
species_config = input_dir / "genome_prepare" / "species_config.json"
if species_config.exists():
try:
import json
data = json.loads(species_config.read_text(encoding="utf-8"))
target = data.get("target") if isinstance(data.get("target"), dict) else None
if target:
value = _nonempty_prefix(target.get("prefix"))
if value:
return normalize_gene_family_prefix(value)
value = _nonempty_prefix(data.get("prefix"))
if value:
return normalize_gene_family_prefix(value)
except Exception as exc:
print(f"[Celery] Failed to read species_config prefix: {exc}")
for base in [final_dir, input_dir / "final"]:
if base.exists():
for mf in sorted(base.glob("Target.*.genome_prepare.manifest.json")):
name = mf.name
return name.split(".genome_prepare.manifest", 1)[0]
for mf in sorted(base.glob("Multi.*.genome_prepare.manifest.json")):
name = mf.name
return normalize_gene_family_prefix(name.split(".genome_prepare.manifest", 1)[0])
family = _gene_family_from_config(input_dir / "config.xlsx")
if family:
return f"Target.{family}"
for pattern in ("Target.*.final_members.pep.fa", "Multi.*.final_members.pep.fa"):
for file in sorted(final_dir.glob(pattern)):
return normalize_gene_family_prefix(file.name[:-len(".final_members.pep.fa")])
return ""
def _split_prefix(value: str) -> tuple[str, str]:
text = str(value or "").strip()
if "." in text:
left, right = text.split(".", 1)
return left.strip(), right.strip()
return "", text
def _list_param(value) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
import re
return [item.strip() for item in re.split(r"[,;,;\s]+", str(value or "")) if item.strip()]
def _resolved_artifacts_from_decisions(decisions: dict) -> dict:
resolved = {}
for key, decision in (decisions or {}).items():
if not isinstance(decision, dict):
continue
selected = decision.get("selected") if isinstance(decision.get("selected"), dict) else None
if selected and selected.get("path"):
resolved[key] = {
"path": selected.get("path"),
"source": selected.get("source", ""),
"kind": selected.get("kind", ""),
"role": selected.get("role", ""),
"species": selected.get("species", ""),
"family": selected.get("family", ""),
}
return resolved
def attach_semantic_execution_context(job_id: str, stage: str, prefix: str, params: dict | None) -> dict:
"""Attach resolver decisions to params for execution and diagnosis.
/api/execute already performs a semantic preflight, but Celery workers can
also be called directly or run after code reloads. Rebuilding here keeps
execution, diagnosis, and logs on the same project-context view.
"""
params = dict(params or {})
if isinstance(params.get("semanticPreflight"), dict) and isinstance(params.get("resolvedArtifacts"), dict):
return params
try:
from gfa_core.mas.artifact_resolver import ArtifactResolver
from gfa_core.mas.context_index import build_project_context
context = build_project_context(job_id=job_id, prefix=prefix, project_root=PROJECT_ROOT)
resolver = ArtifactResolver(context)
prefix_species, prefix_family = _split_prefix(prefix)
family = str(params.get("geneFamily") or params.get("family") or prefix_family or context.family or "").strip()
target_species = _list_param(params.get("targetSpecies") or params.get("target_species") or [])
if target_species:
species_list = target_species
elif prefix_species and prefix_species.lower() not in {"multi", "target"}:
species_list = [prefix_species]
elif len(context.target_species) == 1:
species_list = [context.target_species[0]]
else:
species_list = [""]
decisions = {}
for species in species_list:
for role, decision in resolver.resolve_stage(stage, species=species, family=family).items():
decisions[f"{species or 'auto'}:{role}"] = decision.to_dict()
semantic_preflight = {
"valid": True,
"stage": stage,
"prefix": prefix,
"decisions": decisions,
"context": {
"family": context.family,
"target_species": context.target_species,
"reference_species": context.reference_species,
"comparative_species": context.comparative_species,
"config_path": context.config_path,
"input_dir": context.input_dir,
"final_dir": context.final_dir,
},
}
params["semanticPreflight"] = semantic_preflight
params["resolvedArtifacts"] = _resolved_artifacts_from_decisions(decisions)
except Exception as exc:
params["semanticContextError"] = str(exc)
return params
# 初始化Orchestrator(每个worker进程初始化一次)
_orchestrator = None
def get_orchestrator():
global _orchestrator
if _orchestrator is None:
from gfa_core.mas.config import config as mas_config
from gfa_core.mas.llm import LLMManager
from gfa_core.mas.memory import MemoryManager
from gfa_core.mas.core.langgraph_orchestrator import LangGraphOrchestrator
llm_config = mas_config.load_llm_config()
llm_config["openai_api_key"] = mas_config.settings.openai_api_key
llm_config["openai_base_url"] = mas_config.settings.openai_base_url
llm_manager = LLMManager(config=llm_config)
_orchestrator = LangGraphOrchestrator(llm_manager=llm_manager)
return _orchestrator
def validate_single_stage_artifacts(job_id: str, prefix: str, stage: str, result: dict | None = None) -> dict | None:
"""Return a failure response if a single-stage task did not produce valid artifacts."""
from gfa_core.mas.tools.stage_identity import get_stage_status_for_identity, resolve_stage_identity
from gfa_core.mas.tools.stage_status import get_stage_status
actual_stage = stage
actual_prefix = prefix
manifest = {}
if isinstance(result, dict):
final_output = result.get("final_output", {})
if isinstance(final_output, dict) and isinstance(final_output.get("manifest"), dict):
manifest = final_output.get("manifest") or {}
elif isinstance(result.get("manifest"), dict):
manifest = result.get("manifest") or {}
if isinstance(manifest, dict) and manifest:
actual_stage = _nonempty_prefix(manifest.get("stage")) or actual_stage
actual_prefix = _nonempty_prefix(manifest.get("prefix")) or actual_prefix
stage_context = manifest.get("stage_context") or manifest.get("stageContext") or {} if isinstance(manifest, dict) else {}
identity_params = {
"prefix": actual_prefix,
"scopeFile": manifest.get("scope_file") or stage_context.get("scope_file") or "" if isinstance(manifest, dict) else "",
"scopeId": manifest.get("scope_id") or stage_context.get("scope_id") or "" if isinstance(manifest, dict) else "",
"scopeMode": manifest.get("scope_mode") or stage_context.get("scope_mode") or "" if isinstance(manifest, dict) else "",
"analysisRoute": manifest.get("analysis_route") or stage_context.get("analysis_route") or "" if isinstance(manifest, dict) else "",
"memberSetId": manifest.get("member_set_id") or stage_context.get("member_set_id") or "" if isinstance(manifest, dict) else "",
"geneFamily": manifest.get("gene_family") or manifest.get("family") or stage_context.get("family") or "" if isinstance(manifest, dict) else "",
"targetSpecies": manifest.get("target_species") or stage_context.get("target_species") or [] if isinstance(manifest, dict) else [],
"referenceSpecies": manifest.get("reference_species") or stage_context.get("reference_species") or [] if isinstance(manifest, dict) else [],
"comparativeSpecies": manifest.get("comparative_species") or stage_context.get("comparative_species") or [] if isinstance(manifest, dict) else [],
"wgsSampleSetId": manifest.get("wgs_sample_set_id") if isinstance(manifest, dict) else "",
"wgsReferenceId": manifest.get("wgs_reference_id") if isinstance(manifest, dict) else "",
"wgsAnalysisId": manifest.get("wgs_analysis_id") if isinstance(manifest, dict) else "",
"genotypeScopeId": manifest.get("genotype_scope_id") if isinstance(manifest, dict) else "",
"phenotypeScopeId": manifest.get("phenotype_scope_id") if isinstance(manifest, dict) else "",
"covariateScopeId": manifest.get("covariate_scope_id") if isinstance(manifest, dict) else "",
"modelScopeId": manifest.get("model_scope_id") if isinstance(manifest, dict) else "",
"sourceModelScopeId": manifest.get("source_model_scope_id") if isinstance(manifest, dict) else "",
"analysisScopeId": manifest.get("analysis_scope_id") if isinstance(manifest, dict) else "",
"runId": manifest.get("run_id") if isinstance(manifest, dict) else "",
}
try:
identity = resolve_stage_identity(
stage=actual_stage,
prefix=actual_prefix,
params=identity_params,
job_id=job_id,
)
stage_status = get_stage_status_for_identity(
job_id=job_id,
identity=identity,
2026-08-09 04:22:38 +08:00
strict_identity=actual_stage != "gene_family_report" and bool(
2026-08-03 18:42:47 +08:00
identity.scope_id
or identity.analysis_route
or identity.member_set_id
or identity.wgs_sample_set_id
or identity.wgs_reference_id
or identity.wgs_analysis_id
or identity.genotype_scope_id
or identity.phenotype_scope_id
or identity.covariate_scope_id
or identity.model_scope_id
or identity.source_model_scope_id
or identity.analysis_scope_id
),
)
except Exception as exc:
scope_id = str(identity_params.get("scopeId") or "")
analysis_route = str(identity_params.get("analysisRoute") or "")
member_set_id = str(identity_params.get("memberSetId") or "")
print(f"[Celery] Identity-aware artifact validation fallback for {stage}: {exc}")
stage_status = get_stage_status(
job_id=job_id,
prefix=actual_prefix,
stage=actual_stage,
scope_id=scope_id,
analysis_route=analysis_route,
member_set_id=member_set_id,
strict_identity=bool(scope_id or analysis_route or member_set_id),
)
if actual_stage in {"rnaseq_go_enrich", "rnaseq_kegg_enrich"} and stage_status.completed:
final_dir = PROJECT_ROOT / "workspace" / job_id / "final"
suffix = ".GO_enrich.txt" if actual_stage == "rnaseq_go_enrich" else ".KEGG_enrich.txt"
if any(final_dir.glob(f"*{suffix}")):
return None
if stage_status.completed:
return None
business_messages: list[str] = []
if isinstance(result, dict):
for error in result.get("errors", []) or []:
if error:
business_messages.append(str(error))
final_output = result.get("final_output", {})
if isinstance(final_output, dict):
for key in ("response", "message", "error"):
value = final_output.get(key)
if value:
business_messages.append(str(value))
business_error = next(
(
msg for msg in business_messages
if "❌" in msg or "failed" in msg.lower() or "cannot choose a background" in msg.lower()
),
"",
)
detail_parts = []
if stage_status.errors:
detail_parts.extend(stage_status.errors)
if stage_status.missing_artifacts:
detail_parts.append("缺失产物: " + "; ".join(stage_status.missing_artifacts))
detail_msg = ";".join(detail_parts) or f"{stage} 未生成有效输出"
error_msg = business_error or f"{stage} 执行失败:{detail_msg}"
print(f"[Celery] 单阶段产物校验失败: {stage_status.to_dict()}")
return {
'status': 'failed',
'success': False,
'current_stage': stage,
'progress': 0,
'message': error_msg,
'errors': [error_msg],
'stage_status': stage_status.to_dict(),
'result': (result or {}).get("final_output", {}) if isinstance(result, dict) else {},
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': error_msg}
]
}
def _diagnose_single_stage_failure(
job_id: str,
stage: str,
prefix: str = "",
*,
prepare_patch_workspace: bool = False,
) -> dict:
"""DiagnosisAgent is disabled; keep failures as plain execution failures."""
return {
"status": "diagnosis_disabled",
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"issues": [],
}
try:
from gfa_core.mas.agents.diagnosis import DiagnosisAgent
diagnosis = DiagnosisAgent(None).diagnose_job_stage(
job_id=job_id,
stage=stage,
prefix=prefix or "",
project_root=PROJECT_ROOT,
prepare_patch_workspace=prepare_patch_workspace,
)
return diagnosis if isinstance(diagnosis, dict) else {}
except Exception as exc:
print(f"[Celery] DiagnosisAgent failed for {job_id}/{stage}: {exc}")
return {
"status": "diagnosis_failed",
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"error": str(exc),
"policy": {
"auto_modify_input": False,
"auto_modify_global_code": False,
"auto_rerun_stage": False,
},
}
def _diagnose_stage_gate(
job_id: str,
stage: str,
prefix: str = "",
*,
gate: str,
params: dict | None = None,
) -> dict:
return {
"status": "diagnosis_disabled",
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"gate": gate,
"issues": [],
}
try:
from gfa_core.mas.agents.diagnosis import DiagnosisAgent
diagnosis_prefix = "genome_prepare" if stage in {"genome_prepare", "genome_prepare_v2"} else (prefix or "")
return DiagnosisAgent(None).diagnose_stage_gate(
job_id=job_id,
stage=stage,
prefix=diagnosis_prefix,
gate=gate,
project_root=PROJECT_ROOT,
params=params or {},
)
except Exception as exc:
print(f"[Celery] Diagnosis gate failed for {job_id}/{stage}/{gate}: {exc}")
return {
"status": "diagnosis_failed",
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"gate": gate,
"error": str(exc),
"issues": [],
}
def _apply_runtime_repairs(
job_id: str,
stage: str,
prefix: str = "",
diagnosis: dict | None = None,
*,
execute_heavy: bool = False,
) -> dict:
return {
"success": False,
"status": "diagnosis_disabled",
"stage": stage,
"job_id": job_id,
"results": [],
}
try:
from gfa_core.mas.agents.diagnosis import DiagnosisAgent
return DiagnosisAgent(None).apply_runtime_repairs(
job_id=job_id,
stage=stage,
prefix=prefix or "",
diagnosis=diagnosis,
project_root=PROJECT_ROOT,
execute_heavy=execute_heavy,
)
except Exception as exc:
print(f"[Celery] Runtime repair failed for {job_id}/{stage}: {exc}")
return {
"success": False,
"status": "repair_failed",
"stage": stage,
"job_id": job_id,
"error": str(exc),
"results": [],
}
def _attach_diagnosis_to_failure(response: dict, job_id: str, stage: str, prefix: str = "") -> dict:
"""Keep failed task responses free of DiagnosisAgent side effects."""
if not isinstance(response, dict) or response.get("success") is not False:
return response
response.pop("pending_confirmation", None)
response.pop("diagnosis", None)
return response
diagnosis = response.get("diagnosis")
if not isinstance(diagnosis, dict) or not diagnosis:
diagnosis = _diagnose_single_stage_failure(job_id=job_id, stage=stage, prefix=prefix)
response["diagnosis"] = diagnosis
session_id = str(response.get("session_id") or job_id or "").strip()
if diagnosis.get("requires_user_confirmation"):
response["pending_confirmation"] = {
"status": "waiting",
"type": "diagnosis_review",
"session_id": session_id,
"stage": stage,
"job_id": job_id,
"prefix": prefix,
"diagnosis": diagnosis,
"message": "检测到阶段失败,已生成诊断。请用户确认后再修订输入、执行副本修复或升级全局代码。",
"allowed_actions": [
"review_only",
"prepare_job_patch",
"apply_confirmed_global_fix_after_backup",
],
}
return response
@app.task(bind=True, max_retries=3)
def execute_agent_task(self, task_id: str, message: str, session_id: str, job_id: str = "", history: list = None, prefix: str = ""):
"""
执行Agent分析任务
"""
try:
print(f"[Celery] Starting task {task_id}, job_id={job_id}")
# 更新任务状态为运行中(使用self.request.id获取Celery任务ID)
celery_task_id = self.request.id if self.request else task_id
self.update_state(
task_id=celery_task_id,
state='RUNNING',
meta={
'current_stage': 'intent_parsing',
'progress': 0,
'message': message,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'info', 'message': '开始分析任务...'},
{'time': datetime.now().isoformat(), 'level': 'info', 'message': f'解析用户意图: {message}'}
]
}
)
# 获取Orchestrator
orchestrator = get_orchestrator()
# 查询已完成的阶段
completed_stages = []
if job_id:
from gfa_core.mas.tools.stage_status import list_completed_stages
completed_stages = list_completed_stages(job_id=job_id, prefix=prefix or "")
print(f"[tasks.py] Completed stages: {completed_stages}")
# 构建初始状态
from gfa_core.mas.core.state import GraphState
initial_state = {
"query": message,
"session_id": session_id,
"job_id": job_id, # 传入 job_id 用于 auto_continue
"files": [],
"current_agent": "router",
"status": "running",
"errors": [],
"final_output": {},
"history": history or [],
"messages": [], # 添加 messages 字段
"completed_stages": completed_stages, # 添加已完成阶段
"retry_count": 0, # 添加重试计数
"max_retries": 3, # 添加最大重试次数
"user_input": {"prefix": prefix} # 传递 prefix 参数
}
print(f"[tasks.py] initial_state keys: {list(initial_state.keys())}")
print(f"[tasks.py] initial_state['completed_stages']: {initial_state.get('completed_stages', 'NOT FOUND')}")
# 运行Orchestrator
import asyncio
result = asyncio.run(orchestrator.run(initial_state))
# 处理结果
final_output = result.get("final_output", {})
if result.get("status") == "completed":
# ============ 生成累积解读报告(新增) ============
try:
import asyncio
from gfa_core.mas.agents.result_parser import create_result_parser_agent
from gfa_core.mas.llm import LLMManager
# 从 result 中提取 prefix(如果传入的 prefix 为空)
if not prefix:
prefix = result.get("final_output", {}).get("user_input", {}).get("prefix", "")
if not prefix:
print("[Celery] 未解析到输出标签,跳过累积解读报告生成")
raise RuntimeError("missing_prefix_for_interpretation")
# 获取已完成的阶段列表(统一 manifest/artifacts 校验)
from gfa_core.mas.tools.stage_status import list_completed_stages
completed_stages = list_completed_stages(job_id=job_id, prefix=prefix)
# 按顺序排序
stage_order = ["identify", "phylogeny", "structure", "motif", "domain", "promoter", "synteny", "interspecies_synteny", "karyotype", "expression", "kaks", "ppi"]
completed_stages = [s for s in stage_order if s in completed_stages] + [s for s in completed_stages if s not in stage_order]
print(f"[Celery] 生成累积解读,已完成阶段: {completed_stages}")
# 创建 ResultParserAgent(不需要 LLMManager)
agent = create_result_parser_agent(None)
# 生成累积解读报告
report = asyncio.run(agent.generate_cumulative_report(
job_id=job_id,
prefix=prefix,
completed_stages=completed_stages
))
# 保存解读报告
save_success = agent.save_interpretation(job_id, prefix, report)
if save_success:
print(f"[Celery] 解读报告已保存到 interpretation.json")
else:
print(f"[Celery] 保存解读报告失败")
except Exception as e:
# 解读失败不阻塞主流程
print(f"[Celery] 生成解读报告失败(非阻塞): {e}")
import traceback
traceback.print_exc()
return {
'status': 'completed',
'current_stage': result.get("current_agent", "unknown"),
'progress': 100,
'message': message,
'result': {
'response': final_output.get("response", ""),
'analysis_plan': final_output.get("analysis_plan", {}),
'execution_result': final_output.get("execution_result", {})
},
'logs': [
{'time': datetime.now().isoformat(), 'level': 'success', 'message': '分析任务完成'}
]
}
else:
return {
'status': 'failed',
'current_stage': result.get("current_agent", "unknown"),
'progress': 0,
'message': message,
'result': {},
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': f"分析失败: {result.get('errors', ['Unknown error'])}"}
]
}
except Exception as e:
import traceback
error_msg = f"任务异常: {str(e)}\n{traceback.format_exc()}"
print(f"[Celery] Task {task_id} failed: {error_msg}")
celery_task_id = self.request.id if self.request else task_id
self.update_state(
task_id=celery_task_id,
state='FAILURE',
meta={
'exc_type': type(e).__name__,
'exc_message': str(e),
}
)
raise
@app.task(bind=True, max_retries=3)
def execute_single_stage_task(self, task_id: str, stage: str, job_id: str, prefix: str,
params: dict = None, session_id: str = None):
"""
执行单个分析阶段任务(用于/api/execute统一架构)
参数:
- task_id: 任务ID
- stage: 分析阶段(如identify, phylogeny等)
- job_id: 任务目录ID
- prefix: 分析前缀(例如 At.WRKY;基因家族流程通常由 config/scope 自动生成)
- params: 执行参数
- session_id: 会话ID
"""
resource_acquired = False
resource_outcome = "failed"
resource_lease = None
try:
print(f"[Celery] Starting single stage task {task_id}, stage={stage}, job_id={job_id}")
params, resource_profile = normalize_stage_params(stage, params)
print(
"[Celery] Resource profile "
f"stage={stage}, queue={resource_profile.get('queue')}, "
f"class={resource_profile.get('resource_class')}, "
f"requested_cpu={params.get('requested_cpu')}, effective_cpu={params.get('cpu')}"
)
celery_task_id = self.request.id if self.request else task_id
try:
from gfa_core.mas.resource_governor import ResourceLease
from gfa_core.mas.stage_catalog import get_stage_queue
resource_queue = get_stage_queue(stage)
resource_lease = ResourceLease(
task_id=task_id,
stage=stage,
job_id=job_id,
prefix=prefix,
cpu=int(params.get("cpu") or 1),
queue=resource_queue,
resource_class=str(resource_profile.get("resource_class") or ""),
concurrency_group=str(resource_profile.get("concurrency_group") or ""),
celery_task_id=celery_task_id,
)
def _update_waiting_resource(wait_result: dict):
usage = wait_result.get("usage", {}) if isinstance(wait_result, dict) else {}
reason = wait_result.get("reason", "等待资源可用") if isinstance(wait_result, dict) else "等待资源可用"
self.update_state(
task_id=celery_task_id,
state="WAITING_RESOURCE",
meta={
"status": "waiting_resource",
"current_stage": stage,
"progress": 0,
"message": reason,
"resource_usage": usage,
"resource_profile": resource_profile,
"logs": [
{
"time": datetime.now().isoformat(),
"level": "info",
"message": reason,
}
],
},
)
acquire_result = resource_lease.wait(update_state=_update_waiting_resource)
resource_acquired = True
print(f"[Celery] Resource lease granted for {task_id}: {acquire_result.get('usage')}")
except Exception as exc:
print(f"[Celery] Resource governor unavailable, continuing without lease: {exc}")
# 更新任务状态
self.update_state(
task_id=celery_task_id,
state='RUNNING',
meta={
'current_stage': stage,
'progress': 10,
'message': (
f"开始执行 {stage}(请求CPU {params.get('requested_cpu') or params.get('cpu')},"
f"调度CPU {params.get('cpu')},{resource_profile.get('resource_class')}队列)..."
),
'logs': [
{
'time': datetime.now().isoformat(),
'level': 'info',
'message': (
f"开始执行 {stage}; requested_cpu={params.get('requested_cpu')}, "
f"effective_cpu={params.get('cpu')}, queue_class={resource_profile.get('resource_class')}"
)
}
]
}
)
# /api/execute 是“用户已明确选择阶段”的入口。这里直接调用
# StageToolRegistry,避免再走自然语言规划/PreFlight,把
# rnaseq_enrich 误展开成 rnaseq_qc 并错误要求 FASTQ。
try:
import asyncio
from gfa_core.mas.tools.stage_tools import stage_registry
prefix = resolve_effective_prefix(job_id, prefix, stage)
if params is not None:
params["prefix"] = prefix
params = attach_semantic_execution_context(job_id, stage, prefix, params)
print(f"[Celery] Effective prefix for {stage}: {prefix or '<empty>'}")
if params.get("resolvedArtifacts"):
print(f"[Celery] Resolved artifacts for {stage}: {params.get('resolvedArtifacts')}")
qa_preflight = _diagnose_stage_gate(job_id, stage, prefix, gate="preflight", params=params)
qa_repair = {}
if qa_preflight.get("runtime_repair_candidates"):
execute_heavy = bool((params or {}).get("diagnosisHeavyRepair", True))
qa_repair = _apply_runtime_repairs(
job_id,
stage,
prefix,
qa_preflight,
execute_heavy=execute_heavy,
)
qa_preflight = _diagnose_stage_gate(job_id, stage, prefix, gate="preflight", params=params)
blocking_preflight = [
issue for issue in qa_preflight.get("issues", [])
if issue.get("severity") == "blocking" or issue.get("blocks_downstream")
]
if blocking_preflight:
error_msg = "❌ 诊断门控阻断执行:发现输入/上游产物存在会导致阶段失败的问题"
return _attach_diagnosis_to_failure({
'status': 'failed',
'success': False,
'current_stage': stage,
'progress': 0,
'session_id': session_id or job_id,
'message': error_msg,
'errors': [error_msg],
'diagnosis': qa_preflight,
'qa_preflight': qa_preflight,
'qa_runtime_repair': qa_repair,
'semantic_preflight': params.get("semanticPreflight"),
'resolved_artifacts': params.get("resolvedArtifacts", {}),
'resource_profile': resource_profile,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': error_msg}
]
}, job_id, stage, prefix)
direct_result = asyncio.run(stage_registry.execute(
stage_id=stage,
job_id=job_id,
prefix=prefix,
extra_params=params or {},
))
print(f"[Celery] Direct single-stage result: {direct_result}")
success = direct_result.get("status") == "success"
if success:
result_manifest = direct_result.get("manifest") if isinstance(direct_result.get("manifest"), dict) else {}
result_stage = _nonempty_prefix(result_manifest.get("stage")) or stage
result_prefix = _nonempty_prefix(result_manifest.get("prefix")) or prefix
artifact_failure = validate_single_stage_artifacts(job_id, result_prefix, result_stage, {
"final_output": {
"message": direct_result.get("message", ""),
"manifest": result_manifest,
"manifest_path": direct_result.get("manifest_path"),
}
})
if artifact_failure:
artifact_failure["session_id"] = session_id or job_id
return _attach_diagnosis_to_failure(artifact_failure, job_id, result_stage, result_prefix)
postflight_params = _merge_manifest_identity_params(params, result_manifest)
qa_postflight = _diagnose_stage_gate(job_id, result_stage, result_prefix, gate="postflight", params=postflight_params)
qa_post_repair = {}
if qa_postflight.get("runtime_repair_candidates"):
qa_post_repair = _apply_runtime_repairs(
job_id,
result_stage,
result_prefix,
qa_postflight,
execute_heavy=bool((params or {}).get("diagnosisHeavyRepair", True)),
)
qa_postflight = _diagnose_stage_gate(job_id, result_stage, result_prefix, gate="postflight", params=postflight_params)
blocking_postflight = [
issue for issue in qa_postflight.get("issues", [])
if issue.get("severity") == "blocking" or issue.get("blocks_downstream")
]
if blocking_postflight:
error_msg = "❌ 阶段已运行,但诊断 postflight 发现关键产物不可安全下游使用"
return _attach_diagnosis_to_failure({
'status': 'failed',
'success': False,
'current_stage': result_stage,
'progress': 100,
'session_id': session_id or job_id,
'message': error_msg,
'errors': [error_msg],
'result': direct_result,
'diagnosis': qa_postflight,
'qa_preflight': qa_preflight,
'qa_runtime_repair': qa_repair,
'qa_postflight': qa_postflight,
'qa_postflight_repair': qa_post_repair,
'semantic_preflight': params.get("semanticPreflight"),
'resolved_artifacts': params.get("resolvedArtifacts", {}),
'resource_profile': resource_profile,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': error_msg}
]
}, job_id, result_stage, result_prefix)
resource_outcome = "completed"
return {
'status': 'completed',
'success': True,
'current_stage': result_stage,
'progress': 100,
'message': f'{result_stage} 执行完成',
'result': direct_result,
'qa_preflight': qa_preflight,
'qa_runtime_repair': qa_repair,
'qa_postflight': qa_postflight,
'qa_postflight_repair': qa_post_repair,
'semantic_preflight': params.get("semanticPreflight"),
'resolved_artifacts': params.get("resolvedArtifacts", {}),
'resource_profile': resource_profile,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'success', 'message': direct_result.get("message", f'{stage} 执行成功')}
]
}
error_msg = direct_result.get("error") or direct_result.get("message") or f"{stage} 执行失败"
qa_failure = _diagnose_stage_gate(job_id, stage, prefix, gate="failure", params=params)
return _attach_diagnosis_to_failure({
'status': 'failed',
'success': False,
'current_stage': stage,
'progress': 0,
'session_id': session_id or job_id,
'message': error_msg,
'errors': [error_msg],
'result': direct_result,
'diagnosis': qa_failure,
'qa_preflight': qa_preflight,
'qa_runtime_repair': qa_repair,
'qa_failure': qa_failure,
'semantic_preflight': params.get("semanticPreflight"),
'resolved_artifacts': params.get("resolvedArtifacts", {}),
'resource_profile': resource_profile,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': error_msg}
]
}, job_id, stage, prefix)
except Exception as direct_exc:
print(f"[Celery] Direct single-stage execution failed, falling back to orchestrator: {direct_exc}")
# 获取Orchestrator
orchestrator = get_orchestrator()
# 构建初始状态(单stage模式)
from gfa_core.mas.core.langgraph_orchestrator import GraphState
initial_state: GraphState = {
"query": f"执行{stage}分析",
"session_id": session_id or task_id,
"job_id": job_id,
"files": [],
"current_agent": "router",
"status": "running",
"errors": [],
"final_output": {},
"messages": [],
"retry_count": 0,
"max_retries": 3,
"user_input": {
"prefix": prefix,
"stage": stage, # 指定要执行的stage
"params": params or {}
},
"single_stage_mode": True, # 标记为单stage模式
"target_stage": stage # 目标stage
}
print(f"[tasks.py] 单stage模式: target={stage}, prefix={prefix}")
# 运行LangGraph
import asyncio
result = asyncio.run(orchestrator.run(initial_state))
# 处理结果
if result.get("status") in ["completed", "success"]:
# 单阶段任务的最终成功标准必须是真实产物,而不是仅依赖
# LangGraph/Celery 的运行状态。
artifact_failure = validate_single_stage_artifacts(job_id, prefix, stage, result)
if artifact_failure:
artifact_failure["session_id"] = session_id or job_id
return _attach_diagnosis_to_failure(artifact_failure, job_id, stage, prefix)
# ============ 生成累积解读报告(新增) ============
try:
import asyncio
from gfa_core.mas.agents.result_parser import create_result_parser_agent
# 获取已完成的阶段列表(统一 manifest/artifacts 校验)
from gfa_core.mas.tools.stage_status import list_completed_stages
completed_stages = list_completed_stages(job_id=job_id, prefix=prefix)
print(f"[Celery DEBUG] Unified completed_stages: {completed_stages}")
# 按顺序排序
stage_order = ["identify", "phylogeny", "structure", "motif", "domain", "promoter", "synteny", "interspecies_synteny", "karyotype", "expression", "kaks", "ppi"]
completed_stages = [s for s in stage_order if s in completed_stages] + [s for s in completed_stages if s not in stage_order]
print(f"[Celery] 生成累积解读,已完成阶段: {completed_stages}")
# 创建 ResultParserAgent(不需要 LLMManager)
agent = create_result_parser_agent(None)
# 生成累积解读报告
report = asyncio.run(agent.generate_cumulative_report(
job_id=job_id,
prefix=prefix,
completed_stages=completed_stages
))
# 保存解读报告
save_success = agent.save_interpretation(job_id, prefix, report)
if save_success:
print(f"[Celery] 解读报告已保存到 interpretation.json")
else:
print(f"[Celery] 保存解读报告失败")
except Exception as e:
# 解读失败不阻塞主流程
print(f"[Celery] 生成解读报告失败(非阻塞): {e}")
import traceback
traceback.print_exc()
resource_outcome = "completed"
return {
'status': 'completed',
'success': True, # 添加 success 字段,供前端判断
'current_stage': stage,
'progress': 100,
'message': f'{stage} 执行完成',
'result': result.get("final_output", {}),
'resource_profile': resource_profile,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'success', 'message': f'{stage} 执行成功'}
]
}
else:
# 检查是否有等待确认的状态(优先检查 result.status)
if result.get("status") == "waiting_confirmation":
pending_confirmation = result.get("pending_confirmation", {})
# 将确认状态存储到Celery任务meta中
return {
'status': 'waiting_confirmation',
'current_stage': stage,
'progress': 50,
'message': '等待用户确认修复方案',
'pending_confirmation': pending_confirmation,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'warning', 'message': '检测到错误,等待用户确认修复方案'}
]
}
# 旧逻辑:检查 pending_confirmation.status == "waiting"(兼容)
pending_confirmation = result.get("pending_confirmation")
if pending_confirmation and pending_confirmation.get("status") == "waiting":
# 将确认状态存储到Celery任务meta中
return {
'status': 'waiting_confirmation',
'current_stage': stage,
'progress': 50,
'message': '等待用户确认修复方案',
'pending_confirmation': pending_confirmation,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'warning', 'message': '检测到错误,等待用户确认修复方案'}
]
}
# 构建详细错误消息:优先保留 run_stage/stage_tools 返回的业务错误,
# 避免被后续 manifest 校验错误覆盖成 "Manifest not found"。
final_output = result.get("final_output", {}) or {}
execution_results = result.get("execution_results", []) or []
business_error = (
final_output.get("response")
or final_output.get("message")
or final_output.get("error")
or result.get("execution_error")
)
if not business_error:
for item in execution_results:
stage_result = item.get("result", {}) if isinstance(item, dict) else {}
candidate = stage_result.get("error") or stage_result.get("message")
if candidate:
business_error = candidate
break
if isinstance(business_error, str) and (
"❌" in business_error
or "failed" in business_error.lower()
or "无法" in business_error
or "缺少" in business_error
):
error_msg = business_error
else:
error_msg = f'{stage} 执行失败'
diagnosis = result.get("diagnosis_result", {})
if diagnosis:
suggestion = diagnosis.get("suggestion", "")
error_type = diagnosis.get("error_type", "")
if suggestion:
error_msg += f"\n\n诊断: {error_type}\n建议: {suggestion}"
return _attach_diagnosis_to_failure({
'status': 'failed',
'success': False, # 添加 success 字段,供前端判断
'current_stage': stage,
'progress': 0,
'session_id': session_id or job_id,
'message': error_msg,
'errors': result.get("errors", ["Unknown error"]),
'diagnosis': diagnosis,
'logs': [
{'time': datetime.now().isoformat(), 'level': 'error', 'message': error_msg}
]
}, job_id, stage, prefix)
except Exception as e:
import traceback
error_msg = f"任务异常: {str(e)}\n{traceback.format_exc()}"
print(f"[Celery] Single stage task {task_id} failed: {error_msg}")
celery_task_id = self.request.id if self.request else task_id
self.update_state(
task_id=celery_task_id,
state='FAILURE',
meta={
'exc_type': type(e).__name__,
'exc_message': str(e),
}
)
raise
finally:
if resource_acquired and resource_lease is not None:
try:
resource_lease.release(status=resource_outcome, message=f"{stage} {resource_outcome}")
except Exception as release_exc:
print(f"[Celery] Failed to release resource lease for {task_id}: {release_exc}")