Files
PlantAI-dev/gfa_core/mas/agents/conversation_tools.py
T
2026-08-03 18:42:47 +08:00

1898 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import csv
import inspect
import io
import json
import math
import re
import urllib.parse
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from gfa_core.mas.agents.base import AgentMessage, AgentState
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
from gfa_core.mas.agents.interpretation_evidence import build_evidence_report
from gfa_core.mas.context_index import build_project_context
from gfa_core.mas.dual_kb_qa import query_dual_kb_qa
from gfa_core.mas.llm import LLMManager
from gfa_core.mas.mutation_surface import ToolSurfaceEntry
from gfa_core.mas.stage_capabilities import get_stage_capability
from gfa_core.mas.tools.stage_status import SUCCESS_STATUSES
from gfa_core.mas.tools.stage_tools import stage_registry
from gfa_core.mas.workflow_catalog import (
load_canonical_workflow_contract,
load_rnaseq_business_context,
)
from gfa_core.mas.workflow_models import GoalLedger
@dataclass(frozen=True)
class _ConversationTool:
description: str
args_model: type[BaseModel]
handler: Callable
availability: Callable[[], bool] | None
permission: Literal["read_only", "pure"]
max_result_chars: int
class ConversationToolRegistry:
def __init__(self, progress_callback: Callable[[dict[str, Any]], None] | None = None) -> None:
self._tools: dict[str, _ConversationTool] = {}
self._permission_guard: Callable[[str], None] | None = None
self._progress_callback = progress_callback
self._analysis_handoff_resolver: Callable[..., str] | None = None
def set_analysis_handoff_resolver(self, resolver: Callable[..., str]) -> None:
self._analysis_handoff_resolver = resolver
def resolve_analysis_handoff_request(
self,
query: str,
*,
workflow_id: str | None = None,
guidance: bool = False,
) -> str:
text = str(query or "").strip()
if self._analysis_handoff_resolver is None:
return text
resolved = str(
self._analysis_handoff_resolver(
text,
workflow_id=workflow_id,
guidance=guidance,
)
or ""
).strip()
return resolved or text
def _notify_progress(self, event: dict[str, Any]) -> None:
if self._progress_callback is None:
return
try:
self._progress_callback(event)
except Exception:
pass
def set_permission_guard(self, guard: Callable[[str], None]) -> None:
self._permission_guard = guard
def register(
self,
*,
name: str,
description: str,
args_model: type[BaseModel],
handler: Callable,
availability: Callable[[], bool] | None = None,
permission: Literal["read_only", "pure"],
max_result_chars: int = 24_000,
) -> None:
if permission not in {"read_only", "pure"}:
raise ValueError("permission must be 'read_only' or 'pure'")
if max_result_chars <= 0:
raise ValueError("max_result_chars must be positive")
if name in self._tools:
raise ValueError(f"tool already registered: {name}")
self._tools[name] = _ConversationTool(
description=description,
args_model=args_model,
handler=handler,
availability=availability,
permission=permission,
max_result_chars=max_result_chars,
)
def definitions(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": name,
"description": tool.description,
"parameters": tool.args_model.model_json_schema(),
},
}
for name, tool in self._tools.items()
]
def policy_inventory(self) -> tuple[ToolSurfaceEntry, ...]:
return tuple(
ToolSurfaceEntry(
name=name,
permission=tool.permission,
handler=f"{tool.handler.__module__}.{tool.handler.__qualname__}",
)
for name, tool in sorted(self._tools.items())
)
async def dispatch(self, name: str, raw_arguments: str) -> str:
tool = self._tools.get(name)
if tool is None:
return self._error("unknown_tool", f"unknown tool: {name}")
if self._permission_guard is not None:
try:
self._permission_guard(tool.permission)
except Exception as exc:
return self._error("evidence_root_read_only", str(exc))
if tool.availability is not None:
try:
available = tool.availability()
except Exception:
available = False
if not available:
return self._error("tool_unavailable", f"tool unavailable: {name}")
try:
arguments = tool.args_model.model_validate_json(raw_arguments or "{}")
except ValidationError as exc:
return self._error("invalid_arguments", str(exc))
self._notify_progress({"stage": "tool_started", "tool": name})
try:
result = tool.handler(arguments)
if inspect.isawaitable(result):
result = await result
return self._success(result, tool.max_result_chars)
except Exception as exc:
return self._error("tool_error", str(exc))
finally:
self._notify_progress({"stage": "tool_completed", "tool": name})
@staticmethod
def _error(code: str, message: str) -> str:
return json.dumps(
{"ok": False, "error": {"code": code, "message": message}},
ensure_ascii=False,
)
@staticmethod
def _success(data: Any, max_result_chars: int) -> str:
data_json = json.dumps(data, ensure_ascii=False)
if len(data_json) > max_result_chars:
return json.dumps(
{
"ok": True,
"data": data_json[:max_result_chars],
"truncated": True,
},
ensure_ascii=False,
)
return json.dumps({"ok": True, "data": data}, ensure_ascii=False)
def _validate_job_id(value: str) -> str:
if value and (Path(value).is_absolute() or "/" in value or "\\" in value or value in {".", ".."}):
raise ValueError("job_id must be a single project job name")
return value
def _validate_required_job_id(value: str) -> str:
if not value.strip():
raise ValueError("job_id is required")
return _validate_job_id(value)
def _validate_prefix(value: str) -> str:
if any(token in value for token in ("/", "\\", "..", "*", "?", "[", "]")):
raise ValueError("prefix must not contain path or glob syntax")
if value and not re.fullmatch(
r"(?:Target\.)?[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9_-]{0,63}",
value,
):
raise ValueError("prefix must be a canonical species.family identity")
return value
def _canonical_species_codes(values: Any) -> list[str]:
if not isinstance(values, list):
return []
return list(
dict.fromkeys(
value
for value in values
if isinstance(value, str)
and re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,15}", value)
)
)
def _normalize_result_stage(value: str) -> str:
stage = str(value or "").strip()
return {
"differential_expression": "rnaseq_de",
}.get(stage.lower(), stage)
class InspectProjectContextArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
job_id: str
prefix: str = ""
_job_id_is_local = field_validator("job_id")(_validate_required_job_id)
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
class InspectAnalysisResultsArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
job_id: str
prefix: str = ""
stage: str = ""
scope_id: str = ""
analysis_route: str = ""
member_set_id: str = ""
_job_id_is_local = field_validator("job_id")(_validate_required_job_id)
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
_stage_is_canonical = field_validator("stage")(_normalize_result_stage)
class QueryPlantAIRagArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
question: str
family: str = ""
stage: str = ""
class RecognizeAnalysisIntentArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str
job_id: str = ""
prefix: str = ""
_job_id_is_local = field_validator("job_id")(_validate_job_id)
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
class RecognizeUtilityIntentArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str
class BoundInspectProjectContextArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
prefix: str = ""
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
class BoundInspectAnalysisResultsArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
prefix: str = ""
stage: str = ""
scope_id: str = ""
analysis_route: str = ""
member_set_id: str = ""
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
_stage_is_canonical = field_validator("stage")(_normalize_result_stage)
class BoundRecognizeAnalysisIntentArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str
prefix: str = ""
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
class InspectJobFileArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str
max_lines: int = Field(default=200, ge=1, le=500)
class ListJobFilesArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
roots: list[Literal["input", "final", "logs", "workspace"]] = Field(
default_factory=lambda: ["input", "final", "logs"]
)
limit: int = Field(default=100, ge=1, le=200)
cursor: int = Field(default=0, ge=0)
def normalize_rag_tool_result(raw: Any) -> dict[str, Any]:
"""Expose only truthful RAG availability, success, answer, and citations."""
data = raw if isinstance(raw, dict) else {}
request_id = str(data.get("request_id") or f"rag-{uuid.uuid4().hex[:12]}")
error_code = str(data.get("error_code") or "")
available = error_code not in {"dual_kb_unavailable", "rag_unreachable"}
answer = str(data.get("answer") or data.get("response") or "").strip()
citations: list[dict[str, str]] = []
seen: set[tuple[str, str, str, str]] = set()
raw_sources = data.get("sources") or data.get("citations") or []
if isinstance(raw_sources, list):
for source in raw_sources:
if not isinstance(source, dict):
continue
doc_id = str(
source.get("doc_id")
or source.get("paper_id")
or source.get("pubmed_id")
or source.get("id")
or ""
).strip()
title = str(
source.get("title")
or source.get("paper_title")
or source.get("source")
or doc_id
or ""
).strip()
locator = str(
source.get("locator")
or source.get("page")
or source.get("section")
or doc_id
or source.get("target")
or ""
).strip()
if not title or not locator:
continue
url = str(source.get("url") or source.get("doi_url") or "").strip()
if url:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
url = ""
key = (title, locator, url, doc_id)
if key in seen:
continue
seen.add(key)
citation = {"title": title, "locator": locator}
if url:
citation["url"] = url
if doc_id:
citation["doc_id"] = doc_id
citations.append(citation)
raw_success = data.get("success") is True
if not raw_success:
return {
"registered": True,
"available": available,
"success": False,
"request_id": request_id,
"error_code": error_code or "rag_request_failed",
"answer": "",
"citations": [],
}
if not answer:
error_code = "rag_empty_answer"
elif not citations:
error_code = "rag_no_usable_citations"
if error_code:
return {
"registered": True,
"available": available,
"success": False,
"request_id": request_id,
"error_code": error_code,
"answer": "",
"citations": [],
}
return {
"registered": True,
"available": True,
"success": True,
"request_id": request_id,
"error_code": "",
"answer": answer,
"citations": citations,
}
_FORBIDDEN_INTENT_KEYS = {
"benchmarkMode",
"forceRerun",
"forceRerunStages",
"scopeId",
"scope_id",
"memberSetId",
"member_set_id",
}
def _without_forbidden_intent_keys(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _without_forbidden_intent_keys(item)
for key, item in value.items()
if key not in _FORBIDDEN_INTENT_KEYS
}
if isinstance(value, list):
return [_without_forbidden_intent_keys(item) for item in value]
return value
def _manifest_identity(manifest: dict[str, Any]) -> dict[str, str]:
def value(key: str) -> str:
raw = manifest.get(key) or ""
return raw if isinstance(raw, str) else ""
return {
"scope_id": value("scope_id"),
"analysis_route": value("analysis_route"),
"member_set_id": value("member_set_id"),
}
def _canonical_identity_is_complete(identity: dict[str, Any]) -> bool:
return all(
_safe_manifest_token(identity.get(key), max_length=512)
for key in ("prefix", "scope_id", "analysis_route", "member_set_id")
)
def _result_identity_is_complete(identity: dict[str, Any], *, stage: str) -> bool:
"""Validate identity at the requested stage's actual business boundary."""
if not all(
_safe_manifest_token(identity.get(key), max_length=512)
for key in ("prefix", "scope_id", "analysis_route")
):
return False
if stage == "identify":
# Identify creates the candidate set; member_set_id exists only after
# the user confirms those candidates.
return identity.get("member_set_id") in {None, ""}
return _safe_manifest_token(identity.get("member_set_id"), max_length=512)
def _safe_manifest_token(value: Any, *, max_length: int) -> bool:
allowed_punctuation = "._-:"
return (
isinstance(value, str)
and value == value.strip()
and 0 < len(value) <= max_length
and all(
char.isascii() and (char.isalnum() or char in allowed_punctuation)
for char in value
)
and not Path(value).is_absolute()
and "/" not in value
and "\\" not in value
and ".." not in value
)
def _known_result_stage(stage: str) -> bool:
return (
stage == "extension_stage"
or stage_registry.get(stage) is not None
or get_stage_capability(stage) is not None
)
def _validated_manifest_rows(
manifests: list[dict[str, Any]],
*,
final_dir: Path,
) -> list[dict[str, Any]]:
try:
resolved_final = final_dir.resolve(strict=True)
except OSError:
return []
validated = []
for row in manifests:
path = Path(str(row.get("path") or ""))
try:
resolved_path = path.resolve(strict=True)
resolved_path.relative_to(resolved_final)
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
continue
parameters = payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {}
raw = {
"prefix": (
payload.get("prefix")
or parameters.get("rnaseq_output_prefix")
or parameters.get("prefix")
),
"stage": payload.get("stage"),
"scope_id": payload.get("scope_id"),
"analysis_route": payload.get("analysis_route"),
"member_set_id": payload.get("member_set_id"),
"rnaseq_scope_id": (
payload.get("rnaseq_scope_id")
or parameters.get("rnaseq_scope_id")
),
}
identity = {key: raw[key] for key in ("prefix", "scope_id", "analysis_route", "member_set_id")}
is_rnaseq = str(raw["stage"] or "").startswith("rnaseq_")
is_shared_prepare = raw["stage"] == "genome_prepare"
if is_rnaseq:
if not all(
_safe_manifest_token(raw.get(key), max_length=512)
for key in ("prefix", "rnaseq_scope_id")
):
continue
elif is_shared_prepare:
if not _safe_manifest_token(raw.get("prefix"), max_length=512):
continue
elif not _result_identity_is_complete(identity, stage=str(raw["stage"] or "")):
continue
if not _safe_manifest_token(raw["stage"], max_length=128) or not _known_result_stage(raw["stage"]):
continue
validated.append({**row, **raw, "status": payload.get("status")})
return validated
_UNSAFE_METRIC_KEY_TOKENS = {
"path", "file", "dir", "directory", "warning", "diagnostic", "reason",
"message", "raw", "error", "exception", "traceback", "log", "stdout",
"stderr", "command", "detail",
}
_DROP_METRIC = object()
class _ProjectionTooLarge(ValueError):
pass
def _safe_metric_key(key: Any, value: Any = None) -> bool:
if not _safe_manifest_token(key, max_length=128):
return False
if isinstance(value, (bool, int, float)):
return True
tokens = {token for token in key.lower().replace("-", "_").split("_") if token}
return not bool(tokens & _UNSAFE_METRIC_KEY_TOKENS)
def _safe_metric_value(value: Any, *, depth: int = 0) -> Any:
if isinstance(value, bool):
return value
if isinstance(value, int) and not isinstance(value, bool):
return value
if isinstance(value, float):
return value if math.isfinite(value) else _DROP_METRIC
if isinstance(value, str):
return _DROP_METRIC
if depth >= 4:
return _DROP_METRIC
if isinstance(value, list):
if len(value) > 64:
return _DROP_METRIC
compact = []
for item in value:
safe = _safe_metric_value(item, depth=depth + 1)
if safe is not _DROP_METRIC:
compact.append(safe)
return compact if compact or not value else _DROP_METRIC
if isinstance(value, dict):
if len(value) > 128:
return _DROP_METRIC
compact = {}
for key in sorted(value):
if not _safe_metric_key(key, value[key]):
continue
safe = _safe_metric_value(value[key], depth=depth + 1)
if safe is not _DROP_METRIC:
compact[key] = safe
return compact if compact or not value else _DROP_METRIC
return _DROP_METRIC
def _compact_stage_metrics(metrics_by_stage: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
if len(metrics_by_stage) > 16:
raise _ProjectionTooLarge
compact: dict[str, dict[str, Any]] = {}
for stage in sorted(metrics_by_stage):
safe = _safe_metric_value(metrics_by_stage[stage])
if not isinstance(safe, dict):
safe = {}
compact[stage] = safe
return compact
def _compact_source_summary(summary: Any) -> dict[str, int]:
if not isinstance(summary, dict):
return {}
allowed = {
"manifest_count", "raw_evidence_count", "risk_count", "high_risk_count",
"rag_reference_stage_count", "rag_reference_count", "claim_count",
"major_claim_count",
}
return {
key: value
for key, value in sorted(summary.items())
if key in allowed
and isinstance(value, int)
and not isinstance(value, bool)
and value >= 0
}
def _compact_result_findings(report: dict[str, Any], *, limit: int = 3) -> list[dict[str, Any]]:
findings = report.get("key_findings")
if not isinstance(findings, list):
return []
allowed = ("stage", "title", "description", "confidence", "interpretation_level")
return [
_selected_fields(item, allowed)
for item in findings[:limit]
if isinstance(item, dict)
]
def _compact_structured_interpretations(report: dict[str, Any]) -> dict[str, dict[str, Any]]:
interpretations = report.get("structured_result_interpretations")
if not isinstance(interpretations, dict):
return {}
allowed = (
"stage", "stage_name", "status", "result_interpretation", "limitations",
"boundary", "interpretation_level",
)
return {
str(stage): _selected_fields(value, allowed)
for stage, value in list(sorted(interpretations.items()))[:16]
if _safe_binding_label(stage) and isinstance(value, dict)
}
def _bounded_tabular_artifact_rows(
manifest_path: Path,
*,
final_dir: Path,
limit: int = 5,
) -> list[dict[str, str]]:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return []
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, dict):
return []
resolved_final = final_dir.resolve()
excluded = {"plot", "raw", "manifest", "command", "environment", "checksum", "report"}
candidates: list[tuple[str, Path]] = []
for key, artifact in sorted(artifacts.items()):
if not isinstance(artifact, dict):
continue
raw_path = str(artifact.get("path") or "").strip()
artifact_type = str(artifact.get("type") or "").strip().lower()
suffix = Path(raw_path).suffix.lower()
key_tokens = {token for token in str(key).lower().replace("-", "_").split("_") if token}
if key_tokens & excluded:
continue
if artifact_type not in {"tsv", "csv"} and suffix not in {".tsv", ".txt", ".csv"}:
continue
declared = Path(raw_path)
candidate = declared if declared.is_absolute() else (
final_dir.parent / declared if declared.parts[:1] == ("final",) else final_dir / declared
)
try:
resolved = candidate.resolve(strict=True)
resolved.relative_to(resolved_final)
except (OSError, ValueError):
continue
candidates.append((str(key), resolved))
if not candidates:
return []
selected = candidates[0][1]
try:
with selected.open("r", encoding="utf-8", errors="replace", newline="") as handle:
sample = handle.read(131_072)
except OSError:
return []
delimiter = "," if selected.suffix.lower() == ".csv" else "\t"
reader = csv.DictReader(io.StringIO(sample), delimiter=delimiter)
if not reader.fieldnames or len(reader.fieldnames) > 20:
return []
rows: list[dict[str, str]] = []
for raw_row in reader:
row = {
str(key)[:128]: str(value or "")[:512]
for key, value in list(raw_row.items())[:20]
if key is not None
}
rows.append(row)
if len(rows) >= limit:
break
return rows
def _safe_binding_label(value: Any) -> bool:
return _safe_manifest_token(value, max_length=128)
def _candidate_identities(
manifests: list[dict[str, Any]],
*,
prefix: str,
stage: str,
) -> list[dict[str, str]]:
identities: list[dict[str, str]] = []
seen: set[tuple[str, str, str, str]] = set()
for manifest in manifests:
manifest_prefix = str(manifest.get("prefix") or "")
if prefix and manifest_prefix != prefix:
continue
if stage and str(manifest.get("stage") or "") != stage:
continue
identity = _manifest_identity(manifest)
key = (
manifest_prefix,
identity["scope_id"],
identity["analysis_route"],
identity["member_set_id"],
)
if key in seen:
continue
seen.add(key)
identities.append({"prefix": manifest_prefix, **identity})
return identities
_INTEGRATED_ROUTE_PRIORITY = {
"multi_species": 0,
"single_species": 1,
"confirmed_subfamily": 2,
"subfamily_prediction": 3,
"subfamily_evidence": 4,
}
def _manifest_row_metrics(row: dict[str, Any]) -> dict[str, Any]:
metrics = row.get("metrics")
if isinstance(metrics, dict) and metrics:
return metrics
try:
payload = json.loads(Path(str(row.get("path") or "")).read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {}
metrics = payload.get("metrics")
return metrics if isinstance(metrics, dict) else {}
def _integrated_gene_family_rows(
manifests: list[dict[str, Any]],
*,
prefix: str,
) -> tuple[dict[str, dict[str, Any]] | None, str]:
"""Select one evidence-bound successful Manifest per biological stage."""
rows_by_stage: dict[str, list[dict[str, Any]]] = {}
for row in manifests:
stage = str(row.get("stage") or "")
if (
row.get("status") not in SUCCESS_STATUSES
or stage.startswith("rnaseq_")
or stage == "genome_prepare"
or str(row.get("prefix") or "") != prefix
):
continue
rows_by_stage.setdefault(stage, []).append(row)
selected: dict[str, dict[str, Any]] = {}
for stage, rows in rows_by_stage.items():
rows_by_identity: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
for row in rows:
identity = _manifest_identity(row)
key = (
identity["scope_id"],
identity["analysis_route"],
identity["member_set_id"],
)
rows_by_identity.setdefault(key, []).append(row)
identity_rows: list[dict[str, Any]] = []
for identity_group in rows_by_identity.values():
metrics_by_row = [
(row, _manifest_row_metrics(row)) for row in identity_group
]
for _, left in metrics_by_row:
for _, right in metrics_by_row:
shared_keys = set(left) & set(right)
if any(left[key] != right[key] for key in shared_keys):
return None, stage
identity_rows.append(
sorted(
metrics_by_row,
key=lambda item: (
-len(item[1]),
str(item[0].get("path") or ""),
),
)[0][0]
)
best_priority = min(
_INTEGRATED_ROUTE_PRIORITY.get(str(row.get("analysis_route") or ""), 50)
for row in identity_rows
)
best_rows = [
row
for row in identity_rows
if _INTEGRATED_ROUTE_PRIORITY.get(
str(row.get("analysis_route") or ""), 50
)
== best_priority
]
if len(best_rows) != 1:
return None, stage
selected[stage] = best_rows[0]
return selected, ""
def _selected_fields(item: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
return {key: item[key] for key in fields if item.get(key) not in (None, "", [], {})}
class _NoCostLLMManager:
def __init__(self, wrapped: LLMManager):
self._wrapped = wrapped
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
def call(self, *args: Any, **kwargs: Any) -> Any:
kwargs["track_cost"] = False
return self._wrapped.call(*args, **kwargs)
async def acall(self, *args: Any, **kwargs: Any) -> Any:
kwargs["track_cost"] = False
return await self._wrapped.acall(*args, **kwargs)
def _matching_manifest_rows(
manifests: list[dict[str, Any]],
*,
prefix: str,
stage: str,
identity: dict[str, str],
) -> list[dict[str, Any]]:
return [
manifest
for manifest in manifests
if (not prefix or str(manifest.get("prefix") or "") == prefix)
and (not stage or str(manifest.get("stage") or "") == stage)
and _manifest_identity(manifest) == identity
]
def _bind_exact_manifest_sources(
manifests: list[dict[str, Any]],
*,
identity: dict[str, str],
final_dir: Path,
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]] | None:
resolved_final = final_dir.resolve()
rows_by_stage: dict[str, list[dict[str, Any]]] = {}
for manifest in manifests:
rows_by_stage.setdefault(str(manifest.get("stage") or ""), []).append(manifest)
metrics_by_stage: dict[str, dict[str, Any]] = {}
bindings: dict[str, dict[str, Any]] = {}
for stage, rows in rows_by_stage.items():
if not _safe_binding_label(stage):
return None
payloads: list[tuple[Path, dict[str, Any]]] = []
for row in rows:
path = Path(str(row.get("path") or ""))
try:
resolved_path = path.resolve()
resolved_path.relative_to(resolved_final)
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return None
if _manifest_identity(payload) != identity or str(payload.get("stage") or "") != stage:
return None
payloads.append((path, payload))
metric_variants = {json.dumps(payload.get("metrics") or {}, sort_keys=True) for _, payload in payloads}
if len(metric_variants) != 1:
return None
path, payload = sorted(payloads, key=lambda item: str(item[0]))[0]
if payload.get("status") not in SUCCESS_STATUSES:
return None
metrics_by_stage[stage] = payload.get("metrics") or {}
bindings[stage] = {
"manifest_ref": (Path("final") / path.resolve().relative_to(resolved_final)).as_posix(),
"canonical_identity": dict(identity),
"stage": stage,
"status": payload.get("status", ""),
}
return metrics_by_stage, bindings
def _report_identity_is_compatible(metadata: dict[str, Any], identity: dict[str, str]) -> bool:
report_scope = str(metadata.get("scope_id") or "")
report_route = str(metadata.get("analysis_route") or "")
report_member = str(metadata.get("member_set_id") or "")
if report_scope != identity["scope_id"]:
return False
if report_route == identity["analysis_route"]:
return report_member == identity["member_set_id"]
normalized_target_plus_reference = (
identity["analysis_route"] == "multi_species"
and "target_plus_reference" in identity["scope_id"]
and report_route == "target_plus_reference"
)
return normalized_target_plus_reference and report_member in {"", identity["member_set_id"]}
def build_plantai_conversation_tools(
*,
llm_manager: LLMManager,
project_root: Path,
bound_job_id: str = "",
goal_ledger: GoalLedger | None = None,
allow_legacy_manifest_migration: bool = True,
progress_callback: Callable[[dict[str, Any]], None] | None = None,
) -> ConversationToolRegistry:
project_root = Path(project_root)
bound_job_id = _validate_job_id(str(bound_job_id or ""))
registry = ConversationToolRegistry(progress_callback=progress_callback)
def current_job(args: BaseModel) -> str:
return bound_job_id or str(getattr(args, "job_id", "") or "")
context_options = (
{}
if allow_legacy_manifest_migration
else {"allow_legacy_manifest_migration": False}
)
async def inspect_project(args: InspectProjectContextArgs) -> dict[str, Any]:
job_id = current_job(args)
index = await asyncio.to_thread(
build_project_context,
job_id,
args.prefix,
project_root,
**context_options,
)
data = index.to_dict()
scopes = [
_selected_fields(
item,
(
"scope_id",
"scope_mode",
"family",
"target_species",
"reference_species",
"comparative_species",
"selected_species",
),
)
for item in data.get("scope_files", [])
]
for item in scopes:
for key in (
"target_species",
"reference_species",
"comparative_species",
"selected_species",
):
if key in item:
item[key] = _canonical_species_codes(item[key])
manifests = [
_selected_fields(
item,
(
"stage",
"status",
"prefix",
"scope_id",
"analysis_route",
"member_set_id",
"scope_mode",
"family",
"target_species",
"reference_species",
"comparative_species",
),
)
for item in data.get("manifests", [])
]
for item in manifests:
for key in (
"target_species",
"reference_species",
"comparative_species",
):
if key in item:
item[key] = _canonical_species_codes(item[key])
stage_status = {
stage: _selected_fields(status, ("stage", "status"))
for stage, status in data.get("stage_status", {}).items()
}
warnings = [
_selected_fields(item, ("code", "message", "stage"))
for item in data.get("warnings", [])
]
return {
"job_id": data.get("job_id", job_id),
"family": data.get("family", ""),
"species_roles": {
"target": _canonical_species_codes(data.get("target_species", [])),
"reference": _canonical_species_codes(data.get("reference_species", [])),
"comparative": _canonical_species_codes(data.get("comparative_species", [])),
},
"scopes": scopes,
"manifests": manifests,
"stage_status": stage_status,
"warnings": warnings,
}
async def inspect_results(args: InspectAnalysisResultsArgs) -> dict[str, Any]:
job_id = current_job(args)
index = await asyncio.to_thread(
build_project_context,
job_id,
args.prefix,
project_root,
**context_options,
)
index_data = index.to_dict()
final_dir = project_root / "workspace" / job_id / "final"
manifests = _validated_manifest_rows(
index_data.get("manifests", []),
final_dir=final_dir,
)
rnaseq_rows = [
row
for row in manifests
if str(row.get("stage") or "").startswith("rnaseq_")
and row.get("status") in SUCCESS_STATUSES
and (not args.stage or row.get("stage") == args.stage)
and (not args.prefix or row.get("prefix") == args.prefix)
]
if args.stage == "genome_prepare":
prepare_rows = [
row
for row in manifests
if row.get("stage") == "genome_prepare"
and row.get("status") in SUCCESS_STATUSES
]
if len(prepare_rows) != 1:
return {
"success": False,
"error_code": (
"ambiguous_evidence_binding"
if prepare_rows
else "result_not_found"
),
"message": "Genome preparation result is missing or ambiguous.",
}
selected = prepare_rows[0]
effective_prefix = str(selected.get("prefix") or "genome_prepare")
report = await asyncio.to_thread(
build_evidence_report,
final_dir,
effective_prefix,
job_id=job_id,
include_stages=["genome_prepare"],
)
if report is None:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
}
manifest_path = Path(str(selected.get("path") or ""))
return {
"projection_version": "compact_v1",
"success": True,
"canonical_identity": {"prefix": effective_prefix},
"metadata": {"prefix": effective_prefix},
"result_agent": {
"stage_metrics": _compact_stage_metrics(
{"genome_prepare": dict(selected.get("metrics") or {})}
),
"key_findings": _compact_result_findings(report),
"structured_result_interpretations": _compact_structured_interpretations(report),
"top_results": {},
},
"source_bindings": {
"genome_prepare": {
"manifest_ref": (
Path("final")
/ manifest_path.resolve().relative_to(final_dir.resolve())
).as_posix(),
"stage": "genome_prepare",
"status": selected.get("status", ""),
}
},
"source_summary": _compact_source_summary(report.get("source_summary")),
}
if not args.stage and rnaseq_rows:
rnaseq_identities = sorted(
{
(
str(row.get("prefix") or ""),
str(row.get("rnaseq_scope_id") or ""),
)
for row in rnaseq_rows
if str(row.get("prefix") or "").strip()
and str(row.get("rnaseq_scope_id") or "").strip()
}
)
if len(rnaseq_identities) != 1:
return {
"success": False,
"error_code": "ambiguous_scope",
"message": "Multiple RNA-seq result identities match; provide prefix or stage.",
"candidates": [
{"prefix": prefix, "rnaseq_scope_id": scope_id}
for prefix, scope_id in rnaseq_identities
],
}
effective_prefix, rnaseq_scope_id = rnaseq_identities[0]
selected_rows = [
row
for row in rnaseq_rows
if str(row.get("prefix") or "") == effective_prefix
and str(row.get("rnaseq_scope_id") or "") == rnaseq_scope_id
]
rows_by_stage: dict[str, list[dict[str, Any]]] = {}
for row in selected_rows:
rows_by_stage.setdefault(str(row.get("stage") or ""), []).append(row)
canonical_rows: dict[str, dict[str, Any]] = {}
for stage, rows in rows_by_stage.items():
metric_variants = {
json.dumps(row.get("metrics") or {}, sort_keys=True)
for row in rows
}
if not stage or len(metric_variants) != 1:
return {
"success": False,
"error_code": "ambiguous_evidence_binding",
"message": "RNA-seq manifests disagree within one stage identity.",
}
canonical_rows[stage] = sorted(
rows, key=lambda row: str(row.get("path") or "")
)[0]
report = await asyncio.to_thread(
build_evidence_report,
final_dir,
effective_prefix,
job_id=job_id,
rnaseq_scope_id=rnaseq_scope_id,
include_stages=list(canonical_rows),
)
if report is None:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
}
top_results: dict[str, list[dict[str, str]]] = {}
source_bindings: dict[str, dict[str, Any]] = {}
stage_metrics: dict[str, dict[str, Any]] = {}
for stage, row in canonical_rows.items():
manifest_path = Path(str(row.get("path") or ""))
stage_metrics[stage] = dict(row.get("metrics") or {})
if stage in {
"rnaseq_de",
"rnaseq_go_enrich",
"rnaseq_kegg_enrich",
}:
rows = await asyncio.to_thread(
_bounded_tabular_artifact_rows,
manifest_path,
final_dir=final_dir,
)
if rows:
top_results[stage] = rows
source_bindings[stage] = {
"manifest_ref": (
Path("final")
/ manifest_path.resolve().relative_to(final_dir.resolve())
).as_posix(),
"stage": stage,
"status": row.get("status", ""),
}
projection = {
"projection_version": "compact_v1",
"success": True,
"canonical_identity": {
"prefix": effective_prefix,
"rnaseq_scope_id": rnaseq_scope_id,
},
"metadata": {
"prefix": effective_prefix,
"rnaseq_scope_id": rnaseq_scope_id,
},
"result_agent": {
"stage_metrics": _compact_stage_metrics(stage_metrics),
"key_findings": _compact_result_findings(report, limit=16),
"structured_result_interpretations": _compact_structured_interpretations(report),
"top_results": top_results,
},
"source_bindings": source_bindings,
"source_summary": _compact_source_summary(report.get("source_summary")),
}
if len(json.dumps(projection, ensure_ascii=False, sort_keys=True)) > 20_000:
projection["result_agent"]["stage_metrics"] = {}
if len(json.dumps(projection, ensure_ascii=False, sort_keys=True)) > 20_000:
return {
"success": False,
"error_code": "result_payload_too_large",
"message": "The compact result exceeds the safe response budget.",
}
return projection
has_exact_identity_selector = any(
(args.scope_id, args.analysis_route, args.member_set_id)
)
if not args.stage and not has_exact_identity_selector:
effective_prefix = args.prefix
if not effective_prefix:
family = str(index_data.get("family") or "").strip()
target_species = index_data.get("target_species") or []
expected_prefix = (
f"{target_species[0]}.{family}"
if len(target_species) == 1 and family
else ""
)
available_prefixes = {
str(row.get("prefix") or "")
for row in manifests
if row.get("status") in SUCCESS_STATUSES
and not str(row.get("stage") or "").startswith("rnaseq_")
and str(row.get("stage") or "") != "genome_prepare"
}
if expected_prefix in available_prefixes:
effective_prefix = expected_prefix
else:
canonical_prefixes = {
value
for value in available_prefixes
if re.fullmatch(
r"[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9_-]{0,63}",
value,
)
}
if len(canonical_prefixes) == 1:
effective_prefix = next(iter(canonical_prefixes))
elif len(canonical_prefixes) > 1:
return {
"success": False,
"error_code": "ambiguous_prefix",
"message": "Multiple gene-family result prefixes match; provide prefix.",
"candidates": sorted(canonical_prefixes),
}
integrated_identities = [
candidate
for candidate in _candidate_identities(
manifests,
prefix=effective_prefix,
stage="",
)
if _canonical_identity_is_complete(candidate)
]
if effective_prefix and len(integrated_identities) > 1:
integrated_manifests = list(manifests)
ambiguous_stages: list[str] = []
while True:
canonical_rows, ambiguous_stage = _integrated_gene_family_rows(
integrated_manifests,
prefix=effective_prefix,
)
if canonical_rows is not None or not ambiguous_stage:
break
ambiguous_stages.append(ambiguous_stage)
integrated_manifests = [
row
for row in integrated_manifests
if str(row.get("stage") or "") != ambiguous_stage
]
if canonical_rows is None or (not canonical_rows and ambiguous_stages):
return {
"success": False,
"error_code": "ambiguous_evidence_binding",
"message": "Multiple equally valid result identities match one stage.",
"stage": ambiguous_stages[0] if ambiguous_stages else ambiguous_stage,
}
if canonical_rows:
source_bindings: dict[str, dict[str, Any]] = {}
stage_metrics: dict[str, dict[str, Any]] = {}
key_findings: list[dict[str, Any]] = []
structured_interpretations: dict[str, dict[str, Any]] = {}
source_summary: dict[str, int] = {}
for stage, row in sorted(canonical_rows.items()):
identity = _manifest_identity(row)
report = await asyncio.to_thread(
build_evidence_report,
final_dir,
effective_prefix,
job_id=job_id,
scope_id=identity["scope_id"],
analysis_route=identity["analysis_route"],
member_set_id=identity["member_set_id"],
include_stages=[stage],
)
if isinstance(report, dict):
key_findings.extend(_compact_result_findings(report, limit=1))
structured_interpretations.update(
_compact_structured_interpretations(report)
)
for key, value in _compact_source_summary(
report.get("source_summary")
).items():
source_summary[key] = source_summary.get(key, 0) + value
manifest_path = Path(str(row.get("path") or ""))
stage_metrics[stage] = dict(_manifest_row_metrics(row))
source_bindings[stage] = {
"manifest_ref": (
Path("final")
/ manifest_path.resolve().relative_to(final_dir.resolve())
).as_posix(),
"stage": stage,
"status": row.get("status", ""),
"canonical_identity": identity,
}
projection = {
"projection_version": "compact_v1",
"success": True,
"canonical_identity": {"prefix": effective_prefix},
"metadata": {
"prefix": effective_prefix,
"report_mode": "integrated_gene_family",
},
"result_agent": {
"completed_stages": sorted(canonical_rows),
"ambiguous_stages": [
{
"stage": stage,
"reason": "multiple_canonical_identities",
}
for stage in ambiguous_stages
],
"stage_metrics": _compact_stage_metrics(stage_metrics),
"key_findings": key_findings[:16],
"structured_result_interpretations": structured_interpretations,
},
"source_bindings": source_bindings,
"source_summary": source_summary,
}
if len(json.dumps(projection, ensure_ascii=False, sort_keys=True)) > 20_000:
projection["result_agent"]["stage_metrics"] = {}
if len(json.dumps(projection, ensure_ascii=False, sort_keys=True)) > 20_000:
return {
"success": False,
"error_code": "result_payload_too_large",
"message": "The compact result exceeds the safe response budget.",
}
return projection
if args.stage.startswith("rnaseq_"):
if len(rnaseq_rows) > 1:
return {
"success": False,
"error_code": "ambiguous_scope",
"message": "Multiple RNA-seq results match; provide prefix.",
"candidates": [
{
"prefix": row["prefix"],
"rnaseq_scope_id": row["rnaseq_scope_id"],
"stage": row["stage"],
}
for row in rnaseq_rows
],
}
if not rnaseq_rows:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
}
selected = rnaseq_rows[0]
effective_prefix = str(selected["prefix"])
rnaseq_scope_id = str(selected["rnaseq_scope_id"])
report = await asyncio.to_thread(
build_evidence_report,
final_dir,
effective_prefix,
job_id=job_id,
rnaseq_scope_id=rnaseq_scope_id,
include_stages=[str(selected["stage"])],
)
if report is None:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
}
metrics = dict(selected.get("metrics") or {})
manifest_path = Path(str(selected["path"]))
if not metrics:
metrics = json.loads(manifest_path.read_text(encoding="utf-8")).get("metrics") or {}
stage = str(selected["stage"])
top_rows = await asyncio.to_thread(
_bounded_tabular_artifact_rows,
manifest_path,
final_dir=final_dir,
)
return {
"success": True,
"canonical_identity": {
"prefix": effective_prefix,
"rnaseq_scope_id": rnaseq_scope_id,
},
"metadata": {
"prefix": effective_prefix,
"rnaseq_scope_id": rnaseq_scope_id,
},
"result_agent": {
"stage_metrics": _compact_stage_metrics({stage: metrics}),
"key_findings": _compact_result_findings(report),
"structured_result_interpretations": _compact_structured_interpretations(report),
"top_results": {stage: top_rows} if top_rows else {},
},
"source_bindings": {
stage: {
"manifest_ref": (
Path("final")
/ Path(str(selected["path"])).resolve().relative_to(final_dir.resolve())
).as_posix(),
"stage": selected["stage"],
"status": selected["status"],
}
},
"source_summary": _compact_source_summary(report.get("source_summary")),
}
candidates = _candidate_identities(manifests, prefix=args.prefix, stage=args.stage)
candidates = [
candidate
for candidate in candidates
if _result_identity_is_complete(candidate, stage=args.stage)
]
requested = {
"scope_id": args.scope_id,
"analysis_route": args.analysis_route,
"member_set_id": args.member_set_id,
}
matching = [
candidate
for candidate in candidates
if all(not requested[key] or candidate[key] == requested[key] for key in requested)
]
if len(matching) > 1:
canonical_identities = {
(candidate["scope_id"], candidate["analysis_route"], candidate["member_set_id"])
for candidate in matching
}
prefixes = {candidate["prefix"] for candidate in matching}
ambiguous_prefix = len(canonical_identities) == 1 and len(prefixes) > 1
return {
"success": False,
"error_code": "ambiguous_prefix" if ambiguous_prefix else "ambiguous_scope",
"message": (
"Multiple result prefixes match; provide prefix."
if ambiguous_prefix
else "Multiple result identities match; provide scope_id, analysis_route, and member_set_id."
),
"candidates": matching,
}
if candidates and not matching:
return {
"success": False,
"error_code": "scope_not_found",
"message": "No result matches the requested identity.",
"candidates": candidates,
}
if not matching:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
}
selected = matching[0]
effective_prefix = selected["prefix"]
identity = {key: selected[key] for key in requested}
same_scope = [
{key: candidate[key] for key in requested}
for candidate in candidates
if candidate["scope_id"] == identity["scope_id"]
]
if any(candidate != identity for candidate in same_scope):
return {
"success": False,
"error_code": "ambiguous_evidence_binding",
"message": "Existing evidence loading cannot isolate this member set without mixing identities.",
"candidates": same_scope,
}
source_rows = _matching_manifest_rows(
manifests,
prefix=effective_prefix,
stage=args.stage,
identity=identity,
)
bound_sources = await asyncio.to_thread(
_bind_exact_manifest_sources,
source_rows,
identity=identity,
final_dir=final_dir,
)
if bound_sources is None:
return {
"success": False,
"error_code": "ambiguous_evidence_binding",
"message": "Exact manifest sources are missing, inconsistent, or disagree on metrics.",
"canonical_identity": identity,
}
bound_metrics, source_bindings = bound_sources
report = await asyncio.to_thread(
build_evidence_report,
final_dir,
effective_prefix,
job_id=job_id,
scope_id=identity["scope_id"],
analysis_route=identity["analysis_route"],
member_set_id=identity["member_set_id"],
include_stages=[args.stage] if args.stage else None,
)
if report is None:
return {
"success": False,
"error_code": "result_not_found",
"message": "No analysis result matched the request.",
"canonical_identity": identity,
}
report_metadata = report.get("metadata") if isinstance(report.get("metadata"), dict) else {}
if not _report_identity_is_compatible(report_metadata, identity):
return {
"success": False,
"error_code": "ambiguous_evidence_binding",
"message": "The evidence report identity is incompatible with the selected Manifest identity.",
"canonical_identity": identity,
}
try:
projection = {
"projection_version": "compact_v1",
"success": True,
"canonical_identity": identity,
"metadata": {"prefix": effective_prefix, **identity},
"result_agent": {
"stage_metrics": _compact_stage_metrics(bound_metrics),
"key_findings": _compact_result_findings(report),
"structured_result_interpretations": _compact_structured_interpretations(report),
},
"source_bindings": source_bindings,
"source_summary": _compact_source_summary(report.get("source_summary")),
}
except _ProjectionTooLarge:
projection = None
if (
projection is None
or len(json.dumps(projection, ensure_ascii=False, sort_keys=True)) > 20_000
):
return {
"success": False,
"error_code": "result_payload_too_large",
"message": "The compact result exceeds the safe response budget.",
}
return projection
async def query_rag(args: QueryPlantAIRagArgs) -> dict[str, Any]:
try:
raw = await asyncio.to_thread(query_dual_kb_qa, args.question, top_k=10)
except Exception:
raw = {
"success": False,
"error_code": "dual_kb_unavailable",
"answer": "",
"sources": [],
}
return normalize_rag_tool_result(raw)
def intent_query_with_confirmed_context(
query: str,
*,
workflow_id: str | None = None,
guidance: bool = False,
) -> str:
"""Adapt MainAgent memory to the original IntentAgent text contract."""
text = str(query or "").strip()
if (
text.startswith("执行数据准备(genome_prepare)")
and "当前业务动作已由数据准备契约唯一确定" in text
):
return text
if goal_ledger is None:
return text
terms = goal_ledger.user_terms
context: list[str] = []
canonical_request = ""
family = str(terms.get("gene_family") or "").strip()
if family:
context.append(f"基因家族 {family}")
for key, label in (
("target_species", "目标物种"),
("reference_species", "参考物种"),
("comparative_species", "比较物种"),
):
values = terms.get(key) or ()
if isinstance(values, (list, tuple)):
cleaned = [
str(value).strip()
for value in values
if str(value or "").strip()
]
if cleaned:
context.append(f"{label} {','.join(cleaned)}")
rna_route = str(terms.get("rna_route") or "").strip().lower()
if workflow_id in {"rnaseq_whole_genome", "rnaseq_family_member"}:
business_context = load_rnaseq_business_context(workflow_id)
mode = business_context["analysis_mode"]["mode"]
experiment_design = business_context["authoritative_inputs"][
"experiment_design"
]
if mode == "whole_genome":
enabled_designs = "、".join(business_context["enabled_designs"])
disabled_designs = "、".join(business_context["disabled_designs"])
context.append(
f"RNA-seq业务模式 全基因组;{experiment_design} 是实验设计权威输入;"
f"可解释设计为 {enabled_designs},{disabled_designs} 本轮禁用;"
"设计或 batch 降级不得静默发生"
)
else:
context.append(
"RNA-seq业务模式 基因家族成员;使用已成功的全基因组差异表达结果"
"和精确确认的基因家族成员进行投影;不得自动选择首个成员文件,"
"不得隐式重跑差异表达"
)
elif rna_route == "whole_genome":
context.append("RNA-seq模式 全基因组")
elif rna_route == "family_member":
context.append("RNA-seq模式 基因家族成员")
job_id = bound_job_id or str(terms.get("active_job_id") or "").strip()
formal_config_exists = bool(
job_id and (project_root / "input" / job_id / "config.xlsx").is_file()
)
config_confirmed = (
bool(terms.get("config_confirmed"))
or "config.xlsx 已确认" in goal_ledger.approved_decisions
or formal_config_exists
)
genome_prepare_observed = False
subfamily_confirm_observed = False
if job_id:
final_dir = project_root / "workspace" / job_id / "final"
for manifest_path in final_dir.glob("*.manifest.json") if final_dir.exists() else ():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
stage = str(manifest.get("stage") or "").strip()
status = manifest.get("status")
if stage == "genome_prepare" and status in {"success", "partial_success"}:
genome_prepare_observed = True
manifest_prefix = str(manifest.get("prefix") or "").strip()
family_matches = not family or bool(
re.search(
rf"(?:^|[.]){re.escape(family)}(?:[.]|$)",
manifest_prefix,
flags=re.IGNORECASE,
)
)
if (
stage == "subfamily_confirm"
and status == "success"
and family_matches
):
subfamily_confirm_observed = True
if config_confirmed and not genome_prepare_observed:
context.append(
"正式 config.xlsx 已确认;这只表示配置已核对,不等于数据准备已经完成,"
"当前 job 尚无数据准备完成证据;具体分析意图和阶段仍由现有分析系统判断"
)
if guidance and workflow_id in {
"data_preparation",
"gene_family",
"rnaseq_whole_genome",
"rnaseq_family_member",
}:
contract = load_canonical_workflow_contract("data_preparation")
transition = next(
item
for item in contract.transitions
if item.from_state_id == "config_confirmed"
)
actions = {item.id: item for item in contract.actions}
target_stage = actions[transition.action_id].target_stage
canonical_request = (
f"执行数据准备({target_stage})\n"
"当前业务动作已由数据准备契约唯一确定"
)
if family and subfamily_confirm_observed:
context.append(
"当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认,"
"这是当前项目状态事实;具体分析意图和阶段仍由现有分析系统判断"
)
if canonical_request:
return canonical_request
if not context:
return text
return f"{text}\n已确认上下文:{';'.join(context)}。"
registry.set_analysis_handoff_resolver(intent_query_with_confirmed_context)
async def recognize_intent(args: RecognizeAnalysisIntentArgs) -> dict[str, Any]:
job_id = current_job(args)
state = AgentState(
session_id=f"router:{job_id or 'unbound'}",
job_id=job_id,
results={"completed_stages": []},
)
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={
"query": intent_query_with_confirmed_context(args.query),
"job_id": job_id,
"prefix": args.prefix,
"workspace": str(project_root / "workspace" / job_id) if job_id else "",
**(
{
"goal_context": {
"active_goal": goal_ledger.active_goal,
"user_terms": goal_ledger.model_dump(
mode="json", round_trip=True
)["user_terms"],
}
}
if goal_ledger is not None
else {}
),
},
)
returned_state = await IntentAgentV2(_NoCostLLMManager(llm_manager)).execute(state, message)
candidate = dict(
((returned_state.results.get("results") or {}).get("intent") or {})
)
stages = [str(stage) for stage in candidate.get("stages") or ()]
if any(stage.startswith("rnaseq_") for stage in stages):
query_lower = str(args.query or "").lower()
family_member = any(
token in query_lower
for token in (
"基因家族成员",
"家族成员",
"family member",
"family-member",
)
)
whole_genome = any(
token in query_lower
for token in ("全基因组", "whole genome", "whole-genome")
)
if family_member or whole_genome:
rna_route = "family_member" if family_member else "whole_genome"
foreground_mode = "family" if family_member else "global"
candidate["rna_route"] = rna_route
candidate["foreground_mode"] = foreground_mode
params = dict(candidate.get("params") or {})
params["analysisRoute"] = rna_route
params["rnaseqForegroundMode"] = foreground_mode
candidate["params"] = params
return _without_forbidden_intent_keys(candidate)
def recognize_utility(args: RecognizeUtilityIntentArgs) -> dict[str, Any]:
from gfa_core.mas.utils.intent_parser import parse_sequence_extract_request
params = parse_sequence_extract_request(args.query)
if not params:
return {
"success": False,
"error_code": "utility_intent_not_found",
"message": "No supported utility request was recognized.",
}
return {
"success": True,
"taskType": "utility",
"stage": "sequence_extract_by_id",
"stages": ["sequence_extract_by_id"],
"utility_tool": (
"fasta_header_clean"
if params.get("sequenceExtractCleanHeaders")
else "sequence_extract_by_id"
),
"params": params,
}
def inspect_job_file(args: InspectJobFileArgs) -> dict[str, Any]:
from gfa_core.mas.job_file_reader import read_job_file_preview
return read_job_file_preview(
project_root,
bound_job_id,
args.path,
max_lines=args.max_lines,
)
def list_current_job_files(args: ListJobFilesArgs) -> dict[str, Any]:
from gfa_core.mas.job_file_reader import list_job_files
return list_job_files(
project_root,
bound_job_id,
roots=tuple(args.roots),
limit=args.limit,
cursor=args.cursor,
)
registry.register(
name="inspect_project_context",
description="Inspect one PlantAI job's project context without changing it.",
args_model=(BoundInspectProjectContextArgs if bound_job_id else InspectProjectContextArgs),
handler=inspect_project,
permission="read_only",
)
registry.register(
name="inspect_analysis_results",
description="Read evidence from one canonical PlantAI analysis result identity.",
args_model=(BoundInspectAnalysisResultsArgs if bound_job_id else InspectAnalysisResultsArgs),
handler=inspect_results,
permission="read_only",
)
registry.register(
name="query_plantai_rag",
description="Query PlantAI's existing gene-family knowledge service.",
args_model=QueryPlantAIRagArgs,
handler=query_rag,
permission="read_only",
)
registry.register(
name="recognize_analysis_intent",
description="Ask IntentAgentV2 for a candidate analysis intent without planning or execution.",
args_model=(BoundRecognizeAnalysisIntentArgs if bound_job_id else RecognizeAnalysisIntentArgs),
handler=recognize_intent,
permission="pure",
)
registry.register(
name="recognize_utility_intent",
description="Recognize one supported utility request without planning or execution.",
args_model=RecognizeUtilityIntentArgs,
handler=recognize_utility,
permission="pure",
)
if bound_job_id:
registry.register(
name="list_job_files",
description=(
"List real safe file handles in the current PlantAI job. "
"Use input/final/logs by default; request workspace only when the user asks for intermediates or all files."
),
args_model=ListJobFilesArgs,
handler=list_current_job_files,
permission="read_only",
max_result_chars=70_000,
)
registry.register(
name="inspect_job_file",
description="Preview one safe text file attached to the current PlantAI job.",
args_model=InspectJobFileArgs,
handler=inspect_job_file,
permission="read_only",
max_result_chars=70_000,
)
return registry