fix: preserve gene-family stage intent and completed results

This commit is contained in:
wtx
2026-08-24 09:34:13 +08:00
parent 311a49110f
commit 4a5fdff6f0
10 changed files with 910 additions and 20 deletions
+14 -5
View File
@@ -23500,16 +23500,20 @@ compare: [[flesh, peel]]`;
let stagesDetail = (rawExecutableDetails || []).filter(stage => !completedSet.has(stage.id || stage.name));
if (stages.length === 0) {
const plannedStageIds = (plan.stages || [])
const plannedStageIds = (
(plan.all_stages || []).length > 0
? plan.all_stages
: (plan.stages || [])
)
.map(stage => typeof stage === 'object' ? stage.id : stage)
.filter(Boolean);
const completedOnlyPlan = plannedStageIds.length === 0
&& completedSet.size > 0
&& ['所有阶段已完成', 'All stages completed'].includes(String(plan.description || ''));
const requestedCompletedStage = (plannedStageIds.length > 0
? plannedStageIds.filter(stageId => completedSet.has(stageId))
: Array.from(completedSet)
).pop() || '';
const requestedCompletedStage = plannedStageIds.length === 1
&& completedSet.has(plannedStageIds[0])
? plannedStageIds[0]
: '';
const allPlannedCompleted = (plannedStageIds.length > 0 && plannedStageIds.every(stageId => completedSet.has(stageId)))
|| completedOnlyPlan;
if (allPlannedCompleted && requestedCompletedStage) {
@@ -23520,6 +23524,11 @@ compare: [[flesh, peel]]`;
taskType: 'analysis_completed',
plan: plan
});
} else if (allPlannedCompleted) {
addAgentMessage('assistant', t('当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。'), {
taskType: 'analysis_completed',
plan: plan
});
} else {
addAgentMessage('assistant', t('未生成可执行阶段。未检测到当前 scope 的完成结果时,不再把空计划解释为已完成;请检查阶段名称或前置依赖。'), {
taskType: 'analysis_plan_empty',
+171 -9
View File
@@ -2682,12 +2682,26 @@ def _normalize_full_gene_family_workflow_plan(
stages_detail: list[Dict[str, Any]],
plan_params: Dict[str, Any],
plan_prefix: str,
authorized_stage_ids: Optional[list[str]] = None,
) -> tuple[list[str], list[Dict[str, Any]]]:
if not _looks_like_gene_family_workflow_query(message or ""):
return stage_ids, stages_detail
desired = list(FULL_GENE_FAMILY_WORKFLOW_STAGES)
if "expression" in stage_ids or any(token in (message or "").lower() for token in ["表达", "expression", "rna"]):
authorized = None if not authorized_stage_ids else {
str(stage_id).strip()
for stage_id in authorized_stage_ids
if str(stage_id).strip()
}
desired = [
stage_id
for stage_id in FULL_GENE_FAMILY_WORKFLOW_STAGES
if authorized is None or stage_id in authorized
]
include_expression = (
"expression" in stage_ids
or any(token in (message or "").lower() for token in ["表达", "expression", "rna"])
)
if include_expression and (authorized is None or "expression" in authorized):
desired.append("expression")
shared_refs = _param_list(plan_params.get("referenceSpecies") or plan_params.get("reference_species") or plan_params.get("refSpeciesList"))
@@ -2699,7 +2713,7 @@ def _normalize_full_gene_family_workflow_plan(
plan_params["scopeMode"] = "target_plus_comparative"
ordered: list[str] = []
if "identify" in stage_ids:
if "identify" in stage_ids and (authorized is None or "identify" in authorized):
ordered.append("identify")
for sid in desired:
if sid not in ordered:
@@ -2707,6 +2721,8 @@ def _normalize_full_gene_family_workflow_plan(
for sid in stage_ids:
if sid == "ortholog":
continue
if authorized is not None and sid not in authorized:
continue
if sid and sid not in ordered:
ordered.append(sid)
@@ -3681,13 +3697,38 @@ def _identity_completed_stages_for_plan(
workspace_root=WORKSPACE_ROOT,
)
status = _stage_status_for_plan_identity(job_id, stage_id, identity, strict_identity=True)
workflow_phase = str(
merged_status_params.get("workflowPhase")
or merged_status_params.get("workflow_phase")
or ""
).strip()
requires_confirmed = bool(
merged_status_params.get("requiresConfirmedSubfamily")
or merged_status_params.get("requires_confirmed_subfamily")
)
contextual_kaks_shared_reference = (
stage_id == "kaks"
and workflow_phase == "post_subfamily_confirm"
and requires_confirmed
)
technical_reference_keys = (
"referenceSpecies",
"refSpeciesList",
"ppiReferenceSpecies",
"referenceBundleSpecies",
"ppiKaksReferenceSpecies",
"reference_species",
(
"ppiReferenceSpecies",
"referenceBundleSpecies",
"ppiKaksReferenceSpecies",
"ppi_reference_species",
"reference_bundle_species",
"ppi_kaks_reference_species",
)
if contextual_kaks_shared_reference
else (
"referenceSpecies",
"refSpeciesList",
"ppiReferenceSpecies",
"referenceBundleSpecies",
"ppiKaksReferenceSpecies",
"reference_species",
)
)
explicit_technical_identity = any(
_param_list(merged_status_params.get(key)) for key in technical_reference_keys
@@ -3774,6 +3815,114 @@ def _identity_completed_stages_for_plan(
strict_identity=True,
)
break
if (
status.completed is not True
and stage_id in set(FULL_GENE_FAMILY_WORKFLOW_STAGES) - {"ppi", "kaks"}
and workflow_phase == "post_subfamily_confirm"
and requires_confirmed
):
requested_family = str(
merged_status_params.get("geneFamily")
or merged_status_params.get("gene_family")
or (str(detail_prefix).split(".")[-1] if "." in str(detail_prefix) else "")
).strip()
requested_targets = _param_list(
merged_status_params.get("targetSpecies")
or merged_status_params.get("target_species")
)
expected_route = str(
merged_status_params.get("analysisRoute")
or merged_status_params.get("analysis_route")
or ""
).strip()
plan_references = _param_list(
(plan_params or {}).get("referenceSpecies")
or (plan_params or {}).get("reference_species")
)
final_dir = WORKSPACE_ROOT / job_id / "final"
latest_confirmation_mtime = 0.0
for confirmation_path in final_dir.glob("*.subfamily_confirm.manifest.json") if final_dir.is_dir() else []:
try:
confirmation = json.loads(confirmation_path.read_text(encoding="utf-8"))
except Exception:
continue
if (
confirmation.get("status") == "success"
and str(confirmation.get("prefix") or "").strip() == str(detail_prefix)
):
latest_confirmation_mtime = max(latest_confirmation_mtime, confirmation_path.stat().st_mtime)
candidates = sorted(
final_dir.glob(f"*.{stage_id}.manifest.json") if final_dir.is_dir() else [],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for manifest_path in candidates:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
continue
stage_context = manifest.get("stage_context") if isinstance(manifest.get("stage_context"), dict) else {}
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {}
def regular_manifest_value(*keys: str):
for source in (manifest, stage_context, parameters):
for key in keys:
if source.get(key) not in (None, "", []):
return source.get(key)
return ""
manifest_prefix = str(manifest.get("prefix") or manifest.get("output_prefix") or "").strip()
manifest_family = str(
regular_manifest_value("family", "gene_family")
or (manifest_prefix.split(".")[-1] if "." in manifest_prefix else "")
).strip()
manifest_targets = _param_list(regular_manifest_value("target_species", "targetSpecies"))
manifest_references = _param_list(regular_manifest_value("reference_species", "referenceSpecies"))
manifest_route = str(regular_manifest_value("analysis_route", "analysisRoute")).strip()
if (
manifest.get("stage") != stage_id
or manifest.get("status") != "success"
or manifest_prefix != str(detail_prefix)
or manifest_family != requested_family
or set(manifest_targets) != set(requested_targets)
or manifest_route != expected_route
or (
manifest_references
and set(manifest_references) != set(plan_references)
)
or (
latest_confirmation_mtime
and manifest_path.stat().st_mtime < latest_confirmation_mtime
)
):
continue
inherited_params = {
**merged_status_params,
"prefix": detail_prefix,
"targetSpecies": manifest_targets,
"referenceSpecies": manifest_references,
"scopeMode": regular_manifest_value("scope_mode", "scopeMode"),
"analysisRoute": manifest_route,
"memberSetId": regular_manifest_value("member_set_id", "memberSetId"),
}
inherited_params.pop("scopeId", None)
inherited_params.pop("scope_id", None)
inherited_identity = resolve_stage_identity(
stage=stage_id,
prefix=detail_prefix,
params=inherited_params,
job_id=job_id,
workspace_root=WORKSPACE_ROOT,
)
inherited_status = _stage_status_for_plan_identity(
job_id,
stage_id,
inherited_identity,
strict_identity=True,
)
if inherited_status.completed is True:
status = inherited_status
break
if status.completed is True:
if stage_id == "combo":
required_panels = _combo_panels_from_params(merged_status_params)
@@ -19096,6 +19245,7 @@ def _build_contextual_gene_family_plan_response(request: Any, session_id: str, c
stages_detail=stages_detail,
plan_params=plan_params,
plan_prefix=plan_prefix,
authorized_stage_ids=list(request_intent.get("stages") or requested_stage_ids),
)
completed_stages: list[str] = []
@@ -21292,6 +21442,11 @@ async def agent_plan(request: AgentPlanRequest):
"description": "执行完整 GWAS 分析链",
"confidence": 0.98,
})
authoritative_intent_stage_ids = [
str(stage_id).strip()
for stage_id in (incoming_intent.get("stages") or [])
if str(stage_id).strip()
]
incoming_params = dict(incoming_intent.get("params") or {})
def _request_list(*keys):
for key in keys:
@@ -22202,6 +22357,11 @@ async def agent_plan(request: AgentPlanRequest):
if result.get("status") == "completed":
plan = result.get("final_output", {}).get("analysis_plan", {})
intent = result.get("final_output", {}).get("intent", {})
plan_authorized_stage_ids = authoritative_intent_stage_ids or [
str(stage_id).strip()
for stage_id in (intent.get("stages") or [])
if str(stage_id).strip()
]
if authoritative_full_gwas_stages:
gwas_prefix = (
intent.get("prefix")
@@ -22907,6 +23067,7 @@ async def agent_plan(request: AgentPlanRequest):
stages_detail=stages_detail,
plan_params=plan_params,
plan_prefix=plan_prefix or effective_plan_prefix or request.prefix or "",
authorized_stage_ids=plan_authorized_stage_ids,
)
raw_stages = stage_ids
plan["stages"] = stage_ids
@@ -22939,6 +23100,7 @@ async def agent_plan(request: AgentPlanRequest):
stages_detail=stages_detail,
plan_params=plan_params,
plan_prefix=plan_prefix or effective_plan_prefix or request.prefix or "",
authorized_stage_ids=plan_authorized_stage_ids,
)
raw_stages = stage_ids
plan["stages"] = stage_ids
+1
View File
@@ -593,6 +593,7 @@ window.__I18N_EN__ = {
" 亚家族分类已完成预测,但确认表尚未保存;后续会继续显示为待确认。": " subfamily classification predicted; confirmation table not yet saved; will show as pending.",
" 到后端;系统会先生成 ": " to backend; system will generate ",
" 前端连接短暂中断,正在按后端产物状态恢复,不会立即判定阶段失败": " frontend connection interrupted; recovering from backend artifact state. Stage will not be marked as failed.",
"当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。": "All analysis stages in the current request already have valid results for the same analysis scope. No need to re-execute.",
" 已有同一分析范围的有效结果,无需重复执行。": " already has valid results for the same analysis scope. No need to re-execute.",
" 的依赖尚未完成": " dependency not yet completed",
" 重新提交(自动注释背景)...": " re-submitting (auto-annotation background)...",
+65 -3
View File
@@ -13,6 +13,7 @@ from gfa_core.mas.stage_catalog import (
complete_dependencies as catalog_complete_dependencies,
)
from gfa_core.mas.utils.intent_parser import (
extract_negated_target_stages,
is_gwas_fmt_intent,
parse_gene_family,
parse_gene_family_scope_selection,
@@ -154,6 +155,18 @@ class IntentAgentV2(BaseAgent):
def __init__(self, llm_manager: LLMManager):
super().__init__(llm_manager)
@staticmethod
def _current_request_text(query: str) -> str:
"""Return the user request without Router-appended confirmed context."""
text = str(query or "")
boundaries = [
index
for marker in ("\n已确认上下文:", "\nConfirmed context:")
if (index := text.find(marker)) >= 0
]
request = text[:min(boundaries)] if boundaries else text
return request.strip() or text.strip()
@staticmethod
def _apply_structured_goal_context(
intent: Dict[str, Any],
@@ -237,6 +250,7 @@ class IntentAgentV2(BaseAgent):
使用LLM理解用户输入,提取完整意图信息
"""
query = message.payload.get("query", "")
request_query = self._current_request_text(query)
# 获取前端传递的 prefix(例如 "At.WRKY")
provided_prefix = message.payload.get("prefix", "")
@@ -264,7 +278,15 @@ class IntentAgentV2(BaseAgent):
# ===== 关键修改:使用公共模块的硬编码规则解析意图 =====
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
base_intent = parse_intent_from_query(query)
request_intent = parse_intent_from_query(request_query)
context_intent = parse_intent_from_query(query)
base_intent = dict(request_intent)
for key in (
"gene_family", "species", "target_species", "reference_species",
"comparative_species", "scopeMode", "params", "prefix",
):
if context_intent.get(key):
base_intent[key] = context_intent[key]
print(f"[IntentAgentV2] Base intent from parser: {base_intent}")
# 如果硬编码规则解析成功(有明确阶段),直接使用
@@ -301,7 +323,22 @@ class IntentAgentV2(BaseAgent):
else:
# 硬编码规则未命中,使用 LLM 细化 stages
print(f"[IntentAgentV2] Hardcoded rule not hit, using LLM")
intent_result = await self._recognize_intent_with_llm(query, completed_stages, provided_prefix)
intent_result = await self._recognize_intent_with_llm(request_query, completed_stages, provided_prefix)
excluded_stages = set(extract_negated_target_stages(request_query))
if excluded_stages:
intent_result["stages"] = [
stage for stage in intent_result.get("stages", [])
if stage not in excluded_stages
]
if intent_result.get("primary_stage") in excluded_stages:
intent_result["primary_stage"] = intent_result["stages"][0] if intent_result["stages"] else None
if not intent_result["stages"]:
intent_result.update({
"description": "未检测到需要执行的正向分析阶段",
"needs_clarification": True,
"clarification_type": "analysis_stage",
"clarification_question": "请说明要执行的具体分析阶段",
})
# 关键修复:保护 Parser 已解析的 gene_family/species,不被 LLM 的"未知"覆盖
if base_intent.get("gene_family") and base_intent["gene_family"] not in ["未知", "待用户指定"]:
@@ -803,6 +840,22 @@ class IntentAgentV2(BaseAgent):
}
)
excluded_stages = set(extract_negated_target_stages(query))
if excluded_stages:
intent_data["stages"] = [
stage for stage in intent_data.get("stages", [])
if stage not in excluded_stages
]
if intent_data.get("primary_stage") in excluded_stages:
intent_data["primary_stage"] = intent_data["stages"][0] if intent_data["stages"] else None
if not intent_data["stages"]:
intent_data.update({
"description": "未检测到需要执行的正向分析阶段",
"needs_clarification": True,
"clarification_type": "analysis_stage",
"clarification_question": "请说明要执行的具体分析阶段",
})
# 根据依赖关系补充缺失的阶段(关键修复:确保依赖展开)
original_stages = intent_data["stages"]
intent_data["stages"] = self._complete_dependencies(original_stages)
@@ -1069,6 +1122,8 @@ class IntentAgentV2(BaseAgent):
stages = [stage for stage in stages if stage not in {"identify", "prepare_extract_pep", "prepare_extract_cds"}]
if any(stage.startswith("rnaseq") for stage in stages):
stages = [stage for stage in stages if stage not in {"expression", "msa"}]
excluded_stages = set(extract_negated_target_stages(query))
stages = [stage for stage in stages if stage not in excluded_stages]
return list(dict.fromkeys(stages))
def _fallback_recognition(self, query: str) -> Dict[str, Any]:
@@ -1077,13 +1132,20 @@ class IntentAgentV2(BaseAgent):
# 使用关键词匹配提取阶段
stages = self._extract_stages_from_query(query)
excluded_stages = set(extract_negated_target_stages(query))
if not stages:
query_lower = query.lower()
has_rnaseq_keyword = any(
kw in query_lower for kw in ["rnaseq", "rna-seq", "rna seq", "转录组", "fastq"]
)
if has_rnaseq_keyword:
stages = ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna"]
stages = [
stage for stage in (
"rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich",
"rnaseq_fgsea", "rnaseq_wgcna",
)
if stage not in excluded_stages
]
# 补充依赖(使用 stage_catalog 的主规划依赖链)
original_stages = stages.copy()
+31 -1
View File
@@ -4,7 +4,15 @@
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_gene_family_report_intent, resolve_gwas_intent, resolve_population_intent
from gfa_core.mas.utils.intent_parser import (
extract_negated_target_stages,
is_gwas_fmt_intent,
parse_gene_family_scope_selection,
parse_intent_from_query,
resolve_gene_family_report_intent,
resolve_gwas_intent,
resolve_population_intent,
)
# 分析类型关键词映射(注意:key必须与orchestrator.py中的analysis_type匹配)
ANALYSIS_KEYWORDS = {
@@ -180,6 +188,28 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]:
}
except Exception:
pass
excluded_stages = extract_negated_target_stages(query)
if excluded_stages:
parsed = parse_intent_from_query(query)
target_stages = list(parsed.get("stages") or [])
if not target_stages:
return None
all_stages = _complete_dependencies(target_stages)
return {
"stage": parsed.get("primary_stage") or target_stages[0],
"stages": all_stages,
"gene_family": parsed.get("gene_family", ""),
"species": parsed.get("species", ""),
"prefix": parsed.get("prefix", ""),
"target_species": parsed.get("target_species", []),
"reference_species": parsed.get("reference_species", []),
"comparative_species": parsed.get("comparative_species", []),
"scopeMode": parsed.get("scopeMode", "target_only"),
"params": parsed.get("params", {}),
"confidence": parsed.get("confidence", 0.9),
"task_kind": parsed.get("task_kind", "pipeline"),
"method": "keyword_negation",
}
has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS)
if has_rnaseq_marker and any(
keyword in query_lower
+52 -2
View File
@@ -1344,6 +1344,46 @@ def _build_msa_region_plot_message(params: Dict) -> str:
return "我理解你想对 MSA 指定区间进行局部绘图;请在确认弹窗中填写 alignment 起止列。"
def _stage_keyword_pattern(keyword: str) -> str:
"""Build a boundary-safe regex for one existing stage keyword."""
escaped = re.escape(keyword.lower())
if keyword and keyword[0].isascii() and keyword[0].isalnum():
escaped = rf"(?<![a-z0-9_]){escaped}"
if keyword and keyword[-1].isascii() and keyword[-1].isalnum():
escaped = rf"{escaped}(?![a-z0-9_])"
return escaped
def extract_negated_target_stages(query: str) -> List[str]:
"""Return explicitly excluded ordinary gene-family/RNA-seq target stages."""
query_lower = str(query or "").lower()
if any(marker in query_lower for marker in (
"亚家族", "subfamily", "reference_classification", "subfamily_confirm",
"build_subfamily_evidence", "subfamily_evidence", "subfamily_prediction",
)):
return []
if re.search(r"(?:不是|并非)\s*(?:不执行|不单独执行|不要|不用|无需|不做|跳过|排除|去掉)", query_lower):
return []
eligible = set(CORE_STAGES) | set(RNASEQ_STAGES) | {"expression", "annotation", "msa"}
chinese_prefix = r"(?:不单独执行|不执行|不要(?:执行|做|进行)?|不用(?:执行|做|进行)?|无需(?:执行|做|进行)?|不做|跳过|排除|去掉)\s*"
english_prefix = r"(?:without(?:\s+(?:running|executing|performing))?|skip|exclude|do\s+not\s+(?:run|execute|perform)?)\s+"
excluded = []
for stage, keywords in STAGE_KEYWORDS.items():
if stage not in eligible:
continue
for keyword in sorted(keywords, key=len, reverse=True):
keyword_pattern = _stage_keyword_pattern(keyword)
if (
re.search(chinese_prefix + keyword_pattern, query_lower)
or re.search(english_prefix + keyword_pattern, query_lower)
or re.search(r"(?:^|[\s,,;;。::])-\s*" + keyword_pattern, query_lower)
):
excluded.append(stage)
break
return list(dict.fromkeys(excluded))
def _handle_stage_resolution(query: str, query_lower: str, stages: List[str]) -> Dict:
"""
方案B: 处理阶段解析逻辑
@@ -1352,11 +1392,15 @@ def _handle_stage_resolution(query: str, query_lower: str, stages: List[str]) ->
- 有"分析"但不明确 → 引导澄清
- 完全无法识别 → 引导澄清
"""
excluded_stages = set(extract_negated_target_stages(query))
stages = [stage for stage in stages if stage not in excluded_stages]
# 情况1: 明确指定了具体阶段 → 直接执行。
# 如果同时出现“完整/全流程”等词,则以基础基因家族链路为主体,
# 再追加用户明确点名的 Expression/KaKs/PPI/种间共线性等可选阶段。
if stages:
resolved_stages = list(dict.fromkeys([*CORE_STAGES, *stages])) if _is_complete_workflow_query(query_lower) else stages
resolved_stages = [stage for stage in resolved_stages if stage not in excluded_stages]
return {
"stages": resolved_stages,
"need_clarify": False,
@@ -1368,7 +1412,13 @@ def _handle_stage_resolution(query: str, query_lower: str, stages: List[str]) ->
if any(marker in query_lower for marker in RNASEQ_MARKERS):
if any(kw in query_lower for kw in ["分析", "执行", "运行", "开始", "流程", "pipeline"]):
return {
"stages": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family"],
"stages": [
stage for stage in (
"rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich",
"rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family",
)
if stage not in excluded_stages
],
"need_clarify": False,
"confidence": 0.9,
"note": "generic_rnaseq_workflow"
@@ -1377,7 +1427,7 @@ def _handle_stage_resolution(query: str, query_lower: str, stages: List[str]) ->
# 情况2: "基因家族分析" = 基础基因家族链路
if _is_complete_workflow_query(query_lower):
return {
"stages": CORE_STAGES,
"stages": [stage for stage in CORE_STAGES if stage not in excluded_stages],
"need_clarify": False,
"confidence": 0.9,
"note": "完整流程"
@@ -0,0 +1,90 @@
import json
import subprocess
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_PATH = PROJECT_ROOT / "apps/web/vue3/index_ml.html"
I18N_PATH = PROJECT_ROOT / "apps/web/vue3/static/i18n_en.js"
def _run_empty_plan_branch(tmp_path: Path, plan_data: dict) -> dict:
frontend = FRONTEND_PATH.read_text(encoding="utf-8")
start = frontend.index("const plan = planData.plan;")
end = frontend.index("// 显示智能规划结果", start)
branch = frontend[start:end]
script = tmp_path / "empty-plan-branch.cjs"
script.write_text(
"const planData=" + json.dumps(plan_data, ensure_ascii=False) + ";"
"const events={messages:[],filters:[],refreshes:[]};"
"const t=value=>value;"
"const getStageLabel=id=>({genome_prepare:'0️⃣ Genome Prepare',ppi:'PPI分析'}[id]||id);"
"const setResultStageFilter=id=>events.filters.push(id);"
"const refreshResults=async(jobId,id)=>events.refreshes.push([jobId,id]);"
"const addAgentMessage=(role,message,meta)=>events.messages.push({role,message,meta});"
"const jobInfo={job_id:'job-test'};"
"const nlLoading={value:true};const nlInput={value:'request'};"
"async function run(){" + branch + "}"
"run().then(()=>process.stdout.write(JSON.stringify(events)));",
encoding="utf-8",
)
completed = subprocess.run(
["/usr/bin/node", str(script)],
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_completed_full_workflow_does_not_masquerade_as_genome_prepare(tmp_path: Path) -> None:
requested = ["phylogeny", "motif", "domain", "structure", "combo", "promoter"]
result = _run_empty_plan_branch(
tmp_path,
{
"success": True,
"plan": {
"stages": [],
"all_stages": requested,
"description": "所有阶段已完成",
},
"completed_stages": [*requested, "genome_prepare"],
},
)
assert result["filters"] == []
assert result["refreshes"] == []
assert [item["message"] for item in result["messages"]] == [
"当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。"
]
def test_completed_single_stage_keeps_stage_specific_result_handoff(tmp_path: Path) -> None:
result = _run_empty_plan_branch(
tmp_path,
{
"success": True,
"plan": {
"stages": [],
"all_stages": ["ppi"],
"description": "所有阶段已完成",
},
"completed_stages": ["ppi"],
},
)
assert result["filters"] == ["ppi"]
assert result["refreshes"] == [["job-test", "ppi"]]
assert [item["message"] for item in result["messages"]] == [
"PPI分析 已有同一分析范围的有效结果,无需重复执行。"
]
def test_generic_completed_plan_label_has_english_translation() -> None:
translations = I18N_PATH.read_text(encoding="utf-8")
assert (
'"当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。": '
'"All analysis stages in the current request already have valid results for the same analysis scope. No need to re-execute."'
) in translations
@@ -0,0 +1,124 @@
from pathlib import Path
_ORIGINAL_PATH_MKDIR = Path.mkdir
def _ignore_broken_remote_mount_mkdir(path: Path, *args, **kwargs):
if path.is_symlink() and not path.exists():
return None
return _ORIGINAL_PATH_MKDIR(path, *args, **kwargs)
Path.mkdir = _ignore_broken_remote_mount_mkdir
try:
from apps.web.vue3 import main as web_main
finally:
Path.mkdir = _ORIGINAL_PATH_MKDIR
def _full_plan_inputs():
stage_ids = ["identify", *web_main.FULL_GENE_FAMILY_WORKFLOW_STAGES]
details = [web_main._stage_detail_defaults(stage_id) for stage_id in stage_ids]
params = {
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"geneFamily": "SPL",
}
return stage_ids, details, params
def test_full_workflow_normalization_respects_intent_stage_exclusions():
stage_ids, details, params = _full_plan_inputs()
for excluded_stage in ("kaks", "karyotype"):
authorized = [stage for stage in stage_ids if stage != excluded_stage]
normalized, _ = web_main._normalize_full_gene_family_workflow_plan(
message=f"执行基因家族分析,但是不要执行{excluded_stage}",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=authorized,
)
assert excluded_stage not in normalized
assert set(normalized) == set(authorized)
def test_full_workflow_normalization_keeps_full_intent_and_required_dependencies():
stage_ids, details, params = _full_plan_inputs()
normalized, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=stage_ids,
)
assert normalized == stage_ids
assert normalized.index("ppi") < normalized.index("kaks")
normalized_without_supplied_intent, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=[],
)
assert normalized_without_supplied_intent == stage_ids
dependency_stages = ["identify", "synteny", "karyotype"]
dependency_details = [web_main._stage_detail_defaults(stage) for stage in dependency_stages]
normalized_dependencies, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行核型分析",
stage_ids=dependency_stages,
stages_detail=dependency_details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=dependency_stages,
)
assert normalized_dependencies == dependency_stages
def test_contextual_full_workflow_uses_request_intent_as_stage_authority():
stage_ids, _, params = _full_plan_inputs()
authorized = [stage for stage in stage_ids if stage != "kaks"]
request = web_main.AgentPlanRequest(
message="执行基因家族分析,但是不要执行kaks分析",
prefix="Ft.SPL",
intent={"stages": authorized, "params": params},
)
contextual_request = {
"family": "SPL",
"prefix": "Ft.SPL",
"stages": stage_ids,
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
"scopeMode": "target_plus_reference",
"requiresConfirmedSubfamily": True,
"skipSubfamilyLabels": False,
"subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
"contextGate": {"status": "ready", "issues": []},
}
response = web_main._build_contextual_gene_family_plan_response(
request,
"test_intent_stage_authority",
contextual_request,
)
assert "kaks" not in response["plan"]["stages"]
def test_llm_plan_normalization_calls_keep_original_intent_stage_authority():
source = (web_main.PROJECT_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
start = source.index("full_workflow_normalized = False")
end = source.index("stage_ids, stages_detail = _apply_standard_combo_panel_plan", start)
normalization_block = source[start:end]
assert "plan_authorized_stage_ids = authoritative_intent_stage_ids or" in source
assert normalization_block.count("authorized_stage_ids=plan_authorized_stage_ids") == 2
+144
View File
@@ -0,0 +1,144 @@
import asyncio
import json
from types import SimpleNamespace
from gfa_core.mas.agents.base import AgentMessage, AgentState
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
from gfa_core.mas.core.fast_intent import fast_intent_recognition
from gfa_core.mas.utils.intent_parser import CORE_STAGES, parse_intent_from_query
def test_complete_gene_family_workflow_excludes_explicitly_negated_ppi():
intent = parse_intent_from_query("执行完整流程,但不执行PPI")
assert intent["stages"] == [stage for stage in CORE_STAGES if stage != "ppi"]
def test_symbolic_minus_excludes_stage_from_complete_workflow():
intent = parse_intent_from_query("完整流程 - PPI")
assert intent["stages"] == [stage for stage in CORE_STAGES if stage != "ppi"]
def test_mixed_positive_and_negative_stages_keep_only_positive_targets():
intent = parse_intent_from_query("做系统发育和Motif,不要做结构域分析")
assert intent["stages"] == ["phylogeny", "motif"]
def test_positive_ppi_and_kaks_request_is_unchanged():
intent = parse_intent_from_query("执行PPI和KaKs分析")
assert intent["stages"] == ["kaks", "ppi"]
def test_generic_rnaseq_workflow_excludes_wgcna_target():
intent = parse_intent_from_query("执行RNA-seq分析,但不要WGCNA")
assert intent["stages"] == [
"rnaseq_de",
"rnaseq_go_enrich",
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
]
def test_pure_negative_request_never_becomes_positive_in_rule_fallbacks():
parsed = parse_intent_from_query("不要执行PPI")
fallback = IntentAgentV2(None)._fallback_recognition("不要执行PPI")
fast = fast_intent_recognition("不要执行PPI")
assert parsed["stages"] == []
assert parsed["need_clarify"] is True
assert fallback["stages"] == []
assert fallback["needs_clarification"] is True
assert fast is None
def test_model_output_cannot_reintroduce_a_negated_stage():
class _PpiLLM:
async def acall(self, *_args, **_kwargs):
return SimpleNamespace(
content=json.dumps(
{
"gene_family": "SPL",
"species": "Ft",
"stages": ["ppi"],
"primary_stage": "ppi",
"description": "执行PPI分析",
"requirements": [],
"confidence": 0.9,
}
)
)
state = AgentState(session_id="negated-stage", job_id="job-negated-stage")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={"query": "不要执行PPI"},
)
returned = asyncio.run(IntentAgentV2(_PpiLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["stages"] == []
assert intent["needs_clarification"] is True
def test_dependency_completion_can_restore_required_non_target_stage():
class _ForbiddenLLM:
async def acall(self, *_args, **_kwargs):
raise AssertionError("deterministic intent must not call the LLM")
state = AgentState(session_id="dependency", job_id="job-dependency")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={"query": "执行核型分析,不单独执行种内共线性"},
)
returned = asyncio.run(IntentAgentV2(_ForbiddenLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["primary_stage"] == "karyotype"
assert intent["stages"] == ["identify", "synteny", "karyotype"]
def test_completed_subfamily_context_does_not_override_current_stage_negation():
class _ForbiddenLLM:
async def acall(self, *_args, **_kwargs):
raise AssertionError("deterministic intent must not call the LLM")
state = AgentState(session_id="context-negation", job_id="job-context-negation")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={
"query": (
"帮我执行Ft的基因家族分析,但是不要执行kaks分析\n"
"已确认上下文:基因家族 SPL;目标物种 Ft;参考物种 At;"
"当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认。"
),
},
)
returned = asyncio.run(IntentAgentV2(_ForbiddenLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["gene_family"] == "SPL"
assert intent["species"] == "Ft"
assert "ppi" in intent["stages"]
assert "kaks" not in intent["stages"]
assert "reference_classification" not in intent["stages"]
def test_subfamily_classification_bypasses_generic_stage_negation():
intent = parse_intent_from_query("执行亚家族分类,不要结构域分析")
assert "reference_classification" in intent["stages"]
assert "domain" in intent["stages"]
+218
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
import os
def _write_ft_technical_reference_manifest(final_dir, stage, *, status="success"):
@@ -34,6 +35,50 @@ def _write_ft_technical_reference_manifest(final_dir, stage, *, status="success"
return manifest
def _write_regular_gene_family_manifest(
final_dir,
stage,
*,
scope_id="SPL.target-Ft.ref-At.target_plus_reference",
route="multi_species",
references=None,
member_set_id="",
filename="",
with_artifact=True,
):
references = ["At"] if references is None else list(references)
member_set_id = member_set_id or f"{scope_id}:{route}:all_members"
artifact = final_dir / f"Ft.SPL.{stage}.result.tsv"
if with_artifact:
artifact.write_text(f"{stage}\tresult\n", encoding="utf-8")
manifest = final_dir / (filename or f"Ft.SPL.scoped.{stage}.manifest.json")
manifest.write_text(
json.dumps(
{
"manifest_version": "1.0",
"stage": stage,
"status": "success",
"prefix": "Ft.SPL",
"scope_id": scope_id,
"scope_mode": "target_plus_reference" if references else "target_only",
"analysis_route": route,
"member_set_id": member_set_id,
"family": "SPL",
"target_species": ["Ft"],
"reference_species": references,
"artifacts": (
{"result": {"path": f"final/{artifact.name}", "type": "tsv"}}
if with_artifact
else {"result": {"path": "final/missing.tsv", "type": "tsv"}}
),
},
ensure_ascii=False,
),
encoding="utf-8",
)
return manifest
def _confirmed_contextual_ppi_fixture(web_main, workspace_root, job_id, intent_params):
from types import SimpleNamespace
@@ -416,6 +461,179 @@ def test_identity_completed_stages_force_rerun_keeps_technical_stages_pending(tm
assert completed == []
def test_post_confirm_plan_recovers_regular_manifest_identity(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_recovery" / "final"
final_dir.mkdir(parents=True)
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
manifest = _write_regular_gene_family_manifest(final_dir, "phylogeny")
os.utime(confirm, (1, 1))
os.utime(manifest, (2, 2))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": f"{scope_id}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_recovery",
["phylogeny"],
plan_params,
[{"id": "phylogeny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["phylogeny"]
def test_post_confirm_plan_tries_next_manifest_after_incomplete_alias(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_alias" / "final"
final_dir.mkdir(parents=True)
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
canonical = _write_regular_gene_family_manifest(
final_dir,
"synteny",
route="single_species",
filename="Ft.SPL.scoped.synteny.manifest.json",
)
alias = _write_regular_gene_family_manifest(
final_dir,
"synteny",
route="single_species",
filename="Ft.SPL.synteny.manifest.json",
with_artifact=False,
)
alias_payload = json.loads(alias.read_text(encoding="utf-8"))
for key in ("scope_id", "scope_mode", "analysis_route", "member_set_id"):
alias_payload.pop(key, None)
alias.write_text(json.dumps(alias_payload), encoding="utf-8")
os.utime(confirm, (1, 1))
os.utime(canonical, (2, 2))
os.utime(alias, (3, 3))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
target_scope = "SPL.target-Ft.target_only"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"referenceSpecies": [],
"scopeMode": "target_only",
"analysisRoute": "single_species",
"memberSetId": f"{target_scope}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_alias",
["synteny"],
plan_params,
[{"id": "synteny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["synteny"]
def test_post_confirm_plan_rejects_result_older_than_confirmation(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_stale" / "final"
final_dir.mkdir(parents=True)
manifest = _write_regular_gene_family_manifest(final_dir, "phylogeny")
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
os.utime(manifest, (1, 1))
os.utime(confirm, (2, 2))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": f"{scope_id}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_stale",
["phylogeny"],
plan_params,
[{"id": "phylogeny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == []
def test_post_confirm_kaks_inherits_completed_shared_ppi_reference(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_contextual_kaks" / "final"
final_dir.mkdir(parents=True)
_write_ft_technical_reference_manifest(final_dir, "kaks")
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": "SPL.target-Ft.ref-At.target_plus_reference:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_contextual_kaks",
["kaks"],
plan_params,
[{"id": "kaks", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["kaks"]
def test_contextual_plan_returns_only_pending_stages(tmp_path, monkeypatch):