diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 22962c7f0..b727ff36f 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -777,18 +777,12 @@ def _reconcile_confirmed_gene_family_species( job_id, _param_list(routed_params.get("comparativeSpecies") or routed_intent.get("comparative_species")), ) - explicit_reference_set = set(explicit["referenceSpecies"]) - explicit_assigned_set = set( - explicit["targetSpecies"] - + explicit["referenceSpecies"] - + explicit["comparativeSpecies"] - ) + explicit_unassigned_set = set(explicit["unassigned"]) for species in structured_targets: config_role = config_role_by_species.get(species) if ( config_role in {None, "reference"} - and species not in explicit_reference_set - and species not in explicit_assigned_set + and species in explicit_unassigned_set ): conflicts.append({ "code": "analysis_target_config_role_conflict", @@ -865,10 +859,24 @@ def _species_role_conflict_message(conflicts: list[Dict[str, str]]) -> str: config_role = role_labels.get(conflict.get("config_role") or "", conflict.get("config_role") or "未知") requested_role = role_labels.get(conflict.get("requested_role") or "", conflict.get("requested_role") or "配置内部重复角色") details.append(f"{species}:config.xlsx 为{config_role},本轮为{requested_role}") + conflict_codes = {str(conflict.get("code") or "") for conflict in conflicts} + detail_text = ";".join(details) + if "config_species_role_conflict" in conflict_codes: + return ( + "检测到 config.xlsx 内部物种角色冲突:" + + detail_text + + "。请发送“打开 config.xlsx”,修正并重新确认物种角色后再继续。" + ) + if "analysis_target_config_role_conflict" in conflict_codes: + return ( + "检测到系统意图解析发生角色漂移:" + + detail_text + + "。config.xlsx 未被修改,系统已阻止按错误范围执行,请重新提交当前分析请求。" + ) return ( - "检测到物种角色不一致:" - + ";".join(details) - + "。为避免按错误范围执行,请发送“打开 config.xlsx”,核对并重新确认物种角色后再继续。" + "检测到本轮请求与项目物种角色不一致:" + + detail_text + + "。系统已阻止执行,请按 config.xlsx 中已确认的角色重新描述分析范围。" ) diff --git a/gfa_core/mas/agents/intent_v2.py b/gfa_core/mas/agents/intent_v2.py index 8ba8e8da4..334c8a33a 100755 --- a/gfa_core/mas/agents/intent_v2.py +++ b/gfa_core/mas/agents/intent_v2.py @@ -12,7 +12,13 @@ from gfa_core.mas.stage_catalog import ( STAGE_INFO as CATALOG_STAGE_INFO, complete_dependencies as catalog_complete_dependencies, ) -from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family, resolve_gwas_intent, resolve_population_intent +from gfa_core.mas.utils.intent_parser import ( + is_gwas_fmt_intent, + parse_gene_family, + parse_gene_family_scope_selection, + resolve_gwas_intent, + resolve_population_intent, +) class IntentAgentV2(BaseAgent): @@ -696,18 +702,68 @@ class IntentAgentV2(BaseAgent): print(f"[IntentAgentV2] Stages expanded: {original_stages} -> {intent_data['stages']}") query_lower = query.lower() - species_codes = re.findall(r"(? List[str]: + if isinstance(value, str): + values = [item.strip() for item in value.split(",")] + elif isinstance(value, (list, tuple, set)): + values = [str(item).strip() for item in value] + else: + values = [] + return list(dict.fromkeys(item for item in values if item)) + + target_species = as_species_list(selection.get("targetSpecies")) + reference_species = as_species_list(selection.get("referenceSpecies")) + comparative_species = as_species_list(selection.get("comparativeSpecies")) + has_query_scope = bool(target_species or reference_species or comparative_species) + + prefix_species = provided_prefix.split(".", 1)[0].strip() if provided_prefix and "." in provided_prefix else "" + if not target_species and prefix_species: + target_species = [prefix_species] + if not target_species: + target_species = as_species_list(params.get("targetSpecies") or intent_data.get("target_species")) + if not reference_species: + reference_species = as_species_list(params.get("referenceSpecies") or intent_data.get("reference_species")) + if not comparative_species: + comparative_species = as_species_list(params.get("comparativeSpecies") or intent_data.get("comparative_species")) + if not target_species and not has_query_scope: + llm_species = str(intent_data.get("species") or "").strip() + if llm_species and llm_species != "Multi": + target_species = [llm_species] + + reference_species = [sp for sp in reference_species if sp not in target_species] + comparative_species = [ + sp for sp in comparative_species + if sp not in target_species and sp not in reference_species + ] + scope_mode = ( + "target_plus_reference_plus_comparative" + if reference_species and comparative_species + else "target_plus_reference" + if reference_species + else "target_plus_comparative" + if comparative_species + else "target_only" + ) + + intent_data["target_species"] = target_species + intent_data["reference_species"] = reference_species + intent_data["comparative_species"] = comparative_species + intent_data["scopeMode"] = scope_mode + params.update({ + "targetSpecies": target_species, + "referenceSpecies": reference_species, + "comparativeSpecies": comparative_species, + "scopeMode": scope_mode, + "target_species": target_species, + }) explicit_species = bool(target_species) if target_species: - intent_data["target_species"] = target_species - intent_data.setdefault("params", {})["target_species"] = target_species intent_data["species"] = target_species[0] if len(target_species) == 1 else "Multi" + elif has_query_scope: + intent_data["species"] = "" generic_identify = ( "identify" in intent_data.get("stages", []) and not provided_prefix diff --git a/tests/unit/test_intent_species_roles.py b/tests/unit/test_intent_species_roles.py index bd3c2046d..c51c22541 100644 --- a/tests/unit/test_intent_species_roles.py +++ b/tests/unit/test_intent_species_roles.py @@ -1,3 +1,6 @@ +import pytest + +from gfa_core.mas.agents.intent_v2 import IntentAgentV2 from gfa_core.mas.utils.intent_parser import ( parse_explicit_species_role_mentions, parse_gene_family_scope_selection, @@ -30,3 +33,58 @@ def test_explicit_role_parser_keeps_unassigned_mentions_separate() -> None: "comparativeSpecies": [], "unassigned": ["Ppy"], } + + +@pytest.mark.parametrize("species", ["Gm", "Br", "Pt", "Sb"]) +def test_scope_parser_preserves_species_supported_by_legacy_intent(species: str) -> None: + scope = parse_gene_family_scope_selection(f"分析 {species} 的 SPL 基因家族") + + assert scope["targetSpecies"] == [species] + + +def test_scope_parser_accepts_explicit_role_bound_custom_species_code() -> None: + scope = parse_gene_family_scope_selection("目标物种 Sv;参考物种 At") + + assert scope["targetSpecies"] == ["Sv"] + assert scope["referenceSpecies"] == ["At"] + + +def test_scope_parser_does_not_treat_gene_family_as_species() -> None: + scope = parse_gene_family_scope_selection("目标物种 Ft;分析 SPL 基因家族") + + assert scope["targetSpecies"] == ["Ft"] + + +def test_scope_parser_does_not_bind_custom_code_to_generic_goal_word() -> None: + scope = parse_gene_family_scope_selection("分析目标 SPL 基因家族") + + assert scope["targetSpecies"] == [] + + +def test_intent_validation_keeps_confirmed_target_and_reference_separate() -> None: + agent = object.__new__(IntentAgentV2) + + intent = agent._validate_intent( + {"gene_family": "SPL", "stages": ["identify"], "params": {}}, + ( + "请按照你规划的步骤来帮我执行\n" + "已确认上下文:基因家族 SPL;目标物种 Ft;参考物种 At。" + ), + ) + + assert intent["target_species"] == ["Ft"] + assert intent["reference_species"] == ["At"] + assert intent["species"] == "Ft" + + +def test_intent_validation_preserves_unlabelled_multi_target_request() -> None: + agent = object.__new__(IntentAgentV2) + + intent = agent._validate_intent( + {"gene_family": "SPL", "stages": ["identify"], "params": {}}, + "分析 Ft 和 Os 的 SPL 基因家族", + ) + + assert intent["target_species"] == ["Ft", "Os"] + assert intent["reference_species"] == [] + assert intent["species"] == "Multi" diff --git a/tests/unit/test_subfamily_prediction_plan.py b/tests/unit/test_subfamily_prediction_plan.py index d2e6f6167..ff9f8f508 100755 --- a/tests/unit/test_subfamily_prediction_plan.py +++ b/tests/unit/test_subfamily_prediction_plan.py @@ -426,6 +426,64 @@ def test_confirmed_multi_species_roles_remain_separate(monkeypatch): assert resolved["classification_species"] == ["Ft", "Ppy"] +def test_confirmation_ack_normalizes_structured_role_drift(monkeypatch): + main = _load_vue3_main() + monkeypatch.setattr( + main, + "_confirmed_config_species_scope", + lambda _job_id: { + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + "comparativeSpecies": [], + }, + ) + + resolved = main._reconcile_confirmed_gene_family_species( + "job-confirmed-ack", + "是", + { + "gene_family": "SPL", + "stages": ["identify"], + "target_species": ["Ft", "At"], + "params": {"targetSpecies": ["Ft", "At"]}, + }, + ) + + assert resolved["conflicts"] == [] + assert resolved["target_species"] == ["Ft"] + assert resolved["reference_species"] == ["At"] + + +def test_unassigned_reference_species_analysis_request_remains_blocked(monkeypatch): + main = _load_vue3_main() + monkeypatch.setattr( + main, + "_confirmed_config_species_scope", + lambda _job_id: { + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + "comparativeSpecies": [], + }, + ) + + resolved = main._reconcile_confirmed_gene_family_species( + "job-unassigned-reference", + "分析 At 的 SPL 基因家族", + { + "gene_family": "SPL", + "stages": ["identify"], + "target_species": ["At"], + "params": {"targetSpecies": ["At"]}, + }, + ) + + assert any( + conflict["code"] == "analysis_target_config_role_conflict" + and conflict["species"] == "At" + for conflict in resolved["conflicts"] + ) + + def test_confirmed_config_duplicate_roles_are_blocked(monkeypatch): main = _load_vue3_main() monkeypatch.setattr(