feat: 轻量拓展分析 + WGS2GWAS 阶段支持 + 结果区域重构 + 物种角色解析增强 + 会话 Job 隔离

【轻量拓展分析】
- 新增 gfa_core/extension_stage_lightweight/ 核心模块,支持用户上传代码/数据/原始命令
- 前端卡片式交互流程:候选核对 → 验证 → 结果审查 → 授权发布 → 运行
- Celery 不受限任务 + 轮询机制 + sessionStorage 操作恢复
- 启动脚本新增 BBAV5_LIGHTWEIGHT_* 环境变量

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
potaser
2026-08-05 09:40:45 +08:00
co-authored by Claude Opus 5
parent f745ee9d7e
commit e641c8b9ab
53 changed files with 15831 additions and 92 deletions
+23 -9
View File
@@ -1073,14 +1073,22 @@ def resolve_scoped_protein_fasta_from_phylogeny(
)
def resolve_scoped_tree_from_phylogeny(
ws: Path,
final_dir: Path,
prefix: str,
scope_path: Path,
) -> tuple[Path, dict]:
scope_data = read_scope(scope_path)
manifest_candidates = stage_manifest_candidates(final_dir, "phylogeny", prefix, scope_data)
def resolve_scoped_tree_from_phylogeny(
ws: Path,
final_dir: Path,
prefix: str,
scope_path: Path,
explicit_tree_file: str = "",
) -> tuple[Path, dict]:
scope_data = read_scope(scope_path)
if explicit_tree_file:
selected_tree = Path(explicit_tree_file).expanduser()
if not selected_tree.is_absolute():
selected_tree = ws / selected_tree
if selected_tree.exists() and selected_tree.stat().st_size > 0:
return selected_tree.resolve(), scope_data
raise FileNotFoundError(f"Specified scoped tree file not found: {explicit_tree_file}")
manifest_candidates = stage_manifest_candidates(final_dir, "phylogeny", prefix, scope_data)
for manifest_file in manifest_candidates:
if not manifest_file.exists():
@@ -8261,7 +8269,13 @@ def run_stage(
if member_set_id:
cmd += ["--member-set-id", member_set_id]
try:
motif_tree_file, _motif_scope_data = resolve_scoped_tree_from_phylogeny(ws, final_dir, prefix, scope_path)
motif_tree_file, _motif_scope_data = resolve_scoped_tree_from_phylogeny(
ws,
final_dir,
prefix,
scope_path,
explicit_tree_file=tree_file,
)
cmd += ["--tree", str(motif_tree_file)]
motif_scope_id = str(_motif_scope_data.get("scope_id") or "")
except Exception as exc:
+1166 -29
View File
File diff suppressed because it is too large Load Diff
+173 -43
View File
@@ -665,19 +665,10 @@ def _apply_scope_selection_to_intent(intent: Dict[str, Any], message: str) -> No
params["geneFamily"] = family
def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
"""Read species roles only when the job has authoritative config confirmation."""
def _configured_species_scope(job_id: str) -> Dict[str, list[str]]:
"""Read the species roles declared by the current job config."""
if not job_id:
return {}
try:
from gfa_core.mas.confirmation_snapshot import build_confirmation_snapshot
confirmation = build_confirmation_snapshot(PROJECT_ROOT, job_id)
except (OSError, ValueError):
return {}
if not confirmation.get("config_confirmed"):
return {}
config_path = INPUT_ROOT / job_id / "config.xlsx"
try:
import pandas as pd
@@ -705,6 +696,21 @@ def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
return scope if any(scope.values()) else {}
def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
"""Read species roles only when the job has authoritative config confirmation."""
if not job_id:
return {}
try:
from gfa_core.mas.confirmation_snapshot import build_confirmation_snapshot
confirmation = build_confirmation_snapshot(PROJECT_ROOT, job_id)
except (OSError, ValueError):
return {}
if not confirmation.get("config_confirmed"):
return {}
return _configured_species_scope(job_id)
def _normalize_main_agent_analysis_intent(
intent: Dict[str, Any],
*,
@@ -1137,7 +1143,7 @@ def _load_identify_species_selection(job_id: str) -> Dict[str, Any]:
current_role = entry.get("role", "unspecified")
if role == "target":
entry["role"] = "target"
elif role in {"ref", "seed_reference", "known_family_reference"} and current_role != "target":
elif role in {"ref", "reference", "seed_reference", "known_family_reference"} and current_role != "target":
entry["role"] = role
elif role in {"comparative", "compactive", "outgroup"} and current_role not in {"target", "ref", "seed_reference", "known_family_reference"}:
entry["role"] = role
@@ -1185,7 +1191,7 @@ def _load_identify_species_selection(job_id: str) -> Dict[str, Any]:
groups["target"].append(item)
elif role in {"comparative", "compactive", "outgroup"}:
groups["comparative"].append(item)
elif role in {"ref", "seed_reference", "known_family_reference"}:
elif role in {"ref", "reference", "seed_reference", "known_family_reference"}:
groups["reference"].append(item)
else:
groups["other"].append(item)
@@ -1344,6 +1350,53 @@ def _extract_species_codes_from_text_for_job(job_id: str, text: str) -> list[str
return ordered
def _resolve_subfamily_request_species_roles(
job_id: str,
message: str,
intent: Optional[Dict[str, Any]] = None,
) -> Dict[str, list[str]]:
"""Preserve config roles while resolving species mentioned in a subfamily request."""
routed_intent = intent or {}
params = routed_intent.get("params") or {}
structured_targets = _canonicalize_species_codes_for_job(
job_id,
_param_list(params.get("targetSpecies") or routed_intent.get("target_species")),
)
structured_refs = _canonicalize_species_codes_for_job(
job_id,
_param_list(params.get("referenceSpecies") or routed_intent.get("reference_species")),
)
structured_comps = _canonicalize_species_codes_for_job(
job_id,
_param_list(params.get("comparativeSpecies") or routed_intent.get("comparative_species")),
)
mentioned = _extract_species_codes_from_text_for_job(job_id, message or "")
configured = _configured_species_scope(job_id)
configured_targets = _canonicalize_species_codes_for_job(job_id, configured.get("targetSpecies") or [])
configured_refs = _canonicalize_species_codes_for_job(job_id, configured.get("referenceSpecies") or [])
configured_comps = _canonicalize_species_codes_for_job(job_id, configured.get("comparativeSpecies") or [])
if configured:
target_candidates = [*structured_targets, *mentioned]
comp_candidates = [*structured_comps, *mentioned]
ref_candidates = [*structured_refs, *mentioned]
targets = [species for species in configured_targets if species in target_candidates]
comps = [species for species in configured_comps if species in comp_candidates]
refs = [species for species in configured_refs if species in ref_candidates]
if not refs:
refs = list(configured_refs)
else:
targets = structured_targets or mentioned
refs = structured_refs
comps = structured_comps
classification_species = list(dict.fromkeys([*targets, *comps]))
return {
"target_species": targets,
"reference_species": refs,
"comparative_species": comps,
"classification_species": classification_species,
}
def _species_groups_from_selection(selection: Dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
groups = selection.get("groups", {}) if isinstance(selection, dict) else {}
@@ -1584,6 +1637,10 @@ def _split_subfamily_selected_species_by_config_roles(
selected_set = set(selected)
target_role = _subfamily_group_species(identify_selection or {}, ["target"])
comparative_role = _subfamily_group_species(identify_selection or {}, ["comparative", "other"])
if not target_role and not comparative_role:
configured = _configured_species_scope(job_id)
target_role = _canonicalize_species_codes_for_job(job_id, configured.get("targetSpecies") or [])
comparative_role = _canonicalize_species_codes_for_job(job_id, configured.get("comparativeSpecies") or [])
targets = [sp for sp in target_role if sp in selected_set]
comps = [sp for sp in comparative_role if sp in selected_set and sp not in targets]
if not targets:
@@ -1726,18 +1783,47 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
return list(dict.fromkeys(stages))
def _resolve_contextual_gene_family_request(job_id: str, message: str, request_prefix: str = "") -> Dict[str, Any]:
def _resolve_contextual_gene_family_request(
job_id: str,
message: str,
request_prefix: str = "",
requested_stages: list[str] | None = None,
request_intent: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""Resolve post-subfamily/default phylogeny context from real project state.
This is deliberately rule-based: it decides scope/route boundaries from
files and config, while LLM/intent parsing may still supply user wording.
"""
text = str(message or "")
structured_stages = list(dict.fromkeys(
str(stage).strip()
for stage in (requested_stages or [])
if str(stage).strip()
))
contextual_stage_request = bool(
set(structured_stages) & {"phylogeny", "motif", "domain", "structure", "combo"}
)
routed_intent = request_intent or {}
routed_params = routed_intent.get("params") or {}
structured_targets = _canonicalize_species_codes_for_job(
job_id,
_param_list(routed_params.get("targetSpecies") or routed_intent.get("target_species")),
)
structured_refs = _canonicalize_species_codes_for_job(
job_id,
_param_list(routed_params.get("referenceSpecies") or routed_intent.get("reference_species")),
)
structured_comps = _canonicalize_species_codes_for_job(
job_id,
_param_list(routed_params.get("comparativeSpecies") or routed_intent.get("comparative_species")),
)
if not (
_looks_like_phylogeny_query(text)
or _looks_like_gene_family_workflow_query(text)
or _looks_like_intraspecies_synteny_query(text)
or _looks_like_tfbs_query(text)
or contextual_stage_request
):
return {}
selection = _load_identify_species_selection(job_id)
@@ -1762,11 +1848,13 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
if sp not in mentioned_targets:
mentioned_targets.append(sp)
has_explicit_scope = bool(mentioned)
has_explicit_scope = bool(mentioned or structured_targets or structured_refs or structured_comps)
is_all_species_request = _looks_like_all_species_query(text) or _looks_like_all_subfamily_species_query(text)
is_intraspecies_synteny_request = _looks_like_intraspecies_synteny_query(text)
if has_explicit_scope:
if mentioned:
targets = mentioned_targets or [sp for sp in mentioned if sp not in mentioned_refs and sp not in mentioned_comps]
elif structured_targets:
targets = list(structured_targets)
elif is_all_species_request and config_targets:
targets = list(config_targets)
elif is_intraspecies_synteny_request and config_targets:
@@ -1780,8 +1868,8 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
else:
targets = []
refs = mentioned_refs or (list(config_refs) if is_all_species_request else [])
comps = mentioned_comps or (list(config_comps) if is_all_species_request else [])
refs = mentioned_refs or list(structured_refs) or (list(config_refs) if is_all_species_request else [])
comps = mentioned_comps or list(structured_comps) or (list(config_comps) if is_all_species_request else [])
if refs and comps:
scope_mode = "target_plus_reference_plus_comparative"
elif refs:
@@ -1793,7 +1881,7 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
if not family or not targets:
return {}
stages = _downstream_gene_family_stages_for_context(text)
stages = structured_stages or _downstream_gene_family_stages_for_context(text)
subfamily_label_stages = {"phylogeny", "motif", "domain", "structure", "combo"}
needs_subfamily_labels_for_stage = any(stage in subfamily_label_stages for stage in stages)
prefix = f"{targets[0]}.{family}" if len(targets) == 1 else f"Target.{family}"
@@ -1826,6 +1914,7 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
or _requires_confirmed_subfamily_query(text)
or _looks_like_gene_family_workflow_query(text)
or _looks_like_phylogeny_query(text)
or contextual_stage_request
)
)
requires_confirmed = wants_subfamily_context
@@ -3870,6 +3959,9 @@ def _attach_agent_manifest_status(task: Dict) -> None:
print(f"[API DEBUG] Failed to attach agent manifest status: {e}")
app = FastAPI(title="BotanicalBaseAgent API")
from gfa_core.extension_stage_lightweight.api import create_lightweight_extension_router
app.include_router(create_lightweight_extension_router())
# MainAgent runtime state. Analysis planning and execution remain owned by
# the original IntentAgent/PlanningAgent/frontend flow below.
llm_manager = None
@@ -4115,11 +4207,15 @@ async def link_agent_conversation_to_job(
store = globals().get("agent_conversation_store")
if store is None:
raise HTTPException(status_code=503, detail="会话存储尚未初始化")
store.rebind_job(
conversation_id = store.rebind_job(
session_id=request.conversation_id,
job_id=request.job_id,
)
return {"success": True, "message": "会话已关联 Working Directory"}
return {
"success": True,
"conversation_id": conversation_id,
"message": "会话已关联 Working Directory",
}
@@ -6014,6 +6110,13 @@ def _api_semantic_preflight(job_id: str, stage: str, prefix: str, params: Dict[s
decisions[decision_key] = decision.to_dict() if decision else None
if decision and decision.resolved:
if (
need.role == "tree"
and decision.selected
and decision.selected.path
and not str(params.get("treeFile") or "").strip()
):
materialized_params["treeFile"] = decision.selected.path
if (
stage == "wgs_index"
and need.role == "reference_fasta"
@@ -12598,7 +12701,7 @@ def _get_celery_overview() -> Dict[str, Any]:
resource = snapshot(include_terminal=False)
overview["resource_usage"] = resource.get("usage")
overview["resource_tasks"] = resource.get("tasks", [])[:20]
overview["resource_tasks"] = resource.get("tasks", [])
except Exception as exc:
overview["errors"].append(f"Resource governor snapshot failed: {exc}")
overview["thresholds"] = {
@@ -13925,13 +14028,6 @@ async def get_results(
def _add_manifest_result_group(manifest_file: Path, manifest: dict):
if "combo_panel_test" in manifest_file.name.lower():
return
if _is_subfamily_intermediate_manifest(manifest_file, manifest):
# These manifests are intermediate evidence generated inside the
# reference_classification branch. Final combo/circular tree/MSA
# artifacts are exposed under 2.5 亚家族确认 instead.
_remember_hidden_manifest_artifacts(manifest_file, manifest)
hidden_intermediate_artifact_names.add(manifest_file.name)
return
group_name = (
manifest.get("result_group")
or (manifest.get("parameters") or {}).get("result_group")
@@ -14206,6 +14302,12 @@ async def get_results(
except Exception:
pass
# Results are a persisted job view. Apply manifests by execution time so
# the latest attempt determines the visible stage status.
manifest_payloads.sort(
key=lambda item: (item[0].stat().st_mtime, item[0].name)
)
# A refreshed GWAS format result request may not carry the genotype scope.
# Rehydrate it only when the requested prefix (if any) identifies exactly
# one successful scientific scope; otherwise remain fail-closed.
@@ -16729,6 +16831,7 @@ async def _run_agent_chat(
{
"taskType": task_type,
"message": outcome_message,
"ui_action": outcome.get("ui_action"),
"job_id": request.job_id,
"workspace": (
str(PROJECT_ROOT / "workspace" / request.job_id)
@@ -17904,7 +18007,7 @@ def _build_contextual_gene_family_plan_response(request: Any, session_id: str, c
# Subfamily review happens after reference_classification.
if "identify" in stage_ids:
detail["dependencies"] = ["identify"]
if contextual_request.get("scopeMode") != "target_only" and contextual_request.get("context_route") in {"custom_phylogeny_scope", "plain_phylogeny"}:
if not needs_subfamily_evidence_chain and contextual_request.get("scopeMode") != "target_only" and contextual_request.get("context_route") in {"custom_phylogeny_scope", "plain_phylogeny"}:
detail_params["analysisRoute"] = "custom_phylogeny"
detail["params"] = enrich_stage_params_with_context(stage_id, plan_prefix, detail_params)
stages_detail.append(detail)
@@ -19652,8 +19755,12 @@ async def agent_plan(request: AgentPlanRequest):
request.message or "",
subfamily=True,
)
explicit_species_for_subfamily = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
targets_for_subfamily = explicit_species_for_subfamily or _target_species_fallback_from_prefix(requested_prefix_for_subfamily)
request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
request.job_id,
request.message or "",
request.intent,
)
targets_for_subfamily = request_roles_for_subfamily["classification_species"] or _target_species_fallback_from_prefix(requested_prefix_for_subfamily)
if not targets_for_subfamily and prefix_species and prefix_species not in {"Target", "Multi"}:
targets_for_subfamily = [prefix_species]
scope_defaults_for_subfamily = _latest_gene_family_scope_defaults(request.job_id, family_for_subfamily, requested_prefix_for_subfamily)
@@ -19675,7 +19782,7 @@ async def agent_plan(request: AgentPlanRequest):
*(scope_defaults_for_subfamily.get("target_species") or []),
*(scope_defaults_for_subfamily.get("comparative_species") or []),
])) or targets_for_subfamily
refs_for_subfamily = scope_defaults_for_subfamily.get("reference_species") or [
refs_for_subfamily = request_roles_for_subfamily["reference_species"] or scope_defaults_for_subfamily.get("reference_species") or [
item.get("species")
for item in ((identify_selection_for_subfamily.get("groups") or {}).get("reference") or [])
if item.get("species")
@@ -19828,6 +19935,8 @@ async def agent_plan(request: AgentPlanRequest):
request.job_id,
request.message,
request.prefix,
requested_stages=list((request.intent or {}).get("stages") or []),
request_intent=request.intent,
)
if contextual_request_pre_mas and not subfamily_prediction_query_for_context and not subfamily_confirm_query_for_context:
response = _build_contextual_gene_family_plan_response(request, session_id, contextual_request_pre_mas)
@@ -19978,12 +20087,16 @@ async def agent_plan(request: AgentPlanRequest):
and not _requires_confirmed_subfamily_query(query_lower_early)
)
if subfamily_prediction_query_early:
explicit_species = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
request.job_id,
request.message or "",
incoming_intent,
)
current_targets = _canonicalize_species_codes_for_job(
request.job_id,
_param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")),
)
target_species_for_subfamily = explicit_species or current_targets
target_species_for_subfamily = request_roles_for_subfamily["classification_species"] or current_targets
family_for_subfamily = _resolve_authoritative_gene_family(
request.job_id,
incoming_params.get("geneFamily") or incoming_intent.get("gene_family"),
@@ -19996,7 +20109,7 @@ async def agent_plan(request: AgentPlanRequest):
if not target_species_for_subfamily:
target_species_for_subfamily = scope_defaults_for_subfamily.get("target_species") or _target_species_fallback_from_prefix(effective_plan_prefix or requested_prefix)
identify_selection_for_subfamily = _load_identify_species_selection(request.job_id) if request.job_id else {}
reference_defaults_for_subfamily = _primary_reference_species_from_scope(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
reference_defaults_for_subfamily = request_roles_for_subfamily["reference_species"] or _primary_reference_species_from_scope(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
if not reference_defaults_for_subfamily:
reference_defaults_for_subfamily = scope_defaults_for_subfamily.get("reference_species") or []
if not reference_defaults_for_subfamily:
@@ -20014,8 +20127,8 @@ async def agent_plan(request: AgentPlanRequest):
incoming_intent["reference_species"] = reference_defaults_for_subfamily
incoming_params["scopeMode"] = "target_plus_reference"
incoming_intent["scopeMode"] = "target_plus_reference"
incoming_params["comparativeSpecies"] = []
incoming_intent["comparative_species"] = []
incoming_params["comparativeSpecies"] = request_roles_for_subfamily["comparative_species"]
incoming_intent["comparative_species"] = request_roles_for_subfamily["comparative_species"]
incoming_params["geneFamily"] = family_for_subfamily
incoming_intent["gene_family"] = family_for_subfamily
if target_species_for_subfamily and family_for_subfamily:
@@ -20036,14 +20149,19 @@ async def agent_plan(request: AgentPlanRequest):
request.message or "",
subfamily=True,
)
targets_for_subfamily = _canonicalize_species_codes_for_job(
request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
request.job_id,
_param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")) or _extract_species_codes_from_text_for_job(request.job_id, request.message or ""),
request.message or "",
incoming_intent,
)
targets_for_subfamily = request_roles_for_subfamily["classification_species"] or _canonicalize_species_codes_for_job(
request.job_id,
_param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")),
)
scope_defaults_for_subfamily = _latest_gene_family_scope_defaults(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
if not targets_for_subfamily:
targets_for_subfamily = scope_defaults_for_subfamily.get("target_species") or _target_species_fallback_from_prefix(effective_plan_prefix or requested_prefix)
refs_for_subfamily = _canonicalize_species_codes_for_job(
refs_for_subfamily = request_roles_for_subfamily["reference_species"] or _canonicalize_species_codes_for_job(
request.job_id,
_param_list(incoming_params.get("referenceSpecies")) or _param_list(incoming_intent.get("reference_species")) or scope_defaults_for_subfamily.get("reference_species"),
)
@@ -20246,7 +20364,13 @@ async def agent_plan(request: AgentPlanRequest):
print(f"[Agent Plan] Deterministic subfamily intent plan: targets={targets_for_subfamily}, refs={refs_for_subfamily}, stages={deterministic_stages}")
return response
contextual_request_early = _resolve_contextual_gene_family_request(request.job_id, request.message, request.prefix)
contextual_request_early = _resolve_contextual_gene_family_request(
request.job_id,
request.message,
request.prefix,
requested_stages=list(incoming_intent.get("stages") or []),
request_intent=incoming_intent,
)
if contextual_request_early and not subfamily_prediction_query_early and not subfamily_confirm_query_early:
response = _build_contextual_gene_family_plan_response(request, session_id, contextual_request_early)
plan_for_log = response.get('plan') or {}
@@ -20939,7 +21063,13 @@ async def agent_plan(request: AgentPlanRequest):
for key in ("targetSpecies", "referenceSpecies", "comparativeSpecies", "scopeMode", "geneFamily"):
if scope_params.get(key):
plan_params[key] = scope_params[key]
contextual_request = _resolve_contextual_gene_family_request(request.job_id, request.message, request.prefix)
contextual_request = _resolve_contextual_gene_family_request(
request.job_id,
request.message,
request.prefix,
requested_stages=list(intent.get("stages") or []),
request_intent=intent,
)
if contextual_request and not subfamily_prediction_query and not subfamily_confirm_query:
stage_ids = list(contextual_request.get("stages") or [])
raw_stages = stage_ids