diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index 4acfb84e4..48a862bcd 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -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', diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 084662736..74bdc9a5f 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -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 diff --git a/apps/web/vue3/static/i18n_en.js b/apps/web/vue3/static/i18n_en.js index e72f794f4..df1fb06b9 100644 --- a/apps/web/vue3/static/i18n_en.js +++ b/apps/web/vue3/static/i18n_en.js @@ -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)...", diff --git a/gfa_core/mas/agents/intent_v2.py b/gfa_core/mas/agents/intent_v2.py index d0860a5aa..648c2788e 100755 --- a/gfa_core/mas/agents/intent_v2.py +++ b/gfa_core/mas/agents/intent_v2.py @@ -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() diff --git a/gfa_core/mas/core/fast_intent.py b/gfa_core/mas/core/fast_intent.py index d32dc204f..626ba34a7 100755 --- a/gfa_core/mas/core/fast_intent.py +++ b/gfa_core/mas/core/fast_intent.py @@ -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 diff --git a/gfa_core/mas/utils/intent_parser.py b/gfa_core/mas/utils/intent_parser.py index 2afae0fea..d24f9ac4f 100755 --- a/gfa_core/mas/utils/intent_parser.py +++ b/gfa_core/mas/utils/intent_parser.py @@ -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"(? 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": "完整流程" diff --git a/tests/unit/test_gene_family_empty_plan_frontend.py b/tests/unit/test_gene_family_empty_plan_frontend.py new file mode 100644 index 000000000..665845ed8 --- /dev/null +++ b/tests/unit/test_gene_family_empty_plan_frontend.py @@ -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 diff --git a/tests/unit/test_gene_family_plan_stage_authority.py b/tests/unit/test_gene_family_plan_stage_authority.py new file mode 100644 index 000000000..573cad96b --- /dev/null +++ b/tests/unit/test_gene_family_plan_stage_authority.py @@ -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 diff --git a/tests/unit/test_intent_stage_negation.py b/tests/unit/test_intent_stage_negation.py new file mode 100644 index 000000000..b5c2f1d3a --- /dev/null +++ b/tests/unit/test_intent_stage_negation.py @@ -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"] diff --git a/tests/unit/test_task_status_attachment.py b/tests/unit/test_task_status_attachment.py index 449d9ba00..e1a7e2864 100755 --- a/tests/unit/test_task_status_attachment.py +++ b/tests/unit/test_task_status_attachment.py @@ -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):