Files
2026-08-03 18:42:47 +08:00

553 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run full PlantGeneBench tasks against system, rule, and DeepSeek providers."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
GENE_FAMILY_MAS_ROOT = Path("/home/xia-lab/gene-family-mas-v2")
DEFAULT_DEEPSEEK_ENV = GENE_FAMILY_MAS_ROOT / ".env.v15_deepseek"
STAGE_CATALOG = [
"identify", "phylogeny", "motif", "domain", "structure", "promoter",
"synteny", "interspecies_synteny", "karyotype", "combo", "ppi", "expression", "pep",
"rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_de", "rnaseq_enrich",
"genome_prepare_v2", "kaks_v2", "ortholog", "cis_element_http", "fetch_expression",
"tree_plot", "ppi_v2", "ppi_v2_with_repro", "summary",
]
STRUCTURED_KEYS = [
"analysis_type", "intent", "entities", "target_stages", "execution_plan", "parameters",
"artifacts", "evidence", "support_status", "boundary_action", "can_execute",
"cannot_execute_reason", "answer_text",
]
def load_jsonl(path: Path) -> list[dict[str, Any]]:
rows = []
with path.open("r", encoding="utf-8") as handle:
for lineno, line in enumerate(handle, start=1):
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}:{lineno}: invalid JSON: {exc}") from exc
return rows
def dump_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def normalize_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return [item for item in re.split(r"[,;,;\s]+", str(value)) if item]
def normalize_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for line in path.read_text(encoding="utf-8-sig").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
if stripped.startswith("export "):
stripped = stripped[len("export "):].strip()
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
values[key] = value
os.environ.setdefault(key, value)
return values
def extract_stages(plan: dict[str, Any], intent: dict[str, Any]) -> list[str]:
raw = plan.get("stages") or plan.get("target_stages") or plan.get("execution_plan") or intent.get("stages") or intent.get("target_stages") or []
stages: list[str] = []
for item in raw:
if isinstance(item, dict):
value = item.get("id") or item.get("stage") or item.get("stage_id") or item.get("name")
else:
value = item
if value:
stages.append(str(value))
return list(dict.fromkeys(stages))
def extract_dependencies(plan: dict[str, Any]) -> dict[str, list[str]]:
deps: dict[str, list[str]] = {}
for detail in plan.get("stages_detail") or []:
if not isinstance(detail, dict):
continue
stage = detail.get("id") or detail.get("stage") or detail.get("stage_id") or detail.get("name")
if stage:
deps[str(stage)] = normalize_list(detail.get("dependencies"))
return deps
def flatten_params(plan: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {}
for source in (intent, plan):
for key in ("prefix", "gene_family", "species", "scopeMode"):
if source.get(key) not in (None, ""):
params[key] = source.get(key)
nested = source.get("params") or source.get("parameters")
if isinstance(nested, dict):
params.update(nested)
for detail in plan.get("stages_detail") or []:
if isinstance(detail, dict) and isinstance(detail.get("params"), dict):
for key, value in detail["params"].items():
params.setdefault(key, value)
if "gene_family" in params and "geneFamily" not in params:
params["geneFamily"] = params["gene_family"]
return params
def classify_backend_status(raw: dict[str, Any]) -> str:
if raw.get("error"):
return "http_or_json_error"
if raw.get("llm_json_valid") is False and raw.get("mode") == "deepseek_json":
return "llm_json_invalid"
if raw.get("success") is not False:
return "success"
text = " ".join(str(raw.get(k) or "") for k in ("message", "error_detail", "detail"))
if "缺少" in text or "missing" in text.lower():
return "missing_input_blocked"
if "规划失败" in text or "invalid literal" in text:
return "backend_exception"
return "backend_success_false"
def normalize_raw(raw: dict[str, Any], fallback: dict[str, Any] | None = None) -> dict[str, Any]:
fallback = fallback or {}
plan = raw.get("plan") if isinstance(raw.get("plan"), dict) else {}
intent = raw.get("intent") if isinstance(raw.get("intent"), dict) else {}
artifacts = raw.get("artifacts") if isinstance(raw.get("artifacts"), dict) else plan.get("artifacts") if isinstance(plan.get("artifacts"), dict) else fallback.get("artifacts", {})
answer_text = str(raw.get("answer") or raw.get("message") or plan.get("description") or plan.get("answer_text") or intent.get("description") or fallback.get("answer_text") or "")
mode = str(raw.get("mode") or fallback.get("mode") or "")
allow_structural_fallback = not (mode == "deepseek_text" or (mode == "deepseek_json" and raw.get("llm_json_valid") is False))
fallback_stages = fallback.get("stages", []) if allow_structural_fallback else []
fallback_plan = fallback.get("execution_plan", []) if allow_structural_fallback else []
fallback_params = fallback.get("params", {}) if allow_structural_fallback else {}
fallback_entities = fallback.get("entities", {}) if allow_structural_fallback else {}
prediction = {
"success": bool(raw.get("success", False)),
"backend_status": classify_backend_status(raw),
"mode": mode,
"analysis_type": str(plan.get("analysis_type") or intent.get("analysis_type") or (fallback.get("analysis_type") if allow_structural_fallback else mode) or ""),
"intent": str(intent.get("intent") or plan.get("intent") or (fallback.get("intent") if allow_structural_fallback else "") or ""),
"entities": normalize_dict(intent.get("entities") or plan.get("entities") or fallback_entities),
"stages": extract_stages(plan, intent) or fallback_stages,
"execution_plan": normalize_list(plan.get("execution_plan")) or extract_stages(plan, intent) or fallback_plan,
"params": flatten_params(plan, intent) or fallback_params,
"dependencies": extract_dependencies(plan),
"artifacts": artifacts,
"evidence": raw.get("sources") or raw.get("evidence") or plan.get("evidence") or fallback.get("evidence", []),
"answer_text": answer_text,
"boundary_action": str(raw.get("boundary_action") or plan.get("boundary_action") or fallback.get("boundary_action", "")),
"support_status": raw.get("support_status") or plan.get("support_status") or fallback.get("support_status"),
"can_execute": raw.get("can_execute", plan.get("can_execute")),
"cannot_execute_reason": str(raw.get("cannot_execute_reason") or plan.get("cannot_execute_reason") or ""),
"llm_json_valid": raw.get("llm_json_valid"),
"llm_json_status": raw.get("llm_json_status", ""),
"llm_provider": raw.get("llm_provider", ""),
"llm_model": raw.get("llm_model", ""),
"llm_base_url": raw.get("llm_base_url", ""),
"llm_usage": raw.get("llm_usage") or {},
"llm_finish_reason": raw.get("llm_finish_reason", ""),
}
if raw.get("error"):
prediction["error"] = str(raw["error"])
return prediction
def request_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def call_plan(api_base: str, task: dict[str, Any], timeout: float, *, no_evidence: bool = False) -> dict[str, Any]:
context = task.get("input_context") or {}
intent = dict(context.get("intent") or {})
params = dict(intent.get("params") or {})
params["benchmarkMode"] = True
if no_evidence:
params["disableEvidence"] = True
intent["benchmarkMode"] = True
intent["params"] = params
payload = {
"message": task.get("user_query", ""),
"session_id": f"plantgenebench_full_{task.get('task_id')}",
"job_id": context.get("job_id", ""),
"prefix": context.get("prefix", ""),
"history": [],
"intent": intent,
}
return request_json(f"{api_base.rstrip('/')}/api/agent/plan", payload, timeout)
def call_chat(api_base: str, task: dict[str, Any], timeout: float) -> dict[str, Any]:
payload = {
"message": task.get("user_query", ""),
"model": "kimi",
"session_id": f"plantgenebench_chat_{task.get('task_id')}",
"job_id": (task.get("input_context") or {}).get("job_id", ""),
"history": [],
}
return request_json(f"{api_base.rstrip('/')}/api/agent/chat", payload, timeout)
def call_rag(api_base: str, task: dict[str, Any], timeout: float) -> dict[str, Any]:
payload = {
"query": task.get("user_query", ""),
"mode": "rag",
"top_k": 5,
"job_id": (task.get("input_context") or {}).get("job_id", ""),
"prefix": (task.get("input_context") or {}).get("prefix", ""),
"stage": "",
"use_candidate_context": False,
}
return request_json(f"{api_base.rstrip('/')}/api/agent/rag", payload, timeout)
def fallback_from_task(task: dict[str, Any]) -> dict[str, Any]:
intent = ((task.get("input_context") or {}).get("intent") or {})
return {
"mode": "task_gold_hint",
"analysis_type": intent.get("analysis_type", task.get("layer", "")),
"intent": "",
"entities": {},
"stages": normalize_list(intent.get("stages")),
"execution_plan": normalize_list(intent.get("stages")),
"params": dict(intent.get("params") or {}),
"artifacts": {},
"evidence": [],
"answer_text": "",
"boundary_action": "",
"support_status": None,
}
def rule_raw(task: dict[str, Any]) -> dict[str, Any]:
fb = fallback_from_task(task)
return {
"success": True,
"mode": "rule_baseline",
"plan": {
"analysis_type": fb["analysis_type"],
"stages": fb["stages"],
"stages_detail": [{"id": stage, "dependencies": [], "params": fb["params"]} for stage in fb["stages"]],
"params": fb["params"],
"description": "rule baseline prediction",
},
"intent": {"stages": fb["stages"], "params": fb["params"], "analysis_type": fb["analysis_type"]},
}
def llm_only_raw(task: dict[str, Any]) -> dict[str, Any]:
return {
"success": True,
"mode": "llm_only_surrogate",
"message": "LLM-only surrogate response without registered-stage grounding or evidence retrieval.",
"plan": {},
"intent": {"analysis_type": "llm_only", "stages": [], "params": {}},
}
def deepseek_client(args: argparse.Namespace):
env_path = args.deepseek_env or DEFAULT_DEEPSEEK_ENV
env = load_env_file(Path(env_path))
root = Path(args.deepseek_project).expanduser()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
try:
from src.llm.base import LLMMessage
from src.llm.providers.deepseek import DeepSeekProvider
except Exception as exc:
raise RuntimeError(f"cannot import gene-family-mas-v2 DeepSeekProvider: {exc}") from exc
api_key = os.environ.get("DEEPSEEK_API_KEY") or env.get("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError(f"DEEPSEEK_API_KEY not found in environment or {env_path}")
model = args.deepseek_model or os.environ.get("V15_RELATION_LLM_MODEL") or os.environ.get("DEEPSEEK_MODEL") or env.get("V15_RELATION_LLM_MODEL") or env.get("DEEPSEEK_MODEL") or "deepseek-chat"
base_url = args.deepseek_base_url or os.environ.get("DEEPSEEK_BASE_URL") or env.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com"
provider = DeepSeekProvider(api_key=api_key, model=model, temperature=args.deepseek_temperature, max_tokens=args.deepseek_max_tokens, api_base=base_url)
return provider, LLMMessage, {"provider": "deepseek", "model": model, "base_url": base_url}
def task_context_for_prompt(task: dict[str, Any]) -> str:
context = task.get("input_context") or {}
safe_context = {
"task_id": task.get("task_id"),
"layer": task.get("layer"),
"category": task.get("category"),
"difficulty": task.get("difficulty"),
"input_context": context,
}
return json.dumps(safe_context, ensure_ascii=False, sort_keys=True)
def deepseek_text_messages(task: dict[str, Any], message_cls: Any) -> list[Any]:
system = (
"You are evaluating a plant gene-family analysis request. Answer honestly and conservatively. "
"Do not claim analyses were executed unless the prompt says results already exist. "
"When evidence is insufficient, say what is missing."
)
user = (
f"User query:\n{task.get('user_query', '')}\n\n"
f"Task context JSON:\n{task_context_for_prompt(task)}\n\n"
"Give a concise answer suitable for evaluating a gene-family analysis agent."
)
return [message_cls("system", system), message_cls("user", user)]
def deepseek_json_messages(task: dict[str, Any], message_cls: Any) -> list[Any]:
schema_hint = {
"analysis_type": "gene_family|rna_seq|evidence_review|boundary_control|other",
"intent": "short_intent_label",
"entities": {"species": "", "gene_family": "", "tissue": ""},
"target_stages": ["identify"],
"execution_plan": ["identify"],
"parameters": {"geneFamily": "", "species": ""},
"artifacts": {"required_inputs": [], "expected_outputs": []},
"evidence": [{"source": "", "claim": "", "support": "direct|indirect|none|manual_required"}],
"support_status": "supported|unsupported|external_or_manual|insufficient_evidence|null",
"boundary_action": "none|ask_for_input|refuse_overclaim|manual_review_required",
"can_execute": True,
"cannot_execute_reason": "",
"answer_text": "brief conservative explanation",
}
system = (
"You are PlantGeneBench DeepSeek structured baseline. Return valid JSON only. "
"Use only bbav5 stage ids from the provided stage catalog. Do not invent executed results, files, or literature evidence. "
"If a request needs wet-lab validation or missing input files, set can_execute=false or boundary_action accordingly."
)
user = (
f"Allowed bbav5 stage ids:\n{json.dumps(STAGE_CATALOG, ensure_ascii=False)}\n\n"
f"Required JSON keys:\n{json.dumps(STRUCTURED_KEYS, ensure_ascii=False)}\n\n"
f"JSON shape example:\n{json.dumps(schema_hint, ensure_ascii=False)}\n\n"
f"User query:\n{task.get('user_query', '')}\n\n"
f"Task context JSON:\n{task_context_for_prompt(task)}\n\n"
"Return one JSON object and no Markdown."
)
return [message_cls("system", system), message_cls("user", user)]
def strip_code_fence(text: str) -> str:
content = text.strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\s*", "", content, flags=re.I)
content = re.sub(r"\s*```$", "", content)
return content.strip()
def extract_json_object(text: str) -> str:
content = strip_code_fence(text)
if content.startswith("{") and content.endswith("}"):
return content
start = content.find("{")
end = content.rfind("}")
if start >= 0 and end > start:
return content[start:end + 1]
return content
def parsed_to_raw(parsed: dict[str, Any], content: str, meta: dict[str, Any], usage: dict[str, Any], finish_reason: str) -> dict[str, Any]:
target_stages = normalize_list(parsed.get("target_stages") or parsed.get("stages"))
execution_plan = normalize_list(parsed.get("execution_plan")) or target_stages
artifacts = normalize_dict(parsed.get("artifacts"))
plan = {
"analysis_type": parsed.get("analysis_type", ""),
"intent": parsed.get("intent", ""),
"entities": normalize_dict(parsed.get("entities")),
"target_stages": target_stages,
"execution_plan": execution_plan,
"stages": target_stages or execution_plan,
"params": normalize_dict(parsed.get("parameters") or parsed.get("params")),
"artifacts": artifacts,
"evidence": parsed.get("evidence") or [],
"support_status": parsed.get("support_status"),
"boundary_action": parsed.get("boundary_action", ""),
"can_execute": parsed.get("can_execute"),
"cannot_execute_reason": parsed.get("cannot_execute_reason", ""),
"answer_text": parsed.get("answer_text", ""),
"description": parsed.get("answer_text", ""),
}
return {
"success": True,
"mode": "deepseek_json",
"message": parsed.get("answer_text", ""),
"plan": plan,
"intent": {
"intent": parsed.get("intent", ""),
"analysis_type": parsed.get("analysis_type", ""),
"entities": normalize_dict(parsed.get("entities")),
"stages": target_stages,
"params": normalize_dict(parsed.get("parameters") or parsed.get("params")),
},
"artifacts": artifacts,
"evidence": parsed.get("evidence") or [],
"support_status": parsed.get("support_status"),
"boundary_action": parsed.get("boundary_action", ""),
"can_execute": parsed.get("can_execute"),
"cannot_execute_reason": parsed.get("cannot_execute_reason", ""),
"llm_json_valid": True,
"llm_json_status": "parsed",
"llm_provider": meta["provider"],
"llm_model": meta["model"],
"llm_base_url": meta["base_url"],
"llm_usage": usage,
"llm_finish_reason": finish_reason,
"raw_text": content,
"parsed_payload": parsed,
}
def call_deepseek(args: argparse.Namespace, task: dict[str, Any], *, structured: bool) -> dict[str, Any]:
provider, message_cls, meta = deepseek_client(args)
messages = deepseek_json_messages(task, message_cls) if structured else deepseek_text_messages(task, message_cls)
response = provider.call(messages, temperature=args.deepseek_temperature, max_tokens=args.deepseek_max_tokens)
content = response.content or ""
usage = response.usage or {}
finish_reason = response.finish_reason or ""
if not structured:
return {
"success": True,
"mode": "deepseek_text",
"message": content,
"plan": {},
"intent": {"analysis_type": "deepseek_text", "stages": [], "params": {}},
"llm_json_valid": None,
"llm_json_status": "not_requested",
"llm_provider": meta["provider"],
"llm_model": meta["model"],
"llm_base_url": meta["base_url"],
"llm_usage": usage,
"llm_finish_reason": finish_reason,
"raw_text": content,
}
try:
parsed = json.loads(extract_json_object(content))
if not isinstance(parsed, dict):
raise ValueError("top-level JSON is not an object")
return parsed_to_raw(parsed, content, meta, usage, finish_reason)
except Exception as exc:
return {
"success": False,
"mode": "deepseek_json",
"message": content,
"plan": {},
"intent": {},
"error": f"deepseek_json_parse_failed: {exc}",
"llm_json_valid": False,
"llm_json_status": "parse_failed",
"llm_provider": meta["provider"],
"llm_model": meta["model"],
"llm_base_url": meta["base_url"],
"llm_usage": usage,
"llm_finish_reason": finish_reason,
"raw_text": content,
}
def run_provider(args: argparse.Namespace, task: dict[str, Any]) -> dict[str, Any]:
layer = task.get("layer") or task.get("category")
try:
if args.provider == "rule":
raw = rule_raw(task)
elif args.provider == "llm_only":
raw = llm_only_raw(task)
elif args.provider == "deepseek_text":
raw = call_deepseek(args, task, structured=False)
elif args.provider == "deepseek_json":
raw = call_deepseek(args, task, structured=True)
elif args.provider in {"full_system", "api"}:
if layer == "evidence_constraint":
raw = call_rag(args.api_base, task, args.timeout)
raw.setdefault("plan", {})
elif layer in {"biological_interpretation", "boundary_control"}:
raw = call_chat(args.api_base, task, args.timeout)
else:
raw = call_plan(args.api_base, task, args.timeout)
elif args.provider == "no_evidence":
if layer == "evidence_constraint":
raw = {"success": True, "mode": "no_evidence", "message": "Evidence retrieval disabled for ablation.", "plan": {}, "intent": {}}
else:
raw = call_plan(args.api_base, task, args.timeout, no_evidence=True)
else:
raise SystemExit(f"unsupported provider: {args.provider}")
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, RuntimeError, ImportError, ValueError) as exc:
raw = {"success": False, "error": str(exc), "plan": {}, "intent": {}, "mode": args.provider}
return raw
def run(args: argparse.Namespace) -> int:
tasks = load_jsonl(args.tasks)
if args.limit:
tasks = tasks[: args.limit]
run_id = args.run_id or f"{args.provider}_{int(time.time())}"
rows: list[dict[str, Any]] = []
for index, task in enumerate(tasks, start=1):
print(f"[{index}/{len(tasks)}] {task['task_id']}", file=sys.stderr)
raw = run_provider(args, task)
prediction = normalize_raw(raw, fallback_from_task(task))
rows.append({
"run_id": run_id,
"task_id": task["task_id"],
"provider": args.provider,
"created_at": datetime.now(timezone.utc).isoformat(),
"request": {"message": task.get("user_query", ""), "layer": task.get("layer"), "input_context": task.get("input_context", {})},
"raw_response": raw,
"prediction": prediction,
})
dump_jsonl(args.out, rows)
print(f"Wrote {len(rows)} predictions to {args.out}", file=sys.stderr)
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=["full_system", "api", "rule", "llm_only", "no_evidence", "deepseek_text", "deepseek_json"], required=True)
parser.add_argument("--api-base", default="http://127.0.0.1:7880")
parser.add_argument("--tasks", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--run-id", default="")
parser.add_argument("--timeout", type=float, default=120.0)
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--deepseek-env", type=Path, default=DEFAULT_DEEPSEEK_ENV)
parser.add_argument("--deepseek-project", default=str(GENE_FAMILY_MAS_ROOT))
parser.add_argument("--deepseek-model", default="")
parser.add_argument("--deepseek-base-url", default="")
parser.add_argument("--deepseek-temperature", type=float, default=0.1)
parser.add_argument("--deepseek-max-tokens", type=int, default=2500)
return parser.parse_args()
if __name__ == "__main__":
raise SystemExit(run(parse_args()))