289 lines
11 KiB
Python
289 lines
11 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Run PlantGeneBench tasks against a provider.
|
||
|
|
|
||
|
|
Providers:
|
||
|
|
- rule: lightweight deterministic baseline for smoke tests.
|
||
|
|
- api: call the live bbav5 /api/agent/plan endpoint.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
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, Dict, Iterable, List
|
||
|
|
|
||
|
|
|
||
|
|
DEFAULT_TASKS = Path("benchmarks/plantgenebench/tasks/plantgenebench_core_seed.jsonl")
|
||
|
|
DEFAULT_GOLD = Path("benchmarks/plantgenebench/gold/plantgenebench_core_seed.gold.jsonl")
|
||
|
|
|
||
|
|
|
||
|
|
def load_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||
|
|
rows: List[Dict[str, Any]] = []
|
||
|
|
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: Iterable[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(item).strip() for item in value if str(item).strip()]
|
||
|
|
if isinstance(value, tuple):
|
||
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
||
|
|
return [item for item in re.split(r"[,;,;\s]+", str(value)) if item]
|
||
|
|
|
||
|
|
|
||
|
|
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")
|
||
|
|
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"]
|
||
|
|
if "target_species" in params and "targetSpecies" not in params:
|
||
|
|
params["targetSpecies"] = params["target_species"]
|
||
|
|
if "reference_species" in params and "referenceSpecies" not in params:
|
||
|
|
params["referenceSpecies"] = params["reference_species"]
|
||
|
|
return params
|
||
|
|
|
||
|
|
|
||
|
|
def extract_stages(plan: Dict[str, Any], intent: Dict[str, Any]) -> List[str]:
|
||
|
|
raw = plan.get("stages")
|
||
|
|
if not raw:
|
||
|
|
raw = intent.get("stages")
|
||
|
|
stages: List[str] = []
|
||
|
|
for item in raw or []:
|
||
|
|
if isinstance(item, dict):
|
||
|
|
stage = item.get("id") or item.get("stage") or item.get("name")
|
||
|
|
else:
|
||
|
|
stage = item
|
||
|
|
if stage:
|
||
|
|
stages.append(str(stage))
|
||
|
|
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("name")
|
||
|
|
if not stage:
|
||
|
|
continue
|
||
|
|
deps[str(stage)] = normalize_list(detail.get("dependencies"))
|
||
|
|
return deps
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_response(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||
|
|
plan = raw.get("plan") if isinstance(raw.get("plan"), dict) else {}
|
||
|
|
intent = raw.get("intent") if isinstance(raw.get("intent"), dict) else {}
|
||
|
|
prediction = {
|
||
|
|
"success": bool(raw.get("success", False)),
|
||
|
|
"mode": str(raw.get("mode") or ""),
|
||
|
|
"analysis_type": str(plan.get("analysis_type") or intent.get("analysis_type") or ""),
|
||
|
|
"stages": extract_stages(plan, intent),
|
||
|
|
"params": flatten_params(plan, intent),
|
||
|
|
"dependencies": extract_dependencies(plan),
|
||
|
|
"answer_text": str(raw.get("message") or plan.get("description") or intent.get("description") or ""),
|
||
|
|
}
|
||
|
|
if raw.get("error"):
|
||
|
|
prediction["error"] = str(raw["error"])
|
||
|
|
return prediction
|
||
|
|
|
||
|
|
|
||
|
|
def infer_rule_prediction(task: Dict[str, Any]) -> Dict[str, Any]:
|
||
|
|
context = task.get("input_context") or {}
|
||
|
|
intent = context.get("intent") if isinstance(context.get("intent"), dict) else {}
|
||
|
|
params = dict(intent.get("params") or {})
|
||
|
|
if context.get("prefix"):
|
||
|
|
params.setdefault("prefix", context.get("prefix"))
|
||
|
|
for src_key, dst_key in [
|
||
|
|
("gene_family", "geneFamily"),
|
||
|
|
("target_species", "targetSpecies"),
|
||
|
|
("reference_species", "referenceSpecies"),
|
||
|
|
]:
|
||
|
|
if intent.get(src_key) is not None:
|
||
|
|
params.setdefault(dst_key, intent.get(src_key))
|
||
|
|
|
||
|
|
query = str(task.get("user_query") or "").lower()
|
||
|
|
stages = normalize_list(intent.get("stages"))
|
||
|
|
analysis_type = str(intent.get("analysis_type") or "gene_family")
|
||
|
|
if not intent.get("analysis_type") and any(stage.startswith("rnaseq_") for stage in stages):
|
||
|
|
analysis_type = "rnaseq"
|
||
|
|
if not intent.get("analysis_type") and ("论文 methods" in query or "methods" in query):
|
||
|
|
analysis_type = "method_alignment"
|
||
|
|
if any(term in query for term in ["编", "随便", "没有 fastq", "没有表达矩阵", "fabricat"]):
|
||
|
|
if task.get("category") == "safety_boundary":
|
||
|
|
stages = []
|
||
|
|
analysis_type = "safety"
|
||
|
|
|
||
|
|
dependencies = {
|
||
|
|
"phylogeny": ["identify"],
|
||
|
|
"motif": ["identify", "phylogeny"],
|
||
|
|
"domain": ["identify", "phylogeny"],
|
||
|
|
"structure": ["identify", "phylogeny"],
|
||
|
|
"promoter": ["identify", "phylogeny"],
|
||
|
|
"expression": ["identify", "phylogeny"],
|
||
|
|
"synteny": ["identify"],
|
||
|
|
"karyotype": ["identify", "synteny"],
|
||
|
|
"interspecies_synteny": ["identify"],
|
||
|
|
"ppi": ["identify", "interspecies_synteny"],
|
||
|
|
"kaks": ["identify", "interspecies_synteny"],
|
||
|
|
"combo": ["identify", "phylogeny", "motif", "domain", "structure"],
|
||
|
|
"pep": ["identify"],
|
||
|
|
"reference_classification": ["phylogeny"],
|
||
|
|
"rnaseq_align": ["rnaseq_qc"],
|
||
|
|
"rnaseq_quant": ["rnaseq_align"],
|
||
|
|
"rnaseq_sample_qc": ["rnaseq_quant"],
|
||
|
|
"rnaseq_de": ["rnaseq_sample_qc"],
|
||
|
|
"rnaseq_enrich": ["rnaseq_de"],
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
"success": True,
|
||
|
|
"mode": "rule_baseline",
|
||
|
|
"analysis_type": analysis_type,
|
||
|
|
"stages": stages,
|
||
|
|
"params": params,
|
||
|
|
"dependencies": {stage: dependencies.get(stage, []) for stage in stages},
|
||
|
|
"answer_text": "rule baseline prediction",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def call_plan_api(api_base: str, task: Dict[str, Any], timeout: float) -> Dict[str, Any]:
|
||
|
|
context = task.get("input_context") or {}
|
||
|
|
intent = dict(context.get("intent") or {})
|
||
|
|
intent["benchmarkMode"] = True
|
||
|
|
params = dict(intent.get("params") or {})
|
||
|
|
params["benchmarkMode"] = True
|
||
|
|
intent["params"] = params
|
||
|
|
payload = {
|
||
|
|
"message": task.get("user_query", ""),
|
||
|
|
"session_id": f"plantgenebench_{task.get('task_id')}",
|
||
|
|
"job_id": context.get("job_id", ""),
|
||
|
|
"prefix": context.get("prefix", ""),
|
||
|
|
"history": [],
|
||
|
|
"intent": intent,
|
||
|
|
}
|
||
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{api_base.rstrip('/')}/api/agent/plan",
|
||
|
|
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 build_row(
|
||
|
|
*,
|
||
|
|
run_id: str,
|
||
|
|
provider: str,
|
||
|
|
task: Dict[str, Any],
|
||
|
|
raw_response: Dict[str, Any],
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
context = task.get("input_context") or {}
|
||
|
|
return {
|
||
|
|
"run_id": run_id,
|
||
|
|
"task_id": task["task_id"],
|
||
|
|
"provider": provider,
|
||
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"request": {
|
||
|
|
"message": task.get("user_query", ""),
|
||
|
|
"job_id": context.get("job_id", ""),
|
||
|
|
"prefix": context.get("prefix", ""),
|
||
|
|
"intent": context.get("intent", {}),
|
||
|
|
},
|
||
|
|
"raw_response": raw_response,
|
||
|
|
"prediction": normalize_response(raw_response),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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)
|
||
|
|
if args.provider == "rule":
|
||
|
|
prediction = infer_rule_prediction(task)
|
||
|
|
raw_response = {
|
||
|
|
"success": prediction["success"],
|
||
|
|
"mode": prediction["mode"],
|
||
|
|
"plan": {
|
||
|
|
"analysis_type": prediction["analysis_type"],
|
||
|
|
"stages": prediction["stages"],
|
||
|
|
"stages_detail": [
|
||
|
|
{"id": stage, "dependencies": prediction["dependencies"].get(stage, []), "params": prediction["params"]}
|
||
|
|
for stage in prediction["stages"]
|
||
|
|
],
|
||
|
|
"params": prediction["params"],
|
||
|
|
"description": prediction["answer_text"],
|
||
|
|
},
|
||
|
|
"intent": {"stages": prediction["stages"], "params": prediction["params"]},
|
||
|
|
}
|
||
|
|
elif args.provider == "api":
|
||
|
|
try:
|
||
|
|
raw_response = call_plan_api(args.api_base, task, args.timeout)
|
||
|
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||
|
|
raw_response = {"success": False, "error": str(exc), "plan": {}, "intent": {}}
|
||
|
|
else:
|
||
|
|
raise SystemExit(f"unsupported provider: {args.provider}")
|
||
|
|
|
||
|
|
rows.append(build_row(run_id=run_id, provider=args.provider, task=task, raw_response=raw_response))
|
||
|
|
|
||
|
|
dump_jsonl(args.out, rows)
|
||
|
|
print(f"Wrote {len(rows)} predictions to {args.out}", file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def parse_args(argv: List[str]) -> argparse.Namespace:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--provider", choices=["rule", "api"], default="rule")
|
||
|
|
parser.add_argument("--api-base", default="http://127.0.0.1:7880")
|
||
|
|
parser.add_argument("--tasks", type=Path, default=DEFAULT_TASKS)
|
||
|
|
parser.add_argument("--gold", type=Path, default=DEFAULT_GOLD, help="Reserved for run metadata compatibility.")
|
||
|
|
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)
|
||
|
|
return parser.parse_args(argv)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(run(parse_args(sys.argv[1:])))
|
||
|
|
|