fix: preserve confirmed species roles in agent planning

This commit is contained in:
potaser
2026-08-05 15:42:32 +08:00
parent 231aa4517d
commit 35fbee8c85
5 changed files with 1465 additions and 51 deletions
+325 -51
View File
@@ -711,6 +711,211 @@ def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
return _configured_species_scope(job_id)
def _reconcile_confirmed_gene_family_species(
job_id: str,
message: str,
intent: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Keep confirmed config roles separate from the current run selection."""
config_scope = _confirmed_config_species_scope(job_id)
if not config_scope:
return {"applicable": False, "conflicts": []}
from gfa_core.mas.utils.intent_parser import parse_explicit_species_role_mentions
role_keys = {
"targetSpecies": "target",
"referenceSpecies": "reference",
"comparativeSpecies": "comparative",
}
canonical_config = {
key: _canonicalize_species_codes_for_job(job_id, config_scope.get(key) or [])
for key in role_keys
}
config_role_by_species: Dict[str, str] = {}
conflicts: list[Dict[str, str]] = []
for key, role in role_keys.items():
for species in canonical_config[key]:
existing = config_role_by_species.get(species)
if existing and existing != role:
conflicts.append({
"code": "config_species_role_conflict",
"species": species,
"config_role": f"{existing}/{role}",
"requested_role": "",
})
else:
config_role_by_species[species] = role
explicit_raw = parse_explicit_species_role_mentions(message or "")
explicit = {
key: _canonicalize_species_codes_for_job(job_id, explicit_raw.get(key) or [])
for key in (*role_keys, "unassigned")
}
for key, requested_role in role_keys.items():
for species in explicit[key]:
config_role = config_role_by_species.get(species)
if config_role != requested_role:
conflicts.append({
"code": "user_config_species_role_conflict",
"species": species,
"config_role": config_role or "not_configured",
"requested_role": requested_role,
})
routed_intent = 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")),
)
explicit_reference_set = set(explicit["referenceSpecies"])
explicit_assigned_set = set(
explicit["targetSpecies"]
+ explicit["referenceSpecies"]
+ explicit["comparativeSpecies"]
)
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
):
conflicts.append({
"code": "analysis_target_config_role_conflict",
"species": species,
"config_role": config_role or "not_configured",
"requested_role": "analysis_target",
})
mentioned = list(dict.fromkeys(
species
for key in (*role_keys, "unassigned")
for species in explicit[key]
))
target_candidates = list(dict.fromkeys([
*structured_targets,
*explicit["targetSpecies"],
*explicit["unassigned"],
]))
comparative_candidates = list(dict.fromkeys([
*structured_comps,
*structured_targets,
*explicit["comparativeSpecies"],
*explicit["unassigned"],
]))
targets = [
species for species in canonical_config["targetSpecies"]
if species in target_candidates
] or list(canonical_config["targetSpecies"])
comparatives = [
species for species in canonical_config["comparativeSpecies"]
if species in comparative_candidates
]
references = [
species for species in canonical_config["referenceSpecies"]
if not structured_refs or species in structured_refs or species in mentioned
] or list(canonical_config["referenceSpecies"])
classification_species = list(dict.fromkeys([*targets, *comparatives]))
unique_conflicts: list[Dict[str, str]] = []
seen_conflicts: set[tuple[str, str, str, str]] = set()
for conflict in conflicts:
identity = (
conflict["code"],
conflict["species"],
conflict["config_role"],
conflict["requested_role"],
)
if identity not in seen_conflicts:
seen_conflicts.add(identity)
unique_conflicts.append(conflict)
return {
"applicable": True,
"config_scope": canonical_config,
"target_species": targets,
"reference_species": references,
"comparative_species": comparatives,
"classification_species": classification_species,
"conflicts": unique_conflicts,
}
def _species_role_conflict_message(conflicts: list[Dict[str, str]]) -> str:
role_labels = {
"target": "目标物种",
"reference": "参考物种",
"comparative": "比较物种",
"analysis_target": "本轮分析对象",
"not_configured": "未登记",
}
details = []
for conflict in conflicts:
species = conflict.get("species") or "未知物种"
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}")
return (
"检测到物种角色不一致:"
+ ";".join(details)
+ "。为避免按错误范围执行,请发送“打开 config.xlsx”,核对并重新确认物种角色后再继续。"
)
def _apply_reconciled_species_to_intent(intent: Dict[str, Any], resolution: Dict[str, Any]) -> None:
if not resolution.get("applicable") or resolution.get("conflicts"):
return
targets = list(resolution.get("target_species") or [])
references = list(resolution.get("reference_species") or [])
comparatives = list(resolution.get("comparative_species") or [])
params = intent.setdefault("params", {})
params.update({
"targetSpecies": targets,
"referenceSpecies": references,
"comparativeSpecies": comparatives,
})
intent["target_species"] = targets
intent["reference_species"] = references
intent["comparative_species"] = comparatives
scope_mode = (
"target_plus_reference_plus_comparative"
if references and comparatives
else "target_plus_reference"
if references
else "target_plus_comparative"
if comparatives
else "target_only"
)
params["scopeMode"] = scope_mode
intent["scopeMode"] = scope_mode
def _is_gene_family_analysis_intent(intent: Optional[Dict[str, Any]]) -> bool:
candidate = intent or {}
stages = [str(stage or "").strip() for stage in candidate.get("stages") or []]
if stages and all(stage.startswith("rnaseq_") or stage.startswith("wgs_") for stage in stages):
return False
params = candidate.get("params") or {}
family = str(candidate.get("gene_family") or params.get("geneFamily") or "").strip()
gene_family_stages = {
"genome_prepare", "identify", "phylogeny", "motif", "domain",
"structure", "combo", "reference_classification", "subfamily_confirm",
"promoter", "tfbs", "synteny", "interspecies_synteny", "karyotype",
"pep", "ortholog", "kaks", "ppi", "expression",
}
return bool(family or set(stages) & gene_family_stages)
def _normalize_main_agent_analysis_intent(
intent: Dict[str, Any],
*,
@@ -1356,6 +1561,14 @@ def _resolve_subfamily_request_species_roles(
intent: Optional[Dict[str, Any]] = None,
) -> Dict[str, list[str]]:
"""Preserve config roles while resolving species mentioned in a subfamily request."""
confirmed_resolution = _reconcile_confirmed_gene_family_species(job_id, message, intent)
if confirmed_resolution.get("applicable") and not confirmed_resolution.get("conflicts"):
return {
"target_species": list(confirmed_resolution.get("target_species") or []),
"reference_species": list(confirmed_resolution.get("reference_species") or []),
"comparative_species": list(confirmed_resolution.get("comparative_species") or []),
"classification_species": list(confirmed_resolution.get("classification_species") or []),
}
routed_intent = intent or {}
params = routed_intent.get("params") or {}
structured_targets = _canonicalize_species_codes_for_job(
@@ -1826,8 +2039,19 @@ def _resolve_contextual_gene_family_request(
or contextual_stage_request
):
return {}
role_resolution = _reconcile_confirmed_gene_family_species(
job_id,
text,
routed_intent,
)
selection = _load_identify_species_selection(job_id)
config_targets, config_refs, config_comps = _species_groups_from_selection(selection)
if role_resolution.get("applicable") and not role_resolution.get("conflicts"):
confirmed_scope = role_resolution.get("config_scope") or {}
config_targets = list(confirmed_scope.get("targetSpecies") or [])
config_refs = list(confirmed_scope.get("referenceSpecies") or [])
config_comps = list(confirmed_scope.get("comparativeSpecies") or [])
else:
config_targets, config_refs, config_comps = _species_groups_from_selection(selection)
family = _nonempty_prefix(_gene_family_from_config(INPUT_ROOT / job_id / "config.xlsx")) if job_id else ""
if not family and request_prefix and "." in request_prefix:
family = request_prefix.split(".", 1)[1]
@@ -1839,37 +2063,38 @@ def _resolve_contextual_gene_family_request(
prefix_species = request_prefix.split(".", 1)[0].strip()
if prefix_species in {"Target", "Multi", "Ref", "Reference"}:
prefix_species = ""
mentioned_targets = [sp for sp in mentioned if sp in config_targets or sp == prefix_species or sp in confirmed_species]
mentioned_refs = [sp for sp in mentioned if sp in config_refs]
mentioned_comps = [sp for sp in mentioned if sp in config_comps]
unassigned_mentions = [sp for sp in mentioned if sp not in mentioned_targets and sp not in mentioned_refs and sp not in mentioned_comps]
if unassigned_mentions:
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 role_resolution.get("applicable") and not role_resolution.get("conflicts"):
targets = list(role_resolution.get("target_species") or [])
refs = list(role_resolution.get("reference_species") or [])
comps = list(role_resolution.get("comparative_species") or [])
else:
mentioned_targets = [sp for sp in mentioned if sp in config_targets or sp == prefix_species or sp in confirmed_species]
mentioned_refs = [sp for sp in mentioned if sp in config_refs]
mentioned_comps = [sp for sp in mentioned if sp in config_comps]
unassigned_mentions = [sp for sp in mentioned if sp not in mentioned_targets and sp not in mentioned_refs and sp not in mentioned_comps]
for sp in unassigned_mentions:
if sp not in mentioned_targets:
mentioned_targets.append(sp)
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 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:
targets = list(config_targets)
elif prefix_species:
targets = [prefix_species]
elif config_targets:
targets = [config_targets[0]] if len(config_targets) == 1 else list(config_targets)
elif confirmed_species:
targets = [confirmed_species[0]]
else:
targets = []
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 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:
targets = list(config_targets)
elif prefix_species:
targets = [prefix_species]
elif config_targets:
targets = [config_targets[0]] if len(config_targets) == 1 else list(config_targets)
elif confirmed_species:
targets = [confirmed_species[0]]
else:
targets = []
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:
@@ -1895,7 +2120,14 @@ def _resolve_contextual_gene_family_request(
for sp, data in (subfamily_state.get("by_species") or {}).items()
}
all_targets_confirmed = bool(classifiable_species_for_labels) and bool(subfamily_state.get("all_confirmed"))
explicit_custom_scope = bool(refs or comps or len(targets) > 1)
if role_resolution.get("applicable") and not role_resolution.get("conflicts"):
explicit_custom_scope = (
targets != config_targets
or refs != config_refs
or comps != config_comps
)
else:
explicit_custom_scope = bool(refs or comps or len(targets) > 1)
skip_subfamily_labels = _skip_subfamily_label_query(text)
if skip_subfamily_labels:
context_route = "plain_phylogeny" if _looks_like_phylogeny_query(text) else "gene_family_without_subfamily"
@@ -3959,9 +4191,6 @@ 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
@@ -12701,7 +12930,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", [])
overview["resource_tasks"] = resource.get("tasks", [])[:20]
except Exception as exc:
overview["errors"].append(f"Resource governor snapshot failed: {exc}")
overview["thresholds"] = {
@@ -14302,8 +14531,9 @@ 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.
# Result browsing is a persisted job view, so apply manifests in execution
# order. The newest attempt for a stage becomes its visible status instead
# of depending on filesystem iteration order.
manifest_payloads.sort(
key=lambda item: (item[0].stat().st_mtime, item[0].name)
)
@@ -16754,11 +16984,27 @@ async def _run_agent_chat(
verified_intent = outcome.get("intent")
if action == "propose_analysis" and isinstance(verified_intent, dict):
verified_intent = _normalize_main_agent_analysis_intent(
verified_intent,
job_id=request.job_id,
context_terms=router_context_terms,
role_resolution = (
_reconcile_confirmed_gene_family_species(
request.job_id,
request.message,
verified_intent,
)
if _is_gene_family_analysis_intent(verified_intent)
else {"applicable": False, "conflicts": []}
)
if role_resolution.get("conflicts"):
action = "clarify"
outcome["message"] = _species_role_conflict_message(role_resolution["conflicts"])
outcome["proposal"] = None
verified_intent = None
else:
verified_intent = _normalize_main_agent_analysis_intent(
verified_intent,
job_id=request.job_id,
context_terms=router_context_terms,
)
_apply_reconciled_species_to_intent(verified_intent, role_resolution)
verified_stages = (
verified_intent.get("stages")
if isinstance(verified_intent, dict)
@@ -19732,6 +19978,28 @@ async def agent_plan(request: AgentPlanRequest):
extension_intent = _match_extension_stage_intent(request.message, request.job_id or "")
if extension_intent:
return _extension_stage_plan_response(extension_intent, session_id, request.job_id or "")
plan_role_resolution = (
_reconcile_confirmed_gene_family_species(
request.job_id,
request.message,
request.intent,
)
if _is_gene_family_analysis_intent(request.intent)
else {"applicable": False, "conflicts": []}
)
if plan_role_resolution.get("conflicts"):
return {
"success": False,
"status": "species_role_conflict",
"message": _species_role_conflict_message(plan_role_resolution["conflicts"]),
"issues": plan_role_resolution["conflicts"],
"suggested_command": "打开 config.xlsx",
"session_id": session_id,
}
if plan_role_resolution.get("applicable"):
request.intent = dict(request.intent or {})
request.intent["params"] = dict(request.intent.get("params") or {})
_apply_reconciled_species_to_intent(request.intent, plan_role_resolution)
query_lower_for_context = (request.message or "").lower()
subfamily_confirm_query_for_context = any(
kw in query_lower_for_context
@@ -21042,18 +21310,24 @@ async def agent_plan(request: AgentPlanRequest):
intent["prefix"] = request.prefix
intent.setdefault("params", {})["prefix"] = request.prefix
plan_params["prefix"] = request.prefix
_apply_scope_selection_to_intent(intent, request.message)
if plan_role_resolution.get("applicable"):
_apply_reconciled_species_to_intent(intent, plan_role_resolution)
else:
_apply_scope_selection_to_intent(intent, request.message)
if subfamily_prediction_query:
explicit_species = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
if explicit_species:
intent["target_species"] = explicit_species
intent.setdefault("params", {})["targetSpecies"] = explicit_species
intent["species"] = explicit_species[0] if len(explicit_species) == 1 else "Multi"
ref_defaults = [
item.get("species")
for item in ((_load_identify_species_selection(request.job_id).get("groups") or {}).get("reference") or [])
if item.get("species")
] if request.job_id else []
if not plan_role_resolution.get("applicable"):
explicit_species = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
if explicit_species:
intent["target_species"] = explicit_species
intent.setdefault("params", {})["targetSpecies"] = explicit_species
intent["species"] = explicit_species[0] if len(explicit_species) == 1 else "Multi"
ref_defaults = list(plan_role_resolution.get("reference_species") or [])
if not ref_defaults and request.job_id:
ref_defaults = [
item.get("species")
for item in ((_load_identify_species_selection(request.job_id).get("groups") or {}).get("reference") or [])
if item.get("species")
]
if ref_defaults:
intent["reference_species"] = ref_defaults
intent.setdefault("params", {})["referenceSpecies"] = ref_defaults